From f60035ec5ec98433a361f2a7c667e785c306324a Mon Sep 17 00:00:00 2001 From: Sergey Gladkovskiy Date: Thu, 10 Oct 2024 13:16:16 +0300 Subject: [PATCH] Refactoring, go version up, dependencies update --- .circleci/config.yml | 32 --- .github/workflows/go.yml | 36 ++++ Dockerfile | 5 - README.md | 17 +- .../configuration/dev/service.yaml | 2 +- env_stage.go | 33 +++ env_stage_test.go | 39 ++++ go.mod | 13 +- go.sum | 21 +- logging.go | 26 +++ merger.go | 33 +++ options.go | 59 ++++++ reader.go | 189 ++++++++++-------- reader_internal_test.go | 33 +-- reader_test.go | 47 +++-- stage/env_stage.go | 32 --- stage/env_stage_internal_test.go | 26 --- stage/env_stage_test.go | 32 --- stage/interface.go | 6 - stage/name.go | 13 -- stage/name_test.go | 16 -- stage_name.go | 17 ++ stage_name_test.go | 16 ++ .../deeper/thedeepest/relative_path_test.go | 5 +- tests/stage.go | 14 +- 25 files changed, 430 insertions(+), 332 deletions(-) delete mode 100644 .circleci/config.yml create mode 100644 .github/workflows/go.yml delete mode 100644 Dockerfile create mode 100644 env_stage.go create mode 100644 env_stage_test.go create mode 100644 merger.go create mode 100644 options.go delete mode 100644 stage/env_stage.go delete mode 100644 stage/env_stage_internal_test.go delete mode 100644 stage/env_stage_test.go delete mode 100644 stage/interface.go delete mode 100644 stage/name.go delete mode 100644 stage/name_test.go create mode 100644 stage_name.go create mode 100644 stage_name_test.go diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 1dd4624..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,32 +0,0 @@ -version: 2.1 - -orbs: - codecov: codecov/codecov@1.0.4 - -jobs: - build: - docker: - - image: circleci/golang:1.14 - environment: - IN_CONTAINER: true - steps: - - checkout - - run: go mod vendor - - run: - name: "Create a temp directory for artifacts" - command: | - mkdir -p /tmp/artifacts - - run: - name: tests - command: | - sudo mkdir -p /cfgs/defaults - sudo mkdir -p /cfgs/dev - sudo cp config_examples/configuration/defaults/* /cfgs/defaults - sudo cp config_examples/configuration/dev/* /cfgs/dev - make tests_html - mv coverage.html /tmp/artifacts - mv c.out /tmp/artifacts - - store_artifacts: - path: /tmp/artifacts - - codecov/upload: - file: /tmp/artifacts/* diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..3b999a9 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,36 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Go + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Install dependencies + run: go mod download + + - name: Test + run: go test -cover -covermode=atomic -coverprofile=coverage.txt -coverpkg=./... -race -v ./... + + - name: Upload results to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 43df508..0000000 --- a/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -# Dockerfile for test image -FROM spacetabio/docker-test-golang:1.14-1.0.2 - -COPY . /app -RUN make tests \ No newline at end of file diff --git a/README.md b/README.md index e5391c1..f981224 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Golang Microservice configuration module ---------------------------------------- -[![CircleCI](https://circleci.com/gh/spacetab-io/configuration-go.svg?style=shield)](https://circleci.com/gh/spacetab-io/configuration-go) [![codecov](https://codecov.io/gh/spacetab-io/configuration-go/graph/badge.svg)](https://codecov.io/gh/spacetab-io/configuration-go) +[![codecov](https://codecov.io/gh/spacetab-io/configuration-go/graph/badge.svg)](https://codecov.io/gh/spacetab-io/configuration-go) Configuration module for microservices written on Go. Preserves [corporate standards for services configuration](https://confluence.teamc.io/pages/viewpage.action?pageId=4227704). @@ -25,7 +25,7 @@ Some agreements: 1. Configuration must be declared as struct and reveals yaml structure 2. Default config folder: `./configuration`. If you need to override, pass your path in `ReadConfig` function -3. Stage is passed as `stage.Interface` implementation. In example below stageEnv is used to pass stage through env variable `STAGE`. +3. Stage is passed as `config.Stageable` implementation. In example below stageEnv is used to pass stage through env variable `STAGE`. Code example: @@ -33,12 +33,13 @@ Code example: package main import ( + "context" "fmt" "log" config "github.com/spacetab-io/configuration-go" "github.com/spacetab-io/configuration-go/stage" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) // ConfigStruct is your app config structure. This must be related to yaml config file structure. @@ -68,17 +69,17 @@ type ConfigStruct struct { } func main() { - // config.Read receives stage as stage.Interface implementation. + // config.Read receives stage as config.Stageable implementation. // You can use envStage to pass stage name via ENV param STAGE. // In NewEnvStage you can pass fallback value if STAGE param is empty. - envStage := stage.NewEnvStage("development") + envStage := config.NewEnvStage("development") // Reading ALL config files in defaults configuration folder and recursively merge them with STAGE configs - configBytes, err := config.Read(envStage, "./configuration", true) + configBytes, err := config.Read(context.TODO(), envStage, config.WithConfigPath("./configuration")) if err != nil { log.Fatalf("config reading error: %+v", err) } - cfg := ConfigStruct{} + var cfg ConfigStruct // unmarshal config into Config structure err = yaml.Unmarshal(configBytes, &cfg) if err != nil { @@ -93,7 +94,7 @@ func main() { The MIT License -Copyright © 2021 SpaceTab.io, Inc. https://spacetab.io +Copyright © 2024 SpaceTab.io, Inc. https://spacetab.io Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the " Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, diff --git a/config_examples/configuration/dev/service.yaml b/config_examples/configuration/dev/service.yaml index e01bda8..6de6eb2 100644 --- a/config_examples/configuration/dev/service.yaml +++ b/config_examples/configuration/dev/service.yaml @@ -1,4 +1,4 @@ dev: debug: true - string_test: null + string_test: "" bool_test: false \ No newline at end of file diff --git a/env_stage.go b/env_stage.go new file mode 100644 index 0000000..3baf4da --- /dev/null +++ b/env_stage.go @@ -0,0 +1,33 @@ +package config + +import ( + "os" +) + +type Stageable interface { + Name() StageName +} + +type EnvStage StageName + +const ENVStageKey = "STAGE" + +var _ Stageable = (*EnvStage)(nil) + +func NewEnvStage(fallback string) Stageable { + value, ok := os.LookupEnv(ENVStageKey) + if !ok { + return EnvStage(fallback) + } + + return EnvStage(value) +} + +// Name Loads stage name with fallback to 'dev'. +func (s EnvStage) Name() StageName { + if s == "" { + return StageNameDefaults + } + + return StageName(s) +} diff --git a/env_stage_test.go b/env_stage_test.go new file mode 100644 index 0000000..62a2a79 --- /dev/null +++ b/env_stage_test.go @@ -0,0 +1,39 @@ +package config_test + +import ( + "os" + "testing" + + "github.com/spacetab-io/configuration-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewEnvStage(t *testing.T) { + envStage := config.NewEnvStage("dev") + assert.Equal(t, config.EnvStage("dev"), envStage) + + require.NoError(t, os.Setenv(config.ENVStageKey, "prod")) + envStage = config.NewEnvStage("dev") + assert.Equal(t, config.EnvStage("prod"), envStage) + require.NoError(t, os.Unsetenv(config.ENVStageKey)) +} + +func TestEnvStage_Get(t *testing.T) { + t.Parallel() + + envStage := config.NewEnvStage("dev") + + assert.Equal(t, config.StageName("dev"), envStage.Name()) +} + +func TestEnvStage_Name(t *testing.T) { + t.Parallel() + + envStage := config.NewEnvStage("dev") + + assert.Equal(t, "dev", envStage.Name().String()) + + envStage = config.EnvStage("") + assert.Equal(t, config.StageNameDefaults, envStage.Name()) +} diff --git a/go.mod b/go.mod index 71d6419..1714257 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,14 @@ module github.com/spacetab-io/configuration-go -go 1.14 +go 1.22 require ( - github.com/imdario/mergo v0.3.12 - github.com/stretchr/testify v1.7.0 - gopkg.in/yaml.v2 v2.4.0 + dario.cat/mergo v1.0.1 + github.com/stretchr/testify v1.9.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index e0f4463..9604d83 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,12 @@ -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= -github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/logging.go b/logging.go index d912156..81466f3 100644 --- a/logging.go +++ b/logging.go @@ -1 +1,27 @@ package config + +import ( + "context" +) + +type Logger interface { + Debug(context.Context, string, ...any) + Warn(context.Context, string, ...any) + Error(context.Context, string, ...any) +} + +var _ Logger = (*noOpLogger)(nil) + +type noOpLogger struct{} + +func (n noOpLogger) Debug(ctx context.Context, msg string, args ...any) { + return +} + +func (n noOpLogger) Error(ctx context.Context, msg string, args ...any) { + return +} + +func (n noOpLogger) Warn(ctx context.Context, msg string, args ...any) { + return +} diff --git a/merger.go b/merger.go new file mode 100644 index 0000000..2efff50 --- /dev/null +++ b/merger.go @@ -0,0 +1,33 @@ +package config + +import "sync" + +const defaultConfigPath = "./configuration" + +type merger struct { + logger Logger + stage Stageable + cfgPath string + mu sync.RWMutex + configs map[StageName]map[string]any + fileList map[StageName][]string +} + +func newMerger(opts ...Option) (*merger, error) { + mc := merger{ + logger: noOpLogger{}, + mu: sync.RWMutex{}, + stage: EnvStage(StageNameDefaults), + cfgPath: defaultConfigPath, + fileList: make(map[StageName][]string), + configs: make(map[StageName]map[string]any), + } + + for _, opt := range opts { + if err := opt(&mc); err != nil { + return nil, err + } + } + + return &mc, nil +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..df3cd97 --- /dev/null +++ b/options.go @@ -0,0 +1,59 @@ +package config + +import ( + "errors" + "fmt" + "os" + "strings" +) + +type Option func(*merger) error + +var ( + ErrLoggerIsEmpty = fmt.Errorf("logger is empty") + ErrEmptyConfigPath = errors.New("empty config path") + ErrConfigPath = errors.New("config path error") + ErrStageIsEmpty = errors.New("stage data is empty") +) + +func WithLogger(logger Logger) Option { + return func(mc *merger) error { + if logger == Logger(nil) { + return ErrLoggerIsEmpty + } + + mc.logger = logger + + return nil + } +} + +func withStageName(stage Stageable) Option { + return func(mc *merger) error { + if stage == nil { + return ErrStageIsEmpty + } + + mc.stage = stage + + return nil + } +} + +func WithConfigPath(cfgPath string) Option { + return func(mc *merger) error { + if cfgPath == "" { + return ErrEmptyConfigPath + } + + cfgPath = strings.TrimRight(cfgPath, "/") + + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + return fmt.Errorf("%w: %s", ErrConfigPath, err) + } + + mc.cfgPath = cfgPath + + return nil + } +} diff --git a/reader.go b/reader.go index 4d325de..52c7790 100644 --- a/reader.go +++ b/reader.go @@ -1,137 +1,152 @@ package config import ( + "context" "errors" "fmt" - "io/ioutil" - logs "log" "os" "path/filepath" - "strings" - "github.com/imdario/mergo" - "github.com/spacetab-io/configuration-go/stage" - "gopkg.in/yaml.v2" + "dario.cat/mergo" + "gopkg.in/yaml.v3" ) var ErrNoDefaults = errors.New("no default config") -const defaultConfigPath = "./configuration" +var ( + ErrMergingError = errors.New("merging with defaults error") +) // Read Reads yaml files from configuration directory with sub folders // as application stage and merges config files in one configuration per stage. -func Read(stageI stage.Interface, cfgPath string, verbose bool) ([]byte, error) { - cfgPath, err := checkConfigPath(cfgPath) +func Read(ctx context.Context, stage Stageable, opts ...Option) ([]byte, error) { + mc, err := newMerger(append(opts, withStageName(stage))...) if err != nil { return nil, err } - if verbose { - log("Current stage: `%s`", stageI.String()) - log("Config path: `%v`", cfgPath) - } + mc.logger.Debug(ctx, "Current stage", mc.stage.Name().String()) + mc.logger.Debug(ctx, "Config path", mc.cfgPath) - fileList := getFileList(stageI, cfgPath) + if err = mc.getFileList(); err != nil { + mc.logger.Error(ctx, "get file list error", err) + + return nil, err + } // check defaults config existence. Fall down if not - if _, ok := fileList[stage.Defaults]; !ok || len(fileList[stage.Defaults]) == 0 { - log("defaults config is not found in file list `%+v`! Fall down.", fileList) + if !mc.defaultConfigExists() { + mc.logger.Error(ctx, "defaults config is not found in file list! Fall down.", mc.fileList) return nil, ErrNoDefaults } - if verbose { - log("Existing config list: %+v", fileList) - } + mc.logger.Debug(ctx, "Existing config list", mc.fileList) - fileListResult := make(map[stage.Name][]string) - configs := make(map[stage.Name]map[string]interface{}) + fileListResult := make(map[StageName][]string) - for folder, files := range fileList { + for stageName, files := range mc.fileList { for _, file := range files { - fullFilePath := cfgPath + "/" + folder.String() + "/" + file + var configBytes []byte - configBytes, err := ioutil.ReadFile(fullFilePath) + configBytes, err = os.ReadFile(file) if err != nil { - log("%s %s config read fail! Fall down.", folder, file) + mc.logger.Error(ctx, "Config read fail! Fall down.", stageName, file) - return nil, fmt.Errorf("config file `%s` read fail: %w", fullFilePath, err) + return nil, fmt.Errorf("config file `%s` read fail: %w", file, err) } - var configFromFile map[stage.Name]map[string]interface{} + var configFromFile map[StageName]map[string]any - if verbose { - log("file `%s` content: \n%v", fullFilePath, string(configBytes)) - } + mc.logger.Debug(ctx, "file content", file, string(configBytes)) - if err := yaml.Unmarshal(configBytes, &configFromFile); err != nil { - log("%s %s config read fail! Fall down.", folder, file) + if err = yaml.Unmarshal(configBytes, &configFromFile); err != nil { + mc.logger.Error(ctx, "config read fail! Fall down.", stageName, file) - return nil, fmt.Errorf("config file `%s` unmarshal fail: %w", fullFilePath, err) + return nil, fmt.Errorf("config file `%s` unmarshal fail: %w", file, err) } - if _, ok := configFromFile[folder]; !ok { - log("WARN! File `%s` excluded from `%s` (it is not for this stage)!", file, folder) + if _, ok := configFromFile[stageName]; !ok { + mc.logger.Warn(ctx, "File excluded from current stage (it is not for this stage)!", file, stageName) continue } - if _, ok := configs[folder]; !ok { - configs[folder] = configFromFile[folder] + cc, ok := mc.getConfigForStage(stageName) + if !ok { + mc.setConfigForStage(stageName, configFromFile[stageName]) + cc, _ = mc.getConfigForStage(stageName) } - cc := configs[folder] + if err = mergo.Merge( + &cc, + configFromFile[stageName], + ); err != nil { + mc.logger.Error(ctx, "config merge fail! Fall down.", stageName, file) - err = mergo.Merge(&cc, configFromFile[folder], mergo.WithOverwriteWithEmptyValue) - if err != nil { - log("%s %s config merge fail! Fall down.", folder, file) - - return nil, fmt.Errorf("merging configs[%s] with configFromFile[%s] config fail: %w", folder, folder, err) + return nil, fmt.Errorf("merging configs[%s] with configFromFile[%s] config fail: %w", stageName, stageName, err) } - configs[folder] = cc + mc.setConfigForStage(stageName, cc) - fileListResult[folder] = append(fileListResult[folder], file) + fileListResult[stageName] = append(fileListResult[stageName], file) } } - if verbose { - log("Parsed config list: `%+v`", fileListResult) - } + mc.logger.Debug(ctx, "Parsed config list", fileListResult) - config := configs[stage.Defaults] + return mc.getResultConfigForStage(ctx) +} + +func (mc *merger) getResultConfigForStage(ctx context.Context) ([]byte, error) { + resultConfig, ok := mc.getConfigForStage(StageNameDefaults) + if !ok { + return nil, ErrNoDefaults + } - if c, ok := configs[stageI.Get()]; ok { - if err := mergo.Merge(&config, c, mergo.WithOverwriteWithEmptyValue); err != nil { - log("merging with defaults error") + if cfg, ok := mc.getConfigForStage(mc.stage.Name()); ok { + if err := mergo.Merge(&resultConfig, cfg, mergo.WithOverride); err != nil { + mc.logger.Error(ctx, "merging with defaults error: %s", err) - return nil, fmt.Errorf("merging with defaults error: %w", err) + return nil, fmt.Errorf("%w: %w", ErrMergingError, err) } - log("Stage `%s` config is loaded and merged with `defaults`", stageI.String()) + mc.logger.Debug(ctx, "Stage config is loaded and merged with `defaults`", mc.stage.Name().String()) } - if verbose { - log("final config:\n%+v", config) + mc.logger.Debug(ctx, "final config", resultConfig) + + return yaml.Marshal(resultConfig) +} + +func (mc *merger) getConfigForStage(stage StageName) (map[string]any, bool) { + mc.mu.RLock() + defer mc.mu.RUnlock() + + if len(mc.configs[stage]) == 0 { + return nil, false } - return yaml.Marshal(config) + cfg := make(map[string]any, len(mc.configs[stage])) + for key, val := range mc.configs[stage] { + cfg[key] = val + } + + return cfg, true + } -func getFileList(stageI stage.Interface, cfgPath string) map[stage.Name][]string { - var ( - fileList = map[stage.Name][]string{} - stageDir string - ) +func (mc *merger) getFileList() error { + var stageDir StageName - _ = filepath.Walk(cfgPath, func(path string, f os.FileInfo, err error) error { - if cfgPath == path { + return filepath.Walk(mc.cfgPath, func(path string, f os.FileInfo, err error) error { + if mc.cfgPath == path { return nil } if f.IsDir() { - if stageDir == "" || f.Name() == stage.Defaults.String() || f.Name() == stageI.String() { - stageDir = f.Name() + if stageDir.String() == "" || f.Name() == StageNameDefaults.String() || mc.stage.Name().String() == f.Name() { + stageDir = NewStageNameUnsafe(f.Name()) return nil } @@ -139,31 +154,47 @@ func getFileList(stageI stage.Interface, cfgPath string) map[stage.Name][]string return filepath.SkipDir } - if filepath.Ext(f.Name()) == ".yaml" && (stageDir == stage.Defaults.String() || stageDir == stageI.String()) { - fileList[stage.Name(stageDir)] = append(fileList[stage.Name(stageDir)], f.Name()) + if fileIsYaml(f.Name()) && (stageDir.isDefault() || mc.isSpecifiedStage(stageDir)) { + mc.addFileToStage(stageDir, f.Name()) } return nil }) +} + +func fileIsYaml(name string) bool { + return filepath.Ext(name) == ".yaml" || filepath.Ext(name) == ".yml" +} - return fileList +func (mc *merger) isSpecifiedStage(stage StageName) bool { + return mc.stage.Name() == stage } -func checkConfigPath(cfgPath string) (string, error) { - if cfgPath == "" { - cfgPath = defaultConfigPath +func (mc *merger) addFileToStage(stage StageName, file string) { + fileList, ok := mc.fileList[stage] + if !ok || len(fileList) == 0 { + fileList = make([]string, 0, 1) } - cfgPath = strings.TrimRight(cfgPath, "/") + file = mc.cfgPath + "/" + stage.String() + "/" + file + + fileList = append(fileList, file) - if _, err := os.Stat(cfgPath); os.IsNotExist(err) { - return cfgPath, fmt.Errorf("config path error: %w", err) + mc.fileList[stage] = fileList +} + +func (mc *merger) defaultConfigExists() bool { + defaultConfig, ok := mc.fileList[StageNameDefaults] + if !ok || len(defaultConfig) == 0 { + return false } - return cfgPath, nil + return true } -// log Logs in stdout when quiet mode is off. -func log(pattern string, args ...interface{}) { - logs.Printf("[config] "+pattern+"\n", args...) +func (mc *merger) setConfigForStage(stageName StageName, cfg map[string]any) { + mc.mu.Lock() + defer mc.mu.Unlock() + + mc.configs[stageName] = cfg } diff --git a/reader_internal_test.go b/reader_internal_test.go index 94f6fc6..3e7b470 100644 --- a/reader_internal_test.go +++ b/reader_internal_test.go @@ -1,17 +1,12 @@ package config import ( - "bytes" - logs "log" - "os" - "syscall" "testing" - "time" "github.com/stretchr/testify/assert" ) -func Test_checkConfigPath(t *testing.T) { +func Test_WithConfigPath(t *testing.T) { type tc struct { name string in string @@ -30,7 +25,7 @@ func Test_checkConfigPath(t *testing.T) { name: "default config path", in: "", exp: "./configuration", - err: syscall.ENOENT, + err: ErrEmptyConfigPath, }, } @@ -40,30 +35,8 @@ func Test_checkConfigPath(t *testing.T) { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() - out, err := checkConfigPath(tc.in) - if tc.err != nil && err != nil { - assert.ErrorIs(t, err, tc.err) - } else { - assert.NoError(t, err) - } - - assert.Equal(t, tc.exp, out) + assert.ErrorIs(t, WithConfigPath(tc.in)(&merger{}), tc.err) }) } } - -func Test_iSay(t *testing.T) { - t.Parallel() - - var buf bytes.Buffer - logs.SetOutput(&buf) - - defer func() { - logs.SetOutput(os.Stderr) - }() - - log("some text") - - assert.Equal(t, time.Now().Format("2006/01/02 15:04:05")+" [config] some text\n", buf.String()) -} diff --git a/reader_test.go b/reader_test.go index e3b8b9c..30d26f6 100644 --- a/reader_test.go +++ b/reader_test.go @@ -1,13 +1,15 @@ package config_test import ( + "context" "os" "testing" config "github.com/spacetab-io/configuration-go" "github.com/spacetab-io/configuration-go/tests" "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v2" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) func TestReadConfigs(t *testing.T) { @@ -16,16 +18,16 @@ func TestReadConfigs(t *testing.T) { t.Run("Success parsing common dirs and files", func(t *testing.T) { t.Parallel() tStage := tests.NewTestStage("dev") - configBytes, err := config.Read(tStage, "./config_examples/configuration", false) - if !assert.NoError(t, err) { - t.FailNow() + configBytes, err := config.Read(context.TODO(), tStage, config.WithConfigPath("./config_examples/configuration")) + require.NoError(t, err) + + type logCfg struct { + Level string `yaml:"level"` + Format string `yaml:"format"` } type cfg struct { - Log struct { - Level string `yaml:"level"` - Format string `yaml:"format"` - } `yaml:"log"` + Log logCfg `yaml:"log"` Host string `yaml:"host"` Port string `yaml:"port"` StringValue string `yaml:"string_test"` @@ -33,13 +35,10 @@ func TestReadConfigs(t *testing.T) { BoolValue bool `yaml:"bool_test"` } - exp := &cfg{} - err = yaml.Unmarshal(configBytes, &exp) - if !assert.NoError(t, err) { - t.FailNow() - } + var exp cfg + require.NoError(t, yaml.Unmarshal(configBytes, &exp)) - refConfig := &cfg{ + refConfig := cfg{ Debug: true, Log: struct { Level string `yaml:"level"` @@ -57,7 +56,7 @@ func TestReadConfigs(t *testing.T) { t.Run("Success parsing merging many config files in default and one file in stage", func(t *testing.T) { t.Parallel() tStage := tests.NewTestStage("local") - configBytes, err := config.Read(tStage, "./config_examples/configuration", false) + configBytes, err := config.Read(context.TODO(), tStage, config.WithConfigPath("./config_examples/configuration")) if !assert.NoError(t, err) { t.FailNow() } @@ -115,7 +114,7 @@ func TestReadConfigs(t *testing.T) { t.Run("Success parsing common dirs and files with different stages", func(t *testing.T) { t.Parallel() tStage := tests.NewTestStage("prod") - configBytes, err := config.Read(tStage, "./config_examples/configuration", false) + configBytes, err := config.Read(context.TODO(), tStage, config.WithConfigPath("./config_examples/configuration")) if !assert.NoError(t, err) { t.FailNow() } @@ -152,7 +151,7 @@ func TestReadConfigs(t *testing.T) { t.Run("Success parsing complex dirs and files", func(t *testing.T) { t.Parallel() tStage := tests.NewTestStage("development") - configBytes, err := config.Read(tStage, "./config_examples/configuration2", false) + configBytes, err := config.Read(context.TODO(), tStage, config.WithConfigPath("./config_examples/configuration2")) if !assert.NoError(t, err) { t.FailNow() } @@ -217,7 +216,7 @@ func TestReadConfigs(t *testing.T) { t.Run("Success parsing symlinked files and dirs", func(t *testing.T) { t.Parallel() tStage := tests.NewTestStage("dev") - configBytes, err := config.Read(tStage, "./config_examples/symnlinkedConfigs", false) + configBytes, err := config.Read(context.TODO(), tStage, config.WithConfigPath("./config_examples/symnlinkedConfigs")) if !assert.NoError(t, err) { t.FailNow() } @@ -232,13 +231,13 @@ func TestReadConfigs(t *testing.T) { Port string `yaml:"port"` } - exp := &cfg{} + var exp cfg err = yaml.Unmarshal(configBytes, &exp) if !assert.NoError(t, err) { t.FailNow() } - refConfig := &cfg{ + refConfig := cfg{ Debug: true, Log: struct { Level string `yaml:"level"` @@ -255,7 +254,7 @@ func TestReadConfigs(t *testing.T) { t.Run("Success parsing symlinked files and dirs in root", func(t *testing.T) { t.Parallel() tStage := tests.NewTestStage("dev") - configBytes, err := config.Read(tStage, "/cfgs", false) + configBytes, err := config.Read(context.TODO(), tStage, config.WithConfigPath("/cfgs")) if !assert.NoError(t, err) { t.FailNow() } @@ -293,7 +292,7 @@ func TestReadConfigs(t *testing.T) { t.Run("Fail dir not found", func(t *testing.T) { t.Parallel() tStage := tests.NewTestStage("dev") - _, err := config.Read(tStage, "", false) + _, err := config.Read(context.TODO(), tStage, config.WithConfigPath("")) if !assert.Error(t, err) { t.FailNow() } @@ -302,7 +301,7 @@ func TestReadConfigs(t *testing.T) { t.Run("no defaults configs", func(t *testing.T) { t.Parallel() tStage := tests.NewTestStage("dev") - _, err := config.Read(tStage, "./config_examples/no_defaults", false) + _, err := config.Read(context.TODO(), tStage, config.WithConfigPath("/config_examples/no_defaults")) if !assert.Error(t, err) { t.FailNow() } @@ -311,7 +310,7 @@ func TestReadConfigs(t *testing.T) { t.Run("merge errors", func(t *testing.T) { t.Parallel() tStage := tests.NewTestStage("dev") - _, err := config.Read(tStage, "./config_examples/merge_error", false) + _, err := config.Read(context.TODO(), tStage, config.WithConfigPath("/config_examples/merge_error")) if !assert.Error(t, err) { t.FailNow() } diff --git a/stage/env_stage.go b/stage/env_stage.go deleted file mode 100644 index 12e47ed..0000000 --- a/stage/env_stage.go +++ /dev/null @@ -1,32 +0,0 @@ -package stage - -import ( - "os" -) - -type EnvStage struct { - Name Name -} - -func NewEnvStage(fallback string) Interface { - return EnvStage{Name: Name(getEnv("STAGE", fallback))} -} - -// Get Load configuration for stage with fallback to 'dev'. -func (s EnvStage) Get() Name { - return s.Name -} - -// Get Load configuration for stage with fallback to 'dev'. -func (s EnvStage) String() string { - return s.Name.String() -} - -// getEnv Getting var from ENV with fallback param on empty. -func getEnv(key, fallback string) string { - if value, ok := os.LookupEnv(key); ok { - return value - } - - return fallback -} diff --git a/stage/env_stage_internal_test.go b/stage/env_stage_internal_test.go deleted file mode 100644 index 35930e2..0000000 --- a/stage/env_stage_internal_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package stage - -import ( - "os" - "testing" - - "github.com/stretchr/testify/assert" -) - -//nolint: paralleltest // there is no parallel with env -func TestGetEnv(t *testing.T) { - t.Run("get env key value", func(t *testing.T) { - _ = os.Setenv("KEY", "VALUE") - val := getEnv("KEY", "") - if !assert.Equal(t, "VALUE", val) { - t.FailNow() - } - }) - t.Run("get env key value fallback", func(t *testing.T) { - _ = os.Setenv("KEY", "VALUE") - val := getEnv("KEY2", "") - if !assert.Equal(t, "", val) { - t.FailNow() - } - }) -} diff --git a/stage/env_stage_test.go b/stage/env_stage_test.go deleted file mode 100644 index ef64aea..0000000 --- a/stage/env_stage_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package stage_test - -import ( - "testing" - - "github.com/spacetab-io/configuration-go/stage" - "github.com/stretchr/testify/assert" -) - -func TestNewEnvStage(t *testing.T) { - t.Parallel() - - envStage := stage.NewEnvStage("dev") - - assert.Equal(t, stage.EnvStage{Name: "dev"}, envStage) -} - -func TestEnvStage_Get(t *testing.T) { - t.Parallel() - - envStage := stage.NewEnvStage("dev") - - assert.Equal(t, stage.Name("dev"), envStage.Get()) -} - -func TestEnvStage_String(t *testing.T) { - t.Parallel() - - envStage := stage.NewEnvStage("dev") - - assert.Equal(t, "dev", envStage.String()) -} diff --git a/stage/interface.go b/stage/interface.go deleted file mode 100644 index 4bfbded..0000000 --- a/stage/interface.go +++ /dev/null @@ -1,6 +0,0 @@ -package stage - -type Interface interface { - Get() Name - String() string -} diff --git a/stage/name.go b/stage/name.go deleted file mode 100644 index 616736e..0000000 --- a/stage/name.go +++ /dev/null @@ -1,13 +0,0 @@ -package stage - -import ( - "fmt" -) - -type Name string - -const Defaults Name = "defaults" - -func (sn Name) String() string { - return fmt.Sprint(string(sn)) -} diff --git a/stage/name_test.go b/stage/name_test.go deleted file mode 100644 index b1994df..0000000 --- a/stage/name_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package stage_test - -import ( - "testing" - - "github.com/spacetab-io/configuration-go/stage" - "github.com/stretchr/testify/assert" -) - -func TestName_String(t *testing.T) { - t.Parallel() - - envStage := stage.NewEnvStage("dev") - - assert.Equal(t, "dev", envStage.String()) -} diff --git a/stage_name.go b/stage_name.go new file mode 100644 index 0000000..02a57c3 --- /dev/null +++ b/stage_name.go @@ -0,0 +1,17 @@ +package config + +type StageName string + +const StageNameDefaults StageName = "defaults" + +func NewStageNameUnsafe(name string) StageName { + return StageName(name) +} + +func (sn StageName) String() string { + return string(sn) +} + +func (sn StageName) isDefault() bool { + return sn == StageNameDefaults +} diff --git a/stage_name_test.go b/stage_name_test.go new file mode 100644 index 0000000..baa1268 --- /dev/null +++ b/stage_name_test.go @@ -0,0 +1,16 @@ +package config_test + +import ( + "testing" + + "github.com/spacetab-io/configuration-go" + "github.com/stretchr/testify/assert" +) + +func TestName_String(t *testing.T) { + t.Parallel() + + envStage := config.NewEnvStage("dev") + + assert.Equal(t, "dev", envStage.Name().String()) +} diff --git a/tests/deep/deeper/thedeepest/relative_path_test.go b/tests/deep/deeper/thedeepest/relative_path_test.go index 59ca335..632dc7e 100644 --- a/tests/deep/deeper/thedeepest/relative_path_test.go +++ b/tests/deep/deeper/thedeepest/relative_path_test.go @@ -1,12 +1,13 @@ package thedeepest_test import ( + "context" "testing" config "github.com/spacetab-io/configuration-go" "github.com/spacetab-io/configuration-go/tests" "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v2" + "gopkg.in/yaml.v3" ) func TestRelativePath(t *testing.T) { @@ -14,7 +15,7 @@ func TestRelativePath(t *testing.T) { t.Run("Success parsing relative dirs", func(t *testing.T) { t.Parallel() tStage := tests.NewTestStage("dev") - configBytes, err := config.Read(tStage, "../../../../config_examples/configuration", false) + configBytes, err := config.Read(context.TODO(), tStage, config.WithConfigPath("../../../../config_examples/configuration")) if !assert.NoError(t, err) { t.FailNow() } diff --git a/tests/stage.go b/tests/stage.go index e181063..2e00663 100644 --- a/tests/stage.go +++ b/tests/stage.go @@ -1,21 +1,17 @@ package tests import ( - "github.com/spacetab-io/configuration-go/stage" + "github.com/spacetab-io/configuration-go" ) type TestStage struct { - name stage.Name + name config.StageName } -func (ts TestStage) Get() stage.Name { +func (ts TestStage) Name() config.StageName { return ts.name } -func (ts TestStage) String() string { - return string(ts.name) -} - -func NewTestStage(stageName string) stage.Interface { - return TestStage{name: stage.Name(stageName)} +func NewTestStage(stageName string) config.Stageable { + return TestStage{name: config.StageName(stageName)} }