diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f35d0d1..29d7223 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,13 +15,13 @@ jobs: strategy: matrix: go-version: + - '1.23' + - '1.22' - '1.21' - '1.20' - '1.19' - '1.18' - '1.17' - - '1.16' - - '1.15' os: - ubuntu-latest - macos-latest diff --git a/cleanenv.go b/cleanenv.go index ce0d0d2..1e2577e 100644 --- a/cleanenv.go +++ b/cleanenv.go @@ -49,7 +49,7 @@ const ( // TagEnvRequired flag to mark a field as required TagEnvRequired = "env-required" - // TagEnvPrefix аlag to specify prefix for structure fields + // TagEnvPrefix flag to specify prefix for structure fields TagEnvPrefix = "env-prefix" ) @@ -113,7 +113,7 @@ func UpdateEnv(cfg interface{}) error { return readEnvVars(cfg, true) } -// parseFile parses configuration file according to it's extension +// parseFile parses configuration file according to its extension // // Currently following file extensions are supported: // @@ -249,6 +249,7 @@ type structMeta struct { description string updatable bool required bool + path string } // isFieldValueZero determines if fieldValue empty or not @@ -302,9 +303,10 @@ func readStructMetadata(cfgRoot interface{}) ([]structMeta, error) { type cfgNode struct { Val interface{} Prefix string + Path string } - cfgStack := []cfgNode{{cfgRoot, ""}} + cfgStack := []cfgNode{{cfgRoot, "", ""}} metas := make([]structMeta, 0) for i := 0; i < len(cfgStack); i++ { @@ -342,7 +344,11 @@ func readStructMetadata(cfgRoot interface{}) ([]structMeta, error) { // add structure to parsing stack if _, found := validStructs[fld.Type()]; !found { prefix, _ := fType.Tag.Lookup(TagEnvPrefix) - cfgStack = append(cfgStack, cfgNode{fld.Addr().Interface(), sPrefix + prefix}) + cfgStack = append(cfgStack, cfgNode{ + Val: fld.Addr().Interface(), + Prefix: sPrefix + prefix, + Path: fmt.Sprintf("%s%s.", cfgStack[i].Path, fType.Name), + }) continue } @@ -392,6 +398,7 @@ func readStructMetadata(cfgRoot interface{}) ([]structMeta, error) { description: fType.Tag.Get(TagEnvDescription), updatable: upd, required: required, + path: cfgStack[i].Path, }) } @@ -408,7 +415,7 @@ func readEnvVars(cfg interface{}, update bool) error { } if updater, ok := cfg.(Updater); ok { - if err := updater.Update(); err != nil { + if err = updater.Update(); err != nil { return err } } @@ -428,10 +435,14 @@ func readEnvVars(cfg interface{}, update bool) error { } } + var envName string + if len(meta.envList) > 0 { + envName = meta.envList[0] + } + if rawValue == nil && meta.required && meta.isFieldValueZero() { - return fmt.Errorf( - "field %q is required but the value is not provided", - meta.fieldName, + return fmt.Errorf("field %q is required but the value is not provided", + meta.path+meta.fieldName, ) } @@ -443,13 +454,10 @@ func readEnvVars(cfg interface{}, update bool) error { continue } - var envName string - if len(meta.envList) > 0 { - envName = meta.envList[0] - } - - if err := parseValue(meta.fieldValue, *rawValue, meta.separator, meta.layout); err != nil { - return fmt.Errorf("parsing field %v env %v: %v", meta.fieldName, envName, err) + if err = parseValue(meta.fieldValue, *rawValue, meta.separator, meta.layout); err != nil { + return fmt.Errorf("parsing field %q env %q: %v", + meta.path+meta.fieldName, envName, err, + ) } } diff --git a/cleanenv_test.go b/cleanenv_test.go index 0a13fcb..c2f0fba 100644 --- a/cleanenv_test.go +++ b/cleanenv_test.go @@ -323,6 +323,87 @@ func TestReadEnvVars(t *testing.T) { } } +func TestReadEnvErrors(t *testing.T) { + type testOneLevel struct { + Host string `env:"HOST" env-required:"true"` + } + + type testTwoLevels struct { + Queue struct { + Host string `env:"HOST"` + } `env-prefix:"TEST_ERRORS_"` + Database struct { + Host string `env:"HOST" env-required:"true"` + TTL time.Duration `env:"TTL"` + } `env-prefix:"TEST_ERRORS_DATABASE_"` + ThirdStruct struct { + Host string `env:"HOST"` + } `env-prefix:"TEST_ERRORS_THIRD_"` + } + + type testThreeLevels struct { + Database struct { + URL struct { + Host string `env:"HOST" env-required:"true"` + } + } + } + + tests := []struct { + name string + env map[string]string + cfg interface{} + expectedError string + }{ + { + name: "required error - one level", + env: nil, + cfg: &testOneLevel{}, + expectedError: `field "Host" is required but the value is not provided`, + }, + { + name: "required error - two levels", + env: nil, + cfg: &testTwoLevels{}, + expectedError: `field "Database.Host" is required but the value is not provided`, + }, + { + name: "required error - three levels", + env: nil, + cfg: &testThreeLevels{}, + expectedError: `field "Database.URL.Host" is required but the value is not provided`, + }, + { + name: "parsing error", + env: map[string]string{ + "TEST_ERRORS_DATABASE_HOST": "localhost", + "TEST_ERRORS_DATABASE_TTL": "bad-value", + }, + cfg: &testTwoLevels{}, + expectedError: `parsing field "Database.TTL" env "TEST_ERRORS_DATABASE_TTL": time: invalid duration "bad-value"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for env, val := range tt.env { + os.Setenv(env, val) + } + defer os.Clearenv() + + err := readEnvVars(tt.cfg, false) + + if err == nil { + t.Fatalf("expected error but got nil") + } + + if err.Error() != tt.expectedError { + t.Errorf("unexpected error message: got %q, want %q", err.Error(), tt.expectedError) + } + }) + } +} + func TestReadEnvVarsURL(t *testing.T) { urlFunc := func(u string) url.URL { parsed, err := url.Parse(u)