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
2 changes: 1 addition & 1 deletion .github/workflows/buildandtest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.17
go-version: 1.18

- name: Build
run: make build
Expand Down
10 changes: 8 additions & 2 deletions .github/workflows/tag-on-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- main
pull_request:

jobs:
create-tag:
Expand All @@ -21,5 +22,10 @@ jobs:
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

- name: Run tag script
run: ./tag.sh
- name: Ensure version bump
run: |
.github/workflows/tag.sh check_version $(find . -name "Version" -or -name "Versions") || (echo 'Version Bump Required' >> $FAIL_REASON;exit 1) && exit 0

- name: Tag this release
run: |
.github/workflows/tag.sh $(find . -name "Version" -or -name "Versions" | cut -d/ -f2-) || (echo 'Failed to tag' >> $FAIL_REASON;exit 1) && exit 0
2 changes: 1 addition & 1 deletion tag.sh β†’ .github/workflows/tag.sh
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ fi
DEFAULT_BRANCH=$( git remote show $( git config --get remote.origin.url ) | grep 'HEAD branch' | cut -d' ' -f5 )
echo "Default branch is: $DEFAULT_BRANCH"

if [ "${1-}" = "check_version" ]; then
if [ "$1" = "check_version" ]; then
blue "\\nChecking version and verify if we need to update the version file."
shift
VERSION_FILES=$*
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ LINT_OPTS ?= --fix
.PHONEY: build
build: ${SRCS}
@go build

.PHONY: tests
tests: ## Run test suite
@go test -race ${PKGS}
Expand Down
2 changes: 1 addition & 1 deletion Version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.4.5
0.4.6
74 changes: 74 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Package env provides utilities for working with environment variables and loading
// configuration from .env files.
//
// This package offers three main areas of functionality:
//
// 1. Simple environment variable access with type conversion
// 2. Struct-based configuration parsing using struct tags
// 3. Loading and parsing of .env files
//
// # Basic Usage
//
// Get environment variables with automatic type conversion:
//
// port, err := env.GetInt("PORT")
// if err != nil {
// port = env.GetOrInt("PORT", 8080) // with default
// }
//
// // Or panic if missing/invalid
// dbHost := env.MustGet("DATABASE_HOST")
//
// # Struct-based Configuration
//
// Parse environment variables into structs using tags:
//
// type Config struct {
// Host string `env:"HOST" envDefault:"localhost"`
// Port int `env:"PORT" envDefault:"8080"`
// Debug bool `env:"DEBUG"`
// }
//
// var cfg Config
// if err := env.Parse(&cfg); err != nil {
// log.Fatal(err)
// }
//
// # File Loading
//
// Load environment variables from .env files:
//
// // Load from .env file
// err := env.Load()
// if err != nil {
// log.Fatal(err)
// }
//
// // Load from specific files
// err = env.Load("config.env", "local.env")
//
// # Supported Types
//
// The package supports automatic conversion for:
// - bool, int, uint, int64, uint64, float32, float64
// - time.Duration (using time.ParseDuration format)
// - *url.URL (using url.ParseRequestURI)
// - []string and other slice types (comma-separated by default)
// - Any type implementing encoding.TextUnmarshaler
//
// # Struct Tags
//
// When using Parse functions, the following struct tags are supported:
// - env:"VAR_NAME" - specifies the environment variable name
// - envDefault:"value" - provides a default value if the variable is not set
// - required:"true" - makes the field required (parsing fails if missing)
// - envSeparator:";" - custom separator for slice types (default is comma)
// - envExpand:"true" - enables variable expansion using os.ExpandEnv
//
// # Error Handling
//
// Functions come in three variants for different error handling approaches:
// - Get*() functions return (value, error)
// - GetOr*() functions return value with a fallback default
// - MustGet*() functions panic if the variable is missing or invalid
package env
123 changes: 95 additions & 28 deletions env.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ import (
"time"
)

// Constants for parsing operations (shared with util.go)
const (
DecimalBase = 10
Int32Bits = 32
Int64Bits = 64
Float32Bits = 32
Float64Bits = 64
)

var (
// ErrNotAStructPtr is returned if you pass something that is not a pointer to a
// Struct to Parse
Expand All @@ -20,6 +29,29 @@ var (
ErrUnsupportedType = errors.New("type is not supported")
// ErrUnsupportedSliceType if the slice element type is not supported by env
ErrUnsupportedSliceType = errors.New("unsupported slice type")
)

// ParseErrors represents multiple errors that occurred during parsing
type ParseErrors []error

// Error implements the error interface for ParseErrors
func (pe ParseErrors) Error() string {
if len(pe) == 0 {
return ""
}
if len(pe) == 1 {
return pe[0].Error()
}

var sb strings.Builder
sb.WriteString(fmt.Sprintf("multiple parsing errors (%d):", len(pe)))
for i, err := range pe {
sb.WriteString(fmt.Sprintf("\n %d. %s", i+1, err.Error()))
}
return sb.String()
}

var (
// OnEnvVarSet is an optional convenience callback, such as for logging purposes.
// If not nil, it's called after successfully setting the given field from the given value.
OnEnvVarSet func(reflect.StructField, string)
Expand All @@ -35,32 +67,67 @@ var (
sliceOfURLs = reflect.TypeOf([]url.URL(nil))
)

// CustomParsers is a friendly name for the type that `ParseWithFuncs()` accepts
// CustomParsers maps Go types to custom parsing functions.
// It allows you to provide custom logic for parsing environment variables
// into specific types that aren't supported by default.
//
// The key is the reflect.Type of the target type, and the value is a ParserFunc
// that knows how to convert a string to that type.
type CustomParsers map[reflect.Type]ParserFunc

// ParserFunc defines the signature of a function that can be used within `CustomParsers`
// ParserFunc defines the signature of a custom parsing function.
// It takes a string value from an environment variable and returns
// the parsed value as an interface{} and any parsing error.
//
// The returned value should be of the type that the parser is designed to handle.
type ParserFunc func(v string) (interface{}, error)

// Parse parses a struct containing `env` tags and loads its values from
// environment variables.
// Parse populates a struct's fields from environment variables.
// The struct fields must be tagged with `env:"VAR_NAME"` to specify
// which environment variable to read.
//
// Supported struct tags:
// - env:"VAR_NAME" - specifies the environment variable name (required)
// - envDefault:"value" - default value if the environment variable is not set
// - required:"true" - makes the field required (causes error if missing)
// - envSeparator:"," - separator for slice types (default is comma)
// - envExpand:"true" - enables variable expansion using os.ExpandEnv
//
// The function supports nested structs and pointers to structs.
// It returns an error if required fields are missing or if type conversion fails.
func Parse(v interface{}) error {
return ParseWithPrefixFuncs(v, "", make(map[reflect.Type]ParserFunc))
}

// ParseWithPrefix parses a struct containing `env` tags and loads its values from
// environment variables. The actual env vars looked up include the passed in prefix.
// ParseWithPrefix populates a struct's fields from environment variables with a prefix.
// This is useful for loading different configurations for the same struct type.
//
// For example, with prefix "CLIENT2_", a field tagged `env:"ENDPOINT"` will
// read from the environment variable "CLIENT2_ENDPOINT".
//
// See Parse for details on supported struct tags and behavior.
func ParseWithPrefix(v interface{}, prefix string) error {
return ParseWithPrefixFuncs(v, prefix, make(map[reflect.Type]ParserFunc))
}

// ParseWithFuncs is the same as `Parse` except it also allows the user to pass
// in custom parsers.
// ParseWithFuncs populates a struct's fields from environment variables,
// using custom parsing functions for specific types.
//
// This allows you to handle types that aren't supported by default.
// The funcMap parameter maps reflect.Type values to ParserFunc implementations.
//
// See Parse for details on supported struct tags and behavior.
func ParseWithFuncs(v interface{}, funcMap CustomParsers) error {
return ParseWithPrefixFuncs(v, "", funcMap)
}

// ParseWithPrefixFuncs is the same as `ParseWithPrefix` except it also allows the user to pass
// in custom parsers.
// ParseWithPrefixFuncs populates a struct's fields from environment variables
// with both a prefix and custom parsing functions.
//
// This combines the functionality of ParseWithPrefix and ParseWithFuncs,
// allowing both prefixed variable names and custom type parsing.
//
// See Parse for details on supported struct tags and behavior.
func ParseWithPrefixFuncs(v interface{}, prefix string, funcMap CustomParsers) error {
ptrRef := reflect.ValueOf(v)
if ptrRef.Kind() != reflect.Ptr {
Expand All @@ -75,7 +142,7 @@ func ParseWithPrefixFuncs(v interface{}, prefix string, funcMap CustomParsers) e

func doParse(ref reflect.Value, prefix string, funcMap CustomParsers) error {
refType := ref.Type()
var errorList []string
var parseErrors ParseErrors

for i := 0; i < refType.NumField(); i++ {
refField := ref.Field(i)
Expand All @@ -89,29 +156,29 @@ func doParse(ref reflect.Value, prefix string, funcMap CustomParsers) error {
refTypeField := refType.Field(i)
value, err := get(refTypeField, prefix)
if err != nil {
errorList = append(errorList, err.Error())
parseErrors = append(parseErrors, err)
continue
}
if value == "" {
if reflect.Struct == refField.Kind() {
if err := doParse(refField, prefix, funcMap); err != nil {
errorList = append(errorList, err.Error())
parseErrors = append(parseErrors, err)
}
}
continue
}
if err := set(refField, refTypeField, value, funcMap); err != nil {
errorList = append(errorList, err.Error())
parseErrors = append(parseErrors, err)
continue
}
if OnEnvVarSet != nil {
OnEnvVarSet(refTypeField, value)
}
}
if len(errorList) == 0 {
if len(parseErrors) == 0 {
return nil
}
return errors.New(strings.Join(errorList, ". "))
return parseErrors
}

func get(field reflect.StructField, prefix string) (string, error) {
Expand Down Expand Up @@ -185,25 +252,25 @@ func set(field reflect.Value, refType reflect.StructField, value string, funcMap
}
field.SetBool(bvalue)
case reflect.Int:
intValue, err := strconv.ParseInt(value, 10, 32)
intValue, err := strconv.ParseInt(value, DecimalBase, Int32Bits)
if err != nil {
return err
}
field.SetInt(intValue)
case reflect.Uint:
uintValue, err := strconv.ParseUint(value, 10, 32)
uintValue, err := strconv.ParseUint(value, DecimalBase, Int32Bits)
if err != nil {
return err
}
field.SetUint(uintValue)
case reflect.Float32:
v, err := strconv.ParseFloat(value, 32)
v, err := strconv.ParseFloat(value, Float32Bits)
if err != nil {
return err
}
field.SetFloat(v)
case reflect.Float64:
v, err := strconv.ParseFloat(value, 64)
v, err := strconv.ParseFloat(value, Float64Bits)
if err != nil {
return err
}
Expand All @@ -216,14 +283,14 @@ func set(field reflect.Value, refType reflect.StructField, value string, funcMap
}
field.Set(reflect.ValueOf(dValue))
} else {
intValue, err := strconv.ParseInt(value, 10, 64)
intValue, err := strconv.ParseInt(value, DecimalBase, Int64Bits)
if err != nil {
return err
}
field.SetInt(intValue)
}
case reflect.Uint64:
uintValue, err := strconv.ParseUint(value, 10, 64)
uintValue, err := strconv.ParseUint(value, DecimalBase, Int64Bits)
if err != nil {
return err
}
Expand Down Expand Up @@ -328,7 +395,7 @@ func parseInts(data []string) ([]int, error) {
intSlice := make([]int, 0, len(data))

for _, v := range data {
intValue, err := strconv.ParseInt(v, 10, 32)
intValue, err := strconv.ParseInt(v, DecimalBase, Int32Bits)
if err != nil {
return nil, err
}
Expand All @@ -341,7 +408,7 @@ func parseInt64s(data []string) ([]int64, error) {
intSlice := make([]int64, 0, len(data))

for _, v := range data {
intValue, err := strconv.ParseInt(v, 10, 64)
intValue, err := strconv.ParseInt(v, DecimalBase, Int64Bits)
if err != nil {
return nil, err
}
Expand All @@ -351,10 +418,10 @@ func parseInt64s(data []string) ([]int64, error) {
}

func parseUint64s(data []string) ([]uint64, error) {
var uintSlice []uint64
uintSlice := make([]uint64, 0, len(data))

for _, v := range data {
uintValue, err := strconv.ParseUint(v, 10, 64)
uintValue, err := strconv.ParseUint(v, DecimalBase, Int64Bits)
if err != nil {
return nil, err
}
Expand All @@ -367,7 +434,7 @@ func parseFloat32s(data []string) ([]float32, error) {
float32Slice := make([]float32, 0, len(data))

for _, v := range data {
data, err := strconv.ParseFloat(v, 32)
data, err := strconv.ParseFloat(v, Float32Bits)
if err != nil {
return nil, err
}
Expand All @@ -380,7 +447,7 @@ func parseFloat64s(data []string) ([]float64, error) {
float64Slice := make([]float64, 0, len(data))

for _, v := range data {
data, err := strconv.ParseFloat(v, 64)
data, err := strconv.ParseFloat(v, Float64Bits)
if err != nil {
return nil, err
}
Expand Down
Loading
Loading