diff --git a/.github/workflows/buildandtest.yml b/.github/workflows/buildandtest.yml index 3987eb6..90136e4 100644 --- a/.github/workflows/buildandtest.yml +++ b/.github/workflows/buildandtest.yml @@ -2,15 +2,31 @@ name: Build and Test on: push: - branches: [ main ] - pull_request: - branches: [ main ] + branches: + - "*" jobs: + check-version: + runs-on: ubuntu-latest + steps: + - uses: lindenlab/check-tag-action@v1 + with: + mode: check + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: golangci-lint + uses: golangci/golangci-lint-action@v8 + with: + args: --config .standard-lint.yml ./... + build-and-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Go uses: actions/setup-go@v5 @@ -22,3 +38,11 @@ jobs: - name: Test run: make cover + + tag-release: + needs: [check-version, lint, build-and-test] + runs-on: ubuntu-latest + steps: + - uses: lindenlab/check-tag-action@v1 + with: + mode: tag diff --git a/.github/workflows/check-version.yml b/.github/workflows/check-version.yml deleted file mode 100644 index 9456fb2..0000000 --- a/.github/workflows/check-version.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: Check Version - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - check-version: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Check version - run: ./tag.sh check_version diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml deleted file mode 100644 index 294276e..0000000 --- a/.github/workflows/golangci-lint.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: golangci-lint -on: - push: - branches: - - main - pull_request: - -permissions: - contents: read - # Optional: allow read access to pull requests. Use with `only-new-issues` option. - # pull-requests: read - -jobs: - golangci: - name: lint - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-go@v6 - with: - go-version: stable - - name: golangci-lint - uses: golangci/golangci-lint-action@v8 - with: - version: v2.1 - args: --config=.standard-lint.yml diff --git a/.github/workflows/tag-on-merge.yml b/.github/workflows/tag-on-merge.yml deleted file mode 100644 index a18000b..0000000 --- a/.github/workflows/tag-on-merge.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Create Tag on Merge - -on: - push: - branches: - - main - pull_request: - -jobs: - create-tag: - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Fetches all history for all branches and tags - - - name: Configure Git - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - 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/.github/workflows/tag.sh b/.github/workflows/tag.sh deleted file mode 100755 index 5d00249..0000000 --- a/.github/workflows/tag.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/bin/bash - -set -e -u -o pipefail - - -MAX_PRE_VERSION_COUNT=50 - - -GREEN='\033[0;32m' -YELLOW='\033[0;33m' -BLUE='\033[0;34m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -function green() { - echo -e "${GREEN}$*${NC}" -} - -function yellow() { - echo -e "${YELLOW}$*${NC}" -} - -function blue() { - echo -e "${BLUE}$*${NC}" -} - -function die () { - echo -e "${RED}${1}${NC}" >&2 - exit 1 -} - -BRANCH=${DRONE_COMMIT_BRANCH:-$(git rev-parse --abbrev-ref HEAD)} - - -calc_version () { - VERSION_FILE=$1 - DIR=$(dirname "$VERSION_FILE") - if [ "$DIR" = "." ]; then - MODULE_PATH= - else - MODULE_PATH=$DIR/ - fi - - echo "${MODULE_PATH}v$(cat "$VERSION_FILE")" -} - -VERSION_FILES=$(find . -name "Version" -or -name "Versions" | cut -d/ -f2-) -if [[ -z "${VERSION_FILES// /}" ]]; then - echo "No Version of Versions file found - skipping this step..." - exit -fi - -# Would do this, except that Drone is very weird. It seems to create a master branch for some reason, -# even though there is no master branch in this repo. -# DEFAULT_BRANCH=$(git rev-parse --abbrev-ref origin/HEAD | cut -c8-) -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 - blue "\\nChecking version and verify if we need to update the version file." - shift - VERSION_FILES=$* - for version_file in $VERSION_FILES - do - VERSION=$(calc_version "$version_file") - VER_EXIST=$(git tag -l "$VERSION") - - if [ -n "$VER_EXIST" ] - then - echo "Version ${VERSION} already tagged" - if [ "$BRANCH" != "$DEFAULT_BRANCH" ] - then - die "Need to update version file: $version_file - exiting" - fi - else - echo "Version ${VERSION} not tagged" - fi - - done - green "Version file(s) look good!" - exit 0 -fi - - -blue "\\nChecking out and pulling the $BRANCH branch" -git checkout "$BRANCH" > /dev/null -git pull --rebase origin "$BRANCH" > /dev/null - -blue "\\nLooking for the next version tag" -FOUND=no -for version_file in $VERSION_FILES -do - VERSION=$(calc_version "$version_file") - - # First sed command attempts to extract DEV-N from the beginning of the branch name, otherwise it leaves the branch name unchanged. - # Second sed command replaces all sequences of characters that aren't letters or digits with a single dash. - PRERELEASE=`echo $BRANCH | sed -e 's/^.*\(DEV-[0-9]\+\).*$/\1/' -e 's/[^0-9a-zA-Z]\+/-/g'` - - if [ "$BRANCH" != "$DEFAULT_BRANCH" ]; then - - COUNTER=1 - while [ $COUNTER -lt $MAX_PRE_VERSION_COUNT ]; do - tag="$VERSION-$PRERELEASE.$COUNTER" - echo "Trying $tag..." - - # Check if the tag exist - if [ -n "$(git tag -l "$tag")" ] - then - yellow "Tag exists!" - - # might be that another developer has pushed out the same version number in another branch that hasn't been merged in yet. - # Let's check and make sure that is not the case - tag_exists_in_other_branches=no - raw_version=$(cat "$version_file") - all_revs=$(git rev-list --all) - # This looks for all commits, across all branches, where that particular version appears in that version file - for commit in $(git grep "$raw_version" $all_revs -- "$version_file" | cut -d: -f1 | sort -u) - do - # Let's see if any of these commits appear in other branches than our own - for branch in $(git --no-pager branch --contains "$commit" --all | rev | cut -d" " -f1 | rev) - do - if [ "$branch" != "$BRANCH" ] - then - tag_exists_in_other_branches=yes - fi - done - done - - if [ "tag_exists_in_other_branches" == "yes" ] - then - die "Tag does exist, but not as a part of your branch (another developer working in parallel?). Consider bumping the version number." - fi - else - break - fi - - (( COUNTER=COUNTER+1 )) - - done - - VERSION=$tag - fi - FOUND=yes - green "About to tag $VERSION" - git tag "$VERSION" -done - -if [ "$FOUND" == "no" ] -then - die "Did not find a new version tag!" -fi - -green "About to push!" -git push --no-verify --tags origin "$BRANCH" - diff --git a/Makefile b/Makefile index 7ef1c9e..5ed424e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,5 @@ PKGS := $(shell go list ./... | grep -v -E mocks) SRCS := $(shell find . go.mod -name '*.go') go.mod -export GO111MODULE=on VERSION := $(shell cat Version) diff --git a/README.md b/README.md index 1247730..9b909a7 100644 --- a/README.md +++ b/README.md @@ -40,14 +40,16 @@ variables but using the following command: ## Using a prefix If the struct you are populating is part of a library you may want to have -parse the same struct but with different values. You can do this by using -a prfic when parsing. +parse the same struct but with different values. You can do this by using +a prefix when parsing. ``` cfg := ClientConfig{} err := env.ParseWithPrefix(&cfg, "CLIENT2_") ``` -In this case the parser would look for the envionment variables CLIENT2_ENDPOINT, CLIENT2_HEALTH_CHECK, etc. +In this case the parser would look for the environment variables CLIENT2_ENDPOINT, CLIENT2_HEALTH_CHECK, etc. + +**Note:** Prefixes must end with an underscore (`_`). If you provide a prefix without a trailing underscore, Parse will return an error. ### Supported types and defaults @@ -84,4 +86,107 @@ The envDefault tag will allow you to provide a default value to use if the envir When assigning to a slice type the "," is used to seperate fields. You can override this with the envSeparator:":" tag to use some other character. +## Advanced Features + +### Context-aware panics + +Use `MustGetWithContext` for better error messages when required variables are missing: + +```go +apiBase := env.MustGetWithContext("WALLET_TRANSACTION_API", "generating wallet URIs") +// Panics with: "required env var WALLET_TRANSACTION_API missing (needed for: generating wallet URIs)" +``` + +### Test helpers + +The package provides convenient functions for managing environment variables in tests: + +```go +func TestConfig(t *testing.T) { + // Automatically restores or removes the variable after the test + env.SetForTest(t, "WALLET_TRANSACTION_API", "https://test.com") + + // Test code here + // Cleanup happens automatically via t.Cleanup() +} + +func TestWithoutEnv(t *testing.T) { + // Temporarily removes a variable and restores it after test + env.UnsetForTest(t, "OPTIONAL_CONFIG") + + // Test code that expects variable to not exist +} +``` + +### Configuration validation + +Validate that all required environment variables are set before parsing: + +```go +// Check required vars without parsing values +if err := env.ValidateRequired(&Config{}, ""); err != nil { + log.Fatalf("Missing required configuration: %v", err) +} + +// Now safe to parse +cfg := &Config{} +env.Parse(&cfg) +``` + +### Environment variable discovery + +Get information about all environment variables that a struct uses: + +```go +// Get all variables (required and optional) +vars, _ := env.GetAllVars(&Config{}, "") +for _, v := range vars { + fmt.Printf("%s: %s (required: %v, default: %s)\n", + v.Name, v.Type, v.Required, v.Default) +} + +// Get only required variables +required, _ := env.GetRequiredVars(&Config{}, "") +fmt.Println("Required vars:", required) +``` + +This is useful for: +- Generating documentation +- Creating example .env files +- Debugging configuration issues +- Validating deployment environments + +### Debug logging + +Enable debug logging to see what environment variables are being read: + +```go +// Enable debug logging +env.EnableDebugLogging(log.Printf) + +cfg := &Config{} +env.Parse(&cfg) +// Logs: "env: DB_CONNECTION = postgres://... (field: Config.Database)" + +// Disable debug logging +env.EnableDebugLogging(nil) +``` + +This is particularly useful for: +- Debugging configuration issues in production +- Understanding what values are being loaded +- Troubleshooting environment variable naming + +### Better error messages + +Parse errors now include field context for easier debugging: + +```go +type Config struct { + Connection string `env:"DB_CONNECTION" required:"true"` +} + +err := env.Parse(&Config{}) +// Error: "field 'Connection' in Config: env var DB_CONNECTION was missing and is required" +``` diff --git a/Version b/Version index ef52a64..cb498ab 100644 --- a/Version +++ b/Version @@ -1 +1 @@ -0.4.6 +0.4.8 diff --git a/env.go b/env.go index c6fbc83..4e8afd8 100644 --- a/env.go +++ b/env.go @@ -55,6 +55,10 @@ 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) + // DebugLogger is an optional function for logging configuration parsing details. + // If not nil, it's called with debug messages during Parse operations. + // Use EnableDebugLogging to set this conveniently. + DebugLogger func(format string, args ...interface{}) // Friendly names for reflect types sliceOfInts = reflect.TypeOf([]int(nil)) sliceOfInt64s = reflect.TypeOf([]int64(nil)) @@ -105,8 +109,13 @@ func Parse(v interface{}) error { // For example, with prefix "CLIENT2_", a field tagged `env:"ENDPOINT"` will // read from the environment variable "CLIENT2_ENDPOINT". // +// The prefix must end with an underscore if it's not empty, otherwise an error is returned. +// // See Parse for details on supported struct tags and behavior. func ParseWithPrefix(v interface{}, prefix string) error { + if prefix != "" && !strings.HasSuffix(prefix, "_") { + return fmt.Errorf("prefix must end with underscore, got: %q", prefix) + } return ParseWithPrefixFuncs(v, prefix, make(map[reflect.Type]ParserFunc)) } @@ -127,8 +136,13 @@ func ParseWithFuncs(v interface{}, funcMap CustomParsers) error { // This combines the functionality of ParseWithPrefix and ParseWithFuncs, // allowing both prefixed variable names and custom type parsing. // +// The prefix must end with an underscore if it's not empty, otherwise an error is returned. +// // See Parse for details on supported struct tags and behavior. func ParseWithPrefixFuncs(v interface{}, prefix string, funcMap CustomParsers) error { + if prefix != "" && !strings.HasSuffix(prefix, "_") { + return fmt.Errorf("prefix must end with underscore, got: %q", prefix) + } ptrRef := reflect.ValueOf(v) if ptrRef.Kind() != reflect.Ptr { return ErrNotAStructPtr @@ -137,15 +151,24 @@ func ParseWithPrefixFuncs(v interface{}, prefix string, funcMap CustomParsers) e if ref.Kind() != reflect.Struct { return ErrNotAStructPtr } - return doParse(ref, prefix, funcMap) + structType := ref.Type() + return doParse(ref, structType, "", prefix, funcMap) } -func doParse(ref reflect.Value, prefix string, funcMap CustomParsers) error { +func doParse(ref reflect.Value, structType reflect.Type, fieldPath string, prefix string, funcMap CustomParsers) error { refType := ref.Type() var parseErrors ParseErrors for i := 0; i < refType.NumField(); i++ { refField := ref.Field(i) + refTypeField := refType.Field(i) + + // Build the field path for better error messages + currentPath := refTypeField.Name + if fieldPath != "" { + currentPath = fieldPath + "." + currentPath + } + if reflect.Ptr == refField.Kind() && !refField.IsNil() && refField.CanSet() { err := ParseWithPrefixFuncs(refField.Interface(), prefix, funcMap) if nil != err { @@ -153,24 +176,34 @@ func doParse(ref reflect.Value, prefix string, funcMap CustomParsers) error { } continue } - refTypeField := refType.Field(i) + value, err := get(refTypeField, prefix) if err != nil { - parseErrors = append(parseErrors, err) + // Enhance error message with field context + parseErrors = append(parseErrors, fmt.Errorf("field '%s' in %s: %w", currentPath, structType.Name(), err)) continue } if value == "" { if reflect.Struct == refField.Kind() { - if err := doParse(refField, prefix, funcMap); err != nil { + nestedStructType := refField.Type() + if err := doParse(refField, nestedStructType, currentPath, prefix, funcMap); err != nil { parseErrors = append(parseErrors, err) } } continue } if err := set(refField, refTypeField, value, funcMap); err != nil { - parseErrors = append(parseErrors, err) + // Enhance error message with field context + parseErrors = append(parseErrors, fmt.Errorf("field '%s' in %s: %w", currentPath, structType.Name(), err)) continue } + + // Debug logging if enabled + if DebugLogger != nil { + envKey := prefix + refTypeField.Tag.Get("env") + DebugLogger("env: %s = %s (field: %s.%s)", envKey, value, structType.Name(), currentPath) + } + if OnEnvVarSet != nil { OnEnvVarSet(refTypeField, value) } @@ -523,3 +556,192 @@ func parseTextUnmarshalers(field reflect.Value, data []string) error { return nil } + +// EnableDebugLogging enables debug logging for configuration parsing. +// The provided logger function will be called with debug messages during Parse operations. +// +// This is useful for debugging configuration issues, especially in production where +// you want to see what environment variables are being read and their values. +// +// Example usage: +// +// env.EnableDebugLogging(log.Printf) +// cfg := &Config{} +// env.Parse(&cfg) // Will log each env var as it's read +// +// To disable debug logging, call env.EnableDebugLogging(nil). +func EnableDebugLogging(logger func(format string, args ...interface{})) { + DebugLogger = logger +} + +// VarInfo contains information about an environment variable used by a struct field. +type VarInfo struct { + // Name is the full environment variable name (including any prefix) + Name string + // FieldName is the struct field name + FieldName string + // FieldPath is the full path to the field (for nested structs) + FieldPath string + // Required indicates if the environment variable is required + Required bool + // Default is the default value if the environment variable is not set + Default string + // Type is the Go type of the field + Type string + // HasDefault indicates if a default value is specified + HasDefault bool +} + +// GetAllVars returns information about all environment variables that would be read +// when parsing the given struct. This includes both required and optional variables. +// +// The prefix parameter is prepended to all environment variable names. +// +// This function is useful for: +// - Generating documentation +// - Creating example .env files +// - Debugging configuration issues +// - Validating that all expected variables are set +// +// Example usage: +// +// vars := env.GetAllVars(&Config{}, "") +// for _, v := range vars { +// fmt.Printf("%s: %s (required: %v, default: %s)\n", +// v.Name, v.Type, v.Required, v.Default) +// } +func GetAllVars(v interface{}, prefix string) ([]VarInfo, error) { + ptrRef := reflect.ValueOf(v) + if ptrRef.Kind() != reflect.Ptr { + return nil, ErrNotAStructPtr + } + ref := ptrRef.Elem() + if ref.Kind() != reflect.Struct { + return nil, ErrNotAStructPtr + } + + var vars []VarInfo + collectVars(ref, ref.Type(), "", prefix, &vars) + return vars, nil +} + +// GetRequiredVars returns the names of all required environment variables +// that would be read when parsing the given struct. +// +// The prefix parameter is prepended to all environment variable names. +// +// This function is useful for validating that all required variables are set +// before attempting to parse the configuration. +// +// Example usage: +// +// required := env.GetRequiredVars(&Config{}, "") +// for _, name := range required { +// if _, ok := os.LookupEnv(name); !ok { +// log.Fatalf("Required environment variable %s is not set", name) +// } +// } +func GetRequiredVars(v interface{}, prefix string) ([]string, error) { + allVars, err := GetAllVars(v, prefix) + if err != nil { + return nil, err + } + + var required []string + for _, v := range allVars { + if v.Required { + required = append(required, v.Name) + } + } + return required, nil +} + +// ValidateRequired checks if all required environment variables for the given struct are set. +// It does not parse the values, only checks for their existence. +// +// This is useful for validating configuration at startup before attempting to parse, +// which can provide clearer error messages. +// +// Example usage: +// +// if err := env.ValidateRequired(&Config{}, ""); err != nil { +// log.Fatalf("Configuration validation failed: %v", err) +// } +// // Now safe to parse +// env.Parse(&cfg) +func ValidateRequired(v interface{}, prefix string) error { + requiredVars, err := GetRequiredVars(v, prefix) + if err != nil { + return err + } + + var missingVars []string + for _, name := range requiredVars { + if _, ok := os.LookupEnv(name); !ok { + missingVars = append(missingVars, name) + } + } + + if len(missingVars) > 0 { + return fmt.Errorf("missing required environment variables: %v", missingVars) + } + return nil +} + +func collectVars(ref reflect.Value, structType reflect.Type, fieldPath string, prefix string, vars *[]VarInfo) { + refType := ref.Type() + + for i := 0; i < refType.NumField(); i++ { + refField := ref.Field(i) + refTypeField := refType.Field(i) + + // Build the field path + currentPath := refTypeField.Name + if fieldPath != "" { + currentPath = fieldPath + "." + currentPath + } + + // Get the env tag + envTag := refTypeField.Tag.Get("env") + if envTag == "" { + // No env tag, check if it's a nested struct + if refField.Kind() == reflect.Struct { + collectVars(refField, refField.Type(), currentPath, prefix, vars) + } + continue + } + + // Parse required tag + required := false + if reqTag := refTypeField.Tag.Get("required"); reqTag != "" { + if b, err := strconv.ParseBool(reqTag); err == nil { + required = b + } + } + + // Get default value + defaultValue := refTypeField.Tag.Get("envDefault") + hasDefault := defaultValue != "" + + // Get the full env var name + fullName := prefix + envTag + + // Get the type name + typeName := refTypeField.Type.String() + + *vars = append(*vars, VarInfo{ + Name: fullName, + FieldName: refTypeField.Name, + FieldPath: currentPath, + Required: required, + Default: defaultValue, + Type: typeName, + HasDefault: hasDefault, + }) + + // Check for nested structs + if refField.Kind() == reflect.Struct { + collectVars(refField, refField.Type(), currentPath, prefix, vars) + } + } +} diff --git a/env_test.go b/env_test.go index aa60cbf..b94aebe 100644 --- a/env_test.go +++ b/env_test.go @@ -456,7 +456,9 @@ func TestErrorRequiredNotValid(t *testing.T) { cfg := &config{} err := Parse(cfg) assert.Error(t, err) - assert.Equal(t, err.Error(), "invalid required tag \"cat\": strconv.ParseBool: parsing \"cat\": invalid syntax") + // Error now includes field context + assert.Contains(t, err.Error(), "invalid required tag \"cat\"") + assert.Contains(t, err.Error(), "field 'IsRequired'") } func TestParseExpandOption(t *testing.T) { @@ -547,7 +549,9 @@ func TestCustomParserError(t *testing.T) { assert.Empty(t, cfg.Var.name, "Var.name should not be filled out when parse errors") assert.Error(t, err) - assert.Equal(t, "custom parser error: something broke", err.Error()) + // Error message now includes field context + assert.Contains(t, err.Error(), "custom parser error: something broke") + assert.Contains(t, err.Error(), "field 'Var'") } func TestCustomParserBasicType(t *testing.T) { @@ -677,11 +681,12 @@ func TestCustomParserBasicUnsupported(t *testing.T) { assert.Zero(t, cfg.Const) assert.Error(t, err) - // With the new ParseErrors, single errors are wrapped + // Error now includes field context and wraps the original error if parseErrors, ok := err.(ParseErrors); ok && len(parseErrors) == 1 { - assert.Equal(t, ErrUnsupportedType, parseErrors[0]) + assert.ErrorIs(t, parseErrors[0], ErrUnsupportedType) + assert.Contains(t, parseErrors[0].Error(), "field 'Const'") } else { - assert.Equal(t, ErrUnsupportedType, err) + t.Fatal("Expected ParseErrors") } } @@ -696,11 +701,12 @@ func TestUnsupportedStructType(t *testing.T) { err := Parse(cfg) assert.Error(t, err) - // With the new ParseErrors, single errors are wrapped + // Error now includes field context and wraps the original error if parseErrors, ok := err.(ParseErrors); ok && len(parseErrors) == 1 { - assert.Equal(t, ErrUnsupportedType, parseErrors[0]) + assert.ErrorIs(t, parseErrors[0], ErrUnsupportedType) + assert.Contains(t, parseErrors[0].Error(), "field 'Foo'") } else { - assert.Equal(t, ErrUnsupportedType, err) + t.Fatal("Expected ParseErrors") } } @@ -775,3 +781,183 @@ func TestParseMultipleErrors(t *testing.T) { errMsg := err.Error() assert.Contains(t, errMsg, "multiple parsing errors") } + +// Test prefix validation +func TestPrefixValidation(t *testing.T) { + type config struct { + Value string `env:"VALUE"` + } + + os.Setenv("TEST_VALUE", "test") + defer os.Unsetenv("TEST_VALUE") + + // Valid prefix (ends with underscore) + cfg := &config{} + err := ParseWithPrefix(cfg, "TEST_") + assert.NoError(t, err) + assert.Equal(t, "test", cfg.Value) + + // Empty prefix is valid + cfg = &config{} + err = ParseWithPrefix(cfg, "") + assert.NoError(t, err) + + // Invalid prefix (doesn't end with underscore) + cfg = &config{} + err = ParseWithPrefix(cfg, "TEST") + assert.Error(t, err) + assert.Contains(t, err.Error(), "prefix must end with underscore") +} + +// Test GetAllVars +func TestGetAllVars(t *testing.T) { + type config struct { + Required string `env:"REQUIRED" required:"true"` + Optional string `env:"OPTIONAL"` + WithDefault string `env:"WITH_DEFAULT" envDefault:"default_value"` + Number int `env:"NUMBER" required:"true"` + } + + vars, err := GetAllVars(&config{}, "") + assert.NoError(t, err) + assert.Len(t, vars, 4) + + // Check required field + requiredVar := findVar(vars, "REQUIRED") + assert.NotNil(t, requiredVar) + assert.True(t, requiredVar.Required) + assert.Equal(t, "Required", requiredVar.FieldName) + assert.Equal(t, "string", requiredVar.Type) + + // Check optional field + optionalVar := findVar(vars, "OPTIONAL") + assert.NotNil(t, optionalVar) + assert.False(t, optionalVar.Required) + + // Check field with default + defaultVar := findVar(vars, "WITH_DEFAULT") + assert.NotNil(t, defaultVar) + assert.True(t, defaultVar.HasDefault) + assert.Equal(t, "default_value", defaultVar.Default) + + // Check number field + numberVar := findVar(vars, "NUMBER") + assert.NotNil(t, numberVar) + assert.True(t, numberVar.Required) + assert.Equal(t, "int", numberVar.Type) +} + +// Test GetAllVars with prefix +func TestGetAllVarsWithPrefix(t *testing.T) { + type config struct { + Value string `env:"VALUE"` + } + + vars, err := GetAllVars(&config{}, "PREFIX_") + assert.NoError(t, err) + assert.Len(t, vars, 1) + assert.Equal(t, "PREFIX_VALUE", vars[0].Name) +} + +// Test GetRequiredVars +func TestGetRequiredVars(t *testing.T) { + type config struct { + Required1 string `env:"REQUIRED1" required:"true"` + Optional string `env:"OPTIONAL"` + Required2 int `env:"REQUIRED2" required:"true"` + } + + required, err := GetRequiredVars(&config{}, "") + assert.NoError(t, err) + assert.Len(t, required, 2) + assert.Contains(t, required, "REQUIRED1") + assert.Contains(t, required, "REQUIRED2") + assert.NotContains(t, required, "OPTIONAL") +} + +// Test ValidateRequired +func TestValidateRequired(t *testing.T) { + type config struct { + Required1 string `env:"REQUIRED1" required:"true"` + Optional string `env:"OPTIONAL"` + Required2 int `env:"REQUIRED2" required:"true"` + } + + // All required vars set + os.Setenv("REQUIRED1", "value1") + os.Setenv("REQUIRED2", "42") + defer os.Unsetenv("REQUIRED1") + defer os.Unsetenv("REQUIRED2") + + err := ValidateRequired(&config{}, "") + assert.NoError(t, err) + + // Missing required var + os.Unsetenv("REQUIRED1") + err = ValidateRequired(&config{}, "") + assert.Error(t, err) + assert.Contains(t, err.Error(), "missing required environment variables") + assert.Contains(t, err.Error(), "REQUIRED1") +} + +// Test EnableDebugLogging +func TestEnableDebugLogging(t *testing.T) { + type config struct { + Value string `env:"TEST_VALUE"` + } + + os.Setenv("TEST_VALUE", "test") + defer os.Unsetenv("TEST_VALUE") + + var logMessages []string + logger := func(format string, args ...interface{}) { + logMessages = append(logMessages, fmt.Sprintf(format, args...)) + } + + EnableDebugLogging(logger) + defer EnableDebugLogging(nil) // Clean up + + cfg := &config{} + err := Parse(cfg) + assert.NoError(t, err) + + // Verify debug logging was called + assert.NotEmpty(t, logMessages) + assert.Contains(t, logMessages[0], "TEST_VALUE") + assert.Contains(t, logMessages[0], "test") +} + +// Test nested structs with GetAllVars +func TestGetAllVarsNested(t *testing.T) { + type NestedConfig struct { + NestedValue string `env:"NESTED_VALUE"` + } + + type Config struct { + TopValue string `env:"TOP_VALUE"` + Nested NestedConfig + } + + vars, err := GetAllVars(&Config{}, "") + assert.NoError(t, err) + assert.Len(t, vars, 2) + + topVar := findVar(vars, "TOP_VALUE") + assert.NotNil(t, topVar) + assert.Equal(t, "TopValue", topVar.FieldName) + + nestedVar := findVar(vars, "NESTED_VALUE") + assert.NotNil(t, nestedVar) + assert.Equal(t, "NestedValue", nestedVar.FieldName) + assert.Contains(t, nestedVar.FieldPath, "Nested") +} + +// Helper function to find a var by name +func findVar(vars []VarInfo, name string) *VarInfo { + for _, v := range vars { + if v.Name == name { + return &v + } + } + return nil +} diff --git a/go.sum b/go.sum index 1cd876d..c4c1710 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,10 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/util.go b/util.go index d3995e7..df3e9b7 100644 --- a/util.go +++ b/util.go @@ -153,6 +153,22 @@ func MustGet(key string) string { panic(fmt.Sprintf("expected environment variable \"%s\" does not exist", key)) } +// MustGetWithContext retrieves the value of an environment variable with additional context. +// If the variable is not set, it panics with a descriptive message that includes the context. +// +// The context parameter should describe why this variable is needed, making errors more helpful. +// For example: "generating wallet URIs" or "connecting to database". +// +// Use this function when the environment variable is required and you want to provide +// additional context in error messages. +func MustGetWithContext(key, context string) string { + value, ok := os.LookupEnv(key) + if ok { + return value + } + panic(fmt.Sprintf("required env var %s missing (needed for: %s)", key, context)) +} + // 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. @@ -354,3 +370,81 @@ func GetOrUrl(key string, defaultValue string) *url.URL { func MustGetUrl(key string) *url.URL { return MustGetParsed(key, ParseURL, "url.URL") } + +// TestHelper is an interface that represents a testing object (typically *testing.T or *testing.B). +// It provides the minimal interface needed for test helper functions. +type TestHelper interface { + Helper() + Cleanup(func()) +} + +// SetForTest sets an environment variable for testing and automatically cleans it up. +// It returns a cleanup function that can be called manually, though cleanup is also +// registered with t.Cleanup() if t is non-nil. +// +// If the environment variable already exists, it will be restored to its original value. +// If it doesn't exist, it will be removed during cleanup. +// +// Example usage: +// +// func TestConfig(t *testing.T) { +// cleanup := env.SetForTest(t, "WALLET_TRANSACTION_API", "https://test.com") +// defer cleanup() // Optional: cleanup is also registered with t.Cleanup() +// // test code +// } +func SetForTest(t TestHelper, key, value string) func() { + if t != nil { + t.Helper() + } + + old, existed := os.LookupEnv(key) + os.Setenv(key, value) + + cleanup := func() { + if existed { + os.Setenv(key, old) + } else { + os.Unsetenv(key) + } + } + + if t != nil { + t.Cleanup(cleanup) + } + + return cleanup +} + +// UnsetForTest removes an environment variable for testing and automatically restores it. +// It returns a cleanup function that can be called manually, though cleanup is also +// registered with t.Cleanup() if t is non-nil. +// +// If the environment variable exists, it will be restored to its original value during cleanup. +// +// Example usage: +// +// func TestWithoutEnv(t *testing.T) { +// cleanup := env.UnsetForTest(t, "OPTIONAL_CONFIG") +// defer cleanup() +// // test code that expects the variable to not exist +// } +func UnsetForTest(t TestHelper, key string) func() { + if t != nil { + t.Helper() + } + + old, existed := os.LookupEnv(key) + os.Unsetenv(key) + + cleanup := func() { + if existed { + os.Setenv(key, old) + } + } + + if t != nil { + t.Cleanup(cleanup) + } + + return cleanup +} diff --git a/util_test.go b/util_test.go index 214f808..eba276a 100644 --- a/util_test.go +++ b/util_test.go @@ -299,3 +299,141 @@ func TestParsingConstants(t *testing.T) { assert.Equal(t, 32, Float32Bits) assert.Equal(t, 64, Float64Bits) } + +// Test MustGetWithContext +func TestMustGetWithContext(t *testing.T) { + os.Setenv("TEST_VAR", "test_value") + defer os.Unsetenv("TEST_VAR") + + // Should work when env var exists + val := MustGetWithContext("TEST_VAR", "testing purposes") + assert.Equal(t, "test_value", val) + + // Should panic with context when env var doesn't exist + assert.PanicsWithValue(t, "required env var MISSING_VAR missing (needed for: database connection)", func() { + MustGetWithContext("MISSING_VAR", "database connection") + }) +} + +// Mock test helper for testing +type mockTestHelper struct { + cleanupFuncs []func() +} + +func (m *mockTestHelper) Helper() {} + +func (m *mockTestHelper) Cleanup(f func()) { + m.cleanupFuncs = append(m.cleanupFuncs, f) +} + +// Test SetForTest +func TestSetForTest(t *testing.T) { + // Test setting a new variable + t.Run("set new variable", func(t *testing.T) { + os.Unsetenv("TEST_VAR") + mock := &mockTestHelper{} + + cleanup := SetForTest(mock, "TEST_VAR", "test_value") + + // Verify it's set + val, exists := os.LookupEnv("TEST_VAR") + assert.True(t, exists) + assert.Equal(t, "test_value", val) + + // Call cleanup + cleanup() + + // Verify it's removed + _, exists = os.LookupEnv("TEST_VAR") + assert.False(t, exists) + + // Verify cleanup was registered + assert.Len(t, mock.cleanupFuncs, 1) + }) + + // Test overwriting an existing variable + t.Run("overwrite existing variable", func(t *testing.T) { + os.Setenv("TEST_VAR", "original") + defer os.Unsetenv("TEST_VAR") + + mock := &mockTestHelper{} + cleanup := SetForTest(mock, "TEST_VAR", "new_value") + + // Verify it's changed + val, _ := os.LookupEnv("TEST_VAR") + assert.Equal(t, "new_value", val) + + // Call cleanup + cleanup() + + // Verify it's restored + val, exists := os.LookupEnv("TEST_VAR") + assert.True(t, exists) + assert.Equal(t, "original", val) + }) + + // Test without test helper + t.Run("without test helper", func(t *testing.T) { + os.Unsetenv("TEST_VAR") + + cleanup := SetForTest(nil, "TEST_VAR", "test_value") + + // Verify it's set + val, exists := os.LookupEnv("TEST_VAR") + assert.True(t, exists) + assert.Equal(t, "test_value", val) + + // Call cleanup + cleanup() + + // Verify it's removed + _, exists = os.LookupEnv("TEST_VAR") + assert.False(t, exists) + }) +} + +// Test UnsetForTest +func TestUnsetForTest(t *testing.T) { + // Test unsetting an existing variable + t.Run("unset existing variable", func(t *testing.T) { + os.Setenv("TEST_VAR", "test_value") + defer os.Unsetenv("TEST_VAR") + + mock := &mockTestHelper{} + cleanup := UnsetForTest(mock, "TEST_VAR") + + // Verify it's unset + _, exists := os.LookupEnv("TEST_VAR") + assert.False(t, exists) + + // Call cleanup + cleanup() + + // Verify it's restored + val, exists := os.LookupEnv("TEST_VAR") + assert.True(t, exists) + assert.Equal(t, "test_value", val) + + // Verify cleanup was registered + assert.Len(t, mock.cleanupFuncs, 1) + }) + + // Test unsetting a non-existent variable + t.Run("unset non-existent variable", func(t *testing.T) { + os.Unsetenv("TEST_VAR") + + mock := &mockTestHelper{} + cleanup := UnsetForTest(mock, "TEST_VAR") + + // Verify it's still unset + _, exists := os.LookupEnv("TEST_VAR") + assert.False(t, exists) + + // Call cleanup (should not restore anything) + cleanup() + + // Verify it's still unset + _, exists = os.LookupEnv("TEST_VAR") + assert.False(t, exists) + }) +}