Skip to content

Commit

Permalink
Configloader strict mode (and refactoring) (#114)
Browse files Browse the repository at this point in the history
* Improve type testing

* Add more handlers

* Big refactor

* Fix linter errors

* Improve documentation

* Improve documentation

* Improve errors

* Move the options to own file

* Fix embedding

* Fix the env / name tags

* Add env tests

* Fix tests

* Fix linter

* Make env default
  • Loading branch information
hadrienk authored Dec 13, 2024
1 parent be8a652 commit c7d2cf3
Show file tree
Hide file tree
Showing 8 changed files with 960 additions and 366 deletions.
418 changes: 245 additions & 173 deletions configloader/configloader.go

Large diffs are not rendered by default.

546 changes: 361 additions & 185 deletions configloader/configloader_test.go

Large diffs are not rendered by default.

64 changes: 64 additions & 0 deletions configloader/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package configloader

import (
"fmt"
"reflect"
"strings"
)

// ConfigLoadError represents the errors that occurred during config loading.
type ConfigLoadError struct {
Value reflect.Type
Errors []error
}

func (e *ConfigLoadError) Add(err error) {
if err != nil {
e.Errors = append(e.Errors, err)
}
}

func (e *ConfigLoadError) Error() string {
var msgs = make([]string, len(e.Errors))
for i, err := range e.Errors {
msgs[i] = err.Error()
}
return fmt.Sprintf("failed to load %s:\n%s", e.Value.String(),
strings.Join(msgs, "\n"))
}

func (e *ConfigLoadError) Unwrap() []error {
return e.Errors
}

// FieldError represents an error that occurred while processing a specific field.
type FieldError struct {
Field
Err error
}

func (e FieldError) Error() string {
return fmt.Sprintf("error processing field %s: %v", e.Field.String(), e.Err)
}

func (e FieldError) Unwrap() error {
return e.Err
}

// MissingEnvError represents an error when a required environment variable is not found.
type MissingEnvError struct {
Key string
}

func (e MissingEnvError) Error() string {
return fmt.Sprintf("environment variable %s not found", e.Key)
}

// UnsupportedTypeError represents an error when trying to process a field with an unsupported type.
type UnsupportedTypeError struct {
Type reflect.Type
}

func (e UnsupportedTypeError) Error() string {
return fmt.Sprintf("unsupported type %v", e.Type)
}
9 changes: 7 additions & 2 deletions configloader/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@ module github.com/coopnorge/member-lib/configloader

go 1.23.0

require (
fortio.org/safecast v1.0.0
github.com/ccoveille/go-safecast v1.2.0
github.com/iancoleman/strcase v0.3.0
github.com/stretchr/testify v1.9.0
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/iancoleman/strcase v0.3.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/testify v1.9.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
5 changes: 5 additions & 0 deletions configloader/go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
fortio.org/safecast v1.0.0 h1:dr3131WPX8iS1pTf76+39WeXbTrerDYLvi9s7Oi3wiY=
fortio.org/safecast v1.0.0/go.mod h1:xZmcPk3vi4kuUFf+tq4SvnlVdwViqf6ZSZl91Jr9Jdg=
github.com/ccoveille/go-safecast v1.2.0 h1:H4X7aosepsU1Mfk+098CTdKpsDH0cfYJ2RmwXFjgvfc=
github.com/ccoveille/go-safecast v1.2.0/go.mod h1:QqwNjxQ7DAqY0C721OIO9InMk9zCwcsO7tnRuHytad8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI=
Expand All @@ -6,6 +10,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
121 changes: 121 additions & 0 deletions configloader/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package configloader

import "reflect"

type Option func(*Loader)

// WithTypeHandler registers a custom type conversion function for a specific type T.
// The function should convert a string environment value to type T, returning an error
// if the conversion fails.
//
// Example:
//
// configloader.Load(&cfg, WithTypeHandler(func(s string) (time.Duration, error) {
// return time.ParseDuration(s)
// }))
func WithTypeHandler[T any](f func(string) (T, error)) Option {
return func(l *Loader) {
var zero T
l.handlers[reflect.TypeOf(zero)] = func(value string) (any, error) {
return f(value)
}
}
}

// WithNameTag sets the struct tag used to override a field's name in the environment variable path.
// The tag value replaces just the field's name segment while still following the normal path construction
// rules (prefix + path + name).
//
// Example with WithNameTag("name"):
//
// type Config struct {
// Database struct {
// Host string `name:"HOSTNAME"` // Looks for DATABASE_HOSTNAME
// }
// }
// configloader.Load(&cfg)
//
// Example with both prefix and name tag:
//
// type Config struct {
// Database struct {
// Host string `name:"HOSTNAME"` // Looks for APP_DATABASE_HOSTNAME
// }
// }
// configloader.Load(&cfg, WithPrefix("APP"))
func WithNameTag(tag string) Option {
return func(l *Loader) {
l.nameTag = tag
}
}

// WithEnvTag sets the struct tag used to completely override the environment variable name for a field.
// When a field has this tag, its value is used as-is for the environment variable name, bypassing all
// other name construction rules including prefixes and path building.
//
// Example with WithEnvTag("env"):
//
// type Config struct {
// Database struct {
// // Despite nesting, looks directly for "DB_HOST"
// Host string `env:"DB_HOST"`
// }
// }
// configloader.Load(&cfg)
//
// Example showing prefix is ignored with env tag:
//
// type Config struct {
// Database struct {
// // Still only looks for "DB_HOST", prefix is not applied
// Host string `env:"DB_HOST"`
// }
// }
// configloader.Load(&cfg, WithPrefix("APP"))
func WithEnvTag(tag string) Option {
return func(l *Loader) {
l.envTag = tag
}
}

// WithDefaultTag sets the struct tag used for specifying default values.
// If an environment variable is not found, the value of this tag will be used instead.
//
// Example:
//
// type Config struct {
// Port int `default:"8080"` // Will use 8080 if PORT is not set
// }
// configloader.Load(&cfg, WithDefaultTag("default"))
func WithDefaultTag(tag string) Option {
return func(l *Loader) {
l.defaultTag = tag
}
}

// WithPrefix sets a prefix that will be prepended to all environment variable names.
// The prefix and field name will be joined with an underscore.
//
// Example:
//
// type Config struct {
// Port int // Will look for "APP_PORT" environment variable
// }
// configloader.Load(&cfg, WithPrefix("APP"))
func WithPrefix(prefix string) Option {
return func(l *Loader) {
l.prefix = prefix
}
}

// WithEnv sets a custom function for looking up environment variables.
// This is primarily useful for testing or when environment variables need to be
// sourced from somewhere other than os.LookupEnv.
//
// The function should return the value and a boolean indicating whether the variable
// was found, similar to os.LookupEnv.
func WithEnv(env func(string) (string, bool)) Option {
return func(l *Loader) {
l.env = env
}
}
53 changes: 53 additions & 0 deletions configloader/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import (
"net"
"net/url"
"regexp"
"strconv"
"time"

"fortio.org/safecast"
)

var defaultTypeHandlers = []Option{
Expand Down Expand Up @@ -47,4 +50,54 @@ var defaultTypeHandlers = []Option{
_, network, err := net.ParseCIDR(val)
return network, err
}),

WithTypeHandler(parseInt(safecast.Convert[int64])),
WithTypeHandler(parseInt(safecast.Convert[int32])),
WithTypeHandler(parseInt(safecast.Convert[int16])),
WithTypeHandler(parseInt(safecast.Convert[int8])),
WithTypeHandler(parseInt(safecast.Convert[int])),

WithTypeHandler(parseUint(safecast.Convert[uint64])),
WithTypeHandler(parseUint(safecast.Convert[uint32])),
WithTypeHandler(parseUint(safecast.Convert[uint16])),
WithTypeHandler(parseUint(safecast.Convert[uint8])),
WithTypeHandler(parseUint(safecast.Convert[uint])),

WithTypeHandler(parseFloat(safecast.Convert[float64])),
WithTypeHandler(parseFloat(safecast.Convert[float32])),

WithTypeHandler(strconv.ParseBool),
WithTypeHandler(func(val string) (string, error) {
return val, nil
}),
}

func parseInt[T any](cast func(int64) (T, error)) func(string) (T, error) {
return func(val string) (res T, err error) {
parsed, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return res, err
}
return cast(parsed)
}
}

func parseUint[T any](cast func(uint64) (T, error)) func(string) (T, error) {
return func(val string) (res T, err error) {
parsed, err := strconv.ParseUint(val, 10, 64)
if err != nil {
return res, err
}
return cast(parsed)
}
}

func parseFloat[T any](cast func(float64) (T, error)) func(string) (T, error) {
return func(val string) (res T, err error) {
parsed, err := strconv.ParseFloat(val, 64)
if err != nil {
return res, err
}
return cast(parsed)
}
}
Loading

0 comments on commit c7d2cf3

Please sign in to comment.