diff --git a/.github/workflows/buildandtest.yml b/.github/workflows/buildandtest.yml index 619e894..3987eb6 100644 --- a/.github/workflows/buildandtest.yml +++ b/.github/workflows/buildandtest.yml @@ -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 diff --git a/.github/workflows/tag-on-merge.yml b/.github/workflows/tag-on-merge.yml index fbd86ae..a18000b 100644 --- a/.github/workflows/tag-on-merge.yml +++ b/.github/workflows/tag-on-merge.yml @@ -4,6 +4,7 @@ on: push: branches: - main + pull_request: jobs: create-tag: @@ -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 diff --git a/tag.sh b/.github/workflows/tag.sh similarity index 99% rename from tag.sh rename to .github/workflows/tag.sh index 6cba75c..5d00249 100755 --- a/tag.sh +++ b/.github/workflows/tag.sh @@ -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=$* diff --git a/Makefile b/Makefile index 9ae4f00..7ef1c9e 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,7 @@ LINT_OPTS ?= --fix .PHONEY: build build: ${SRCS} @go build + .PHONY: tests tests: ## Run test suite @go test -race ${PKGS} diff --git a/Version b/Version index 0bfccb0..ef52a64 100644 --- a/Version +++ b/Version @@ -1 +1 @@ -0.4.5 +0.4.6 diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..6487c25 --- /dev/null +++ b/doc.go @@ -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 diff --git a/env.go b/env.go index d725cec..c6fbc83 100644 --- a/env.go +++ b/env.go @@ -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 @@ -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) @@ -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 { @@ -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) @@ -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) { @@ -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 } @@ -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 } @@ -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 } @@ -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 } @@ -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 } @@ -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 } @@ -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 } diff --git a/env_test.go b/env_test.go index 13e479b..aa60cbf 100644 --- a/env_test.go +++ b/env_test.go @@ -677,7 +677,12 @@ func TestCustomParserBasicUnsupported(t *testing.T) { assert.Zero(t, cfg.Const) assert.Error(t, err) - assert.Equal(t, ErrUnsupportedType, err) + // With the new ParseErrors, single errors are wrapped + if parseErrors, ok := err.(ParseErrors); ok && len(parseErrors) == 1 { + assert.Equal(t, ErrUnsupportedType, parseErrors[0]) + } else { + assert.Equal(t, ErrUnsupportedType, err) + } } func TestUnsupportedStructType(t *testing.T) { @@ -691,7 +696,12 @@ func TestUnsupportedStructType(t *testing.T) { err := Parse(cfg) assert.Error(t, err) - assert.Equal(t, ErrUnsupportedType, err) + // With the new ParseErrors, single errors are wrapped + if parseErrors, ok := err.(ParseErrors); ok && len(parseErrors) == 1 { + assert.Equal(t, ErrUnsupportedType, parseErrors[0]) + } else { + assert.Equal(t, ErrUnsupportedType, err) + } } func TestTextUnmarshalerError(t *testing.T) { @@ -715,3 +725,53 @@ func ExampleParse() { fmt.Println(cfg) // Output: {/tmp/fakehome 3000 false} } + +// Test ParseErrors functionality +func TestParseErrors(t *testing.T) { + // Test empty ParseErrors + var emptyErrors ParseErrors + assert.Equal(t, "", emptyErrors.Error()) + + // Test single error + singleErrors := ParseErrors{errors.New("single error")} + assert.Equal(t, "single error", singleErrors.Error()) + + // Test multiple errors + multipleErrors := ParseErrors{ + errors.New("first error"), + errors.New("second error"), + errors.New("third error"), + } + expected := `multiple parsing errors (3): + 1. first error + 2. second error + 3. third error` + assert.Equal(t, expected, multipleErrors.Error()) +} + +// Test ParseErrors in actual parsing scenario +func TestParseMultipleErrors(t *testing.T) { + type config struct { + RequiredVar1 string `env:"REQUIRED_VAR1" required:"true"` + RequiredVar2 int `env:"REQUIRED_VAR2" required:"true"` + BadInt int `env:"BAD_INT_VAR"` + } + + // Set up environment to cause multiple errors + os.Unsetenv("REQUIRED_VAR1") + os.Unsetenv("REQUIRED_VAR2") + os.Setenv("BAD_INT_VAR", "not_a_number") + + cfg := &config{} + err := Parse(cfg) + + // Should get a ParseErrors with multiple issues + assert.Error(t, err) + parseErrors, ok := err.(ParseErrors) + assert.True(t, ok, "Error should be of type ParseErrors") + assert.Greater(t, len(parseErrors), 1, "Should have multiple errors") + + // Check that error message contains information about multiple errors + errMsg := err.Error() + assert.Contains(t, errMsg, "multiple parsing errors") +} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..879961d --- /dev/null +++ b/example_test.go @@ -0,0 +1,250 @@ +package env_test + +import ( + "fmt" + "log" + "os" + "time" + + "github.com/lindenlab/env" +) + +func ExampleGet() { + os.Setenv("HOME", "/home/user") + fmt.Println(env.Get("HOME")) + fmt.Println(env.Get("NONEXISTENT")) + // Output: + // /home/user + // +} + +func ExampleGetOr() { + os.Setenv("PORT", "8080") + fmt.Println(env.GetOr("PORT", "3000")) + fmt.Println(env.GetOr("MISSING_PORT", "3000")) + // Output: + // 8080 + // 3000 +} + +func ExampleGetInt() { + os.Setenv("PORT", "8080") + os.Setenv("INVALID_PORT", "not-a-number") + + port, err := env.GetInt("PORT") + if err != nil { + log.Fatal(err) + } + fmt.Println("Port:", port) + + _, err = env.GetInt("INVALID_PORT") + fmt.Println("Error:", err != nil) + // Output: + // Port: 8080 + // Error: true +} + +func ExampleGetOrInt() { + os.Setenv("PORT", "8080") + fmt.Println("Existing:", env.GetOrInt("PORT", 3000)) + fmt.Println("Missing:", env.GetOrInt("MISSING_PORT", 3000)) + fmt.Println("Invalid:", env.GetOrInt("INVALID_PORT", 3000)) + // Output: + // Existing: 8080 + // Missing: 3000 + // Invalid: 3000 +} + +func ExampleGetBool() { + os.Setenv("DEBUG", "true") + os.Setenv("VERBOSE", "1") + os.Setenv("QUIET", "false") + + debug, _ := env.GetBool("DEBUG") + verbose, _ := env.GetBool("VERBOSE") + quiet, _ := env.GetBool("QUIET") + + fmt.Println("Debug:", debug) + fmt.Println("Verbose:", verbose) + fmt.Println("Quiet:", quiet) + // Output: + // Debug: true + // Verbose: true + // Quiet: false +} + +func ExampleGetDuration() { + os.Setenv("TIMEOUT", "30s") + os.Setenv("INTERVAL", "5m30s") + + timeout, _ := env.GetDuration("TIMEOUT") + interval, _ := env.GetDuration("INTERVAL") + + fmt.Println("Timeout:", timeout) + fmt.Println("Interval:", interval) + // Output: + // Timeout: 30s + // Interval: 5m30s +} + +func ExampleLoad() { + // Create a temporary .env file + envContent := `# Database configuration +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_NAME=myapp + +# Application settings +DEBUG=true +LOG_LEVEL=info` + + err := os.WriteFile(".env", []byte(envContent), 0600) + if err != nil { + log.Fatal(err) + } + defer os.Remove(".env") + + // Load the .env file + err = env.Load() + if err != nil { + log.Fatal(err) + } + + // Access the loaded variables + fmt.Println("Database Host:", env.Get("DATABASE_HOST")) + fmt.Println("Database Port:", env.GetOrInt("DATABASE_PORT", 0)) + fmt.Println("Debug Mode:", env.GetOrBool("DEBUG", false)) + // Output: + // Database Host: localhost + // Database Port: 5432 + // Debug Mode: true +} + +func ExampleParse() { + // Set up environment variables + os.Setenv("HOME", "/tmp/fakehome") + os.Setenv("PORT", "8080") + os.Setenv("DEBUG", "true") + os.Setenv("TAGS", "web,api,database") + os.Setenv("TIMEOUT", "30s") + + type Config struct { + Home string `env:"HOME"` + Port int `env:"PORT" envDefault:"3000"` + Debug bool `env:"DEBUG"` + Tags []string `env:"TAGS" envSeparator:","` + Timeout time.Duration `env:"TIMEOUT" envDefault:"10s"` + Version string `env:"VERSION" envDefault:"1.0.0"` + Required string `env:"REQUIRED_VAR" required:"true"` + } + + // This will fail because REQUIRED_VAR is not set + var cfg Config + err := env.Parse(&cfg) + if err != nil { + fmt.Println("Error:", err != nil) + } + + // Set the required variable and try again + os.Setenv("REQUIRED_VAR", "important-value") + err = env.Parse(&cfg) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Home: %s\n", cfg.Home) + fmt.Printf("Port: %d\n", cfg.Port) + fmt.Printf("Debug: %t\n", cfg.Debug) + fmt.Printf("Tags: %v\n", cfg.Tags) + fmt.Printf("Timeout: %v\n", cfg.Timeout) + fmt.Printf("Version: %s\n", cfg.Version) + fmt.Printf("Required: %s\n", cfg.Required) + // Output: + // Error: true + // Home: /tmp/fakehome + // Port: 8080 + // Debug: true + // Tags: [web api database] + // Timeout: 30s + // Version: 1.0.0 + // Required: important-value +} + +func ExampleParseWithPrefix() { + // Set up environment variables with prefixes + os.Setenv("CLIENT1_HOST", "api.example.com") + os.Setenv("CLIENT1_PORT", "443") + os.Setenv("CLIENT2_HOST", "internal.example.com") + os.Setenv("CLIENT2_PORT", "8080") + + type ClientConfig struct { + Host string `env:"HOST" envDefault:"localhost"` + Port int `env:"PORT" envDefault:"80"` + } + + var client1, client2 ClientConfig + + err := env.ParseWithPrefix(&client1, "CLIENT1_") + if err != nil { + log.Fatal(err) + } + err = env.ParseWithPrefix(&client2, "CLIENT2_") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Client 1: %s:%d\n", client1.Host, client1.Port) + fmt.Printf("Client 2: %s:%d\n", client2.Host, client2.Port) + // Output: + // Client 1: api.example.com:443 + // Client 2: internal.example.com:8080 +} + +func ExampleRead() { + // Create a temporary .env file + envContent := `DATABASE_URL=postgres://localhost/myapp +REDIS_URL=redis://localhost:6379 +API_KEY=secret123 +DEBUG=true` + + err := os.WriteFile("config.env", []byte(envContent), 0600) + if err != nil { + log.Fatal(err) + } + defer os.Remove("config.env") + + // Read the file without setting environment variables + envMap, err := env.Read("config.env") + if err != nil { + log.Fatal(err) + } + + fmt.Println("Database URL:", envMap["DATABASE_URL"]) + fmt.Println("Redis URL:", envMap["REDIS_URL"]) + fmt.Println("Debug:", envMap["DEBUG"]) + // Output: + // Database URL: postgres://localhost/myapp + // Redis URL: redis://localhost:6379 + // Debug: true +} + +func ExampleMarshal() { + envMap := map[string]string{ + "DATABASE_URL": "postgres://localhost/myapp", + "DEBUG": "true", + "PORT": "8080", + "API_KEY": "secret with spaces", + } + + content, err := env.Marshal(envMap) + if err != nil { + log.Fatal(err) + } + + fmt.Println(content) + // Output: + // API_KEY="secret with spaces" + // DATABASE_URL="postgres://localhost/myapp" + // DEBUG="true" + // PORT="8080" +} diff --git a/file.go b/file.go index f8ddd82..17066a4 100644 --- a/file.go +++ b/file.go @@ -13,17 +13,19 @@ import ( const doubleQuoteSpecialChars = "\\\n\r\"!$`" -// Load will read your env file(s) and load them into ENV for this process. +// Load reads environment variables from .env files and sets them in the current process. +// If no filenames are provided, it defaults to loading ".env" from the current directory. // -// Call this function as close as possible to the start of your program (ideally in main) +// Multiple files can be loaded in order: // -// # If you call Load without any args it will default to loading .env in the current path +// err := env.Load("base.env", "local.env") // -// You can otherwise tell it which files to load (there can be more than one) like +// Important: Load will NOT override environment variables that are already set. +// This allows .env files to provide defaults while respecting existing environment configuration. +// Use Overload if you need to override existing variables. // -// err := env.Load("fileone", "filetwo") -// -// It's important to note that it WILL NOT OVERRIDE an env variable that already exists - consider the .env file to set dev vars or sensible defaults +// The function returns an error if any file cannot be read or parsed. +// Call this early in your program, typically in main(). func Load(filenames ...string) (err error) { filenames = filenamesOrDefault(filenames) @@ -36,18 +38,17 @@ func Load(filenames ...string) (err error) { return } -// MustLoad will read your env file(s) and load them into ENV for this process. -// -// Call this function as close as possible to the start of your program (ideally in main) +// MustLoad reads environment variables from .env files and sets them in the current process. +// If no filenames are provided, it defaults to loading ".env" from the current directory. // -// If you call Load without any args it will default to loading .env in the current path. -// If there are any errors the function will panic. +// This function behaves exactly like Load, except it panics on any error instead of returning it. +// Use this when .env file loading is critical for your application to function. // -// You can otherwise tell it which files to load (there can be more than one) like +// Multiple files can be loaded in order: // -// env.MustLoad("fileone", "filetwo") +// env.MustLoad("base.env", "local.env") // -// It's important to note that it WILL NOT OVERRIDE an env variable that already exists - consider the .env file to set dev vars or sensible defaults +// Like Load, this will NOT override environment variables that are already set. func MustLoad(filenames ...string) { filenames = filenamesOrDefault(filenames) @@ -59,17 +60,18 @@ func MustLoad(filenames ...string) { } } -// Overload will read your env file(s) and load them into ENV for this process. +// Overload reads environment variables from .env files and sets them in the current process, +// overriding any existing environment variables. +// If no filenames are provided, it defaults to loading ".env" from the current directory. // -// Call this function as close as possible to the start of your program (ideally in main) +// Unlike Load, this function WILL override environment variables that are already set. +// Use this when you want .env files to take precedence over existing environment configuration. // -// # If you call Overload without any args it will default to loading .env in the current path +// Multiple files can be loaded in order: // -// You can otherwise tell it which files to load (there can be more than one) like +// err := env.Overload("base.env", "production.env") // -// err := env.Overload("fileone", "filetwo") -// -// It's important to note this WILL OVERRIDE an env variable that already exists - consider the .env file to forcefilly set all vars. +// The function returns an error if any file cannot be read or parsed. func Overload(filenames ...string) (err error) { filenames = filenamesOrDefault(filenames) @@ -82,17 +84,16 @@ func Overload(filenames ...string) (err error) { return } -// MustOverload will read your env file(s) and load them into ENV for this process. -// -// Call this function as close as possible to the start of your program (ideally in main) +// MustOverload reads environment variables from .env files and sets them in the current process, +// overriding any existing environment variables. +// If no filenames are provided, it defaults to loading ".env" from the current directory. // -// # If you call Overload without any args it will default to loading .env in the current path +// This function behaves exactly like Overload, except it panics on any error instead of returning it. +// Use this when .env file loading is critical and should override existing configuration. // -// You can otherwise tell it which files to load (there can be more than one) like +// Multiple files can be loaded in order: // -// env.MustOverload("fileone", "filetwo") -// -// It's important to note this WILL OVERRIDE an env variable that already exists - consider the .env file to forcefilly set all vars. +// env.MustOverload("base.env", "production.env") func MustOverload(filenames ...string) { filenames = filenamesOrDefault(filenames) @@ -104,8 +105,20 @@ func MustOverload(filenames ...string) { } } -// Read all env (with same file loading semantics as Load) but return values as -// a map rather than automatically writing values into env +// Read parses .env files and returns the key-value pairs as a map, +// without setting them as environment variables in the current process. +// +// This is useful when you want to inspect or manipulate the values before +// applying them, or when you want to use the values in a different way. +// If no filenames are provided, it defaults to reading ".env" from the current directory. +// +// Multiple files can be read, with later files overriding earlier ones: +// +// envMap, err := env.Read("base.env", "local.env") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("DATABASE_URL=%s\n", envMap["DATABASE_URL"]) func Read(filenames ...string) (envMap map[string]string, err error) { filenames = filenamesOrDefault(filenames) envMap = make(map[string]string) @@ -126,7 +139,17 @@ func Read(filenames ...string) (envMap map[string]string, err error) { return } -// ParseIO reads an env file from io.Reader, returning a map of keys and values. +// ParseIO reads environment variables from an io.Reader in .env format. +// It parses the content and returns a map of key-value pairs. +// +// This function supports the standard .env file format including: +// - Comments (lines starting with #) +// - Quoted values (both single and double quotes) +// - Variable expansion (${VAR} and $VAR syntax) +// - Multiline values +// - Both KEY=value and KEY: value formats +// +// It does not set any environment variables; it only parses and returns the data. func ParseIO(r io.Reader) (envMap map[string]string, err error) { envMap = make(map[string]string) @@ -154,12 +177,20 @@ func ParseIO(r io.Reader) (envMap map[string]string, err error) { return } -// Unmarshal reads an env file from a string, returning a map of keys and values. +// Unmarshal parses environment variables from a string in .env format. +// It returns a map of key-value pairs without setting any environment variables. +// +// This is a convenience function that wraps ParseIO with a strings.NewReader. +// See ParseIO for details on the supported .env file format. func Unmarshal(str string) (envMap map[string]string, err error) { return ParseIO(strings.NewReader(str)) } -// Write serializes the given environment and writes it to a file +// Write serializes a map of environment variables to a .env file. +// The output file will contain KEY="VALUE" pairs, one per line, +// with keys sorted alphabetically and values properly escaped. +// +// This function will create or overwrite the specified file. func Write(envMap map[string]string, filename string) error { content, error := Marshal(envMap) if error != nil { @@ -173,8 +204,11 @@ func Write(envMap map[string]string, filename string) error { return err } -// Marshal outputs the given environment as a dotenv-formatted environment file. -// Each line is in the format: KEY="VALUE" where VALUE is backslash-escaped. +// Marshal converts a map of environment variables to .env file format. +// It returns a string with KEY="VALUE" pairs, one per line, +// with keys sorted alphabetically and values properly escaped with backslashes. +// +// This function does not write to any file; use Write to save the output to disk. func Marshal(envMap map[string]string) (string, error) { lines := make([]string, 0, len(envMap)) for k, v := range envMap { @@ -197,15 +231,8 @@ func loadFile(filename string, overload bool) error { return err } - currentEnv := map[string]bool{} - rawEnv := os.Environ() - for _, rawEnvLine := range rawEnv { - key := strings.Split(rawEnvLine, "=")[0] - currentEnv[key] = true - } - for key, value := range envMap { - if !currentEnv[key] || overload { + if _, exists := os.LookupEnv(key); !exists || overload { os.Setenv(key, value) } } diff --git a/file_test.go b/file_test.go index fb201df..6b2b8ac 100644 --- a/file_test.go +++ b/file_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "reflect" + "strconv" "strings" "testing" @@ -476,3 +477,93 @@ func TestRoundtrip(t *testing.T) { } } + +// Benchmark tests for performance improvements +func BenchmarkLoadFile(b *testing.B) { + // Create a temporary .env file for benchmarking + tempFile := "benchmark_test.env" + envContent := `TEST_VAR1=value1 +TEST_VAR2=value2 +TEST_VAR3=value3 +TEST_VAR4=value4 +TEST_VAR5=value5 +TEST_VAR6=value6 +TEST_VAR7=value7 +TEST_VAR8=value8 +TEST_VAR9=value9 +TEST_VAR10=value10` + + err := os.WriteFile(tempFile, []byte(envContent), 0600) + if err != nil { + b.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tempFile) + + // Set some existing environment variables to test the lookup optimization + os.Setenv("EXISTING_VAR1", "existing1") + os.Setenv("EXISTING_VAR2", "existing2") + os.Setenv("EXISTING_VAR3", "existing3") + defer func() { + os.Unsetenv("EXISTING_VAR1") + os.Unsetenv("EXISTING_VAR2") + os.Unsetenv("EXISTING_VAR3") + }() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := loadFile(tempFile, false) + if err != nil { + b.Fatalf("loadFile failed: %v", err) + } + } +} + +func BenchmarkLoadFileWithOverload(b *testing.B) { + // Create a temporary .env file for benchmarking + tempFile := "benchmark_overload_test.env" + envContent := `TEST_VAR1=new_value1 +TEST_VAR2=new_value2 +TEST_VAR3=new_value3 +EXISTING_VAR1=overridden1 +EXISTING_VAR2=overridden2` + + err := os.WriteFile(tempFile, []byte(envContent), 0600) + if err != nil { + b.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tempFile) + + // Set some existing environment variables + os.Setenv("EXISTING_VAR1", "original1") + os.Setenv("EXISTING_VAR2", "original2") + defer func() { + os.Unsetenv("EXISTING_VAR1") + os.Unsetenv("EXISTING_VAR2") + }() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := loadFile(tempFile, true) + if err != nil { + b.Fatalf("loadFile with overload failed: %v", err) + } + } +} + +func BenchmarkParseUint64s(b *testing.B) { + // Create test data with many uint64 values + testData := make([]string, 100) + for i := 0; i < 100; i++ { + // Use strconv.Itoa to avoid conversion issues + baseVal := i * 12345 + testData[i] = strconv.Itoa(baseVal) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := parseUint64s(testData) + if err != nil { + b.Fatalf("parseUint64s failed: %v", err) + } + } +} diff --git a/go.mod b/go.mod index 524e01b..59b5813 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/lindenlab/env -go 1.17 +go 1.18 require github.com/stretchr/testify v1.11.1 diff --git a/util.go b/util.go index 9ce6ad5..d3995e7 100644 --- a/util.go +++ b/util.go @@ -4,26 +4,135 @@ import ( "fmt" "net/url" "os" + "regexp" "strconv" "time" ) -// Set - sets an environment variable +// Regular expression for validating environment variable names +// Typically follows pattern: [A-Z_][A-Z0-9_]* +var envVarNameRegex = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`) + +// isValidEnvVarKey validates that an environment variable key follows standard naming conventions +// This is a helper function for internal validation - not exported to avoid breaking changes +func isValidEnvVarKey(key string) bool { + if key == "" { + return false + } + return envVarNameRegex.MatchString(key) +} + +// Generic types and functions for reducing code duplication + +// Parser is a function type that converts a string value to type T. +// It is used by the generic parsing functions to provide type-safe +// conversion from environment variable string values. +type Parser[T any] func(string) (T, error) + +// GetParsed retrieves an environment variable and parses it using the provided parser function. +// It returns the parsed value and any parsing error. If the environment variable is not set, +// the parser receives an empty string. +// +// This is a generic function that can be used with any type that has a corresponding parser. +// For common types, use the specific Get* functions which are more convenient. +func GetParsed[T any](key string, parser Parser[T]) (T, error) { + value := os.Getenv(key) + return parser(value) +} + +// GetOrParsed retrieves an environment variable and parses it using the provided parser function. +// If the environment variable is not set or parsing fails, it returns the default value. +// +// This function never returns an error; it falls back to the default value on any failure. +func GetOrParsed[T any](key string, defaultValue T, parser Parser[T]) T { + strValue, ok := os.LookupEnv(key) + if ok { + if value, err := parser(strValue); err == nil { + return value + } + } + return defaultValue +} + +// MustGetParsed retrieves an environment variable and parses it using the provided parser function. +// If the environment variable is not set or parsing fails, it panics with a descriptive message. +// +// The typeName parameter is used in panic messages to identify the expected type. +// Use this function when the environment variable is required for the application to function. +func MustGetParsed[T any](key string, parser Parser[T], typeName string) T { + strValue, ok := os.LookupEnv(key) + if ok { + if value, err := parser(strValue); err == nil { + return value + } else { + panic(fmt.Sprintf("environment variable \"%s\" could not be converted to %s", key, typeName)) + } + } + panic(fmt.Sprintf("expected environment variable \"%s\" does not exist", key)) +} + +// Type-specific parser functions +var ( + ParseBool = func(s string) (bool, error) { + return strconv.ParseBool(s) + } + ParseInt = func(s string) (int, error) { + v, err := strconv.ParseInt(s, DecimalBase, Int32Bits) + return int(v), err + } + ParseUint = func(s string) (uint, error) { + v, err := strconv.ParseUint(s, DecimalBase, Int32Bits) + return uint(v), err + } + ParseFloat32 = func(s string) (float32, error) { + v, err := strconv.ParseFloat(s, Float32Bits) + return float32(v), err + } + ParseFloat64 = func(s string) (float64, error) { + return strconv.ParseFloat(s, Float64Bits) + } + ParseInt64 = func(s string) (int64, error) { + return strconv.ParseInt(s, DecimalBase, Int64Bits) + } + ParseUint64 = func(s string) (uint64, error) { + return strconv.ParseUint(s, DecimalBase, Int64Bits) + } + ParseDuration = func(s string) (time.Duration, error) { + return time.ParseDuration(s) + } + ParseURL = func(s string) (*url.URL, error) { + return url.ParseRequestURI(s) + } +) + +// Set sets an environment variable to the specified value. +// It returns an error if the operation fails. +// +// This is a simple wrapper around os.Setenv for consistency with other functions in this package. func Set(key, value string) error { return os.Setenv(key, value) } -// Unset - unsets an environment variable +// Unset removes an environment variable. +// It returns an error if the operation fails. +// +// This is a simple wrapper around os.Unsetenv for consistency with other functions in this package. func Unset(key string) error { return os.Unsetenv(key) } -// Get - get an environment variable, empty string if does not exist +// Get retrieves the value of an environment variable. +// If the variable is not set, it returns an empty string. +// +// This is a simple wrapper around os.Getenv for consistency with other functions in this package. func Get(key string) string { return os.Getenv(key) } -// GetOr - get an environment variable or return default value if does not exist +// GetOr retrieves the value of an environment variable. +// If the variable is not set, it returns the provided default value. +// +// This function distinguishes between unset variables and variables set to empty strings. func GetOr(key, defaultValue string) string { value, ok := os.LookupEnv(key) if ok { @@ -32,7 +141,10 @@ func GetOr(key, defaultValue string) string { return defaultValue } -// MustGet - get an environment variable or panic if does not exist +// MustGet retrieves the value of an environment variable. +// If the variable is not set, it panics with a descriptive message. +// +// Use this function when the environment variable is required for the application to function. func MustGet(key string) string { value, ok := os.LookupEnv(key) if ok { @@ -41,236 +153,153 @@ func MustGet(key string) string { panic(fmt.Sprintf("expected environment variable \"%s\" does not exist", key)) } -// GetBool - get an environment variable as boolean +// GetBool retrieves an environment variable and parses it as a boolean. +// It accepts values like "true", "false", "1", "0", "t", "f", "T", "F", "TRUE", "FALSE" (case-insensitive). +// Returns the parsed boolean value and any parsing error. func GetBool(key string) (bool, error) { - return strconv.ParseBool(os.Getenv(key)) + return GetParsed(key, ParseBool) } -// GetOrBool - get an environment variable or return default value if does not exist +// GetOrBool retrieves an environment variable and parses it as a boolean. +// If the variable is not set or parsing fails, it returns the default value. func GetOrBool(key string, defaultValue bool) bool { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := strconv.ParseBool(strValue) - if err == nil { - return value - } - } - return defaultValue + return GetOrParsed(key, defaultValue, ParseBool) } -// MustGetBool - get an environment variable or panic if does not exist +// MustGetBool retrieves an environment variable and parses it as a boolean. +// If the variable is not set or parsing fails, it panics. func MustGetBool(key string) bool { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := strconv.ParseBool(strValue) - if err == nil { - return value - } else { - panic(fmt.Sprintf("environment variable \"%s\" could not be converted to boolean", key)) - } - } - panic(fmt.Sprintf("expected environment variable \"%s\" does not exist", key)) + return MustGetParsed(key, ParseBool, "bool") } -// GetInt - get an environment variable as int +// GetInt retrieves an environment variable and parses it as a signed integer. +// It accepts decimal integers that fit in the int type (platform-dependent size). +// Returns the parsed integer value and any parsing error. func GetInt(key string) (int, error) { - value, err := strconv.ParseInt(os.Getenv(key), 10, 32) - return int(value), err + return GetParsed(key, ParseInt) } -// GetOrInt - get an environment variable or return default value if does not exist +// GetOrInt retrieves an environment variable and parses it as a signed integer. +// If the variable is not set or parsing fails, it returns the default value. func GetOrInt(key string, defaultValue int) int { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := strconv.ParseInt(strValue, 10, 32) - if err == nil { - return int(value) - } - } - return defaultValue + return GetOrParsed(key, defaultValue, ParseInt) } -// MustGetInt - get an environment variable or panic if does not exist +// MustGetInt retrieves an environment variable and parses it as a signed integer. +// If the variable is not set or parsing fails, it panics. func MustGetInt(key string) int { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := strconv.ParseInt(strValue, 10, 32) - if err == nil { - return int(value) - } else { - panic(fmt.Sprintf("environment variable \"%s\" could not be converted to int", key)) - } - } - panic(fmt.Sprintf("expected environment variable \"%s\" does not exist", key)) + return MustGetParsed(key, ParseInt, "int") } -// GetUint - get an environment variable as uint +// GetUint retrieves an environment variable and parses it as an unsigned integer. +// It accepts non-negative decimal integers that fit in the uint type (platform-dependent size). +// Returns the parsed unsigned integer value and any parsing error. func GetUint(key string) (uint, error) { - value, err := strconv.ParseUint(os.Getenv(key), 10, 32) - return uint(value), err + return GetParsed(key, ParseUint) } -// GetOrUint - get an environment variable or return default value if does not exist +// GetOrUint retrieves an environment variable and parses it as an unsigned integer. +// If the variable is not set or parsing fails, it returns the default value. func GetOrUint(key string, defaultValue uint) uint { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := strconv.ParseUint(strValue, 10, 32) - if err == nil { - return uint(value) - } - } - return defaultValue + return GetOrParsed(key, defaultValue, ParseUint) } -// MustGetUint - get an environment variable or panic if does not exist +// MustGetUint retrieves an environment variable and parses it as an unsigned integer. +// If the variable is not set or parsing fails, it panics. func MustGetUint(key string) uint { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := strconv.ParseUint(strValue, 10, 32) - if err == nil { - return uint(value) - } else { - panic(fmt.Sprintf("environment variable \"%s\" could not be converted to uint", key)) - } - } - panic(fmt.Sprintf("expected environment variable \"%s\" does not exist", key)) + return MustGetParsed(key, ParseUint, "uint") } -// GetFloat32 - get an environment variable as float32 +// GetFloat32 retrieves an environment variable and parses it as a 32-bit floating point number. +// It accepts decimal numbers in standard or scientific notation. +// Returns the parsed float32 value and any parsing error. func GetFloat32(key string) (float32, error) { - value, err := strconv.ParseFloat(os.Getenv(key), 32) - return float32(value), err + return GetParsed(key, ParseFloat32) } -// GetOrFloat32 - get an environment variable or return default value if does not exist +// GetOrFloat32 retrieves an environment variable and parses it as a 32-bit floating point number. +// If the variable is not set or parsing fails, it returns the default value. func GetOrFloat32(key string, defaultValue float32) float32 { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := strconv.ParseFloat(strValue, 32) - if err == nil { - return float32(value) - } - } - return defaultValue + return GetOrParsed(key, defaultValue, ParseFloat32) } -// MustGetUFloat32 - get an environment variable or panic if does not exist +// MustGetFloat32 retrieves an environment variable and parses it as a 32-bit floating point number. +// If the variable is not set or parsing fails, it panics. func MustGetFloat32(key string) float32 { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := strconv.ParseFloat(strValue, 32) - if err == nil { - return float32(value) - } else { - panic(fmt.Sprintf("environment variable \"%s\" could not be converted to float32", key)) - } - } - panic(fmt.Sprintf("expected environment variable \"%s\" does not exist", key)) + return MustGetParsed(key, ParseFloat32, "float32") } -// GetFloat64 - get an environment variable as float64 +// GetFloat64 retrieves an environment variable and parses it as a 64-bit floating point number. +// It accepts decimal numbers in standard or scientific notation. +// Returns the parsed float64 value and any parsing error. func GetFloat64(key string) (float64, error) { - value, err := strconv.ParseFloat(os.Getenv(key), 64) - return float64(value), err + return GetParsed(key, ParseFloat64) } -// GetOrFloat64 - get an environment variable or return default value if does not exist +// GetOrFloat64 retrieves an environment variable and parses it as a 64-bit floating point number. +// If the variable is not set or parsing fails, it returns the default value. func GetOrFloat64(key string, defaultValue float64) float64 { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := strconv.ParseFloat(strValue, 64) - if err == nil { - return float64(value) - } - } - return defaultValue + return GetOrParsed(key, defaultValue, ParseFloat64) } -// MustGetUFloat64 - get an environment variable or panic if does not exist +// MustGetFloat64 retrieves an environment variable and parses it as a 64-bit floating point number. +// If the variable is not set or parsing fails, it panics. func MustGetFloat64(key string) float64 { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := strconv.ParseFloat(strValue, 64) - if err == nil { - return float64(value) - } else { - panic(fmt.Sprintf("environment variable \"%s\" could not be converted to float64", key)) - } - } - panic(fmt.Sprintf("expected environment variable \"%s\" does not exist", key)) + return MustGetParsed(key, ParseFloat64, "float64") } -// GetInt64 - get an environment variable as int64 +// GetInt64 retrieves an environment variable and parses it as a 64-bit signed integer. +// It accepts decimal integers in the range -9223372036854775808 to 9223372036854775807. +// Returns the parsed int64 value and any parsing error. func GetInt64(key string) (int64, error) { - value, err := strconv.ParseInt(os.Getenv(key), 10, 64) - return int64(value), err + return GetParsed(key, ParseInt64) } -// GetOrInt64 - get an environment variable or return default value if does not exist +// GetOrInt64 retrieves an environment variable and parses it as a 64-bit signed integer. +// If the variable is not set or parsing fails, it returns the default value. func GetOrInt64(key string, defaultValue int64) int64 { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := strconv.ParseInt(strValue, 10, 64) - if err == nil { - return int64(value) - } - } - return defaultValue + return GetOrParsed(key, defaultValue, ParseInt64) } -// MustGetInt64 - get an environment variable or panic if does not exist +// MustGetInt64 retrieves an environment variable and parses it as a 64-bit signed integer. +// If the variable is not set or parsing fails, it panics. func MustGetInt64(key string) int64 { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := strconv.ParseInt(strValue, 10, 64) - if err == nil { - return int64(value) - } else { - panic(fmt.Sprintf("environment variable \"%s\" could not be converted to int64", key)) - } - } - panic(fmt.Sprintf("expected environment variable \"%s\" does not exist", key)) + return MustGetParsed(key, ParseInt64, "int64") } -// GetUint64 - get an environment variable as uint +// GetUint64 retrieves an environment variable and parses it as a 64-bit unsigned integer. +// It accepts non-negative decimal integers in the range 0 to 18446744073709551615. +// Returns the parsed uint64 value and any parsing error. func GetUint64(key string) (uint64, error) { - value, err := strconv.ParseUint(os.Getenv(key), 10, 64) - return uint64(value), err + return GetParsed(key, ParseUint64) } -// GetOrUint64 - get an environment variable or return default value if does not exist +// GetOrUint64 retrieves an environment variable and parses it as a 64-bit unsigned integer. +// If the variable is not set or parsing fails, it returns the default value. func GetOrUint64(key string, defaultValue uint64) uint64 { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := strconv.ParseUint(strValue, 10, 64) - if err == nil { - return uint64(value) - } - } - return defaultValue + return GetOrParsed(key, defaultValue, ParseUint64) } -// MustGetUint64 - get an environment variable or panic if does not exist +// MustGetUint64 retrieves an environment variable and parses it as a 64-bit unsigned integer. +// If the variable is not set or parsing fails, it panics. func MustGetUint64(key string) uint64 { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := strconv.ParseUint(strValue, 10, 64) - if err == nil { - return uint64(value) - } else { - panic(fmt.Sprintf("environment variable \"%s\" could not be converted to uint64", key)) - } - } - panic(fmt.Sprintf("expected environment variable \"%s\" does not exist", key)) + return MustGetParsed(key, ParseUint64, "uint64") } -// GetDuration - get an environment variable as time.Duration +// GetDuration retrieves an environment variable and parses it as a time.Duration. +// It accepts duration strings like "5s", "2m30s", "1h", "300ms", etc. +// See time.ParseDuration for the complete format specification. +// Returns the parsed duration value and any parsing error. func GetDuration(key string) (time.Duration, error) { - value, err := time.ParseDuration(os.Getenv(key)) - return value, err + return GetParsed(key, ParseDuration) } -// GetOrDuration - get an environment variable or return default value if does not exist +// GetOrDuration retrieves an environment variable and parses it as a time.Duration. +// If the variable is not set or parsing fails, it parses and returns the default value. +// The default value must be a valid duration string, or the function will panic. +// +// Note: This function takes a string default value (unlike other GetOr* functions) +// to maintain backwards compatibility with existing APIs. func GetOrDuration(key string, defaultValue string) time.Duration { strValue, ok := os.LookupEnv(key) if ok { @@ -281,32 +310,30 @@ func GetOrDuration(key string, defaultValue string) time.Duration { } defaultDuration, err := time.ParseDuration(defaultValue) if err != nil { - panic(fmt.Sprintf("default duration \"%s\" could not be converted to time.Duration", key)) + panic(fmt.Sprintf("default duration \"%s\" could not be converted to time.Duration", defaultValue)) } return defaultDuration } -// MustGetDuration - get an environment variable or panic if does not exist +// MustGetDuration retrieves an environment variable and parses it as a time.Duration. +// If the variable is not set or parsing fails, it panics. func MustGetDuration(key string) time.Duration { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := time.ParseDuration(strValue) - if err == nil { - return value - } else { - panic(fmt.Sprintf("environment variable \"%s\" could not be converted to time.Duration", key)) - } - } - panic(fmt.Sprintf("expected environment variable \"%s\" does not exist", key)) + return MustGetParsed(key, ParseDuration, "time.Duration") } -// GetUrl - get an environment variable as url.URL +// GetUrl retrieves an environment variable and parses it as a URL. +// It accepts absolute URLs and parses them using url.ParseRequestURI. +// Returns the parsed *url.URL value and any parsing error. func GetUrl(key string) (*url.URL, error) { - value, err := url.ParseRequestURI(os.Getenv(key)) - return value, err + return GetParsed(key, ParseURL) } -// GetOrUrl - get an environment variable or return default value if does not exist +// GetOrUrl retrieves an environment variable and parses it as a URL. +// If the variable is not set or parsing fails, it parses and returns the default value. +// The default value must be a valid URL string, or the function will panic. +// +// Note: This function takes a string default value (unlike other GetOr* functions) +// to maintain backwards compatibility with existing APIs. func GetOrUrl(key string, defaultValue string) *url.URL { strValue, ok := os.LookupEnv(key) if ok { @@ -317,21 +344,13 @@ func GetOrUrl(key string, defaultValue string) *url.URL { } defaultUrl, err := url.ParseRequestURI(defaultValue) if err != nil { - panic(fmt.Sprintf("default duration \"%s\" could not be converted to url.URL", key)) + panic(fmt.Sprintf("default url \"%s\" could not be converted to url.URL", defaultValue)) } return defaultUrl } -// MustGetUrl - get an environment variable or panic if does not exist +// MustGetUrl retrieves an environment variable and parses it as a URL. +// If the variable is not set or parsing fails, it panics. func MustGetUrl(key string) *url.URL { - strValue, ok := os.LookupEnv(key) - if ok { - value, err := url.ParseRequestURI(strValue) - if err == nil { - return value - } else { - panic(fmt.Sprintf("environment variable \"%s\" could not be converted to url.URL", key)) - } - } - panic(fmt.Sprintf("expected environment variable \"%s\" does not exist", key)) + return MustGetParsed(key, ParseURL, "url.URL") } diff --git a/util_test.go b/util_test.go index a1cf1c3..214f808 100644 --- a/util_test.go +++ b/util_test.go @@ -269,3 +269,33 @@ func TestUrlFuncs(t *testing.T) { assert.Equal(t, "google.com", GetOrUrl("BAD_URL", "http://google.com").Hostname()) assert.Panics(t, func() { MustGetUrl("BAD_URL") }, "The code did not panic") } + +// Test environment variable key validation +func TestEnvVarKeyValidation(t *testing.T) { + // Valid keys + assert.True(t, isValidEnvVarKey("TEST_VAR")) + assert.True(t, isValidEnvVarKey("_PRIVATE_VAR")) + assert.True(t, isValidEnvVarKey("VAR123")) + assert.True(t, isValidEnvVarKey("MY_APP_CONFIG")) + assert.True(t, isValidEnvVarKey("A")) + assert.True(t, isValidEnvVarKey("_")) + + // Invalid keys + assert.False(t, isValidEnvVarKey("")) // empty + assert.False(t, isValidEnvVarKey("123VAR")) // starts with number + assert.False(t, isValidEnvVarKey("my-var")) // contains hyphen + assert.False(t, isValidEnvVarKey("my.var")) // contains dot + assert.False(t, isValidEnvVarKey("my var")) // contains space + assert.False(t, isValidEnvVarKey("myvar")) // lowercase (by convention) + assert.False(t, isValidEnvVarKey("My_Var")) // mixed case + assert.False(t, isValidEnvVarKey("VAR!")) // special character +} + +// Test constants are properly defined +func TestParsingConstants(t *testing.T) { + assert.Equal(t, 10, DecimalBase) + assert.Equal(t, 32, Int32Bits) + assert.Equal(t, 64, Int64Bits) + assert.Equal(t, 32, Float32Bits) + assert.Equal(t, 64, Float64Bits) +}