diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index a2b6507..0bd141c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -17,12 +17,21 @@ jobs: - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: '1.22.2' + go-version: '1.23.x' - name: Build run: go build -v ./... - - name: Test - run: go test -v ./... + - name: Run tests + run: go test -v + + - name: Generate code coverage report + run: go test -coverprofile=coverage.txt + + - name: Upload results to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + diff --git a/README.md b/README.md index 9c3cdee..2583c80 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# Dotconfig [![Go Reference](https://pkg.go.dev/badge/github.com/DeanPDX/dotconfig.svg)](https://pkg.go.dev/github.com/DeanPDX/dotconfig) -This package aims to simplify configuration from environment variables. In local development, we can supply a `.env` file with key/value pairs. When deployed, values come from a secret manager. This is similar to [joho/godotenv](https://github.com/joho/godotenv) but the aim here is to not only read the `.env` file but use reflection to produce a config struct. +# Dotconfig [![Go Reference](https://pkg.go.dev/badge/github.com/DeanPDX/dotconfig.svg)](https://pkg.go.dev/github.com/DeanPDX/dotconfig) [![Go Report Card](https://goreportcard.com/badge/github.com/DeanPDX/dotconfig)](https://goreportcard.com/report/github.com/DeanPDX/dotconfig) +This package aims to simplify configuration from environment variables. In local development, we can supply a `.env` file with key/value pairs. When deployed, values come from a secret manager. This is similar to [joho/godotenv](https://github.com/joho/godotenv) but the aim here is to not only read the `.env` file but use reflection to produce a config struct. We also support optional/required fields in the struct and default values. ## Usage Create a `.env` file in your current working directory with the following contents: @@ -30,9 +30,9 @@ import ( // Our AppConfig with env struct tags: type AppConfig struct { - MaxBytesPerRequest int `env:"MAX_BYTES_PER_REQUEST"` - APIVersion float64 `env:"API_VERSION"` - IsDev bool `env:"IS_DEV"` + MaxBytesPerRequest int `env:"MAX_BYTES_PER_REQUEST" default:"2048"` // Defaults to 2048 + APIVersion float64 `env:"API_VERSION,required"` // Required to be present and not empty string + IsDev bool `env:"IS_DEV,optional"` // Optional: defaults to zero value StripeSecret string `env:"STRIPE_SECRET"` WelcomeMessage string `env:"WELCOME_MESSAGE"` } @@ -52,7 +52,7 @@ So for local dev we can use this `.env` file. But when you deploy your app, you If your key value pairs are coming from a source other than a file, or you want to control file IO yourself, you can call `FromReader` instead and pass in a `io.Reader`. There is [a runnable example of that in the godoc](https://pkg.go.dev/github.com/DeanPDX/dotconfig#example-FromReader). -## Error Handling +## Error Handling and Options By default, file IO errors in `dotconfig.FromFileName` won't produce an error. This is because when you are running in the cloud with a secret manager, not finding a `.env` file is the happy path. If you want to return errors from `os.Open` you can do so with an option: @@ -94,16 +94,20 @@ if err != nil { for _, err := range errs { // Handle various error types however you want switch { - case errors.Is(dotconfig.ErrMissingEnvVar, errors.Unwrap(err)): + case errors.Is(errors.Unwrap(err), dotconfig.ErrMissingEnvVar): // Handle missing environment variable - case errors.Is(dotconfig.ErrMissingStructTag, errors.Unwrap(err)): + case errors.Is(errors.Unwrap(err), dotconfig.ErrMissingStructTag): // Handle missing struct tag - case errors.Is(dotconfig.ErrUnsupportedFieldType, errors.Unwrap(err)): + case errors.Is(errors.Unwrap(err), dotconfig.ErrUnsupportedFieldType): // Handle unsupported field type + case errors.Is(errors.Unwrap(err), dotconfig.ErrMissingRequiredField): + // Handle missing required field } } } ``` ## Contributing -Contributions are always welcome. This is still in the early stages and is mostly for internal use at the moment. Have a new idea or find a bug? Submit a pull request or create an issue! +Contributions are always welcome. Have a new idea or find a bug? Submit a pull request or create an issue! + +**IMPORTANT**: This package is being used in production and any future updates should maintain backwards compatibility. \ No newline at end of file diff --git a/dotconfig.go b/dotconfig.go index 44a9467..b536416 100644 --- a/dotconfig.go +++ b/dotconfig.go @@ -103,7 +103,7 @@ func FromReader[T any](r io.Reader, opts ...DecodeOption) (T, error) { key := line[0:strings.Index(line, "=")] value := line[len(key)+1:] - // If there is a inline commend, so a space and then a #, exclude the commend. + // If there is a inline comment, so a space and then a #, exclude the comment. if strings.Contains(value, " #") { value = value[0:strings.Index(value, " #")] } @@ -132,7 +132,8 @@ func FromReader[T any](r io.Reader, opts ...DecodeOption) (T, error) { var ( ErrConfigMustBeStruct = errors.New("config must be struct") ErrMissingStructTag = errors.New("missing struct tag on field") - ErrMissingEnvVar = errors.New("value not present in env") + ErrMissingEnvVar = errors.New("key not present in ENV") + ErrMissingRequiredField = errors.New("field must have non-zero value") ErrUnsupportedFieldType = errors.New("unsupported field type") ) @@ -154,9 +155,10 @@ func fromEnv[T any](opts options) (T, error) { continue } fieldType := ct.Field(i) - envKey := fieldType.Tag.Get("env") + // Get the env struct tag + envTag := fieldType.Tag.Get("env") // No struct tag - if envKey == "" { + if envTag == "" { // By default we just assume the consumers of this library have // a mixture of fields with env struct tags and some they want // this library to ignore. But consumers can opt in to no struct @@ -166,15 +168,30 @@ func fromEnv[T any](opts options) (T, error) { } continue } + // Parse env tag into environment variable key and options + envKey, opts := parseTag(envTag) envValue, keyExists := os.LookupEnv(envKey) - // Missing env key + // Missing env var if !keyExists { - errs.Add(fmt.Errorf("%w: %v", ErrMissingEnvVar, envKey)) - continue + // Check to see if we have a default value + defaultVal := fieldType.Tag.Get("default") + if defaultVal != "" { + envValue = defaultVal + } else if opts.Contains("optional") { + // Optional so skip missing error + continue + } else { + errs.Add(fmt.Errorf("%w: %v", ErrMissingEnvVar, envKey)) + continue + } } // Empty value - // TODO: we could enforce non-empty values based on struct tags. if strings.TrimSpace(envValue) == "" { + // If required option is set, this is an error + if opts.Contains("required") { + errs.Add(fmt.Errorf("%w: %v", ErrMissingRequiredField, envKey)) + } + // Otherwise zero-values are fine continue } // Based on type, parse and set values. This borrows from encoding/json: diff --git a/dotconfig_test.go b/dotconfig_test.go index 23e1bf6..a548450 100644 --- a/dotconfig_test.go +++ b/dotconfig_test.go @@ -2,7 +2,6 @@ package dotconfig_test import ( "errors" - "fmt" "os" "reflect" "strings" @@ -15,8 +14,10 @@ type sampleConfig struct { StripeSecret string `env:"STRIPE_SECRET"` IsDevEnv bool `env:"IS_DEV"` WelcomeEmail string `env:"WELCOME_EMAIL"` + NumRetries uint `env:"NUM_RETRIES"` } +// Expected output from test below const welcomeEmail = `Hello, Welcome to the app! @@ -26,12 +27,13 @@ Welcome to the app! func TestFromReaderNewlines(t *testing.T) { reader := strings.NewReader(`#just testing # Stripe secret key -STRIPE_SECRET='sk_test_asDF!' +STRIPE_SECRET='sk_test_asDF!' # Don't share this with anybody # Going to leave a file lines blank -IS_DEV='true' -WELCOME_EMAIL='Hello,\n\nWelcome to the app!\n\n-The Team'`) +IS_DEV='true' #Whether or not this is dev +WELCOME_EMAIL='Hello,\n\nWelcome to the app!\n\n-The Team' +NUM_RETRIES=13`) config, err := dotconfig.FromReader[sampleConfig](reader) if err != nil { @@ -41,6 +43,7 @@ WELCOME_EMAIL='Hello,\n\nWelcome to the app!\n\n-The Team'`) StripeSecret: "sk_test_asDF!", IsDevEnv: true, WelcomeEmail: welcomeEmail, + NumRetries: 13, } if !reflect.DeepEqual(config, expected) { t.Fatalf("Expected:\n%#v\nGot:\n%#v", expected, config) @@ -52,6 +55,8 @@ type moreAdvancedConfig struct { APIVersion float64 `env:"API_VERSION"` IsDev bool `env:"IS_DEV"` LogErrors bool `env:"LOG_ERRORS"` + OptionalField string `env:"OPTIONAL_FIELD,optional"` + FieldWithDefault string `env:"FIELD_WITH_DEFAULT" default:"Hello, Default!"` notExported string `env:"NOT_EXPORTED"` } @@ -76,6 +81,8 @@ func TestFromReaderDecoding(t *testing.T) { APIVersion: 1.19, IsDev: true, LogErrors: true, + OptionalField: "", + FieldWithDefault: "Hello, Default!", notExported: "", } if !reflect.DeepEqual(config, expected) { @@ -101,9 +108,10 @@ func TestFromFileNameNoFile(t *testing.T) { os.Setenv("IS_DEV", "t") os.Setenv("LOG_ERRORS", "1") os.Setenv("NOT_EXPORTED", "some value") + os.Setenv("FIELD_WITH_DEFAULT", "Overridden value") config, err := dotconfig.FromFileName[moreAdvancedConfig]("doesn't exist!") if err != nil { - fmt.Printf("Didn't expect error. Got %v.", err) + t.Fatalf("Didn't expect error. Got %v.", err) } expected := moreAdvancedConfig{ @@ -111,6 +119,8 @@ func TestFromFileNameNoFile(t *testing.T) { APIVersion: 1.19, IsDev: true, LogErrors: true, + OptionalField: "", + FieldWithDefault: "Overridden value", notExported: "", } if !reflect.DeepEqual(config, expected) { @@ -150,18 +160,18 @@ func TestMultiError(t *testing.T) { for _, err := range errs { // Handle various error types however you want switch { - case errors.Is(dotconfig.ErrMissingEnvVar, errors.Unwrap(err)): + case errors.Is(errors.Unwrap(err), dotconfig.ErrMissingEnvVar): // Handle missing environment variable knownErrors++ - fmt.Printf("Error: %v\n", err) - case errors.Is(dotconfig.ErrMissingStructTag, errors.Unwrap(err)): + t.Logf("Error: %v\n", err) + case errors.Is(errors.Unwrap(err), dotconfig.ErrMissingStructTag): // Handle missing struct tag knownErrors++ - fmt.Printf("Error: %v\n", err) - case errors.Is(dotconfig.ErrUnsupportedFieldType, errors.Unwrap(err)): + t.Logf("Error: %v\n", err) + case errors.Is(errors.Unwrap(err), dotconfig.ErrUnsupportedFieldType): // Handle unsupported field knownErrors++ - fmt.Printf("Error: %v\n", err) + t.Logf("Error: %v\n", err) } } if knownErrors != 3 { @@ -181,73 +191,3 @@ func TestSingleError(t *testing.T) { t.Errorf("Expecting exactly 1 error") } } - -type AppConfig struct { - MaxBytesPerRequest int `env:"MAX_BYTES_PER_REQUEST"` - APIVersion float64 `env:"API_VERSION"` - IsDev bool `env:"IS_DEV"` - StripeSecret string `env:"STRIPE_SECRET"` - WelcomeMessage string `env:"WELCOME_MESSAGE"` -} - -const appConfigSample = ` -MAX_BYTES_PER_REQUEST="1024" -API_VERSION=1.19 -# All of these are valie for booleans: -# 1, t, T, TRUE, true, True, 0, f, F, FALSE, false, False -IS_DEV='1' -STRIPE_SECRET='sk_test_insertkeyhere' -# Right now supporting newlines via "\n" in strings: -WELCOME_MESSAGE='Hello,\nWelcome to the app!\n-The App Dev Team'` - -func ExampleFromReader() { - config, err := dotconfig.FromReader[AppConfig](strings.NewReader(appConfigSample)) - if err != nil { - fmt.Printf("Didn't expect error. Got %v.", err) - } - // Don't do this in the real world, as your config will - // have secrets from a secret manager and you don't want - // to print them to the console. - fmt.Printf("App config loaded.\nMax Bytes: %v. Version: %v. Dev? %v. Stripe Secret: %v.\nWelcome Message:\n%v", - config.MaxBytesPerRequest, config.APIVersion, config.IsDev, config.StripeSecret, config.WelcomeMessage) - // Output: - // App config loaded. - // Max Bytes: 1024. Version: 1.19. Dev? true. Stripe Secret: sk_test_insertkeyhere. - // Welcome Message: - // Hello, - // Welcome to the app! - // -The App Dev Team -} - -type ConfigWithErrors struct { - StripeSecret string `env:"SHOULD_BE_MISSING"` - Complex complex128 `env:"COMPLEX"` - WelcomeMessage string -} - -func ExampleErrors() { - r := strings.NewReader(`COMPLEX=asdf`) - _, err := dotconfig.FromReader[ConfigWithErrors](r, dotconfig.EnforceStructTags) - if err != nil { - // Get error slice from err - errs := dotconfig.Errors(err) - for _, err := range errs { - // Handle various error types however you want - switch { - case errors.Is(dotconfig.ErrMissingEnvVar, errors.Unwrap(err)): - // Handle missing environment variable - fmt.Printf("Missing env variable: %v\n", err) - case errors.Is(dotconfig.ErrMissingStructTag, errors.Unwrap(err)): - // Handle missing struct tag - fmt.Printf("Missing struct tag: %v\n", err) - case errors.Is(dotconfig.ErrUnsupportedFieldType, errors.Unwrap(err)): - // Handle unsupported field - fmt.Printf("Unsupported type: %v\n", err) - } - } - } - // Output: - // Missing env variable: value not present in env: SHOULD_BE_MISSING - // Unsupported type: unsupported field type: complex128 - // Missing struct tag: missing struct tag on field: WelcomeMessage -} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..fde3dd7 --- /dev/null +++ b/example_test.go @@ -0,0 +1,114 @@ +package dotconfig_test + +import ( + "errors" + "fmt" + "strings" + + "github.com/DeanPDX/dotconfig" +) + +type AppConfig struct { + MaxBytesPerRequest int `env:"MAX_BYTES_PER_REQUEST"` + APIVersion float64 `env:"API_VERSION"` + IsDev bool `env:"IS_DEV"` + StripeSecret string `env:"STRIPE_SECRET"` + WelcomeMessage string `env:"WELCOME_MESSAGE"` +} + +const appConfigSample = ` +MAX_BYTES_PER_REQUEST="1024" +API_VERSION=1.19 +# All of these are valie for booleans: +# 1, t, T, TRUE, true, True, 0, f, F, FALSE, false, False +IS_DEV='1' +STRIPE_SECRET='sk_test_insertkeyhere' +# Right now supporting newlines via "\n" in strings: +WELCOME_MESSAGE='Hello,\nWelcome to the app!\n-The App Dev Team'` + +func ExampleFromReader() { + config, err := dotconfig.FromReader[AppConfig](strings.NewReader(appConfigSample)) + if err != nil { + fmt.Printf("Didn't expect error. Got %v.", err) + } + // Don't do this in the real world, as your config will + // have secrets from a secret manager and you don't want + // to print them to the console. + fmt.Printf("App config loaded.\nMax Bytes: %v. Version: %v. Dev? %v. Stripe Secret: %v.\nWelcome Message:\n%v", + config.MaxBytesPerRequest, config.APIVersion, config.IsDev, config.StripeSecret, config.WelcomeMessage) + // Output: + // App config loaded. + // Max Bytes: 1024. Version: 1.19. Dev? true. Stripe Secret: sk_test_insertkeyhere. + // Welcome Message: + // Hello, + // Welcome to the app! + // -The App Dev Team +} + +type ConfigWithErrors struct { + StripeSecret string `env:"SHOULD_BE_MISSING"` + Complex complex128 `env:"COMPLEX"` + WelcomeMessage string + RequiredField string `env:"REQUIRED_FIELD,required"` // Can't be zero-value +} + +const exampleErrorsEnv = ` +COMPLEX=asdf +REQUIRED_FIELD="" # Will cause error because zero-value` + +func ExampleErrors() { + r := strings.NewReader(exampleErrorsEnv) + _, err := dotconfig.FromReader[ConfigWithErrors](r, dotconfig.EnforceStructTags) + if err != nil { + // Get error slice from err + errs := dotconfig.Errors(err) + for _, err := range errs { + // Handle various error types however you want + switch { + case errors.Is(errors.Unwrap(err), dotconfig.ErrMissingEnvVar): + // Handle missing environment variable + fmt.Printf("Missing env variable: %v\n", err) + case errors.Is(errors.Unwrap(err), dotconfig.ErrMissingStructTag): + // Handle missing struct tag + fmt.Printf("Missing struct tag: %v\n", err) + case errors.Is(errors.Unwrap(err), dotconfig.ErrUnsupportedFieldType): + // Handle unsupported field + fmt.Printf("Unsupported type: %v\n", err) + case errors.Is(errors.Unwrap(err), dotconfig.ErrMissingRequiredField): + // Handle required field + fmt.Printf("Required field can't be zero value: %v\n", err) + } + } + } + // Output: + // Missing env variable: key not present in ENV: SHOULD_BE_MISSING + // Unsupported type: unsupported field type: complex128 + // Missing struct tag: missing struct tag on field: WelcomeMessage + // Required field can't be zero value: field must have non-zero value: REQUIRED_FIELD +} + +type ConfigWithDefaults struct { + MaxBytesPerRequest int `env:"MAX_BYTES" default:"2048"` + IsDev bool `env:"DEVELOPMENT,optional"` // will default to zero value (false) + WelcomeMessage string `env:"APP_HELLO" default:"Hey!"` + AppVersion float64 `env:"APP_VERSION" default:"1.0"` +} + +func Example_defaultValues() { + r := strings.NewReader(`APP_VERSION=2.38`) + conf, err := dotconfig.FromReader[ConfigWithDefaults](r, dotconfig.EnforceStructTags) + if err != nil { + fmt.Printf("Didn't expect error. Got %v.", err) + } + fmt.Println("App config loaded") + fmt.Println("Max bytes:", conf.MaxBytesPerRequest) // 2048 from default tag + fmt.Println("Is dev?", conf.IsDev) // False because optional so zero value + fmt.Println("Welcome message:", conf.WelcomeMessage) // "Hey!" from default tag + fmt.Println("App version:", conf.AppVersion) // 2.38 because ENV value overrides default + // Output: + // App config loaded + // Max bytes: 2048 + // Is dev? false + // Welcome message: Hey! + // App version: 2.38 +} diff --git a/tag.go b/tag.go new file mode 100644 index 0000000..d3e0d7c --- /dev/null +++ b/tag.go @@ -0,0 +1,37 @@ +package dotconfig + +import "strings" + +// tagOptions is the string following a comma in a struct field's "env" +// tag, or an empty string. +// Borrows HEAVILY from: +// https://cs.opensource.google/go/go/+/master:src/encoding/json/tags.go;bpv=0;bpt=1 +// Main difference is I'm trimming the options so this is valid: +// +// type myStruct struct { +// MaxBytesPerRequest int `env:"MAX_BYTES_PER_REQUEST, optionHasASpaceAfter"` +// } +type tagOptions string + +// parseTag splits a struct field's json tag into its name and +// comma-separated options. +func parseTag(tag string) (string, tagOptions) { + tag, opt, _ := strings.Cut(tag, ",") + return tag, tagOptions(opt) +} + +// Contains reports whether our comma-separated options contains a given option. +func (o tagOptions) Contains(optionName string) bool { + if len(o) == 0 { + return false + } + s := string(o) + for s != "" { + var name string + name, s, _ = strings.Cut(s, ",") + if strings.TrimSpace(name) == optionName { + return true + } + } + return false +} diff --git a/tag_test.go b/tag_test.go new file mode 100644 index 0000000..201f36b --- /dev/null +++ b/tag_test.go @@ -0,0 +1,26 @@ +package dotconfig + +import "testing" + +func TestParseTag(t *testing.T) { + name, _ := parseTag("MAX_BYTES_PER_REQUEST,required,optional") + if name != "MAX_BYTES_PER_REQUEST" { + t.Fatalf("name = %q, want MAX_BYTES_PER_REQUEST", name) + } + for _, tt := range []struct { + tag string + opt string + want bool + }{ + {"MAX_BYTES_PER_REQUEST, required, optional", "required", true}, + {"MAX_BYTES_PER_REQUEST", "required", false}, + {"MAX_BYTES_PER_REQUEST,required,optional", "optional", true}, + {"MAX_BYTES_PER_REQUEST,required,optional", "bogus", false}, + {"MAX_BYTES_PER_REQUEST, required", "", false}, + } { + _, opts := parseTag(tt.tag) + if opts.Contains(tt.opt) != tt.want { + t.Errorf("Contains(%q) = %v, want %v", tt.opt, !tt.want, tt.want) + } + } +}