Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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"`
}
Expand All @@ -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:

Expand Down Expand Up @@ -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.
33 changes: 25 additions & 8 deletions dotconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, " #")]
}
Expand Down Expand Up @@ -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")
)

Expand All @@ -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
Expand All @@ -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:
Expand Down
102 changes: 21 additions & 81 deletions dotconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package dotconfig_test

import (
"errors"
"fmt"
"os"
"reflect"
"strings"
Expand All @@ -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!
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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"`
}

Expand All @@ -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) {
Expand All @@ -101,16 +108,19 @@ 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{
MaxBytesPerRequest: 1024,
APIVersion: 1.19,
IsDev: true,
LogErrors: true,
OptionalField: "",
FieldWithDefault: "Overridden value",
notExported: "",
}
if !reflect.DeepEqual(config, expected) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Loading
Loading