diff --git a/README.md b/README.md index bcff5a7..e14c7cc 100644 --- a/README.md +++ b/README.md @@ -1,87 +1,46 @@ # 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) -[![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 Data Types](#supported-data-types) -- [Usage](#usage) + - [Struct Tags](#tags) + - [Other](#other) +- [Merging Values](#merging-values) ## Installation ```bash -go get github.com/h-dav/envconfig/v2 +go get github.com/h-dav/envconfig/v3 ``` ## Features ### Options +- `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. -- Text Replacement: `${EXAMPLE}` can be used to insert other environment variables. - -### Supported Data Types - -- string (slice compatible) -- int (slice compatible) -- float (slice compatible) -- bool -- time.Duration - -## Usage - -```go -package main - -import ( - "time" +- `envjson`: Used for deserialising JSON into config. - "github.com/h-dav/envconfig/v2" -) +### Other -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"` -} +- Text Replacement: `${EXAMPLE}` can be used to insert other discovered values. -func main() { - var cfg Config +## Merging Values - if err := envconfig.Set("./config/default.env", &cfg); 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] -> 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. +> [!IMPORTANT] +> When merging values, `envconfig` uses the following precedence: +> 1. Flags +> 2. Environment Variables +> 3. Config File (provided via `WithFilepath()`) diff --git a/env.go b/env.go deleted file mode 100644 index 59bbf0b..0000000 --- a/env.go +++ /dev/null @@ -1,500 +0,0 @@ -// Package envconfig provides functionality to easily populate your config structure by using both environment variables, and a .env file (optional). -package envconfig - -import ( - "bufio" - "encoding/json" - "fmt" - "os" - "path/filepath" - "reflect" - "regexp" - "strconv" - "strings" - "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 -} - -// 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. -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 := populateConfig(config); err != nil { - return fmt.Errorf("populate config struct: %w", err) - } - - 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) - if configStruct.Kind() != reflect.Ptr || 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) - - // Ensure the field is exported and the field is not already populated. - 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 { - return fmt.Errorf("handle JSON option: %w", err) - } - - continue - } - - if err := handlePrefixOption(field, configFieldValue, ""); err != nil { - return fmt.Errorf("handle prefix option: %w", err) - } - - environmentVariableKey := field.Tag.Get(tagEnv) - if environmentVariableKey == "" { - continue - } - - environmentVariable := fetchEnvironmentVariable(environmentVariableKey, field) - if environmentVariable == "" { - if err := checkRequiredOption(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 -} - -// 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() { - field := nestedConfig.Type().Field(i) - configFieldValue := nestedConfig.Field(i) - - if !configFieldValue.CanSet() || !configFieldValue.IsZero() { - continue - } - - jsonOptionValue, jsonOptionSet := field.Tag.Lookup(tagJSON) - if jsonOptionSet { - err := handleJSONOption(configFieldValue, prefix+jsonOptionValue) - if err != nil { - return fmt.Errorf("handle JSON option: %w", err) - } - - continue - } - - if err := handlePrefixOption(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 := checkRequiredOption(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 -} - -// 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( - 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.go b/envconfig.go new file mode 100644 index 0000000..535abe2 --- /dev/null +++ b/envconfig.go @@ -0,0 +1,179 @@ +// Package envconfig provides functionality to easily load config into your struct. +package envconfig + +import ( + "encoding/json" + "fmt" + "path/filepath" + "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 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{}, + } + + for _, opt := range opts { + opt(s) + } + + 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) + } + } + + if err := s.processEnvironmentVariables(); err != nil { + 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) + } + + 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 { + 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 := s.handlePrefixTag(field, configFieldValue, ""); err != nil { + return fmt.Errorf("handle prefix tag: %w", err) + } + + key := field.Tag.Get(tagEnv) + if key == "" { + continue + } + + value := s.source[key] + if value == "" { + if err := checkRequiredTag(key, field); err != nil { + return fmt.Errorf("check required tag: %w", err) + } + + value = field.Tag.Get(tagDefault) + } + + value, err := s.resolveReplacement(value) + if err != nil { + return fmt.Errorf("resolve replacement: %w", err) + } + + if err := setFieldValue( + configFieldValue, entry{key, value}); err != nil { + return fmt.Errorf("set field value: %w", err) + } + } + + 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) + + 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 (s settings) 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(s.source[jsonOptionValue]), &configFieldValue) + if err != nil { + return fmt.Errorf("handle JSON tag: %w", err) + } + + continue + } + + if err := s.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, s.source[environmentVariableKey]}); err != nil { + return fmt.Errorf("set field value: %w", err) + } + } + + return nil +} diff --git a/env_test.go b/envconfig_test.go similarity index 64% rename from env_test.go rename to envconfig_test.go index 3e4a438..25b1895 100644 --- a/env_test.go +++ b/envconfig_test.go @@ -1,11 +1,15 @@ package envconfig_test import ( + "bufio" + "log" + "os" "slices" + "strings" "testing" "time" - "github.com/h-dav/envconfig/v2" + "github.com/h-dav/envconfig/v3" ) type SuccessWithOneField struct { @@ -31,18 +35,22 @@ type SuccessWithSettingTimeDuration struct { Duration time.Duration `env:"DURATION"` } -// TestSetWithSimpleConfigStructures is test cases for simple use cases, +type SuccessWithPrefixOption struct { + Duration time.Duration `env:"DURATION"` +} + +// TestSet 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 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", }, @@ -51,7 +59,7 @@ func TestSetWithSimpleConfigStructures(t *testing.T) { var config SuccessWithOneField - if err := envconfig.Set(tc.filename, &config); err != nil { + if err := envconfig.Set(&config); err != nil { t.Fail() } @@ -61,7 +69,7 @@ func TestSetWithSimpleConfigStructures(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, }, @@ -70,7 +78,7 @@ func TestSetWithSimpleConfigStructures(t *testing.T) { var config SuccessWithOneIntField - if err := envconfig.Set(tc.filename, &config); err != nil { + if err := envconfig.Set(&config); err != nil { t.Fail() } @@ -80,7 +88,7 @@ func TestSetWithSimpleConfigStructures(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", }, @@ -89,7 +97,7 @@ func TestSetWithSimpleConfigStructures(t *testing.T) { var config SuccessWithDefaultValueAndEmptyEnvFile - if err := envconfig.Set(tc.filename, &config); err != nil { + if err := envconfig.Set(&config); err != nil { t.Fail() } @@ -99,7 +107,7 @@ func TestSetWithSimpleConfigStructures(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", }, @@ -108,7 +116,7 @@ func TestSetWithSimpleConfigStructures(t *testing.T) { var config SuccessWithRequiredField - if err := envconfig.Set(tc.filename, &config); err != nil { + if err := envconfig.Set(&config); err != nil { t.Fail() } @@ -118,7 +126,7 @@ func TestSetWithSimpleConfigStructures(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", }, @@ -127,7 +135,7 @@ func TestSetWithSimpleConfigStructures(t *testing.T) { var config SuccessWithTextReplacement - if err := envconfig.Set(tc.filename, &config); err != nil { + if err := envconfig.Set(&config); err != nil { t.Fail() } @@ -137,7 +145,7 @@ func TestSetWithSimpleConfigStructures(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, }, @@ -146,7 +154,79 @@ func TestSetWithSimpleConfigStructures(t *testing.T) { var config SuccessWithSettingTimeDuration - if err := envconfig.Set(tc.filename, &config); err != nil { + if err := envconfig.Set(&config); 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() + + loadFileIntoEnvironmentVariables(tc.filepath) + + tc.assert(t, tc) + }, + ) + } +} + +func loadFileIntoEnvironmentVariables(filepath string) { + file, err := os.Open(filepath) + 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) + } + + 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() } @@ -180,7 +260,9 @@ 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) + 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) @@ -197,7 +279,9 @@ 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) + 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) @@ -214,7 +298,9 @@ 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) + 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) @@ -235,7 +321,9 @@ func TestSetSuccessWithNestedStruct(t *testing.T) { var want Config want.Server.Port = "8080" - envconfig.Set("./test_data/success_with_nested_struct.env", &config) + loadFileIntoEnvironmentVariables("./test_data/success_with_nested_struct.env") + + envconfig.Set(&config) if config != want { t.Errorf("got %+v, want %+v", config, want) @@ -256,7 +344,9 @@ func TestSetSuccessWithDeeplyNestedStruct(t *testing.T) { var want Config want.Server.Port.Value = "1234" - envconfig.Set("./test_data/success_with_deeply_nested_struct.env", &config) + loadFileIntoEnvironmentVariables("./test_data/success_with_deeply_nested_struct.env") + + envconfig.Set(&config) if config != want { t.Errorf("got %+v, want %+v", config, want) @@ -281,7 +371,9 @@ 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) + loadFileIntoEnvironmentVariables("./test_data/success_with_thrice_deeply_nested_struct.env") + + envconfig.Set(&config) if config != want { t.Errorf("got %+v, want %+v", config, want) @@ -301,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("./test_data/success_with_json_field.env", &config) + envconfig.Set(&config) if config != want { t.Errorf("got %+v, want %+v", config, want) diff --git a/environment_variable.go b/environment_variable.go new file mode 100644 index 0000000..ae66f4a --- /dev/null +++ b/environment_variable.go @@ -0,0 +1,43 @@ +package envconfig + +import ( + "os" + "strings" +) + +// processEnvironmentVariables populates the config struct using all environment variables. +func (s *settings) processEnvironmentVariables() error { //nolint:gocognit // Complexity is reasonable. + e := environmentVariableParser{ + prefix: s.prefix, + source: map[string]string{}, + } + + 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() (map[string]string, error) { + all := os.Environ() + + for _, val := range all { + key, value, found := strings.Cut(val, "=") + if !found { + continue + } + + e.source[key] = value + } + + return e.source, nil +} 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/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/file.go b/file.go new file mode 100644 index 0000000..789aead --- /dev/null +++ b/file.go @@ -0,0 +1,103 @@ +package envconfig + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + envExtension = ".env" +) + +type parser interface { + parse() (map[string]string, error) +} + +func (s *settings) processFilepath() error { + parser, err := identifyFileParser(s.filepath) + if err != nil { + return fmt.Errorf("identify file parser: %w", err) + } + + source, err := parser.parse() + if err != nil { + return fmt.Errorf("parse file: %w", err) + } + + s.source = source + + return nil +} + +// identifyFileParser determines the parser to use based on the filepath received. +func identifyFileParser(f string) (parser, error) { + var parser parser + + switch filepath.Ext(f) { + case envExtension: + parser = envFileParser{ + source: map[string]string{}, + filepath: f, + } + default: + return nil, &FileTypeValidationError{Filepath: f} + } + + return parser, nil +} + +type envFileParser struct { + source map[string]string + filepath string +} + +func (e envFileParser) parse() (map[string]string, error) { + file, err := os.Open(filepath.Clean(e.filepath)) + if err != nil { + return make(map[string]string), &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.parseLine(line) + if err != nil { + return make(map[string]string), fmt.Errorf("parse line: %w", err) + } + + e.source[entry.key] = entry.value + } + + if err := scanner.Err(); err != nil { + return make(map[string]string), &FileReadError{Filepath: e.filepath, Err: err} + } + + return e.source, nil +} + +// 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} + } + + // 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 +} diff --git a/file_internal_test.go b/file_internal_test.go new file mode 100644 index 0000000..28a4f7b --- /dev/null +++ b/file_internal_test.go @@ -0,0 +1,51 @@ +package envconfig + +import ( + "reflect" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func Test_identifyParser(t *testing.T) { + type testCase struct { + filepath string + want parser + wantErr error + } + + testCases := map[string]testCase{ + "expect env parser for env file": { + filepath: "example.env", + want: envFileParser{}, + }, + "expect error due to invalid file extension": { + filepath: "example.invalid", + wantErr: &FileTypeValidationError{ + Filepath: "example.invalid", + }, + }, + } + + for tn, tc := range testCases { + t.Run(tn, + func(t *testing.T) { + t.Parallel() + + got, err := identifyFileParser(tc.filepath) + + if !cmp.Equal(tc.wantErr, err) { + t.Errorf("wantErr: %#v, got: %#v", tc.wantErr, err) + } + + if reflect.TypeOf(tc.want) != reflect.TypeOf(got) { + t.Errorf( + "want: %q, got: %q", + reflect.TypeOf(tc.wantErr), + reflect.TypeOf(got), + ) + } + }, + ) + } +} diff --git a/flag.go b/flag.go new file mode 100644 index 0000000..db193bc --- /dev/null +++ b/flag.go @@ -0,0 +1,15 @@ +package envconfig + +import ( + "flag" +) + +func (s *settings) processFlags() error { + flag.Parse() + + flag.Visit(func(f *flag.Flag) { + s.source[f.Name] = f.Value.String() + }) + + return nil +} diff --git a/go.mod b/go.mod index ac2d21c..564b77b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +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/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..57dff1e --- /dev/null +++ b/option.go @@ -0,0 +1,35 @@ +package envconfig + +type settings struct { + 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) + +// 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.filepath = filepath + } +} + +func WithActiveProfile(activeProfile string) option { + return func(s *settings) { + if activeProfile == "" { + activeProfile = "default" + } + s.activeProfile = activeProfile + } +} + +// 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 + } +} + diff --git a/option_test.go b/option_test.go new file mode 100644 index 0000000..c48ac00 --- /dev/null +++ b/option_test.go @@ -0,0 +1,124 @@ +package envconfig_test + +import ( + "testing" + + "github.com/h-dav/envconfig/v3" +) + +// TestSetWithFilepath is test cases for simple use cases, +// such as flat config structures and fundamental fields, like required, and default. +func TestSetWithFilepath(t *testing.T) { + type testCase struct { + filepath string + want any + assert func(*testing.T, testCase) + } + + testCases := map[string]testCase{ + "success with one field": { + filepath: "./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.WithFilepath(tc.filepath)); err != nil { + t.Fail() + } + + if config != tc.want { + t.Errorf("got %+v, want %+v", config, tc.want) + } + }, + }, + "success with one int field": { + filepath: "./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.WithFilepath(tc.filepath)); 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": { + filepath: "./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.WithFilepath(tc.filepath)); err != nil { + t.Fail() + } + + if config != tc.want { + t.Errorf("got %+v, want %+v", config, tc.want) + } + }, + }, + "success with text replacement": { + filepath: "./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.WithFilepath(tc.filepath)); err != nil { + t.Fail() + } + + if config != tc.want { + t.Errorf("got %+v, want %+v", config, tc.want) + } + }, + }, + "success with setting time.Duration": { + filepath: "./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.WithFilepath(tc.filepath)); 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 new file mode 100644 index 0000000..57b893a --- /dev/null +++ b/tag.go @@ -0,0 +1,69 @@ +package envconfig + +import ( + "fmt" + "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" +) + +// 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 +} + +func (s settings)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 := s.populateNestedConfig(configFieldValue, prefix+prefixOptionValue); err != nil { + return fmt.Errorf("populate nested config struct: %w", err) + } + + return nil +}