package infra import ( "fmt" "os" "reflect" "strconv" "strings" ) // ConfigFromEnv populates a configuration struct from environment variables // using struct field tags. The target must be a non-nil pointer to a struct. // // Supported tags: // - env:"VAR_NAME" — environment variable to read (required tag) // - default:"value" — value used when env var is unset/empty // - required:"true" — error if env var is unset/empty and no default // - secret:"true" — documented only; no runtime effect on loading // // Supported field types: string, int, int64, bool, float64, []string. // []string fields are parsed from comma-separated values. // // Example struct: // // type Config struct { // Port int `env:"PORT" default:"8080" required:"true"` // APIKey string `env:"API_KEY" required:"true" secret:"true"` // Tags []string `env:"TAGS" default:"a,b"` // } func ConfigFromEnv(target any) error { v := reflect.ValueOf(target) if v.Kind() != reflect.Ptr || v.IsNil() { return fmt.Errorf("config_from_env: target must be a non-nil pointer to a struct") } v = v.Elem() if v.Kind() != reflect.Struct { return fmt.Errorf("config_from_env: target must point to a struct, got %s", v.Kind()) } t := v.Type() var errs []string for i := 0; i < t.NumField(); i++ { field := t.Field(i) if !field.IsExported() { continue } envKey := field.Tag.Get("env") if envKey == "" { continue // no env tag → skip } defaultVal := field.Tag.Get("default") required := field.Tag.Get("required") == "true" raw := os.Getenv(envKey) if raw == "" { if defaultVal != "" { raw = defaultVal } else if required { errs = append(errs, fmt.Sprintf("field %s: env var %q is required but not set", field.Name, envKey)) continue } else { continue // optional and unset — leave field at zero value } } fv := v.Field(i) if err := setField(fv, field.Name, raw); err != nil { errs = append(errs, err.Error()) } } if len(errs) > 0 { return fmt.Errorf("config_from_env: %s", strings.Join(errs, "; ")) } return nil } // setField converts the string raw to the appropriate type and sets fv. func setField(fv reflect.Value, fieldName, raw string) error { switch fv.Kind() { case reflect.String: fv.SetString(raw) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: n, err := strconv.ParseInt(raw, 10, 64) if err != nil { return fmt.Errorf("field %s: cannot parse %q as int: %w", fieldName, raw, err) } fv.SetInt(n) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: n, err := strconv.ParseUint(raw, 10, 64) if err != nil { return fmt.Errorf("field %s: cannot parse %q as uint: %w", fieldName, raw, err) } fv.SetUint(n) case reflect.Float32, reflect.Float64: f, err := strconv.ParseFloat(raw, 64) if err != nil { return fmt.Errorf("field %s: cannot parse %q as float: %w", fieldName, raw, err) } fv.SetFloat(f) case reflect.Bool: b, err := strconv.ParseBool(raw) if err != nil { return fmt.Errorf("field %s: cannot parse %q as bool (use true/false/1/0): %w", fieldName, raw, err) } fv.SetBool(b) case reflect.Slice: if fv.Type().Elem().Kind() != reflect.String { return fmt.Errorf("field %s: slice of non-string is not supported, use []string", fieldName) } parts := strings.Split(raw, ",") trimmed := make([]string, 0, len(parts)) for _, p := range parts { t := strings.TrimSpace(p) if t != "" { trimmed = append(trimmed, t) } } fv.Set(reflect.ValueOf(trimmed)) default: return fmt.Errorf("field %s: unsupported type %s", fieldName, fv.Kind()) } return nil }