From aa86bf9f81987e0c5e28f0aa5d1b4ce5f7af6f32 Mon Sep 17 00:00:00 2001 From: hcdav Date: Sun, 26 Oct 2025 15:49:57 +0000 Subject: [PATCH 1/9] style: Refactor lib structure. --- README.md | 6 ++ env.go => envconfig.go | 176 +------------------------------ env_test.go => envconfig_test.go | 0 go.mod | 2 + go.sum | 2 + option.go | 77 ++++++++++++++ parser.go | 138 ++++++++++++++++++++++++ parser_internal_test.go | 64 +++++++++++ 8 files changed, 294 insertions(+), 171 deletions(-) rename env.go => envconfig.go (62%) rename env_test.go => envconfig_test.go (100%) create mode 100644 go.sum create mode 100644 option.go create mode 100644 parser.go create mode 100644 parser_internal_test.go diff --git a/README.md b/README.md index bcff5a7..44c4b64 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Package `envconfig` provides functionality to easily populate your config struct - [Installation](#installation) - [Features](#features) - [Options](#options) + - [Supported File Types](#supported-file-types) - [Supported Data Types](#supported-data-types) - [Usage](#usage) @@ -29,6 +30,11 @@ go get github.com/h-dav/envconfig/v2 - `envjson`: Used for deserialising json into config. - Text Replacement: `${EXAMPLE}` can be used to insert other environment variables. +### Supported File Types + +- .env +- .toml + ### Supported Data Types - string (slice compatible) diff --git a/env.go b/envconfig.go similarity index 62% rename from env.go rename to envconfig.go index 59bbf0b..02cf70e 100644 --- a/env.go +++ b/envconfig.go @@ -1,14 +1,10 @@ -// Package envconfig provides functionality to easily populate your config structure by using both environment variables, and a .env file (optional). +// Package envconfig provides functionality to easily populate your config structure by using both environment variables, and a config file (optional). package envconfig import ( - "bufio" - "encoding/json" "fmt" "os" - "path/filepath" "reflect" - "regexp" "strconv" "strings" "time" @@ -36,30 +32,13 @@ type entry struct { key, value string } -// textReplacementRegex is used to detect text replacement in environment variables. -var textReplacementRegex = regexp.MustCompile(`\${[^}]+}`) - -type Parser interface { - Parse(filename string) error -} - // Set will parse the .env file and set the values in the environment, then populate the passed in struct -// using ALL environment variables. +// using all environment variables. func Set(filename string, config any) error { if filename != "" { - var parser Parser - - switch filepath.Ext(filename) { - case ".env": - parser = EnvFileParser{} - default: - return &FileTypeValidationError{Filename: filename} - } - - if err := parser.Parse(filename); err != nil { - return fmt.Errorf("set environment variables: %w", err) + if err := process(filename); err != nil { + return fmt.Errorf("parse file: %w", err) } - } if err := populateConfig(config); err != nil { @@ -69,82 +48,6 @@ func Set(filename string, config any) error { return nil } -type EnvFileParser struct{} - -// Parse will parse the file and set the values in the environment. -func (e EnvFileParser) Parse(filename string) error { - file, err := os.Open(filepath.Clean(filename)) - if err != nil { - return &OpenFileError{Err: err} - } - defer file.Close() //nolint:errcheck // File closure. - - scanner := bufio.NewScanner(file) - for scanner.Scan() { - line := scanner.Text() - - // Handles empty and commented lines. - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - entry, err := parseEnvLine(line) - if err != nil { - return fmt.Errorf("parse environment variable line: %w", err) - } - - if err = handleTextReplacement(&entry.value); err != nil { - return fmt.Errorf("handle text replacement: %w", err) - } - - if err := os.Setenv(entry.key, entry.value); err != nil { - return &SetEnvironmentVariableError{Err: err} - } - } - - if err := scanner.Err(); err != nil { - return &FileReadError{Filename: filename, Err: err} - } - - return nil -} - -// parseEnvLine parses an individual .env line, and detect comments. -func parseEnvLine(line string) (entry, error) { - key, value, found := strings.Cut(line, "=") - if !found { - return entry{}, &ParseError{Line: line} - } - - // Clean environment variable key. - key = strings.TrimSpace(key) - - // Clean a value of starting whitespace and comments. - value = strings.TrimSpace(value) - value, _, _ = strings.Cut(value, " #") - - return entry{key: key, value: value}, nil -} - -// handleTextReplacement will check a .env file entry value for text replacements, and fulfill the text replacement. -func handleTextReplacement(value *string) error { - match := textReplacementRegex.FindStringSubmatch(*value) - - for _, m := range match { - environmentValue := strings.TrimPrefix(m, "${") - environmentValue = strings.TrimSuffix(environmentValue, "}") - - replacementValue := os.Getenv(environmentValue) - if replacementValue == "" { - return &ReplacementError{VariableName: environmentValue} - } - - *value = strings.ReplaceAll(*value, m, replacementValue) - } - - return nil -} - // populateConfig populated the config struct using all environment variables. func populateConfig(config any) error { //nolint:gocognit // Complexity is reasonable. configStruct := reflect.ValueOf(config) @@ -158,12 +61,11 @@ func populateConfig(config any) error { //nolint:gocognit // Complexity is reaso field := configValue.Type().Field(i) configFieldValue := configValue.Field(i) - // Ensure the field is exported and the field is not already populated. + // Ignore fields that are not exported, or fields have a non-zero value. if !configFieldValue.CanSet() || !configFieldValue.IsZero() { continue } - // Check if tagJSON option is set. jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) if jsonOptionSet { if err := handleJSONOption(configFieldValue, jsonOptionValue); err != nil { @@ -199,51 +101,6 @@ func populateConfig(config any) error { //nolint:gocognit // Complexity is reaso return nil } -// handlePrefixOption will handle nested structures that use the prefix option. -func handlePrefixOption( - field reflect.StructField, - configFieldValue reflect.Value, - prefix string, // extendedPrefix is not zero value when a struct is deeply nested. -) error { - if field.Type.Kind() != reflect.Struct { - return nil - } - - prefixOptionValue, prefixOptionSet := field.Tag.Lookup(tagPrefix) - if !prefixOptionSet { - return &PrefixOptionError{FieldName: field.Name} - } - - if err := populateNestedConfig(configFieldValue, prefix+prefixOptionValue); err != nil { - return fmt.Errorf("populate nested config struct: %w", err) - } - - return nil -} - -// handleJSONOption will handle populating JSON structs via environment variables that are JSON. -func handleJSONOption( - configFieldValue reflect.Value, - environmentKey string, // environmentKey is not zero value when a struct is deeply nested. -) error { - if err := populateJSON(configFieldValue, environmentKey); err != nil { - return fmt.Errorf("populate JSON config struct: %w", err) - } - - return nil -} - -// populateJSON will populate the JSON struct. -func populateJSON(configFieldValue reflect.Value, environmentVariableKey string) error { - environmentValue := os.Getenv(environmentVariableKey) - - if err := json.Unmarshal([]byte(environmentValue), configFieldValue.Addr().Interface()); err != nil { - return fmt.Errorf("unmarshal json: %w", err) - } - - return nil -} - // populateNestedConfig populates a nested struct. func populateNestedConfig(nestedConfig reflect.Value, prefix string) error { for i := range nestedConfig.NumField() { @@ -306,29 +163,6 @@ func fetchEnvironmentVariable(environmentVariableKey string, field reflect.Struc return environmentVariable } -// checkRequiredOption checks if a field is required and returns an error if so. -// -// This function is only called when an environment variable is not set for a field. -func checkRequiredOption(environmentVariableKey string, field reflect.StructField) error { - requiredOptionValue, requiredOptionSet := field.Tag.Lookup(tagRequired) - if !requiredOptionSet { - return nil - } - - requiredOption, err := strconv.ParseBool(requiredOptionValue) - if requiredOption { - return &RequiredFieldError{FieldName: environmentVariableKey} - } else if err != nil { - return &InvalidOptionConversionError{ - FieldName: environmentVariableKey, - Option: tagRequired, - Err: err, - } - } - - return nil -} - // setFieldValue determines the type of a config field, and branch out to the correct // function to populate that data type. func setFieldValue( diff --git a/env_test.go b/envconfig_test.go similarity index 100% rename from env_test.go rename to envconfig_test.go diff --git a/go.mod b/go.mod index ac2d21c..450c512 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/h-dav/envconfig/v2 go 1.24 + +require github.com/google/go-cmp v0.7.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..40e761a --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/option.go b/option.go new file mode 100644 index 0000000..4f80016 --- /dev/null +++ b/option.go @@ -0,0 +1,77 @@ +package envconfig + +import ( + "encoding/json" + "fmt" + "os" + "reflect" + "strconv" +) + +// handlePrefixOption will handle nested structures that use the prefix option. +func handlePrefixOption( + field reflect.StructField, + configFieldValue reflect.Value, + prefix string, // extendedPrefix is not zero value when a struct is deeply nested. +) error { + if field.Type.Kind() != reflect.Struct { + return nil + } + + prefixOptionValue, prefixOptionSet := field.Tag.Lookup(tagPrefix) + if !prefixOptionSet { + return &PrefixOptionError{FieldName: field.Name} + } + + if err := populateNestedConfig(configFieldValue, prefix+prefixOptionValue); err != nil { + return fmt.Errorf("populate nested config struct: %w", err) + } + + return nil +} + +// handleJSONOption will handle populating JSON structs via environment variables that are JSON. +func handleJSONOption( + configFieldValue reflect.Value, + environmentKey string, // environmentKey is not zero value when a struct is deeply nested. +) error { + if err := populateJSON(configFieldValue, environmentKey); err != nil { + return fmt.Errorf("populate JSON config struct: %w", err) + } + + return nil +} + +// populateJSON will populate the JSON struct. +func populateJSON(configFieldValue reflect.Value, environmentVariableKey string) error { + environmentValue := os.Getenv(environmentVariableKey) + + if err := json.Unmarshal([]byte(environmentValue), configFieldValue.Addr().Interface()); err != nil { + return fmt.Errorf("unmarshal json: %w", err) + } + + return nil +} + +// checkRequiredOption checks if a field is required and returns an error if so. +// +// This function is only called when an environment variable is not set for a field. +func checkRequiredOption(environmentVariableKey string, field reflect.StructField) error { + requiredOptionValue, requiredOptionSet := field.Tag.Lookup(tagRequired) + if !requiredOptionSet { + return nil + } + + requiredOption, err := strconv.ParseBool(requiredOptionValue) + if requiredOption { + return &RequiredFieldError{FieldName: environmentVariableKey} + } else if err != nil { + return &InvalidOptionConversionError{ + FieldName: environmentVariableKey, + Option: tagRequired, + Err: err, + } + } + + return nil +} diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..d6069f1 --- /dev/null +++ b/parser.go @@ -0,0 +1,138 @@ +package envconfig + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +const ( + envExtension = ".env" + tomlExtensions = ".toml" +) + +type parser interface { + // parse should ingest a file and set the values as enrivonment variables. + parse(filename string) error +} + +func process(filename string) error { + parser, err := identifyParser(filename) + if err != nil { + return fmt.Errorf("identify parser: %w", err) + } + + if err := parser.parse(filename); err != nil { + return fmt.Errorf("set environment variables: %w", err) + } + return nil +} + +func identifyParser(filename string) (parser, error) { + var parser parser + + switch filepath.Ext(filename) { + case envExtension: + parser = envFileParser{} + case tomlExtensions: + parser = tomlFileParser{} + default: + return nil, &FileTypeValidationError{Filename: filename} + } + + return parser, nil + +} + +type envFileParser struct{} + +func (e envFileParser) parse(filename string) error { + file, err := os.Open(filepath.Clean(filename)) + if err != nil { + return &OpenFileError{Err: err} + } + defer file.Close() //nolint:errcheck // File closure. + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + + // Handles empty and commented lines. + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + entry, err := e.parseEnvLine(line) + if err != nil { + return fmt.Errorf("parse environment variable line: %w", err) + } + + if err = handleTextReplacement(&entry.value); err != nil { + return fmt.Errorf("handle text replacement: %w", err) + } + + if err := os.Setenv(entry.key, entry.value); err != nil { + return &SetEnvironmentVariableError{Err: err} + } + } + + if err := scanner.Err(); err != nil { + return &FileReadError{Filename: filename, Err: err} + } + + return nil +} + +// parseEnvLine parses an individual .env line, and will detect comments. +func (e envFileParser) parseEnvLine(line string) (entry, error) { + key, value, found := strings.Cut(line, "=") + if !found { + return entry{}, &ParseError{Line: line} + } + + // Clean environment variable key. + key = strings.TrimSpace(key) + + // Clean a value of starting whitespace and comments. + value = strings.TrimSpace(value) + value, _, _ = strings.Cut(value, " #") + + return entry{key: key, value: value}, nil +} + +type tomlFileParser struct{} + +func (t tomlFileParser) parse(filename string) error { + file, err := os.Open(filepath.Clean(filename)) + if err != nil { + return &OpenFileError{Err: err} + } + defer file.Close() //nolint:errcheck // File closure. + + return nil +} + +// textReplacementRegex is used to detect text replacement in environment variables. +var textReplacementRegex = regexp.MustCompile(`\${[^}]+}`) + +// handleTextReplacement will take a value and if it has `${[placeholder]}` as a substring, it will be replaced. +func handleTextReplacement(value *string) error { + match := textReplacementRegex.FindStringSubmatch(*value) + + for _, m := range match { + environmentValue := strings.TrimPrefix(m, "${") + environmentValue = strings.TrimSuffix(environmentValue, "}") + + replacementValue := os.Getenv(environmentValue) + if replacementValue == "" { + return &ReplacementError{VariableName: environmentValue} + } + + *value = strings.ReplaceAll(*value, m, replacementValue) + } + + return nil +} diff --git a/parser_internal_test.go b/parser_internal_test.go new file mode 100644 index 0000000..5158d6c --- /dev/null +++ b/parser_internal_test.go @@ -0,0 +1,64 @@ +package envconfig + +import ( + "reflect" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func Test_identifyParser(t *testing.T) { + type testCase struct { + filename string + want parser + wantErr error + } + + testCases := map[string]testCase{ + "expect env parser for env file": { + filename: "example.env", + want: envFileParser{}, + }, + "expect toml parser for env file": { + filename: "example.toml", + want: tomlFileParser{}, + }, + "expect error due to invalid file extension": { + filename: "example.invalid", + wantErr: &FileTypeValidationError{ + Filename: "example.invalid", + }, + }, + } + + for tn, tc := range testCases { + t.Run(tn, + func(t *testing.T) { + t.Parallel() + + got, err := identifyParser(tc.filename) + + if !cmp.Equal(tc.wantErr, err) { + t.Errorf("wantErr: %#v, got: %#v", tc.wantErr, err) + } + + // if !cmp.Equal(reflect.TypeOf(tc.want), reflect.TypeOf(got)) { + // t.Errorf( + // "want: %q, got: %q", + // reflect.TypeOf(tc.wantErr), + // reflect.TypeOf(got), + // ) + // } + + if reflect.TypeOf(tc.want) != reflect.TypeOf(got) { + t.Errorf( + "want: %q, got: %q", + reflect.TypeOf(tc.wantErr), + reflect.TypeOf(got), + ) + } + }, + ) + } + +} From 92a270ee6ce855088885411d274ccf02b878a622 Mon Sep 17 00:00:00 2001 From: hcdav Date: Sun, 26 Oct 2025 17:13:29 +0000 Subject: [PATCH 2/9] feat!: Change Set() signature to take option for setting filename. --- README.md | 8 ++++---- envconfig.go | 12 +++++++++--- envconfig_test.go | 28 ++++++++++++++-------------- example_test.go | 4 ++-- go.mod | 4 ++-- option.go | 13 +++++++++++++ 6 files changed, 44 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 44c4b64..e118e28 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # envconfig [![Go Reference](https://pkg.go.dev/badge/github.com/h-dav/envconfig.svg)](https://pkg.go.dev/github.com/h-dav/envconfig) -[![Go Report Card](https://goreportcard.com/badge/github.com/h-dav/envconfig/v2)](https://goreportcard.com/report/github.com/h-dav/envconfig/v2) +[![Go Report Card](https://goreportcard.com/badge/github.com/h-dav/envconfig/v3)](https://goreportcard.com/report/github.com/h-dav/envconfig/v3) [![Test](https://github.com/h-dav/envconfig/actions/workflows/test.yml/badge.svg)](https://github.com/h-dav/envconfig/actions/workflows/test.yml) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/h-dav/envconfig/blob/main/LICENSE) @@ -17,7 +17,7 @@ Package `envconfig` provides functionality to easily populate your config struct ## Installation ```bash -go get github.com/h-dav/envconfig/v2 +go get github.com/h-dav/envconfig/v3 ``` ## Features @@ -51,7 +51,7 @@ package main import ( "time" - "github.com/h-dav/envconfig/v2" + "github.com/h-dav/envconfig/v3" ) type Config struct { @@ -70,7 +70,7 @@ type Config struct { func main() { var cfg Config - if err := envconfig.Set("./config/default.env", &cfg); err != nil { + if err := envconfig.Set(&cfg, WithFilename("./config/default.env")); err != nil { ... } } diff --git a/envconfig.go b/envconfig.go index 02cf70e..4c34db3 100644 --- a/envconfig.go +++ b/envconfig.go @@ -34,9 +34,15 @@ type entry struct { // Set will parse the .env file and set the values in the environment, then populate the passed in struct // using all environment variables. -func Set(filename string, config any) error { - if filename != "" { - if err := process(filename); err != nil { +func Set(config any, opts ...Option) error { + s := &settings{} + + for _, opt := range opts { + opt(s) + } + + if s.Filename != "" { + if err := process(s.Filename); err != nil { return fmt.Errorf("parse file: %w", err) } } diff --git a/envconfig_test.go b/envconfig_test.go index 3e4a438..dafd749 100644 --- a/envconfig_test.go +++ b/envconfig_test.go @@ -5,7 +5,7 @@ import ( "testing" "time" - "github.com/h-dav/envconfig/v2" + "github.com/h-dav/envconfig/v3" ) type SuccessWithOneField struct { @@ -51,7 +51,7 @@ func TestSetWithSimpleConfigStructures(t *testing.T) { var config SuccessWithOneField - if err := envconfig.Set(tc.filename, &config); err != nil { + if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { t.Fail() } @@ -70,7 +70,7 @@ func TestSetWithSimpleConfigStructures(t *testing.T) { var config SuccessWithOneIntField - if err := envconfig.Set(tc.filename, &config); err != nil { + if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { t.Fail() } @@ -89,7 +89,7 @@ func TestSetWithSimpleConfigStructures(t *testing.T) { var config SuccessWithDefaultValueAndEmptyEnvFile - if err := envconfig.Set(tc.filename, &config); err != nil { + if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { t.Fail() } @@ -108,7 +108,7 @@ func TestSetWithSimpleConfigStructures(t *testing.T) { var config SuccessWithRequiredField - if err := envconfig.Set(tc.filename, &config); err != nil { + if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { t.Fail() } @@ -127,7 +127,7 @@ func TestSetWithSimpleConfigStructures(t *testing.T) { var config SuccessWithTextReplacement - if err := envconfig.Set(tc.filename, &config); err != nil { + if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { t.Fail() } @@ -146,7 +146,7 @@ func TestSetWithSimpleConfigStructures(t *testing.T) { var config SuccessWithSettingTimeDuration - if err := envconfig.Set(tc.filename, &config); err != nil { + if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { t.Fail() } @@ -180,7 +180,7 @@ func TestSetSuccessWithSliceStringField(t *testing.T) { var want Config want.SliceStringField = []string{"first", "second", "third"} - envconfig.Set("./test_data/success_with_slice_string_field.env", &config) + envconfig.Set(&config, envconfig.WithFilename("./test_data/success_with_slice_string_field.env")) if !slices.Equal(config.SliceStringField, want.SliceStringField) { t.Errorf("got %+v, want %+v", config, want) @@ -197,7 +197,7 @@ func TestSetSuccessWithSliceIntField(t *testing.T) { var want Config want.SliceIntField = []int{1, 2, 3} - envconfig.Set("./test_data/success_with_slice_int_field.env", &config) + envconfig.Set(&config, envconfig.WithFilename("./test_data/success_with_slice_int_field.env")) if !slices.Equal(config.SliceIntField, want.SliceIntField) { t.Errorf("got %+v, want %+v", config, want) @@ -214,7 +214,7 @@ func TestSetSuccessWithSliceFloatField(t *testing.T) { var want Config want.SliceFloatField = []float64{1.2, 2.3, 3.4} - envconfig.Set("./test_data/success_with_slice_float_field.env", &config) + envconfig.Set(&config, envconfig.WithFilename("./test_data/success_with_slice_float_field.env")) if !slices.Equal(config.SliceFloatField, want.SliceFloatField) { t.Errorf("got %+v, want %+v", config, want) @@ -235,7 +235,7 @@ func TestSetSuccessWithNestedStruct(t *testing.T) { var want Config want.Server.Port = "8080" - envconfig.Set("./test_data/success_with_nested_struct.env", &config) + envconfig.Set(&config, envconfig.WithFilename("./test_data/success_with_nested_struct.env")) if config != want { t.Errorf("got %+v, want %+v", config, want) @@ -256,7 +256,7 @@ func TestSetSuccessWithDeeplyNestedStruct(t *testing.T) { var want Config want.Server.Port.Value = "1234" - envconfig.Set("./test_data/success_with_deeply_nested_struct.env", &config) + envconfig.Set(&config, envconfig.WithFilename("./test_data/success_with_deeply_nested_struct.env")) if config != want { t.Errorf("got %+v, want %+v", config, want) @@ -281,7 +281,7 @@ func TestSetSuccessWithThriceDeeplyNestedStruct(t *testing.T) { want.Server.Database.Tables.First = "example_table" want.Server.Database.Timezome = "uk/london" - envconfig.Set("./test_data/success_with_thrice_deeply_nested_struct.env", &config) + envconfig.Set(&config, envconfig.WithFilename("./test_data/success_with_thrice_deeply_nested_struct.env")) if config != want { t.Errorf("got %+v, want %+v", config, want) @@ -302,7 +302,7 @@ func TestSetSuccessWithJsonField(t *testing.T) { var want Config want.JSONField.First = "example" - envconfig.Set("./test_data/success_with_json_field.env", &config) + envconfig.Set(&config, envconfig.WithFilename("./test_data/success_with_json_field.env")) if config != want { t.Errorf("got %+v, want %+v", config, want) diff --git a/example_test.go b/example_test.go index b25f90c..fed8012 100644 --- a/example_test.go +++ b/example_test.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "github.com/h-dav/envconfig/v2" + "github.com/h-dav/envconfig/v3" ) func ExampleSet() { @@ -16,7 +16,7 @@ func ExampleSet() { var cfg Config - envconfig.Set("", &cfg) + envconfig.Set(&cfg) fmt.Println(cfg.Value) // Output: diff --git a/go.mod b/go.mod index 450c512..564b77b 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ -module github.com/h-dav/envconfig/v2 +module github.com/h-dav/envconfig/v3 -go 1.24 +go 1.25 require github.com/google/go-cmp v0.7.0 diff --git a/option.go b/option.go index 4f80016..388079c 100644 --- a/option.go +++ b/option.go @@ -8,6 +8,19 @@ import ( "strconv" ) +type settings struct { + Filename string +} + +type Option func(*settings) + +// WithFilename option will cause the file provided to be used to set variables in the environment. +func WithFilename(filename string) Option { + return func(s *settings) { + s.Filename = filename + } +} + // handlePrefixOption will handle nested structures that use the prefix option. func handlePrefixOption( field reflect.StructField, From b59e4ffba87180b09d8d5376febc1c147c450b61 Mon Sep 17 00:00:00 2001 From: hcdav Date: Fri, 31 Oct 2025 20:34:37 +0000 Subject: [PATCH 3/9] feat: New WithPrefix option for global prefixes. As well as some general clean up. --- README.md | 13 ++++-- envconfig.go | 42 ++++++------------ envconfig_test.go | 52 +++++++++++++++++++++- option.go | 81 +++-------------------------------- parser.go | 18 +------- parser_internal_test.go | 12 ------ tag.go | 95 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 174 insertions(+), 139 deletions(-) create mode 100644 tag.go diff --git a/README.md b/README.md index e118e28..2cf1766 100644 --- a/README.md +++ b/README.md @@ -24,16 +24,23 @@ go get github.com/h-dav/envconfig/v3 ### Options +#### Tags + - `required`: `true` or `false` - `default`: Default value if environment variable is not set. - `prefix`: Used for nested structures. -- `envjson`: Used for deserialising json into config. +- `envjson`: Used for deserialising JSON into config. + +#### Other + +- WithFilename("config.env") +- WithPrefix("MY_APP_"): Prefix for every single field in your config struct when fetching from environment variables. + - Text Replacement: `${EXAMPLE}` can be used to insert other environment variables. ### Supported File Types - .env -- .toml ### Supported Data Types @@ -86,8 +93,6 @@ SLICE_INT_FIELD=1, 2, 3 DURATION=30s ``` -> [!NOTE] -> See [test cases](./env_test.go) for more usage examples. > [!NOTE] > This package takes heavy inspiration from [httputil](https://github.com/nickbryan/httputil) for handling reflection. diff --git a/envconfig.go b/envconfig.go index 4c34db3..47f3ee2 100644 --- a/envconfig.go +++ b/envconfig.go @@ -10,24 +10,6 @@ import ( "time" ) -const ( - // tagEnv is used for fetching the environment variable by name. - tagEnv = "env" - - // tagDefault is used to set a fallback value for a config field if the environment variable is not set. - tagDefault = "default" - - // tagRequired is used for config struct fields that are required. If the environment variable is not set, an - // error will be returned. - tagRequired = "required" - - // tagJSON is used for environment variables that are JSON. - tagJSON = "envjson" - - // tagPrefix is used for nested structs inside your config struct. - tagPrefix = "prefix" -) - type entry struct { key, value string } @@ -41,13 +23,13 @@ func Set(config any, opts ...Option) error { opt(s) } - if s.Filename != "" { - if err := process(s.Filename); err != nil { + if s.filename != "" { + if err := process(s.filename); err != nil { return fmt.Errorf("parse file: %w", err) } } - if err := populateConfig(config); err != nil { + if err := s.populateConfig(config); err != nil { return fmt.Errorf("populate config struct: %w", err) } @@ -55,9 +37,9 @@ func Set(config any, opts ...Option) error { } // populateConfig populated the config struct using all environment variables. -func populateConfig(config any) error { //nolint:gocognit // Complexity is reasonable. +func (s settings) populateConfig(config any) error { //nolint:gocognit // Complexity is reasonable. configStruct := reflect.ValueOf(config) - if configStruct.Kind() != reflect.Ptr || configStruct.Elem().Kind() != reflect.Struct { + if configStruct.Kind() != reflect.Pointer || configStruct.Elem().Kind() != reflect.Struct { return &InvalidConfigTypeError{ProvidedType: config} } @@ -74,14 +56,14 @@ func populateConfig(config any) error { //nolint:gocognit // Complexity is reaso jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) if jsonOptionSet { - if err := handleJSONOption(configFieldValue, jsonOptionValue); err != nil { + if err := handleJSONTag(configFieldValue, jsonOptionValue); err != nil { return fmt.Errorf("handle JSON option: %w", err) } continue } - if err := handlePrefixOption(field, configFieldValue, ""); err != nil { + if err := handlePrefixTag(field, configFieldValue, ""); err != nil { return fmt.Errorf("handle prefix option: %w", err) } @@ -90,9 +72,9 @@ func populateConfig(config any) error { //nolint:gocognit // Complexity is reaso continue } - environmentVariable := fetchEnvironmentVariable(environmentVariableKey, field) + environmentVariable := fetchEnvironmentVariable(s.prefix+environmentVariableKey, field) if environmentVariable == "" { - if err := checkRequiredOption(environmentVariableKey, field); err != nil { + if err := checkRequiredTag(environmentVariableKey, field); err != nil { return fmt.Errorf("check required option: %w", err) } @@ -119,7 +101,7 @@ func populateNestedConfig(nestedConfig reflect.Value, prefix string) error { jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) if jsonOptionSet { - err := handleJSONOption(configFieldValue, prefix+jsonOptionValue) + err := handleJSONTag(configFieldValue, prefix+jsonOptionValue) if err != nil { return fmt.Errorf("handle JSON option: %w", err) } @@ -127,7 +109,7 @@ func populateNestedConfig(nestedConfig reflect.Value, prefix string) error { continue } - if err := handlePrefixOption(field, configFieldValue, prefix); err != nil { + if err := handlePrefixTag(field, configFieldValue, prefix); err != nil { return fmt.Errorf("handle prefix option: %w", err) } @@ -138,7 +120,7 @@ func populateNestedConfig(nestedConfig reflect.Value, prefix string) error { environmentValue := fetchEnvironmentVariable(environmentVariableKey, field) if environmentValue == "" { - if err := checkRequiredOption(environmentVariableKey, field); err != nil { + if err := checkRequiredTag(environmentVariableKey, field); err != nil { return fmt.Errorf("check required option: %w", err) } diff --git a/envconfig_test.go b/envconfig_test.go index dafd749..77b0762 100644 --- a/envconfig_test.go +++ b/envconfig_test.go @@ -1,6 +1,7 @@ package envconfig_test import ( + "os" "slices" "testing" "time" @@ -31,9 +32,13 @@ type SuccessWithSettingTimeDuration struct { Duration time.Duration `env:"DURATION"` } -// TestSetWithSimpleConfigStructures is test cases for simple use cases, +type SuccessWithPrefixOption struct { + Duration time.Duration `env:"DURATION"` +} + +// TestSetWithFilename is test cases for simple use cases, // such as flat config structures and fundamental fields, like required, and default. -func TestSetWithSimpleConfigStructures(t *testing.T) { +func TestSetWithFilename(t *testing.T) { type testCase struct { filename string want any @@ -168,6 +173,49 @@ func TestSetWithSimpleConfigStructures(t *testing.T) { } } +func TestSetWithPrefix(t *testing.T) { + type testCase struct { + want any + assert func(*testing.T, testCase) + } + + testCases := map[string]testCase{ + "success with prefix option": { + want: SuccessWithPrefixOption{ + Duration: 10000000000, + }, + assert: func(t *testing.T, tc testCase) { + t.Helper() + + os.Setenv("PREFIX_DURATION", "10s") + + var config SuccessWithPrefixOption + + if err := envconfig.Set( + &config, + envconfig.WithPrefix("PREFIX_"), + ); err != nil { + t.Fail() + } + + if config != tc.want { + t.Errorf("got %+v, want %+v", config, tc.want) + } + }, + }, + } + + for tn, tc := range testCases { + t.Run(tn, + func(t *testing.T) { + t.Parallel() + + tc.assert(t, tc) + }, + ) + } +} + // Slice test cases. func TestSetSuccessWithSliceStringField(t *testing.T) { diff --git a/option.go b/option.go index 388079c..eaf5752 100644 --- a/option.go +++ b/option.go @@ -1,15 +1,8 @@ package envconfig -import ( - "encoding/json" - "fmt" - "os" - "reflect" - "strconv" -) - type settings struct { - Filename string + filename string + prefix string } type Option func(*settings) @@ -17,74 +10,14 @@ type Option func(*settings) // WithFilename option will cause the file provided to be used to set variables in the environment. func WithFilename(filename string) Option { return func(s *settings) { - s.Filename = filename + s.filename = filename } } -// handlePrefixOption will handle nested structures that use the prefix option. -func handlePrefixOption( - field reflect.StructField, - configFieldValue reflect.Value, - prefix string, // extendedPrefix is not zero value when a struct is deeply nested. -) error { - if field.Type.Kind() != reflect.Struct { - return nil - } - - prefixOptionValue, prefixOptionSet := field.Tag.Lookup(tagPrefix) - if !prefixOptionSet { - return &PrefixOptionError{FieldName: field.Name} - } - - if err := populateNestedConfig(configFieldValue, prefix+prefixOptionValue); err != nil { - return fmt.Errorf("populate nested config struct: %w", err) - } - - return nil -} - -// handleJSONOption will handle populating JSON structs via environment variables that are JSON. -func handleJSONOption( - configFieldValue reflect.Value, - environmentKey string, // environmentKey is not zero value when a struct is deeply nested. -) error { - if err := populateJSON(configFieldValue, environmentKey); err != nil { - return fmt.Errorf("populate JSON config struct: %w", err) - } - - return nil -} - -// populateJSON will populate the JSON struct. -func populateJSON(configFieldValue reflect.Value, environmentVariableKey string) error { - environmentValue := os.Getenv(environmentVariableKey) - - if err := json.Unmarshal([]byte(environmentValue), configFieldValue.Addr().Interface()); err != nil { - return fmt.Errorf("unmarshal json: %w", err) +// WithPrefix option will add the prefix to before every set and retrieval to and from env. +func WithPrefix(prefix string) Option { + return func(s *settings) { + s.prefix = prefix } - - return nil } -// checkRequiredOption checks if a field is required and returns an error if so. -// -// This function is only called when an environment variable is not set for a field. -func checkRequiredOption(environmentVariableKey string, field reflect.StructField) error { - requiredOptionValue, requiredOptionSet := field.Tag.Lookup(tagRequired) - if !requiredOptionSet { - return nil - } - - requiredOption, err := strconv.ParseBool(requiredOptionValue) - if requiredOption { - return &RequiredFieldError{FieldName: environmentVariableKey} - } else if err != nil { - return &InvalidOptionConversionError{ - FieldName: environmentVariableKey, - Option: tagRequired, - Err: err, - } - } - - return nil -} diff --git a/parser.go b/parser.go index d6069f1..f108a3d 100644 --- a/parser.go +++ b/parser.go @@ -11,11 +11,10 @@ import ( const ( envExtension = ".env" - tomlExtensions = ".toml" ) type parser interface { - // parse should ingest a file and set the values as enrivonment variables. + // parse should ingest a file and set the values as environment variables. parse(filename string) error } @@ -37,14 +36,11 @@ func identifyParser(filename string) (parser, error) { switch filepath.Ext(filename) { case envExtension: parser = envFileParser{} - case tomlExtensions: - parser = tomlFileParser{} default: return nil, &FileTypeValidationError{Filename: filename} } return parser, nil - } type envFileParser struct{} @@ -103,18 +99,6 @@ func (e envFileParser) parseEnvLine(line string) (entry, error) { return entry{key: key, value: value}, nil } -type tomlFileParser struct{} - -func (t tomlFileParser) parse(filename string) error { - file, err := os.Open(filepath.Clean(filename)) - if err != nil { - return &OpenFileError{Err: err} - } - defer file.Close() //nolint:errcheck // File closure. - - return nil -} - // textReplacementRegex is used to detect text replacement in environment variables. var textReplacementRegex = regexp.MustCompile(`\${[^}]+}`) diff --git a/parser_internal_test.go b/parser_internal_test.go index 5158d6c..27c28fd 100644 --- a/parser_internal_test.go +++ b/parser_internal_test.go @@ -19,10 +19,6 @@ func Test_identifyParser(t *testing.T) { filename: "example.env", want: envFileParser{}, }, - "expect toml parser for env file": { - filename: "example.toml", - want: tomlFileParser{}, - }, "expect error due to invalid file extension": { filename: "example.invalid", wantErr: &FileTypeValidationError{ @@ -42,14 +38,6 @@ func Test_identifyParser(t *testing.T) { t.Errorf("wantErr: %#v, got: %#v", tc.wantErr, err) } - // if !cmp.Equal(reflect.TypeOf(tc.want), reflect.TypeOf(got)) { - // t.Errorf( - // "want: %q, got: %q", - // reflect.TypeOf(tc.wantErr), - // reflect.TypeOf(got), - // ) - // } - if reflect.TypeOf(tc.want) != reflect.TypeOf(got) { t.Errorf( "want: %q, got: %q", diff --git a/tag.go b/tag.go new file mode 100644 index 0000000..2ee57bc --- /dev/null +++ b/tag.go @@ -0,0 +1,95 @@ +package envconfig + +import ( + "encoding/json" + "fmt" + "os" + "reflect" + "strconv" +) + +const ( + // tagEnv is used for fetching the environment variable by name. + tagEnv = "env" + + // tagDefault is used to set a fallback value for a config field if the environment variable is not set. + tagDefault = "default" + + // tagRequired is used for config struct fields that are required. If the environment variable is not set, an + // error will be returned. + tagRequired = "required" + + // tagJSON is used for environment variables that are JSON. + tagJSON = "envjson" + + // tagPrefix is used for nested structs inside your config struct. + tagPrefix = "prefix" +) + +// handlePrefixTag will handle nested structures that use the prefix option. +func handlePrefixTag( + field reflect.StructField, + configFieldValue reflect.Value, + prefix string, // prefix is not zero value when a struct is deeply nested. +) error { + if field.Type.Kind() != reflect.Struct { + return nil + } + + prefixOptionValue, prefixOptionSet := field.Tag.Lookup(tagPrefix) + if !prefixOptionSet { + return &PrefixOptionError{FieldName: field.Name} + } + + if err := populateNestedConfig(configFieldValue, prefix+prefixOptionValue); err != nil { + return fmt.Errorf("populate nested config struct: %w", err) + } + + return nil +} + +// handleJSONTag will handle populating JSON structs via environment variables that are JSON. +func handleJSONTag( + configFieldValue reflect.Value, + environmentKey string, // environmentKey is not zero value when a struct is deeply nested. +) error { + if err := populateJSON(configFieldValue, environmentKey); err != nil { + return fmt.Errorf("populate JSON config struct: %w", err) + } + + return nil +} + +// populateJSON will populate the JSON struct. +func populateJSON(configFieldValue reflect.Value, environmentVariableKey string) error { + environmentValue := os.Getenv(environmentVariableKey) + + if err := json.Unmarshal([]byte(environmentValue), configFieldValue.Addr().Interface()); err != nil { + return fmt.Errorf("unmarshal json: %w", err) + } + + return nil +} + +// checkRequiredTag checks if a field is required and returns an error if so. +// +// This function is only called when an environment variable is not set for a field. +func checkRequiredTag(environmentVariableKey string, field reflect.StructField) error { + requiredOptionValue, requiredOptionSet := field.Tag.Lookup(tagRequired) + if !requiredOptionSet { + return nil + } + + requiredOption, err := strconv.ParseBool(requiredOptionValue) + if requiredOption { + return &RequiredFieldError{FieldName: environmentVariableKey} + } else if err != nil { + return &InvalidOptionConversionError{ + FieldName: environmentVariableKey, + Option: tagRequired, + Err: err, + } + } + + return nil +} From be6c18cec4d1293231877b713f0c08b9a5f0c84d Mon Sep 17 00:00:00 2001 From: hcdav Date: Fri, 7 Nov 2025 17:47:33 +0000 Subject: [PATCH 4/9] feat: WIP: Make WithFilename option set values directly in config struct rather than in env. --- envconfig.go | 4 +- envconfig_test.go | 117 +++++++++++++++++++++++++++------------- option.go | 6 +-- parser.go | 133 ++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 208 insertions(+), 52 deletions(-) diff --git a/envconfig.go b/envconfig.go index 47f3ee2..b050324 100644 --- a/envconfig.go +++ b/envconfig.go @@ -16,7 +16,7 @@ type entry struct { // Set will parse the .env file and set the values in the environment, then populate the passed in struct // using all environment variables. -func Set(config any, opts ...Option) error { +func Set(config any, opts ...option) error { s := &settings{} for _, opt := range opts { @@ -24,7 +24,7 @@ func Set(config any, opts ...Option) error { } if s.filename != "" { - if err := process(s.filename); err != nil { + if err := process(config, s.filename); err != nil { return fmt.Errorf("parse file: %w", err) } } diff --git a/envconfig_test.go b/envconfig_test.go index 77b0762..8eaf626 100644 --- a/envconfig_test.go +++ b/envconfig_test.go @@ -1,8 +1,11 @@ package envconfig_test import ( + "bufio" + "log" "os" "slices" + "strings" "testing" "time" @@ -36,9 +39,9 @@ type SuccessWithPrefixOption struct { Duration time.Duration `env:"DURATION"` } -// TestSetWithFilename is test cases for simple use cases, +// TestSet is test cases for simple use cases, // such as flat config structures and fundamental fields, like required, and default. -func TestSetWithFilename(t *testing.T) { +func TestSet(t *testing.T) { type testCase struct { filename string want any @@ -56,7 +59,7 @@ func TestSetWithFilename(t *testing.T) { var config SuccessWithOneField - if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + if err := envconfig.Set(&config); err != nil { t.Fail() } @@ -75,7 +78,7 @@ func TestSetWithFilename(t *testing.T) { var config SuccessWithOneIntField - if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + if err := envconfig.Set(&config); err != nil { t.Fail() } @@ -94,7 +97,7 @@ func TestSetWithFilename(t *testing.T) { var config SuccessWithDefaultValueAndEmptyEnvFile - if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + if err := envconfig.Set(&config); err != nil { t.Fail() } @@ -113,26 +116,7 @@ func TestSetWithFilename(t *testing.T) { var config SuccessWithRequiredField - if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { - t.Fail() - } - - if config != tc.want { - t.Errorf("got %+v, want %+v", config, tc.want) - } - }, - }, - "success with text replacement": { - filename: "./test_data/success_with_text_replacement.env", - want: SuccessWithTextReplacement{ - ReplaceField: "exampleField", - }, - assert: func(t *testing.T, tc testCase) { - t.Helper() - - var config SuccessWithTextReplacement - - if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + if err := envconfig.Set(&config); err != nil { t.Fail() } @@ -141,6 +125,25 @@ func TestSetWithFilename(t *testing.T) { } }, }, + // "success with text replacement": { + // filename: "./test_data/success_with_text_replacement.env", + // want: SuccessWithTextReplacement{ + // ReplaceField: "exampleField", + // }, + // assert: func(t *testing.T, tc testCase) { + // t.Helper() + // + // var config SuccessWithTextReplacement + // + // if err := envconfig.Set(&config); err != nil { + // t.Fail() + // } + // + // if config != tc.want { + // t.Errorf("got %+v, want %+v", config, tc.want) + // } + // }, + // }, "success with setting time.Duration": { filename: "./test_data/success_with_setting_time_Duration.env", want: SuccessWithSettingTimeDuration{ @@ -151,7 +154,7 @@ func TestSetWithFilename(t *testing.T) { var config SuccessWithSettingTimeDuration - if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + if err := envconfig.Set(&config); err != nil { t.Fail() } @@ -167,16 +170,45 @@ func TestSetWithFilename(t *testing.T) { func(t *testing.T) { t.Parallel() + loadFileIntoEnvironmentVariables(tc.filename) + tc.assert(t, tc) }, ) } } +func loadFileIntoEnvironmentVariables(filename string) { + file, err := os.Open(filename) + if err != nil { + log.Fatal(err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + key, value, _ := strings.Cut(scanner.Text(), "=") + + // Clean environment variable key. + key = strings.TrimSpace(key) + + // Clean a value of starting whitespace and comments. + value = strings.TrimSpace(value) + value, _, _ = strings.Cut(value, " #") + os.Setenv(key, value) + } + + if err := scanner.Err(); err != nil { + log.Fatal(err) + } + + return +} + func TestSetWithPrefix(t *testing.T) { type testCase struct { - want any - assert func(*testing.T, testCase) + want any + assert func(*testing.T, testCase) } testCases := map[string]testCase{ @@ -192,7 +224,7 @@ func TestSetWithPrefix(t *testing.T) { var config SuccessWithPrefixOption if err := envconfig.Set( - &config, + &config, envconfig.WithPrefix("PREFIX_"), ); err != nil { t.Fail() @@ -228,7 +260,9 @@ func TestSetSuccessWithSliceStringField(t *testing.T) { var want Config want.SliceStringField = []string{"first", "second", "third"} - envconfig.Set(&config, envconfig.WithFilename("./test_data/success_with_slice_string_field.env")) + loadFileIntoEnvironmentVariables("./test_data/success_with_slice_string_field.env") + + envconfig.Set(&config) if !slices.Equal(config.SliceStringField, want.SliceStringField) { t.Errorf("got %+v, want %+v", config, want) @@ -245,7 +279,9 @@ func TestSetSuccessWithSliceIntField(t *testing.T) { var want Config want.SliceIntField = []int{1, 2, 3} - envconfig.Set(&config, envconfig.WithFilename("./test_data/success_with_slice_int_field.env")) + loadFileIntoEnvironmentVariables("./test_data/success_with_slice_int_field.env") + + envconfig.Set(&config) if !slices.Equal(config.SliceIntField, want.SliceIntField) { t.Errorf("got %+v, want %+v", config, want) @@ -262,7 +298,9 @@ func TestSetSuccessWithSliceFloatField(t *testing.T) { var want Config want.SliceFloatField = []float64{1.2, 2.3, 3.4} - envconfig.Set(&config, envconfig.WithFilename("./test_data/success_with_slice_float_field.env")) + loadFileIntoEnvironmentVariables("./test_data/success_with_slice_float_field.env") + + envconfig.Set(&config) if !slices.Equal(config.SliceFloatField, want.SliceFloatField) { t.Errorf("got %+v, want %+v", config, want) @@ -283,7 +321,9 @@ func TestSetSuccessWithNestedStruct(t *testing.T) { var want Config want.Server.Port = "8080" - envconfig.Set(&config, envconfig.WithFilename("./test_data/success_with_nested_struct.env")) + loadFileIntoEnvironmentVariables("./test_data/success_with_nested_struct.env") + + envconfig.Set(&config) if config != want { t.Errorf("got %+v, want %+v", config, want) @@ -304,7 +344,9 @@ func TestSetSuccessWithDeeplyNestedStruct(t *testing.T) { var want Config want.Server.Port.Value = "1234" - envconfig.Set(&config, envconfig.WithFilename("./test_data/success_with_deeply_nested_struct.env")) + loadFileIntoEnvironmentVariables("./test_data/success_with_deeply_nested_struct.env") + + envconfig.Set(&config) if config != want { t.Errorf("got %+v, want %+v", config, want) @@ -329,7 +371,9 @@ func TestSetSuccessWithThriceDeeplyNestedStruct(t *testing.T) { want.Server.Database.Tables.First = "example_table" want.Server.Database.Timezome = "uk/london" - envconfig.Set(&config, envconfig.WithFilename("./test_data/success_with_thrice_deeply_nested_struct.env")) + loadFileIntoEnvironmentVariables("./test_data/success_with_thrice_deeply_nested_struct.env") + + envconfig.Set(&config) if config != want { t.Errorf("got %+v, want %+v", config, want) @@ -349,8 +393,9 @@ func TestSetSuccessWithJsonField(t *testing.T) { var want Config want.JSONField.First = "example" + loadFileIntoEnvironmentVariables("./test_data/success_with_json_field.env") - envconfig.Set(&config, envconfig.WithFilename("./test_data/success_with_json_field.env")) + envconfig.Set(&config) if config != want { t.Errorf("got %+v, want %+v", config, want) diff --git a/option.go b/option.go index eaf5752..f110d50 100644 --- a/option.go +++ b/option.go @@ -5,17 +5,17 @@ type settings struct { prefix string } -type Option func(*settings) +type option func(*settings) // WithFilename option will cause the file provided to be used to set variables in the environment. -func WithFilename(filename string) Option { +func WithFilename(filename string) option { return func(s *settings) { s.filename = filename } } // WithPrefix option will add the prefix to before every set and retrieval to and from env. -func WithPrefix(prefix string) Option { +func WithPrefix(prefix string) option { return func(s *settings) { s.prefix = prefix } diff --git a/parser.go b/parser.go index f108a3d..bc5540f 100644 --- a/parser.go +++ b/parser.go @@ -2,29 +2,31 @@ package envconfig import ( "bufio" + "encoding/json" "fmt" "os" "path/filepath" + "reflect" "regexp" "strings" ) const ( - envExtension = ".env" + envExtension = ".env" ) type parser interface { - // parse should ingest a file and set the values as environment variables. - parse(filename string) error + // parse should ingest a file and set the values in config. + parse(config any, filename string) error } -func process(filename string) error { +func process(config any, filename string) error { parser, err := identifyParser(filename) if err != nil { return fmt.Errorf("identify parser: %w", err) } - if err := parser.parse(filename); err != nil { + if err := parser.parse(config, filename); err != nil { return fmt.Errorf("set environment variables: %w", err) } return nil @@ -35,7 +37,9 @@ func identifyParser(filename string) (parser, error) { switch filepath.Ext(filename) { case envExtension: - parser = envFileParser{} + parser = envFileParser{ + config: map[string]string{}, + } default: return nil, &FileTypeValidationError{Filename: filename} } @@ -43,9 +47,11 @@ func identifyParser(filename string) (parser, error) { return parser, nil } -type envFileParser struct{} +type envFileParser struct { + config map[string]string +} -func (e envFileParser) parse(filename string) error { +func (e envFileParser) parse(config any, filename string) error { file, err := os.Open(filepath.Clean(filename)) if err != nil { return &OpenFileError{Err: err} @@ -70,9 +76,11 @@ func (e envFileParser) parse(filename string) error { return fmt.Errorf("handle text replacement: %w", err) } - if err := os.Setenv(entry.key, entry.value); err != nil { - return &SetEnvironmentVariableError{Err: err} - } + e.config[entry.key] = entry.value + } + + if err := e.setFromFileConfig(config); err != nil { + return fmt.Errorf("set config from file: %w", err) } if err := scanner.Err(); err != nil { @@ -82,6 +90,109 @@ func (e envFileParser) parse(filename string) error { return nil } +func (e envFileParser) setFromFileConfig(config any) error { + configStruct := reflect.ValueOf(config) + if configStruct.Kind() != reflect.Pointer || configStruct.Elem().Kind() != reflect.Struct { + return &InvalidConfigTypeError{ProvidedType: config} + } + + configValue := reflect.ValueOf(config).Elem() + + for i := range configValue.NumField() { + field := configValue.Type().Field(i) + configFieldValue := configValue.Field(i) + + // Ignore fields that are not exported. + if !configFieldValue.CanSet() { + continue + } + + jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) + if jsonOptionSet { + err := json.Unmarshal([]byte(e.config[jsonOptionValue]), &configFieldValue) + if err != nil { + return fmt.Errorf("handle JSON option: %w", err) + } + + continue + } + + if err := e.handlePrefixTag(field, configFieldValue, ""); err != nil { + return fmt.Errorf("handle prefix option: %w", err) + } + + environmentVariableKey := field.Tag.Get(tagEnv) + if environmentVariableKey == "" { + continue + } + + if err := setFieldValue( + configFieldValue, entry{environmentVariableKey, e.config[environmentVariableKey]}); err != nil { + return fmt.Errorf("set field value: %w", err) + } + } + + return nil +} + +func (e envFileParser) handlePrefixTag( + field reflect.StructField, + configFieldValue reflect.Value, + prefix string, +) error { + if field.Type.Kind() != reflect.Struct { + return nil + } + + prefixOptionValue, prefixOptionSet := field.Tag.Lookup(tagPrefix) + if !prefixOptionSet { + return &PrefixOptionError{FieldName: field.Name} + } + + if err := e.populateNestedConfig(configFieldValue, prefix+prefixOptionValue); err != nil { + return fmt.Errorf("populate nested config struct: %w", err) + } + + return nil +} + +// populateNestedConfig populates a nested struct. +func (e envFileParser) populateNestedConfig(nestedConfig reflect.Value, prefix string) error { + for i := range nestedConfig.NumField() { + field := nestedConfig.Type().Field(i) + configFieldValue := nestedConfig.Field(i) + + if !configFieldValue.CanSet() || !configFieldValue.IsZero() { + continue + } + + jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) + if jsonOptionSet { + err := json.Unmarshal([]byte(e.config[jsonOptionValue]), &configFieldValue) + if err != nil { + return fmt.Errorf("handle JSON option: %w", err) + } + + continue + } + + if err := e.handlePrefixTag(field, configFieldValue, prefix); err != nil { + return fmt.Errorf("handle prefix option: %w", err) + } + + environmentVariableKey := prefix + field.Tag.Get(tagEnv) + if environmentVariableKey == prefix { // Ensure tag is set. + continue + } + if err := setFieldValue( + configFieldValue, entry{environmentVariableKey, e.config[environmentVariableKey]}); err != nil { + return fmt.Errorf("set field value: %w", err) + } + } + + return nil +} + // parseEnvLine parses an individual .env line, and will detect comments. func (e envFileParser) parseEnvLine(line string) (entry, error) { key, value, found := strings.Cut(line, "=") From e800099228177d611ed6d226b7954abf30696acf Mon Sep 17 00:00:00 2001 From: hcdav Date: Fri, 7 Nov 2025 20:31:00 +0000 Subject: [PATCH 5/9] style: Refactoring environment and filename logic. --- envconfig.go | 296 +----------------- envconfig_test.go | 38 +-- environment_variable.go | 162 ++++++++++ parser.go => file.go | 75 ++--- ..._internal_test.go => file_internal_test.go | 3 +- option_test.go | 152 +++++++++ setter.go | 178 +++++++++++ tag.go | 47 ++- 8 files changed, 578 insertions(+), 373 deletions(-) create mode 100644 environment_variable.go rename parser.go => file.go (67%) rename parser_internal_test.go => file_internal_test.go (94%) create mode 100644 option_test.go create mode 100644 setter.go diff --git a/envconfig.go b/envconfig.go index b050324..79dd333 100644 --- a/envconfig.go +++ b/envconfig.go @@ -1,13 +1,8 @@ -// Package envconfig provides functionality to easily populate your config structure by using both environment variables, and a config file (optional). +// Package envconfig provides functionality to easily load config into your struct. package envconfig import ( "fmt" - "os" - "reflect" - "strconv" - "strings" - "time" ) type entry struct { @@ -24,299 +19,14 @@ func Set(config any, opts ...option) error { } if s.filename != "" { - if err := process(config, s.filename); err != nil { + if err := s.processFilename(config); err != nil { return fmt.Errorf("parse file: %w", err) } } - if err := s.populateConfig(config); err != nil { + if err := s.processEnvironmentVariables(config); err != nil { return fmt.Errorf("populate config struct: %w", err) } return nil } - -// populateConfig populated the config struct using all environment variables. -func (s settings) populateConfig(config any) error { //nolint:gocognit // Complexity is reasonable. - configStruct := reflect.ValueOf(config) - if configStruct.Kind() != reflect.Pointer || configStruct.Elem().Kind() != reflect.Struct { - return &InvalidConfigTypeError{ProvidedType: config} - } - - configValue := reflect.ValueOf(config).Elem() - - for i := range configValue.NumField() { - field := configValue.Type().Field(i) - configFieldValue := configValue.Field(i) - - // Ignore fields that are not exported, or fields have a non-zero value. - if !configFieldValue.CanSet() || !configFieldValue.IsZero() { - continue - } - - jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) - if jsonOptionSet { - if err := handleJSONTag(configFieldValue, jsonOptionValue); err != nil { - return fmt.Errorf("handle JSON option: %w", err) - } - - continue - } - - if err := handlePrefixTag(field, configFieldValue, ""); err != nil { - return fmt.Errorf("handle prefix option: %w", err) - } - - environmentVariableKey := field.Tag.Get(tagEnv) - if environmentVariableKey == "" { - continue - } - - environmentVariable := fetchEnvironmentVariable(s.prefix+environmentVariableKey, field) - if environmentVariable == "" { - if err := checkRequiredTag(environmentVariableKey, field); err != nil { - return fmt.Errorf("check required option: %w", err) - } - - continue - } - - if err := setFieldValue(configFieldValue, entry{environmentVariableKey, environmentVariable}); err != nil { - return fmt.Errorf("set field value: %w", err) - } - } - - return nil -} - -// populateNestedConfig populates a nested struct. -func populateNestedConfig(nestedConfig reflect.Value, prefix string) error { - for i := range nestedConfig.NumField() { - field := nestedConfig.Type().Field(i) - configFieldValue := nestedConfig.Field(i) - - if !configFieldValue.CanSet() || !configFieldValue.IsZero() { - continue - } - - jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) - if jsonOptionSet { - err := handleJSONTag(configFieldValue, prefix+jsonOptionValue) - if err != nil { - return fmt.Errorf("handle JSON option: %w", err) - } - - continue - } - - if err := handlePrefixTag(field, configFieldValue, prefix); err != nil { - return fmt.Errorf("handle prefix option: %w", err) - } - - environmentVariableKey := prefix + field.Tag.Get(tagEnv) - if environmentVariableKey == prefix { // Ensure tag is set. - continue - } - - environmentValue := fetchEnvironmentVariable(environmentVariableKey, field) - if environmentValue == "" { - if err := checkRequiredTag(environmentVariableKey, field); err != nil { - return fmt.Errorf("check required option: %w", err) - } - - continue - } - - if err := setFieldValue(configFieldValue, entry{environmentVariableKey, environmentValue}); err != nil { - return fmt.Errorf("set field value: %w", err) - } - } - - return nil -} - -// fetchEnvironmentVariable returns the environment variable value. This also handles the default option tag. -func fetchEnvironmentVariable(environmentVariableKey string, field reflect.StructField) string { - environmentVariable := os.Getenv(environmentVariableKey) - - if environmentVariable != "" { - return environmentVariable - } - - defaultOptionValue, defaultOptionSet := field.Tag.Lookup(tagDefault) - if defaultOptionSet { - return defaultOptionValue - } - - return environmentVariable -} - -// setFieldValue determines the type of a config field, and branch out to the correct -// function to populate that data type. -func setFieldValue( - configFieldValue reflect.Value, - entry entry, -) error { - switch configFieldValue.Interface().(type) { - case string: - configFieldValue.SetString(entry.value) - case int: - return setIntFieldValue(configFieldValue, entry) - case bool: - return setBoolFieldValue(configFieldValue, entry) - case float64: - return setFloatFieldValue(configFieldValue, entry) - case []string: - return setStringSliceFieldValue(configFieldValue, entry.value) - case []int: - return setIntSliceFieldValue(configFieldValue, entry) - case []float64: - return setFloatSliceFieldValue(configFieldValue, entry) - case time.Duration: - return setDurationFieldValue(configFieldValue, entry) - default: - return &UnsupportedFieldTypeError{FieldType: configFieldValue.Interface()} - } - - return nil -} - -func setIntFieldValue( - configFieldValue reflect.Value, - entry entry, -) error { - intValue, err := strconv.Atoi(entry.value) - if err != nil { - return &FieldConversionError{ - FieldName: entry.key, - TargetType: "int", - Err: err, - } - } - - configFieldValue.SetInt(int64(intValue)) - - return nil -} - -func setBoolFieldValue( - configFieldValue reflect.Value, - entry entry, -) error { - boolValue, err := strconv.ParseBool(entry.value) - if err != nil { - return &FieldConversionError{ - FieldName: entry.key, - TargetType: "bool", - Err: err, - } - } - - configFieldValue.SetBool(boolValue) - - return nil -} - -func setFloatFieldValue( - configFieldValue reflect.Value, - entry entry, -) error { - floatValue, err := strconv.ParseFloat(entry.value, 64) - if err != nil { - return &FieldConversionError{ - FieldName: entry.key, - TargetType: "float", - Err: err, - } - } - - configFieldValue.SetFloat(floatValue) - - return nil -} - -func setStringSliceFieldValue(configFieldValue reflect.Value, environmentValue string) error { - values := strings.Split(environmentValue, ",") - slice := reflect.MakeSlice(configFieldValue.Type(), len(values), len(values)) - - for i, v := range values { - v = strings.TrimSpace(v) - slice.Index(i).SetString(v) - } - - configFieldValue.Set(slice) - - return nil -} - -func setIntSliceFieldValue( - configFieldValue reflect.Value, - entry entry, -) error { - values := strings.Split(entry.value, ",") - slice := reflect.MakeSlice(configFieldValue.Type(), len(values), len(values)) - - for i, v := range values { - v = strings.TrimSpace(v) - - parsed, err := strconv.Atoi(v) - if err != nil { - return &FieldConversionError{ - FieldName: entry.key, - TargetType: "[]int", - Err: err, - } - } - - slice.Index(i).SetInt(int64(parsed)) - } - - configFieldValue.Set(slice) - - return nil -} - -func setFloatSliceFieldValue( - configFieldValue reflect.Value, - entry entry, -) error { - values := strings.Split(entry.value, ",") - slice := reflect.MakeSlice(configFieldValue.Type(), len(values), len(values)) - - for i, v := range values { - v = strings.TrimSpace(v) - - parsed, err := strconv.ParseFloat(v, 64) - if err != nil { - return &FieldConversionError{ - FieldName: entry.key, - TargetType: "[]float64", - Err: err, - } - } - - slice.Index(i).SetFloat(parsed) - } - - configFieldValue.Set(slice) - - return nil -} - -func setDurationFieldValue( - configFieldValue reflect.Value, - entry entry, -) error { - durationValue, err := time.ParseDuration(entry.value) - if err != nil { - return &FieldConversionError{ - FieldName: entry.key, - TargetType: "time.Duration", - Err: err, - } - } - - configFieldValue.Set(reflect.ValueOf(durationValue)) - - return nil -} diff --git a/envconfig_test.go b/envconfig_test.go index 8eaf626..082c00a 100644 --- a/envconfig_test.go +++ b/envconfig_test.go @@ -125,25 +125,25 @@ func TestSet(t *testing.T) { } }, }, - // "success with text replacement": { - // filename: "./test_data/success_with_text_replacement.env", - // want: SuccessWithTextReplacement{ - // ReplaceField: "exampleField", - // }, - // assert: func(t *testing.T, tc testCase) { - // t.Helper() - // - // var config SuccessWithTextReplacement - // - // if err := envconfig.Set(&config); err != nil { - // t.Fail() - // } - // - // if config != tc.want { - // t.Errorf("got %+v, want %+v", config, tc.want) - // } - // }, - // }, + "success with text replacement": { + filename: "./test_data/success_with_text_replacement.env", + want: SuccessWithTextReplacement{ + ReplaceField: "exampleField", + }, + assert: func(t *testing.T, tc testCase) { + t.Helper() + + var config SuccessWithTextReplacement + + if err := envconfig.Set(&config); err != nil { + t.Fail() + } + + if config != tc.want { + t.Errorf("got %+v, want %+v", config, tc.want) + } + }, + }, "success with setting time.Duration": { filename: "./test_data/success_with_setting_time_Duration.env", want: SuccessWithSettingTimeDuration{ diff --git a/environment_variable.go b/environment_variable.go new file mode 100644 index 0000000..9b39013 --- /dev/null +++ b/environment_variable.go @@ -0,0 +1,162 @@ +package envconfig + +import ( + "fmt" + "os" + "reflect" + "strings" +) + +// processEnvironmentVariables populates the config struct using all environment variables. +func (s settings) processEnvironmentVariables(config any) error { //nolint:gocognit // Complexity is reasonable. + e := environmentVariableParser{ + prefix: s.prefix, + } + + if err := e.parse(config); err != nil { + return err + } + + return nil +} + +type environmentVariableParser struct { + prefix string +} + +func (e environmentVariableParser) parse(config any) error { + configStruct := reflect.ValueOf(config) + if configStruct.Kind() != reflect.Pointer || configStruct.Elem().Kind() != reflect.Struct { + return &InvalidConfigTypeError{ProvidedType: config} + } + + configValue := reflect.ValueOf(config).Elem() + + for i := range configValue.NumField() { + field := configValue.Type().Field(i) + configFieldValue := configValue.Field(i) + + // Ignore fields that are not exported, or fields have a non-zero value. + if !configFieldValue.CanSet() || !configFieldValue.IsZero() { + continue + } + + jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) + if jsonOptionSet { + if err := e.handleJSONTag(configFieldValue, jsonOptionValue); err != nil { + return fmt.Errorf("handle JSON tag: %w", err) + } + + continue + } + + if err := e.handlePrefixTag(field, configFieldValue, ""); err != nil { + return fmt.Errorf("handle prefix tag: %w", err) + } + + environmentVariableKey := field.Tag.Get(tagEnv) + if environmentVariableKey == "" { + continue + } + + environmentVariable := e.fetchEnvironmentVariable(e.prefix+environmentVariableKey, field) + if environmentVariable == "" { + if err := checkRequiredTag(environmentVariableKey, field); err != nil { + return fmt.Errorf("check required tag: %w", err) + } + + continue + } + + if err := e.handleTextReplacement(&environmentVariable); err != nil { + return fmt.Errorf("handle text replacement: %w", err) + } + + if err := setFieldValue(configFieldValue, entry{environmentVariableKey, environmentVariable}); err != nil { + return fmt.Errorf("set field value: %w", err) + } + } + + return nil +} + + +// populateNestedConfig populates a nested struct. +func (e environmentVariableParser) populateNestedConfig(nestedConfig reflect.Value, prefix string) error { + for i := range nestedConfig.NumField() { + field := nestedConfig.Type().Field(i) + configFieldValue := nestedConfig.Field(i) + + if !configFieldValue.CanSet() || !configFieldValue.IsZero() { + continue + } + + jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) + if jsonOptionSet { + err := e.handleJSONTag(configFieldValue, prefix+jsonOptionValue) + if err != nil { + return fmt.Errorf("handle JSON option: %w", err) + } + + continue + } + + if err := e.handlePrefixTag(field, configFieldValue, prefix); err != nil { + return fmt.Errorf("handle prefix option: %w", err) + } + + environmentVariableKey := prefix + field.Tag.Get(tagEnv) + if environmentVariableKey == prefix { // Ensure tag is set. + continue + } + + environmentValue := e.fetchEnvironmentVariable(environmentVariableKey, field) + if environmentValue == "" { + if err := checkRequiredTag(environmentVariableKey, field); err != nil { + return fmt.Errorf("check required option: %w", err) + } + + continue + } + + if err := setFieldValue(configFieldValue, entry{environmentVariableKey, environmentValue}); err != nil { + return fmt.Errorf("set field value: %w", err) + } + } + + return nil +} + +// fetchEnvironmentVariable returns the environment variable value. This also handles the default option tag. +func (e environmentVariableParser) fetchEnvironmentVariable(environmentVariableKey string, field reflect.StructField) string { + environmentVariable := os.Getenv(environmentVariableKey) + + if environmentVariable != "" { + return environmentVariable + } + + defaultOptionValue, defaultOptionSet := field.Tag.Lookup(tagDefault) + if defaultOptionSet { + return defaultOptionValue + } + + return environmentVariable +} + +func (e environmentVariableParser) handleTextReplacement(value *string) error { + match := textReplacementRegex.FindStringSubmatch(*value) + + for _, m := range match { + environmentValue := strings.TrimPrefix(m, "${") + environmentValue = strings.TrimSuffix(environmentValue, "}") + + replacementValue := os.Getenv(environmentValue) + if replacementValue == "" { + return &ReplacementError{VariableName: environmentValue} + } + + *value = strings.ReplaceAll(*value, m, replacementValue) + } + + return nil +} diff --git a/parser.go b/file.go similarity index 67% rename from parser.go rename to file.go index bc5540f..4ec78f3 100644 --- a/parser.go +++ b/file.go @@ -16,29 +16,30 @@ const ( ) type parser interface { - // parse should ingest a file and set the values in config. - parse(config any, filename string) error + // parse should populate a config struct. + parse(config any) error } -func process(config any, filename string) error { - parser, err := identifyParser(filename) +func (s settings) processFilename(config any) error { + parser, err := identifyFileParser(s.filename) if err != nil { - return fmt.Errorf("identify parser: %w", err) + return fmt.Errorf("identify file parser: %w", err) } - if err := parser.parse(config, filename); err != nil { - return fmt.Errorf("set environment variables: %w", err) + if err := parser.parse(config); err != nil { + return fmt.Errorf("set config variables: %w", err) } return nil } -func identifyParser(filename string) (parser, error) { +func identifyFileParser(filename string) (parser, error) { var parser parser switch filepath.Ext(filename) { case envExtension: parser = envFileParser{ config: map[string]string{}, + filename: filename, } default: return nil, &FileTypeValidationError{Filename: filename} @@ -49,10 +50,11 @@ func identifyParser(filename string) (parser, error) { type envFileParser struct { config map[string]string + filename string } -func (e envFileParser) parse(config any, filename string) error { - file, err := os.Open(filepath.Clean(filename)) +func (e envFileParser) parse(config any) error { + file, err := os.Open(filepath.Clean(e.filename)) if err != nil { return &OpenFileError{Err: err} } @@ -67,30 +69,30 @@ func (e envFileParser) parse(config any, filename string) error { continue } - entry, err := e.parseEnvLine(line) + entry, err := e.parseLine(line) if err != nil { - return fmt.Errorf("parse environment variable line: %w", err) + return fmt.Errorf("parse line: %w", err) } - if err = handleTextReplacement(&entry.value); err != nil { + if err = e.handleTextReplacement(&entry.value); err != nil { return fmt.Errorf("handle text replacement: %w", err) } e.config[entry.key] = entry.value } - if err := e.setFromFileConfig(config); err != nil { + if err := e.populateFromFileConfig(config); err != nil { return fmt.Errorf("set config from file: %w", err) } if err := scanner.Err(); err != nil { - return &FileReadError{Filename: filename, Err: err} + return &FileReadError{Filename: e.filename, Err: err} } return nil } -func (e envFileParser) setFromFileConfig(config any) error { +func (e envFileParser) populateFromFileConfig(config any) error { configStruct := reflect.ValueOf(config) if configStruct.Kind() != reflect.Pointer || configStruct.Elem().Kind() != reflect.Struct { return &InvalidConfigTypeError{ProvidedType: config} @@ -109,16 +111,14 @@ func (e envFileParser) setFromFileConfig(config any) error { jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) if jsonOptionSet { - err := json.Unmarshal([]byte(e.config[jsonOptionValue]), &configFieldValue) - if err != nil { - return fmt.Errorf("handle JSON option: %w", err) + if err := e.handleJSONTag(configFieldValue, jsonOptionValue); err != nil { + return fmt.Errorf("handle JSON tag: %w", err) } - continue } if err := e.handlePrefixTag(field, configFieldValue, ""); err != nil { - return fmt.Errorf("handle prefix option: %w", err) + return fmt.Errorf("handle prefix tag: %w", err) } environmentVariableKey := field.Tag.Get(tagEnv) @@ -135,27 +135,6 @@ func (e envFileParser) setFromFileConfig(config any) error { return nil } -func (e envFileParser) handlePrefixTag( - field reflect.StructField, - configFieldValue reflect.Value, - prefix string, -) error { - if field.Type.Kind() != reflect.Struct { - return nil - } - - prefixOptionValue, prefixOptionSet := field.Tag.Lookup(tagPrefix) - if !prefixOptionSet { - return &PrefixOptionError{FieldName: field.Name} - } - - if err := e.populateNestedConfig(configFieldValue, prefix+prefixOptionValue); err != nil { - return fmt.Errorf("populate nested config struct: %w", err) - } - - return nil -} - // populateNestedConfig populates a nested struct. func (e envFileParser) populateNestedConfig(nestedConfig reflect.Value, prefix string) error { for i := range nestedConfig.NumField() { @@ -170,14 +149,14 @@ func (e envFileParser) populateNestedConfig(nestedConfig reflect.Value, prefix s if jsonOptionSet { err := json.Unmarshal([]byte(e.config[jsonOptionValue]), &configFieldValue) if err != nil { - return fmt.Errorf("handle JSON option: %w", err) + return fmt.Errorf("handle JSON tag: %w", err) } continue } if err := e.handlePrefixTag(field, configFieldValue, prefix); err != nil { - return fmt.Errorf("handle prefix option: %w", err) + return fmt.Errorf("handle prefix tag: %w", err) } environmentVariableKey := prefix + field.Tag.Get(tagEnv) @@ -193,8 +172,8 @@ func (e envFileParser) populateNestedConfig(nestedConfig reflect.Value, prefix s return nil } -// parseEnvLine parses an individual .env line, and will detect comments. -func (e envFileParser) parseEnvLine(line string) (entry, error) { +// parseLine parses an individual .env line, and will detect comments. +func (e envFileParser) parseLine(line string) (entry, error) { key, value, found := strings.Cut(line, "=") if !found { return entry{}, &ParseError{Line: line} @@ -214,14 +193,14 @@ func (e envFileParser) parseEnvLine(line string) (entry, error) { var textReplacementRegex = regexp.MustCompile(`\${[^}]+}`) // handleTextReplacement will take a value and if it has `${[placeholder]}` as a substring, it will be replaced. -func handleTextReplacement(value *string) error { +func (e envFileParser) handleTextReplacement(value *string) error { match := textReplacementRegex.FindStringSubmatch(*value) for _, m := range match { environmentValue := strings.TrimPrefix(m, "${") environmentValue = strings.TrimSuffix(environmentValue, "}") - replacementValue := os.Getenv(environmentValue) + replacementValue := e.config[environmentValue] if replacementValue == "" { return &ReplacementError{VariableName: environmentValue} } diff --git a/parser_internal_test.go b/file_internal_test.go similarity index 94% rename from parser_internal_test.go rename to file_internal_test.go index 27c28fd..e595782 100644 --- a/parser_internal_test.go +++ b/file_internal_test.go @@ -32,7 +32,7 @@ func Test_identifyParser(t *testing.T) { func(t *testing.T) { t.Parallel() - got, err := identifyParser(tc.filename) + got, err := identifyFileParser(tc.filename) if !cmp.Equal(tc.wantErr, err) { t.Errorf("wantErr: %#v, got: %#v", tc.wantErr, err) @@ -48,5 +48,4 @@ func Test_identifyParser(t *testing.T) { }, ) } - } diff --git a/option_test.go b/option_test.go new file mode 100644 index 0000000..2e7f11d --- /dev/null +++ b/option_test.go @@ -0,0 +1,152 @@ +package envconfig_test + +import ( + "testing" + + "github.com/h-dav/envconfig/v3" +) + +// type SuccessWithOneField struct { +// Example string `env:"KEY"` +// } +// type SuccessWithOneIntField struct { +// Example int `env:"KEY"` +// } +// +// type SuccessWithDefaultValueAndEmptyEnvFile struct { +// Example string `env:"DEFAULT_VALUE" default:"value2"` +// } +// +// type SuccessWithRequiredField struct { +// Example string `env:"REQUIRED_VALUE" required:"true"` +// } +// +// type SuccessWithTextReplacement struct { +// ReplaceField string `env:"REPLACE_FIELD"` +// } +// +// type SuccessWithSettingTimeDuration struct { +// Duration time.Duration `env:"DURATION"` +// } +// +// type SuccessWithPrefixOption struct { +// Duration time.Duration `env:"DURATION"` +// } + +// TestSetWithFilename is test cases for simple use cases, +// such as flat config structures and fundamental fields, like required, and default. +func TestSetWithFilename(t *testing.T) { + type testCase struct { + filename string + want any + assert func(*testing.T, testCase) + } + + testCases := map[string]testCase{ + "success with one field": { + filename: "./test_data/success_with_one_field.env", + want: SuccessWithOneField{ + Example: "value1", + }, + assert: func(t *testing.T, tc testCase) { + t.Helper() + + var config SuccessWithOneField + + if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + t.Fail() + } + + if config != tc.want { + t.Errorf("got %+v, want %+v", config, tc.want) + } + }, + }, + "success with one int field": { + filename: "./test_data/success_with_one_int_field.env", + want: SuccessWithOneIntField{ + Example: 10, + }, + assert: func(t *testing.T, tc testCase) { + t.Helper() + + var config SuccessWithOneIntField + + if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + t.Fail() + } + + if config != tc.want { + t.Errorf("got %+v, want %+v", config, tc.want) + } + }, + }, + "success with default value and empty env file": { + filename: "./test_data/success_with_one_default_value_and_empty_env_file.env", + want: SuccessWithDefaultValueAndEmptyEnvFile{ + Example: "value2", + }, + assert: func(t *testing.T, tc testCase) { + t.Helper() + + var config SuccessWithDefaultValueAndEmptyEnvFile + + if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + t.Fail() + } + + if config != tc.want { + t.Errorf("got %+v, want %+v", config, tc.want) + } + }, + }, + "success with text replacement": { + filename: "./test_data/success_with_text_replacement.env", + want: SuccessWithTextReplacement{ + ReplaceField: "exampleField", + }, + assert: func(t *testing.T, tc testCase) { + t.Helper() + + var config SuccessWithTextReplacement + + if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + t.Fail() + } + + if config != tc.want { + t.Errorf("got %+v, want %+v", config, tc.want) + } + }, + }, + "success with setting time.Duration": { + filename: "./test_data/success_with_setting_time_Duration.env", + want: SuccessWithSettingTimeDuration{ + Duration: 10000000000, + }, + assert: func(t *testing.T, tc testCase) { + t.Helper() + + var config SuccessWithSettingTimeDuration + + if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + t.Fail() + } + + if config != tc.want { + t.Errorf("got %+v, want %+v", config, tc.want) + } + }, + }, + } + + for tn, tc := range testCases { + t.Run(tn, + func(t *testing.T) { + t.Parallel() + + tc.assert(t, tc) + }, + ) + } +} diff --git a/setter.go b/setter.go new file mode 100644 index 0000000..c61ee8b --- /dev/null +++ b/setter.go @@ -0,0 +1,178 @@ +package envconfig + +import ( + "reflect" + "strconv" + "strings" + "time" +) + +// setFieldValue determines the type of a config field, and branch out to the correct +// function to populate that data type. +func setFieldValue( + configFieldValue reflect.Value, + entry entry, +) error { + switch configFieldValue.Interface().(type) { + case string: + configFieldValue.SetString(entry.value) + case int: + return setIntFieldValue(configFieldValue, entry) + case bool: + return setBoolFieldValue(configFieldValue, entry) + case float64: + return setFloatFieldValue(configFieldValue, entry) + case []string: + return setStringSliceFieldValue(configFieldValue, entry.value) + case []int: + return setIntSliceFieldValue(configFieldValue, entry) + case []float64: + return setFloatSliceFieldValue(configFieldValue, entry) + case time.Duration: + return setDurationFieldValue(configFieldValue, entry) + default: + return &UnsupportedFieldTypeError{FieldType: configFieldValue.Interface()} + } + + return nil +} + +func setIntFieldValue( + configFieldValue reflect.Value, + entry entry, +) error { + intValue, err := strconv.Atoi(entry.value) + if err != nil { + return &FieldConversionError{ + FieldName: entry.key, + TargetType: "int", + Err: err, + } + } + + configFieldValue.SetInt(int64(intValue)) + + return nil +} + +func setBoolFieldValue( + configFieldValue reflect.Value, + entry entry, +) error { + boolValue, err := strconv.ParseBool(entry.value) + if err != nil { + return &FieldConversionError{ + FieldName: entry.key, + TargetType: "bool", + Err: err, + } + } + + configFieldValue.SetBool(boolValue) + + return nil +} + +func setFloatFieldValue( + configFieldValue reflect.Value, + entry entry, +) error { + floatValue, err := strconv.ParseFloat(entry.value, 64) + if err != nil { + return &FieldConversionError{ + FieldName: entry.key, + TargetType: "float", + Err: err, + } + } + + configFieldValue.SetFloat(floatValue) + + return nil +} + +func setStringSliceFieldValue(configFieldValue reflect.Value, environmentValue string) error { + values := strings.Split(environmentValue, ",") + slice := reflect.MakeSlice(configFieldValue.Type(), len(values), len(values)) + + for i, v := range values { + v = strings.TrimSpace(v) + slice.Index(i).SetString(v) + } + + configFieldValue.Set(slice) + + return nil +} + +func setIntSliceFieldValue( + configFieldValue reflect.Value, + entry entry, +) error { + values := strings.Split(entry.value, ",") + slice := reflect.MakeSlice(configFieldValue.Type(), len(values), len(values)) + + for i, v := range values { + v = strings.TrimSpace(v) + + parsed, err := strconv.Atoi(v) + if err != nil { + return &FieldConversionError{ + FieldName: entry.key, + TargetType: "[]int", + Err: err, + } + } + + slice.Index(i).SetInt(int64(parsed)) + } + + configFieldValue.Set(slice) + + return nil +} + +func setFloatSliceFieldValue( + configFieldValue reflect.Value, + entry entry, +) error { + values := strings.Split(entry.value, ",") + slice := reflect.MakeSlice(configFieldValue.Type(), len(values), len(values)) + + for i, v := range values { + v = strings.TrimSpace(v) + + parsed, err := strconv.ParseFloat(v, 64) + if err != nil { + return &FieldConversionError{ + FieldName: entry.key, + TargetType: "[]float64", + Err: err, + } + } + + slice.Index(i).SetFloat(parsed) + } + + configFieldValue.Set(slice) + + return nil +} + +func setDurationFieldValue( + configFieldValue reflect.Value, + entry entry, +) error { + durationValue, err := time.ParseDuration(entry.value) + if err != nil { + return &FieldConversionError{ + FieldName: entry.key, + TargetType: "time.Duration", + Err: err, + } + } + + configFieldValue.Set(reflect.ValueOf(durationValue)) + + return nil +} diff --git a/tag.go b/tag.go index 2ee57bc..abc6985 100644 --- a/tag.go +++ b/tag.go @@ -27,7 +27,7 @@ const ( ) // handlePrefixTag will handle nested structures that use the prefix option. -func handlePrefixTag( +func (e environmentVariableParser) handlePrefixTag( field reflect.StructField, configFieldValue reflect.Value, prefix string, // prefix is not zero value when a struct is deeply nested. @@ -41,31 +41,56 @@ func handlePrefixTag( return &PrefixOptionError{FieldName: field.Name} } - if err := populateNestedConfig(configFieldValue, prefix+prefixOptionValue); err != nil { + if err := e.populateNestedConfig(configFieldValue, prefix+prefixOptionValue); err != nil { return fmt.Errorf("populate nested config struct: %w", err) } return nil } -// handleJSONTag will handle populating JSON structs via environment variables that are JSON. -func handleJSONTag( +func (e envFileParser) handlePrefixTag( + field reflect.StructField, configFieldValue reflect.Value, - environmentKey string, // environmentKey is not zero value when a struct is deeply nested. + prefix string, ) error { - if err := populateJSON(configFieldValue, environmentKey); err != nil { - return fmt.Errorf("populate JSON config struct: %w", err) + if field.Type.Kind() != reflect.Struct { + return nil + } + + prefixOptionValue, prefixOptionSet := field.Tag.Lookup(tagPrefix) + if !prefixOptionSet { + return &PrefixOptionError{FieldName: field.Name} + } + + if err := e.populateNestedConfig(configFieldValue, prefix+prefixOptionValue); err != nil { + return fmt.Errorf("populate nested config struct: %w", err) } return nil } -// populateJSON will populate the JSON struct. -func populateJSON(configFieldValue reflect.Value, environmentVariableKey string) error { - environmentValue := os.Getenv(environmentVariableKey) +// handleJSONTag will handle populating JSON structs via environment variables that are JSON. +func (e environmentVariableParser) handleJSONTag( + configFieldValue reflect.Value, + environmentKey string, // environmentKey is not zero value when a struct is deeply nested. +) error { + environmentValue := os.Getenv(environmentKey) if err := json.Unmarshal([]byte(environmentValue), configFieldValue.Addr().Interface()); err != nil { - return fmt.Errorf("unmarshal json: %w", err) + return fmt.Errorf("unmarshal JSON: %w", err) + } + + return nil +} + +// handleJSONTag will handle populating JSON structs via environment variables that are JSON. +func (e envFileParser) handleJSONTag( + configFieldValue reflect.Value, + environmentKey string, // environmentKey is not zero value when a struct is deeply nested. +) error { + err := json.Unmarshal([]byte(e.config[environmentKey]), &configFieldValue) + if err != nil { + return fmt.Errorf("unmarshal JSON: %w", err) } return nil From 24ec169386c48e84d7fac457c033a4dc8c777cad Mon Sep 17 00:00:00 2001 From: hcdav Date: Fri, 7 Nov 2025 21:48:28 +0000 Subject: [PATCH 6/9] refactor: Change structure from settings config directly from sources, instead create a temporary config and draw from that. --- envconfig.go | 127 ++++++++++++++++++++++++++++++++-- environment_variable.go | 147 ++++------------------------------------ file.go | 141 +++++--------------------------------- flag.go | 15 ++++ option.go | 1 + option_test.go | 1 - tag.go | 86 +++++------------------ 7 files changed, 186 insertions(+), 332 deletions(-) create mode 100644 flag.go diff --git a/envconfig.go b/envconfig.go index 79dd333..a519b77 100644 --- a/envconfig.go +++ b/envconfig.go @@ -2,30 +2,147 @@ package envconfig import ( + "encoding/json" "fmt" + "reflect" + "regexp" + "strings" ) type entry struct { key, value string } +// textReplacementRegex is used to detect text replacement in environment variables. +var textReplacementRegex = regexp.MustCompile(`\${[^}]+}`) + // Set will parse the .env file and set the values in the environment, then populate the passed in struct // using all environment variables. func Set(config any, opts ...option) error { - s := &settings{} + s := &settings{ + source: map[string]string{}, + } for _, opt := range opts { opt(s) } if s.filename != "" { - if err := s.processFilename(config); err != nil { - return fmt.Errorf("parse file: %w", err) + if err := s.processFilename(); err != nil { + return fmt.Errorf("process file: %w", err) } } - if err := s.processEnvironmentVariables(config); err != nil { - return fmt.Errorf("populate config struct: %w", err) + if err := s.processEnvironmentVariables(); err != nil { + return fmt.Errorf("process environment variables: %w", err) + } + + if err := s.populateConfig(config); err != nil { + return fmt.Errorf("populate config: %w", err) + } + + return nil +} + +func (s settings) populateConfig(config any) error { + configStruct := reflect.ValueOf(config) + if configStruct.Kind() != reflect.Pointer || configStruct.Elem().Kind() != reflect.Struct { + return &InvalidConfigTypeError{ProvidedType: config} + } + + configValue := reflect.ValueOf(config).Elem() + + for i := range configValue.NumField() { + field := configValue.Type().Field(i) + configFieldValue := configValue.Field(i) + + // Ignore fields that are not exported. + if !configFieldValue.CanSet() { + continue + } + + jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) + if jsonOptionSet { + err := json.Unmarshal([]byte(s.source[jsonOptionValue]), configFieldValue.Addr().Interface()) + if err != nil { + return fmt.Errorf("unmarshal JSON: %w", err) + } + continue + } + + if err := handlePrefixTag(field, configFieldValue, "", s.source); err != nil { + return fmt.Errorf("handle prefix tag: %w", err) + } + + environmentVariableKey := field.Tag.Get(tagEnv) + if environmentVariableKey == "" { + continue + } + + value := s.source[environmentVariableKey] + if value == "" { + if err := checkRequiredTag(environmentVariableKey, field); err != nil { + return fmt.Errorf("check required tag: %w", err) + } + + value = field.Tag.Get(tagDefault) + } + + match := textReplacementRegex.FindStringSubmatch(value) + + for _, m := range match { + environmentValue := strings.TrimPrefix(m, "${") + environmentValue = strings.TrimSuffix(environmentValue, "}") + + replacementValue := s.source[environmentValue] + if replacementValue == "" { + return &ReplacementError{VariableName: environmentValue} + } + + value = strings.ReplaceAll(value, m, replacementValue) + } + + if err := setFieldValue( + configFieldValue, entry{environmentVariableKey, value}); err != nil { + return fmt.Errorf("set field value: %w", err) + } + } + + return nil +} + +// populateNestedConfig populates a nested struct. +func populateNestedConfig(nestedConfig reflect.Value, prefix string, source map[string]string) error { + for i := range nestedConfig.NumField() { + field := nestedConfig.Type().Field(i) + configFieldValue := nestedConfig.Field(i) + + if !configFieldValue.CanSet() || !configFieldValue.IsZero() { + continue + } + + jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) + if jsonOptionSet { + err := json.Unmarshal([]byte(source[jsonOptionValue]), &configFieldValue) + if err != nil { + return fmt.Errorf("handle JSON tag: %w", err) + } + + continue + } + + if err := handlePrefixTag(field, configFieldValue, prefix, source); err != nil { + return fmt.Errorf("handle prefix tag: %w", err) + } + + environmentVariableKey := prefix + field.Tag.Get(tagEnv) + if environmentVariableKey == prefix { // Ensure tag is set. + continue + } + if err := setFieldValue( + configFieldValue, entry{environmentVariableKey, source[environmentVariableKey]}); err != nil { + return fmt.Errorf("set field value: %w", err) + } } return nil diff --git a/environment_variable.go b/environment_variable.go index 9b39013..ae66f4a 100644 --- a/environment_variable.go +++ b/environment_variable.go @@ -1,162 +1,43 @@ package envconfig import ( - "fmt" "os" - "reflect" "strings" ) // processEnvironmentVariables populates the config struct using all environment variables. -func (s settings) processEnvironmentVariables(config any) error { //nolint:gocognit // Complexity is reasonable. +func (s *settings) processEnvironmentVariables() error { //nolint:gocognit // Complexity is reasonable. e := environmentVariableParser{ prefix: s.prefix, + source: map[string]string{}, } - if err := e.parse(config); err != nil { + source, err := e.parse() + if err != nil { return err } + s.source = source + return nil } type environmentVariableParser struct { prefix string + source map[string]string } -func (e environmentVariableParser) parse(config any) error { - configStruct := reflect.ValueOf(config) - if configStruct.Kind() != reflect.Pointer || configStruct.Elem().Kind() != reflect.Struct { - return &InvalidConfigTypeError{ProvidedType: config} - } - - configValue := reflect.ValueOf(config).Elem() - - for i := range configValue.NumField() { - field := configValue.Type().Field(i) - configFieldValue := configValue.Field(i) - - // Ignore fields that are not exported, or fields have a non-zero value. - if !configFieldValue.CanSet() || !configFieldValue.IsZero() { - continue - } - - jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) - if jsonOptionSet { - if err := e.handleJSONTag(configFieldValue, jsonOptionValue); err != nil { - return fmt.Errorf("handle JSON tag: %w", err) - } +func (e environmentVariableParser) parse() (map[string]string, error) { + all := os.Environ() + for _, val := range all { + key, value, found := strings.Cut(val, "=") + if !found { continue } - if err := e.handlePrefixTag(field, configFieldValue, ""); err != nil { - return fmt.Errorf("handle prefix tag: %w", err) - } - - environmentVariableKey := field.Tag.Get(tagEnv) - if environmentVariableKey == "" { - continue - } - - environmentVariable := e.fetchEnvironmentVariable(e.prefix+environmentVariableKey, field) - if environmentVariable == "" { - if err := checkRequiredTag(environmentVariableKey, field); err != nil { - return fmt.Errorf("check required tag: %w", err) - } - - continue - } - - if err := e.handleTextReplacement(&environmentVariable); err != nil { - return fmt.Errorf("handle text replacement: %w", err) - } - - if err := setFieldValue(configFieldValue, entry{environmentVariableKey, environmentVariable}); err != nil { - return fmt.Errorf("set field value: %w", err) - } + e.source[key] = value } - return nil -} - - -// populateNestedConfig populates a nested struct. -func (e environmentVariableParser) populateNestedConfig(nestedConfig reflect.Value, prefix string) error { - for i := range nestedConfig.NumField() { - field := nestedConfig.Type().Field(i) - configFieldValue := nestedConfig.Field(i) - - if !configFieldValue.CanSet() || !configFieldValue.IsZero() { - continue - } - - jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) - if jsonOptionSet { - err := e.handleJSONTag(configFieldValue, prefix+jsonOptionValue) - if err != nil { - return fmt.Errorf("handle JSON option: %w", err) - } - - continue - } - - if err := e.handlePrefixTag(field, configFieldValue, prefix); err != nil { - return fmt.Errorf("handle prefix option: %w", err) - } - - environmentVariableKey := prefix + field.Tag.Get(tagEnv) - if environmentVariableKey == prefix { // Ensure tag is set. - continue - } - - environmentValue := e.fetchEnvironmentVariable(environmentVariableKey, field) - if environmentValue == "" { - if err := checkRequiredTag(environmentVariableKey, field); err != nil { - return fmt.Errorf("check required option: %w", err) - } - - continue - } - - if err := setFieldValue(configFieldValue, entry{environmentVariableKey, environmentValue}); err != nil { - return fmt.Errorf("set field value: %w", err) - } - } - - return nil -} - -// fetchEnvironmentVariable returns the environment variable value. This also handles the default option tag. -func (e environmentVariableParser) fetchEnvironmentVariable(environmentVariableKey string, field reflect.StructField) string { - environmentVariable := os.Getenv(environmentVariableKey) - - if environmentVariable != "" { - return environmentVariable - } - - defaultOptionValue, defaultOptionSet := field.Tag.Lookup(tagDefault) - if defaultOptionSet { - return defaultOptionValue - } - - return environmentVariable -} - -func (e environmentVariableParser) handleTextReplacement(value *string) error { - match := textReplacementRegex.FindStringSubmatch(*value) - - for _, m := range match { - environmentValue := strings.TrimPrefix(m, "${") - environmentValue = strings.TrimSuffix(environmentValue, "}") - - replacementValue := os.Getenv(environmentValue) - if replacementValue == "" { - return &ReplacementError{VariableName: environmentValue} - } - - *value = strings.ReplaceAll(*value, m, replacementValue) - } - - return nil + return e.source, nil } diff --git a/file.go b/file.go index 4ec78f3..26926c3 100644 --- a/file.go +++ b/file.go @@ -2,12 +2,9 @@ package envconfig import ( "bufio" - "encoding/json" "fmt" "os" "path/filepath" - "reflect" - "regexp" "strings" ) @@ -17,18 +14,22 @@ const ( type parser interface { // parse should populate a config struct. - parse(config any) error + parse() (map[string]string, error) } -func (s settings) processFilename(config any) error { +func (s *settings) processFilename() error { parser, err := identifyFileParser(s.filename) if err != nil { return fmt.Errorf("identify file parser: %w", err) } - if err := parser.parse(config); err != nil { - return fmt.Errorf("set config variables: %w", err) + source, err := parser.parse() + if err != nil { + return fmt.Errorf("parse file: %w", err) } + + s.source = source + return nil } @@ -38,7 +39,7 @@ func identifyFileParser(filename string) (parser, error) { switch filepath.Ext(filename) { case envExtension: parser = envFileParser{ - config: map[string]string{}, + source: map[string]string{}, filename: filename, } default: @@ -49,14 +50,14 @@ func identifyFileParser(filename string) (parser, error) { } type envFileParser struct { - config map[string]string + source map[string]string filename string } -func (e envFileParser) parse(config any) error { +func (e envFileParser) parse() (map[string]string, error) { file, err := os.Open(filepath.Clean(e.filename)) if err != nil { - return &OpenFileError{Err: err} + return make(map[string]string), &OpenFileError{Err: err} } defer file.Close() //nolint:errcheck // File closure. @@ -71,105 +72,17 @@ func (e envFileParser) parse(config any) error { entry, err := e.parseLine(line) if err != nil { - return fmt.Errorf("parse line: %w", err) - } - - if err = e.handleTextReplacement(&entry.value); err != nil { - return fmt.Errorf("handle text replacement: %w", err) + return make(map[string]string), fmt.Errorf("parse line: %w", err) } - e.config[entry.key] = entry.value - } - - if err := e.populateFromFileConfig(config); err != nil { - return fmt.Errorf("set config from file: %w", err) + e.source[entry.key] = entry.value } if err := scanner.Err(); err != nil { - return &FileReadError{Filename: e.filename, Err: err} + return make(map[string]string), &FileReadError{Filename: e.filename, Err: err} } - return nil -} - -func (e envFileParser) populateFromFileConfig(config any) error { - configStruct := reflect.ValueOf(config) - if configStruct.Kind() != reflect.Pointer || configStruct.Elem().Kind() != reflect.Struct { - return &InvalidConfigTypeError{ProvidedType: config} - } - - configValue := reflect.ValueOf(config).Elem() - - for i := range configValue.NumField() { - field := configValue.Type().Field(i) - configFieldValue := configValue.Field(i) - - // Ignore fields that are not exported. - if !configFieldValue.CanSet() { - continue - } - - jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) - if jsonOptionSet { - if err := e.handleJSONTag(configFieldValue, jsonOptionValue); err != nil { - return fmt.Errorf("handle JSON tag: %w", err) - } - continue - } - - if err := e.handlePrefixTag(field, configFieldValue, ""); err != nil { - return fmt.Errorf("handle prefix tag: %w", err) - } - - environmentVariableKey := field.Tag.Get(tagEnv) - if environmentVariableKey == "" { - continue - } - - if err := setFieldValue( - configFieldValue, entry{environmentVariableKey, e.config[environmentVariableKey]}); err != nil { - return fmt.Errorf("set field value: %w", err) - } - } - - return nil -} - -// populateNestedConfig populates a nested struct. -func (e envFileParser) populateNestedConfig(nestedConfig reflect.Value, prefix string) error { - for i := range nestedConfig.NumField() { - field := nestedConfig.Type().Field(i) - configFieldValue := nestedConfig.Field(i) - - if !configFieldValue.CanSet() || !configFieldValue.IsZero() { - continue - } - - jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) - if jsonOptionSet { - err := json.Unmarshal([]byte(e.config[jsonOptionValue]), &configFieldValue) - if err != nil { - return fmt.Errorf("handle JSON tag: %w", err) - } - - continue - } - - if err := e.handlePrefixTag(field, configFieldValue, prefix); err != nil { - return fmt.Errorf("handle prefix tag: %w", err) - } - - environmentVariableKey := prefix + field.Tag.Get(tagEnv) - if environmentVariableKey == prefix { // Ensure tag is set. - continue - } - if err := setFieldValue( - configFieldValue, entry{environmentVariableKey, e.config[environmentVariableKey]}); err != nil { - return fmt.Errorf("set field value: %w", err) - } - } - - return nil + return e.source, nil } // parseLine parses an individual .env line, and will detect comments. @@ -188,25 +101,3 @@ func (e envFileParser) parseLine(line string) (entry, error) { return entry{key: key, value: value}, nil } - -// textReplacementRegex is used to detect text replacement in environment variables. -var textReplacementRegex = regexp.MustCompile(`\${[^}]+}`) - -// handleTextReplacement will take a value and if it has `${[placeholder]}` as a substring, it will be replaced. -func (e envFileParser) handleTextReplacement(value *string) error { - match := textReplacementRegex.FindStringSubmatch(*value) - - for _, m := range match { - environmentValue := strings.TrimPrefix(m, "${") - environmentValue = strings.TrimSuffix(environmentValue, "}") - - replacementValue := e.config[environmentValue] - if replacementValue == "" { - return &ReplacementError{VariableName: environmentValue} - } - - *value = strings.ReplaceAll(*value, m, replacementValue) - } - - return nil -} diff --git a/flag.go b/flag.go new file mode 100644 index 0000000..02ed4c0 --- /dev/null +++ b/flag.go @@ -0,0 +1,15 @@ +package envconfig + +import ( + "flag" +) + +func (s *settings) processFlags(_ any) error { + flag.Parse() + + flag.Visit(func(f *flag.Flag) { + s.source[f.Name] = f.Value.String() + }) + + return nil +} diff --git a/option.go b/option.go index f110d50..d6455af 100644 --- a/option.go +++ b/option.go @@ -3,6 +3,7 @@ package envconfig type settings struct { filename string prefix string + source map[string]string } type option func(*settings) diff --git a/option_test.go b/option_test.go index 2e7f11d..9a05ded 100644 --- a/option_test.go +++ b/option_test.go @@ -144,7 +144,6 @@ func TestSetWithFilename(t *testing.T) { t.Run(tn, func(t *testing.T) { t.Parallel() - tc.assert(t, tc) }, ) diff --git a/tag.go b/tag.go index abc6985..0d4d692 100644 --- a/tag.go +++ b/tag.go @@ -1,9 +1,7 @@ package envconfig import ( - "encoding/json" "fmt" - "os" "reflect" "strconv" ) @@ -26,32 +24,34 @@ const ( tagPrefix = "prefix" ) -// handlePrefixTag will handle nested structures that use the prefix option. -func (e environmentVariableParser) handlePrefixTag( - field reflect.StructField, - configFieldValue reflect.Value, - prefix string, // prefix is not zero value when a struct is deeply nested. -) error { - if field.Type.Kind() != reflect.Struct { +// checkRequiredTag checks if a field is required and returns an error if so. +// +// This function is only called when an environment variable is not set for a field. +func checkRequiredTag(environmentVariableKey string, field reflect.StructField) error { + requiredOptionValue, requiredOptionSet := field.Tag.Lookup(tagRequired) + if !requiredOptionSet { return nil } - prefixOptionValue, prefixOptionSet := field.Tag.Lookup(tagPrefix) - if !prefixOptionSet { - return &PrefixOptionError{FieldName: field.Name} - } - - if err := e.populateNestedConfig(configFieldValue, prefix+prefixOptionValue); err != nil { - return fmt.Errorf("populate nested config struct: %w", err) + requiredOption, err := strconv.ParseBool(requiredOptionValue) + if requiredOption { + return &RequiredFieldError{FieldName: environmentVariableKey} + } else if err != nil { + return &InvalidOptionConversionError{ + FieldName: environmentVariableKey, + Option: tagRequired, + Err: err, + } } return nil } -func (e envFileParser) handlePrefixTag( +func handlePrefixTag( field reflect.StructField, configFieldValue reflect.Value, prefix string, + source map[string]string, ) error { if field.Type.Kind() != reflect.Struct { return nil @@ -62,59 +62,9 @@ func (e envFileParser) handlePrefixTag( return &PrefixOptionError{FieldName: field.Name} } - if err := e.populateNestedConfig(configFieldValue, prefix+prefixOptionValue); err != nil { + if err := populateNestedConfig(configFieldValue, prefix+prefixOptionValue, source); err != nil { return fmt.Errorf("populate nested config struct: %w", err) } return nil } - -// handleJSONTag will handle populating JSON structs via environment variables that are JSON. -func (e environmentVariableParser) handleJSONTag( - configFieldValue reflect.Value, - environmentKey string, // environmentKey is not zero value when a struct is deeply nested. -) error { - environmentValue := os.Getenv(environmentKey) - - if err := json.Unmarshal([]byte(environmentValue), configFieldValue.Addr().Interface()); err != nil { - return fmt.Errorf("unmarshal JSON: %w", err) - } - - return nil -} - -// handleJSONTag will handle populating JSON structs via environment variables that are JSON. -func (e envFileParser) handleJSONTag( - configFieldValue reflect.Value, - environmentKey string, // environmentKey is not zero value when a struct is deeply nested. -) error { - err := json.Unmarshal([]byte(e.config[environmentKey]), &configFieldValue) - if err != nil { - return fmt.Errorf("unmarshal JSON: %w", err) - } - - return nil -} - -// checkRequiredTag checks if a field is required and returns an error if so. -// -// This function is only called when an environment variable is not set for a field. -func checkRequiredTag(environmentVariableKey string, field reflect.StructField) error { - requiredOptionValue, requiredOptionSet := field.Tag.Lookup(tagRequired) - if !requiredOptionSet { - return nil - } - - requiredOption, err := strconv.ParseBool(requiredOptionValue) - if requiredOption { - return &RequiredFieldError{FieldName: environmentVariableKey} - } else if err != nil { - return &InvalidOptionConversionError{ - FieldName: environmentVariableKey, - Option: tagRequired, - Err: err, - } - } - - return nil -} From f5c33e2d9cee0ced69a4c434c5417cb050293d40 Mon Sep 17 00:00:00 2001 From: hcdav Date: Fri, 7 Nov 2025 22:35:38 +0000 Subject: [PATCH 7/9] refactor: Take logic out of populate struct function. --- envconfig.go | 59 ++++++++++++++++++++++++++++++---------------------- tag.go | 5 ++--- 2 files changed, 36 insertions(+), 28 deletions(-) diff --git a/envconfig.go b/envconfig.go index a519b77..05932d3 100644 --- a/envconfig.go +++ b/envconfig.go @@ -37,14 +37,14 @@ func Set(config any, opts ...option) error { return fmt.Errorf("process environment variables: %w", err) } - if err := s.populateConfig(config); err != nil { - return fmt.Errorf("populate config: %w", err) + if err := s.populateStruct(config); err != nil { + return fmt.Errorf("populate config struct: %w", err) } return nil } -func (s settings) populateConfig(config any) error { +func (s settings) populateStruct(config any) error { configStruct := reflect.ValueOf(config) if configStruct.Kind() != reflect.Pointer || configStruct.Elem().Kind() != reflect.Struct { return &InvalidConfigTypeError{ProvidedType: config} @@ -70,40 +70,31 @@ func (s settings) populateConfig(config any) error { continue } - if err := handlePrefixTag(field, configFieldValue, "", s.source); err != nil { + if err := s.handlePrefixTag(field, configFieldValue, ""); err != nil { return fmt.Errorf("handle prefix tag: %w", err) } - environmentVariableKey := field.Tag.Get(tagEnv) - if environmentVariableKey == "" { + key := field.Tag.Get(tagEnv) + if key == "" { continue } - value := s.source[environmentVariableKey] + value := s.source[key] if value == "" { - if err := checkRequiredTag(environmentVariableKey, field); err != nil { + if err := checkRequiredTag(key, field); err != nil { return fmt.Errorf("check required tag: %w", err) } value = field.Tag.Get(tagDefault) } - match := textReplacementRegex.FindStringSubmatch(value) - - for _, m := range match { - environmentValue := strings.TrimPrefix(m, "${") - environmentValue = strings.TrimSuffix(environmentValue, "}") - - replacementValue := s.source[environmentValue] - if replacementValue == "" { - return &ReplacementError{VariableName: environmentValue} - } - - value = strings.ReplaceAll(value, m, replacementValue) + value, err := s.resolveReplacement(value) + if err != nil { + return fmt.Errorf("resolve replacement: %w", err) } if err := setFieldValue( - configFieldValue, entry{environmentVariableKey, value}); err != nil { + configFieldValue, entry{key, value}); err != nil { return fmt.Errorf("set field value: %w", err) } } @@ -111,8 +102,26 @@ func (s settings) populateConfig(config any) error { return nil } +func (s settings) resolveReplacement(value string) (string, error) { + match := textReplacementRegex.FindStringSubmatch(value) + + for _, m := range match { + environmentValue := strings.TrimPrefix(m, "${") + environmentValue = strings.TrimSuffix(environmentValue, "}") + + replacementValue := s.source[environmentValue] + if replacementValue == "" { + return "", &ReplacementError{VariableName: environmentValue} + } + + value = strings.ReplaceAll(value, m, replacementValue) + } + + return value, nil +} + // populateNestedConfig populates a nested struct. -func populateNestedConfig(nestedConfig reflect.Value, prefix string, source map[string]string) error { +func (s settings)populateNestedConfig(nestedConfig reflect.Value, prefix string) error { for i := range nestedConfig.NumField() { field := nestedConfig.Type().Field(i) configFieldValue := nestedConfig.Field(i) @@ -123,7 +132,7 @@ func populateNestedConfig(nestedConfig reflect.Value, prefix string, source map[ jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) if jsonOptionSet { - err := json.Unmarshal([]byte(source[jsonOptionValue]), &configFieldValue) + err := json.Unmarshal([]byte(s.source[jsonOptionValue]), &configFieldValue) if err != nil { return fmt.Errorf("handle JSON tag: %w", err) } @@ -131,7 +140,7 @@ func populateNestedConfig(nestedConfig reflect.Value, prefix string, source map[ continue } - if err := handlePrefixTag(field, configFieldValue, prefix, source); err != nil { + if err := s.handlePrefixTag(field, configFieldValue, prefix); err != nil { return fmt.Errorf("handle prefix tag: %w", err) } @@ -140,7 +149,7 @@ func populateNestedConfig(nestedConfig reflect.Value, prefix string, source map[ continue } if err := setFieldValue( - configFieldValue, entry{environmentVariableKey, source[environmentVariableKey]}); err != nil { + configFieldValue, entry{environmentVariableKey, s.source[environmentVariableKey]}); err != nil { return fmt.Errorf("set field value: %w", err) } } diff --git a/tag.go b/tag.go index 0d4d692..57b893a 100644 --- a/tag.go +++ b/tag.go @@ -47,11 +47,10 @@ func checkRequiredTag(environmentVariableKey string, field reflect.StructField) return nil } -func handlePrefixTag( +func (s settings)handlePrefixTag( field reflect.StructField, configFieldValue reflect.Value, prefix string, - source map[string]string, ) error { if field.Type.Kind() != reflect.Struct { return nil @@ -62,7 +61,7 @@ func handlePrefixTag( return &PrefixOptionError{FieldName: field.Name} } - if err := populateNestedConfig(configFieldValue, prefix+prefixOptionValue, source); err != nil { + if err := s.populateNestedConfig(configFieldValue, prefix+prefixOptionValue); err != nil { return fmt.Errorf("populate nested config struct: %w", err) } From c8dfa98a0da6a69e7916003e1d7fd7a97f6e76aa Mon Sep 17 00:00:00 2001 From: hcdav Date: Sat, 8 Nov 2025 14:53:39 +0000 Subject: [PATCH 8/9] feat: Add active profile option. - Rename WithFilename to WithFilepath for more accurate name. - Rename some errors to use "filename" to now use "filepath". --- envconfig.go | 29 ++++++++++++++++++----- envconfig_test.go | 20 ++++++++-------- error.go | 25 ++++++++++++++++---- file.go | 20 ++++++++-------- file_internal_test.go | 10 ++++---- option.go | 23 ++++++++++++++----- option_test.go | 53 +++++++++++-------------------------------- 7 files changed, 99 insertions(+), 81 deletions(-) diff --git a/envconfig.go b/envconfig.go index 05932d3..8fde6a2 100644 --- a/envconfig.go +++ b/envconfig.go @@ -4,6 +4,7 @@ package envconfig import ( "encoding/json" "fmt" + "path/filepath" "reflect" "regexp" "strings" @@ -16,8 +17,7 @@ type entry struct { // textReplacementRegex is used to detect text replacement in environment variables. var textReplacementRegex = regexp.MustCompile(`\${[^}]+}`) -// Set will parse the .env file and set the values in the environment, then populate the passed in struct -// using all environment variables. +// Set will parse multiple sources for config values, and use these values to populate the passed in config struct. func Set(config any, opts ...option) error { s := &settings{ source: map[string]string{}, @@ -27,9 +27,23 @@ func Set(config any, opts ...option) error { opt(s) } - if s.filename != "" { - if err := s.processFilename(); err != nil { - return fmt.Errorf("process file: %w", err) + if s.activeProfile != "" { + if s.filepath == "" { + return fmt.Errorf("assign active profile: %w", &IncompatibleOptionsError{ + FirstOption: "WithActiveProfile()", + SecondOption: "WithFilepath()", + Reason: "directory in filepath option must be provided when using active profile", + }) + } + + dir, _ := filepath.Split(s.filepath) + + s.filepath = dir + s.activeProfile + envExtension + } + + if s.filepath != "" { + if err := s.processFilepath(); err != nil { + return fmt.Errorf("process filepath: %w", err) } } @@ -44,6 +58,7 @@ func Set(config any, opts ...option) error { return nil } +// populateStruct uses the items in settings.source to populate the passed in config struct. func (s settings) populateStruct(config any) error { configStruct := reflect.ValueOf(config) if configStruct.Kind() != reflect.Pointer || configStruct.Elem().Kind() != reflect.Struct { @@ -102,6 +117,8 @@ func (s settings) populateStruct(config any) error { return nil } +// resolveReplacement checks if a string has the pattern of ${...}, and if so, uses values in settings.source to +// replace the pattern, and returns the newly created string. func (s settings) resolveReplacement(value string) (string, error) { match := textReplacementRegex.FindStringSubmatch(value) @@ -121,7 +138,7 @@ func (s settings) resolveReplacement(value string) (string, error) { } // populateNestedConfig populates a nested struct. -func (s settings)populateNestedConfig(nestedConfig reflect.Value, prefix string) error { +func (s settings) populateNestedConfig(nestedConfig reflect.Value, prefix string) error { for i := range nestedConfig.NumField() { field := nestedConfig.Type().Field(i) configFieldValue := nestedConfig.Field(i) diff --git a/envconfig_test.go b/envconfig_test.go index 082c00a..25b1895 100644 --- a/envconfig_test.go +++ b/envconfig_test.go @@ -43,14 +43,14 @@ type SuccessWithPrefixOption struct { // such as flat config structures and fundamental fields, like required, and default. func TestSet(t *testing.T) { type testCase struct { - filename string + filepath string want any assert func(*testing.T, testCase) } testCases := map[string]testCase{ "success with one field": { - filename: "./test_data/success_with_one_field.env", + filepath: "./test_data/success_with_one_field.env", want: SuccessWithOneField{ Example: "value1", }, @@ -69,7 +69,7 @@ func TestSet(t *testing.T) { }, }, "success with one int field": { - filename: "./test_data/success_with_one_int_field.env", + filepath: "./test_data/success_with_one_int_field.env", want: SuccessWithOneIntField{ Example: 10, }, @@ -88,7 +88,7 @@ func TestSet(t *testing.T) { }, }, "success with default value and empty env file": { - filename: "./test_data/success_with_one_default_value_and_empty_env_file.env", + filepath: "./test_data/success_with_one_default_value_and_empty_env_file.env", want: SuccessWithDefaultValueAndEmptyEnvFile{ Example: "value2", }, @@ -107,7 +107,7 @@ func TestSet(t *testing.T) { }, }, "success with required field": { - filename: "./test_data/success_with_required_field.env", + filepath: "./test_data/success_with_required_field.env", want: SuccessWithRequiredField{ Example: "value3", }, @@ -126,7 +126,7 @@ func TestSet(t *testing.T) { }, }, "success with text replacement": { - filename: "./test_data/success_with_text_replacement.env", + filepath: "./test_data/success_with_text_replacement.env", want: SuccessWithTextReplacement{ ReplaceField: "exampleField", }, @@ -145,7 +145,7 @@ func TestSet(t *testing.T) { }, }, "success with setting time.Duration": { - filename: "./test_data/success_with_setting_time_Duration.env", + filepath: "./test_data/success_with_setting_time_Duration.env", want: SuccessWithSettingTimeDuration{ Duration: 10000000000, }, @@ -170,7 +170,7 @@ func TestSet(t *testing.T) { func(t *testing.T) { t.Parallel() - loadFileIntoEnvironmentVariables(tc.filename) + loadFileIntoEnvironmentVariables(tc.filepath) tc.assert(t, tc) }, @@ -178,8 +178,8 @@ func TestSet(t *testing.T) { } } -func loadFileIntoEnvironmentVariables(filename string) { - file, err := os.Open(filename) +func loadFileIntoEnvironmentVariables(filepath string) { + file, err := os.Open(filepath) if err != nil { log.Fatal(err) } diff --git a/error.go b/error.go index 45c3724..7a0096d 100644 --- a/error.go +++ b/error.go @@ -7,12 +7,12 @@ import ( // FileTypeValidationError occurs when the .env config file fails to open. type FileTypeValidationError struct { - Filename string + Filepath string } // Error satisfies the error interface for FileTypeValidationError. func (e *FileTypeValidationError) Error() string { - return fmt.Sprintf("file extension is not a valid environment file: %q", e.Filename) + return fmt.Sprintf("file extension is not a valid environment file: %q", e.Filepath) } // OpenFileError occurs when the .env config file fails to open. @@ -137,14 +137,31 @@ func (e *ParseError) Error() string { // FileReadError occurs when an error occurs when scanning the .env file. type FileReadError struct { - Filename string + Filepath string Err error } // Error satisfies the error interface for FileReadError. func (e *FileReadError) Error() string { - return fmt.Sprintf("reading %v: %v", e.Filename, e.Err.Error()) + return fmt.Sprintf("reading %v: %v", e.Filepath, e.Err.Error()) } // Unwrap allows FileReadError to be used with errors.Is and errors.As. func (e *FileReadError) Unwrap() error { return e.Err } + +// IncompatibleOptionsError occurs when two options are incompatible with each other, or the usage is invalid. +type IncompatibleOptionsError struct { + FirstOption string + SecondOption string + Reason string +} + +// Error satisfies the error interface for FileReadError. +func (e *IncompatibleOptionsError) Error() string { + return fmt.Sprintf( + "incompatible option usage of %v and %v: %v", + e.FirstOption, + e.SecondOption, + e.Reason, + ) +} diff --git a/file.go b/file.go index 26926c3..789aead 100644 --- a/file.go +++ b/file.go @@ -13,12 +13,11 @@ const ( ) type parser interface { - // parse should populate a config struct. parse() (map[string]string, error) } -func (s *settings) processFilename() error { - parser, err := identifyFileParser(s.filename) +func (s *settings) processFilepath() error { + parser, err := identifyFileParser(s.filepath) if err != nil { return fmt.Errorf("identify file parser: %w", err) } @@ -33,17 +32,18 @@ func (s *settings) processFilename() error { return nil } -func identifyFileParser(filename string) (parser, error) { +// identifyFileParser determines the parser to use based on the filepath received. +func identifyFileParser(f string) (parser, error) { var parser parser - switch filepath.Ext(filename) { + switch filepath.Ext(f) { case envExtension: parser = envFileParser{ source: map[string]string{}, - filename: filename, + filepath: f, } default: - return nil, &FileTypeValidationError{Filename: filename} + return nil, &FileTypeValidationError{Filepath: f} } return parser, nil @@ -51,11 +51,11 @@ func identifyFileParser(filename string) (parser, error) { type envFileParser struct { source map[string]string - filename string + filepath string } func (e envFileParser) parse() (map[string]string, error) { - file, err := os.Open(filepath.Clean(e.filename)) + file, err := os.Open(filepath.Clean(e.filepath)) if err != nil { return make(map[string]string), &OpenFileError{Err: err} } @@ -79,7 +79,7 @@ func (e envFileParser) parse() (map[string]string, error) { } if err := scanner.Err(); err != nil { - return make(map[string]string), &FileReadError{Filename: e.filename, Err: err} + return make(map[string]string), &FileReadError{Filepath: e.filepath, Err: err} } return e.source, nil diff --git a/file_internal_test.go b/file_internal_test.go index e595782..28a4f7b 100644 --- a/file_internal_test.go +++ b/file_internal_test.go @@ -9,20 +9,20 @@ import ( func Test_identifyParser(t *testing.T) { type testCase struct { - filename string + filepath string want parser wantErr error } testCases := map[string]testCase{ "expect env parser for env file": { - filename: "example.env", + filepath: "example.env", want: envFileParser{}, }, "expect error due to invalid file extension": { - filename: "example.invalid", + filepath: "example.invalid", wantErr: &FileTypeValidationError{ - Filename: "example.invalid", + Filepath: "example.invalid", }, }, } @@ -32,7 +32,7 @@ func Test_identifyParser(t *testing.T) { func(t *testing.T) { t.Parallel() - got, err := identifyFileParser(tc.filename) + got, err := identifyFileParser(tc.filepath) if !cmp.Equal(tc.wantErr, err) { t.Errorf("wantErr: %#v, got: %#v", tc.wantErr, err) diff --git a/option.go b/option.go index d6455af..57dff1e 100644 --- a/option.go +++ b/option.go @@ -1,17 +1,28 @@ package envconfig type settings struct { - filename string - prefix string - source map[string]string + filepath string + activeProfile string + prefix string + source map[string]string + temporaryPrefix string // temporary prefix is only used we are populating nested structs } type option func(*settings) -// WithFilename option will cause the file provided to be used to set variables in the environment. -func WithFilename(filename string) option { +// WithFilepath option will cause the file provided to be used to set variables in the environment. +func WithFilepath(filepath string) option { return func(s *settings) { - s.filename = filename + s.filepath = filepath + } +} + +func WithActiveProfile(activeProfile string) option { + return func(s *settings) { + if activeProfile == "" { + activeProfile = "default" + } + s.activeProfile = activeProfile } } diff --git a/option_test.go b/option_test.go index 9a05ded..c48ac00 100644 --- a/option_test.go +++ b/option_test.go @@ -6,45 +6,18 @@ import ( "github.com/h-dav/envconfig/v3" ) -// type SuccessWithOneField struct { -// Example string `env:"KEY"` -// } -// type SuccessWithOneIntField struct { -// Example int `env:"KEY"` -// } -// -// type SuccessWithDefaultValueAndEmptyEnvFile struct { -// Example string `env:"DEFAULT_VALUE" default:"value2"` -// } -// -// type SuccessWithRequiredField struct { -// Example string `env:"REQUIRED_VALUE" required:"true"` -// } -// -// type SuccessWithTextReplacement struct { -// ReplaceField string `env:"REPLACE_FIELD"` -// } -// -// type SuccessWithSettingTimeDuration struct { -// Duration time.Duration `env:"DURATION"` -// } -// -// type SuccessWithPrefixOption struct { -// Duration time.Duration `env:"DURATION"` -// } - -// TestSetWithFilename is test cases for simple use cases, +// TestSetWithFilepath is test cases for simple use cases, // such as flat config structures and fundamental fields, like required, and default. -func TestSetWithFilename(t *testing.T) { +func TestSetWithFilepath(t *testing.T) { type testCase struct { - filename string + filepath string want any assert func(*testing.T, testCase) } testCases := map[string]testCase{ "success with one field": { - filename: "./test_data/success_with_one_field.env", + filepath: "./test_data/success_with_one_field.env", want: SuccessWithOneField{ Example: "value1", }, @@ -53,7 +26,7 @@ func TestSetWithFilename(t *testing.T) { var config SuccessWithOneField - if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + if err := envconfig.Set(&config, envconfig.WithFilepath(tc.filepath)); err != nil { t.Fail() } @@ -63,7 +36,7 @@ func TestSetWithFilename(t *testing.T) { }, }, "success with one int field": { - filename: "./test_data/success_with_one_int_field.env", + filepath: "./test_data/success_with_one_int_field.env", want: SuccessWithOneIntField{ Example: 10, }, @@ -72,7 +45,7 @@ func TestSetWithFilename(t *testing.T) { var config SuccessWithOneIntField - if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + if err := envconfig.Set(&config, envconfig.WithFilepath(tc.filepath)); err != nil { t.Fail() } @@ -82,7 +55,7 @@ func TestSetWithFilename(t *testing.T) { }, }, "success with default value and empty env file": { - filename: "./test_data/success_with_one_default_value_and_empty_env_file.env", + filepath: "./test_data/success_with_one_default_value_and_empty_env_file.env", want: SuccessWithDefaultValueAndEmptyEnvFile{ Example: "value2", }, @@ -91,7 +64,7 @@ func TestSetWithFilename(t *testing.T) { var config SuccessWithDefaultValueAndEmptyEnvFile - if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + if err := envconfig.Set(&config, envconfig.WithFilepath(tc.filepath)); err != nil { t.Fail() } @@ -101,7 +74,7 @@ func TestSetWithFilename(t *testing.T) { }, }, "success with text replacement": { - filename: "./test_data/success_with_text_replacement.env", + filepath: "./test_data/success_with_text_replacement.env", want: SuccessWithTextReplacement{ ReplaceField: "exampleField", }, @@ -110,7 +83,7 @@ func TestSetWithFilename(t *testing.T) { var config SuccessWithTextReplacement - if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + if err := envconfig.Set(&config, envconfig.WithFilepath(tc.filepath)); err != nil { t.Fail() } @@ -120,7 +93,7 @@ func TestSetWithFilename(t *testing.T) { }, }, "success with setting time.Duration": { - filename: "./test_data/success_with_setting_time_Duration.env", + filepath: "./test_data/success_with_setting_time_Duration.env", want: SuccessWithSettingTimeDuration{ Duration: 10000000000, }, @@ -129,7 +102,7 @@ func TestSetWithFilename(t *testing.T) { var config SuccessWithSettingTimeDuration - if err := envconfig.Set(&config, envconfig.WithFilename(tc.filename)); err != nil { + if err := envconfig.Set(&config, envconfig.WithFilepath(tc.filepath)); err != nil { t.Fail() } From 8012f2c25eb4502538cfcd844bce4a19cf90abf4 Mon Sep 17 00:00:00 2001 From: hcdav Date: Sat, 8 Nov 2025 15:23:26 +0000 Subject: [PATCH 9/9] refactor: Start complete rewrite of README.md. Also enable flag processing. --- README.md | 92 ++++++++++++---------------------------------------- envconfig.go | 4 +++ flag.go | 2 +- 3 files changed, 25 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 2cf1766..e14c7cc 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,13 @@ # envconfig -[![Go Reference](https://pkg.go.dev/badge/github.com/h-dav/envconfig.svg)](https://pkg.go.dev/github.com/h-dav/envconfig) -[![Go Report Card](https://goreportcard.com/badge/github.com/h-dav/envconfig/v3)](https://goreportcard.com/report/github.com/h-dav/envconfig/v3) -[![Test](https://github.com/h-dav/envconfig/actions/workflows/test.yml/badge.svg)](https://github.com/h-dav/envconfig/actions/workflows/test.yml) -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/h-dav/envconfig/blob/main/LICENSE) - -Package `envconfig` provides functionality to easily populate your config structure by using both environment variables, and a .env file (optional). +Package `envconfig` will populate your config struct based on sources such as environment variables, files, etc. - [Installation](#installation) - [Features](#features) - [Options](#options) - - [Supported File Types](#supported-file-types) - - [Supported Data Types](#supported-data-types) -- [Usage](#usage) + - [Struct Tags](#tags) + - [Other](#other) +- [Merging Values](#merging-values) ## Installation @@ -24,75 +19,28 @@ go get github.com/h-dav/envconfig/v3 ### Options -#### Tags +- `WithFilepath("config/file.env")` +> [!NOTE] +> Currently only .env files are supported. +- `WithActiveProfile("dev_env")` +- `WithPrefix("MY_APP")` + +### Struct Tags +- `env`: Used to determine the key of the value to use when populating config fields. - `required`: `true` or `false` - `default`: Default value if environment variable is not set. - `prefix`: Used for nested structures. - `envjson`: Used for deserialising JSON into config. -#### Other - -- WithFilename("config.env") -- WithPrefix("MY_APP_"): Prefix for every single field in your config struct when fetching from environment variables. - -- Text Replacement: `${EXAMPLE}` can be used to insert other environment variables. - -### Supported File Types - -- .env - -### Supported Data Types - -- string (slice compatible) -- int (slice compatible) -- float (slice compatible) -- bool -- time.Duration - -## Usage +### Other -```go -package main +- Text Replacement: `${EXAMPLE}` can be used to insert other discovered values. -import ( - "time" +## Merging Values - "github.com/h-dav/envconfig/v3" -) - -type Config struct { - // Fields must be exported to be populated. - LogLevel string `env:"LOG_LEVEL" default:"info"` - Server struct { - Port string `env:"PORT" required:"true"` - } `prefix:"SERVER_"` - JSONField struct { - First string `json:"first"` - } `envjson:"JSON_FIELD"` - SliceIntField []int `env:"SLICE_INT_FIELD"` - DurationField time.Duration `env:"DURATION"` -} - -func main() { - var cfg Config - - if err := envconfig.Set(&cfg, WithFilename("./config/default.env")); err != nil { - ... - } -} -``` - -Corresponding .env file: - -```env -LOG_LEVEL=debug -SERVER_PORT=8080 -JSON_FIELD={"first": "example"} -SLICE_INT_FIELD=1, 2, 3 -DURATION=30s -``` - - -> [!NOTE] -> This package takes heavy inspiration from [httputil](https://github.com/nickbryan/httputil) for handling reflection. +> [!IMPORTANT] +> When merging values, `envconfig` uses the following precedence: +> 1. Flags +> 2. Environment Variables +> 3. Config File (provided via `WithFilepath()`) diff --git a/envconfig.go b/envconfig.go index 8fde6a2..535abe2 100644 --- a/envconfig.go +++ b/envconfig.go @@ -51,6 +51,10 @@ func Set(config any, opts ...option) error { return fmt.Errorf("process environment variables: %w", err) } + if err := s.processFlags(); err != nil { + return fmt.Errorf("process flags: %w", err) + } + if err := s.populateStruct(config); err != nil { return fmt.Errorf("populate config struct: %w", err) } diff --git a/flag.go b/flag.go index 02ed4c0..db193bc 100644 --- a/flag.go +++ b/flag.go @@ -4,7 +4,7 @@ import ( "flag" ) -func (s *settings) processFlags(_ any) error { +func (s *settings) processFlags() error { flag.Parse() flag.Visit(func(f *flag.Flag) {