diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 75932c8..e657c15 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -33,14 +33,14 @@ func TestExampleLoad(t *testing.T) { var cfg Config // Mock KeyStore - mockStore := func(ctx context.Context, key string) (string, bool, error) { + MockStore := func(ctx context.Context, key string) (string, bool, error) { if key == "PORT" { return "9000", true, nil } return "", false, nil } - err := Load(ctx, &cfg, WithKeyStore(mockStore)) + err := Load(ctx, &cfg, WithKeyStore(MockStore)) if err != nil { t.Fatalf("Failed to load: %v", err) } @@ -52,9 +52,14 @@ func TestExampleLoad(t *testing.T) { ``` #### 3. Additional Development Information -- **Internal Pipeline**: The library uses a pipeline approach located in `internal/process`. Each field is processed by a `FieldProcessor` which is built based on the field type and tags. -- **Type Handlers**: Type-specific logic is registered in `internal/process/typeregistry.go`. To add support for a new type, implement a `Handler` and register it in `kindParsers` or `specialTypeParsers`. -- **Validation**: Validation is integrated into the processing pipeline. The `Pipe` function in `internal/process/types.go` is used to chain validators to processors. +- **Internal Pipeline**: The library uses a pipeline approach located in `internal/readpipeline`. Each field is processed by a `FieldProcessor` which is built based on the field type and tags. +- **Type Handlers**: The architecture uses a typed handler system (`TypedHandler[T]`). + - `FieldProcessor[T]`: Converts a string to type T. + - `Validator[T]`: Validates a value of type T. + - `Wrapper[T]`: A factory that wraps a `FieldProcessor` with validation based on struct tags. +- **Type Registration**: Type-specific logic is registered in `internal/readpipeline/typeregistry.go`. To add support for a new type, implement a `PipelineBuilder` (often via `WrapTypedHandler`) and register it in `kindHandlers` or `specialTypeHandlers`. +- **Validation**: Validation is integrated into the processing pipeline. The `Pipe` and `PipeMultiple` functions in `internal/readpipeline/typed_handler.go` are used to chain validators to processors. +- **Custom Types**: Users can register custom handlers using `goconfig.WithHandler`. Built-in factories like `NewCustomHandler`, `NewEnumHandler`, `ReplaceParser`, and `AddValidators` are available. - **Struct Tags**: - `key`: The name of the environment variable/key in the store. - `default`: Default value if the key is missing. @@ -62,4 +67,4 @@ func TestExampleLoad(t *testing.T) { - `keyRequired`: If "true", the key must be present (can be empty). - `min`, `max`: Range validation for numbers and durations. - `pattern`: Regex validation for strings. -- **Reflection**: The core logic in `config.go` uses reflection to traverse structs. Ensure that fields are exported (start with an upper-case letter) so that they can be set by the library. +- **Reflection**: The core logic in `config.go` uses reflection to traverse structs and `internal/readpipeline/process.go` to create the processing pipelines. Ensure that fields are exported (start with an upper-case letter). diff --git a/CHANGELOG.md b/CHANGELOG.md index f702527..c782529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ # Changelog -## [v1.0.0] - 2025-12-21 +## [v0.3.0] - 2025-12-23 + +This is a return to 0.x versions due to the significance of the breaking changes. It's a real about turn in the +mechanism for providing custom validation. If you've not been using custom validators, then nothing changes for you +apart from the module rename. + +The AI-generated validation code was becoming messy, with switch statements all over the place. A pipeline mechanism +was made which used a typed pipeline to convert from raw values to typed values. A custom types system was built on top +of this, providing building blocks for custom validators. The raw building blocks are currently an internal package +with much of their functionality exposed through functions and type aliases in the root `goconfig` package. ### Added @@ -15,6 +24,7 @@ ### Breaking Changes - **Module Rename**: The module was renamed from `goconfigtools` to `goconfig`. Its github repository was also renamed. +- **Custom Type System**: The old parser and validation mechanism was replaced by a custom type system. ## [v1.0.0-beta.2] - 2025-12-20 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..85c03e6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,400 @@ +# goconfig - Developer Guide for Claude + +This document provides an overview of the goconfig library architecture and type system for AI assistants helping with development. + +## Overview + +goconfig is a Go library for loading configuration from environment variables (or other sources) into typed structs with validation. It uses a type-based system with struct tags for declaring configuration fields. + +## Core Concepts + +### 1. Struct Tags + +Configuration fields are declared using struct tags: + +```go +type Config struct { + Port int `key:"PORT" default:"8080" min:"1024" max:"65535"` + Host string `key:"HOST" default:"localhost"` +} +``` + +**Available tags:** +- `key` - Environment variable name (required) +- `default` - Default value if not set +- `required` - Must be present and non-empty +- `keyRequired` - Must be present (can be empty) +- `min` - Minimum value (numbers, durations) +- `max` - Maximum value (numbers, durations) +- `pattern` - Regex pattern for strings + +### 2. Built-in Type Support + +The library has built-in support for: +- Primitives: `string`, `bool` +- Integers: `int`, `int8`, `int16`, `int32`, `int64`, `uint`, `uint8`, `uint16`, `uint32`, `uint64` +- Floats: `float32`, `float64` +- Special types: `time.Duration` +- Complex types: JSON (for maps and structs with json tags) +- Pointers: All above types as pointers +- Nested structs + +### 3. Custom Type System - Building Block Approach + +The custom type system uses a **building block architecture** where you compose simple, reusable components to create type handlers. This approach replaced the old parser/validator system and provides maximum flexibility through composition. + +#### Core Building Blocks + +**TypedHandler[T]** - A handler that knows how to parse and validate values of type T. Handlers are built by combining building blocks. + +**FieldProcessor[T]** - A function that converts a string to type T: +```go +type FieldProcessor[T any] func(rawValue string) (T, error) +``` + +**Validator[T]** - A function that validates a value of type T: +```go +type Validator[T any] func(value T) error +``` + +**Wrapper[T]** - A factory that wraps a FieldProcessor to add behavior (like validation): +```go +type Wrapper[T any] func(tags reflect.StructTag, inputProcess FieldProcessor[T]) (FieldProcessor[T], error) +``` + +#### Building Block Functions + +These are the core building blocks you compose to create custom types: + +**NewCustomType[T](parser, validators...)** - Start with a parser and add validators +- Creates a complete handler from a parser function and optional validators +- This is the most common way to create custom types + +**AddValidators[T](handler, validators...)** - Add validators to an existing handler +- Takes any handler and wraps it with additional validators +- Useful for extending built-in type handlers + +**AddDynamicValidation[T](handler, wrapper)** - Add dynamic validation that reads struct tags +- Adds a wrapper that can read struct tags and customize behavior per field +- For advanced scenarios where validation depends on struct tags + +**CastCustomType[T, U](handler)** - Transform a handler from type T to type U +- Uses Go's type conversion rules to transform between compatible types +- Useful for creating handlers for type aliases (e.g., `type Port int`) + +**NewStringEnumType[T](values...)** - Create an enum validator for string-based types +- Specialized builder for string enum types +- Automatically validates that values match one of the provided options + +#### Default Type Handlers + +These provide base handlers for built-in types that you can extend: +- `DefaultStringType()` - Returns `TypedHandler[string]` +- `DefaultIntegerType[T]()` - Returns `TypedHandler[T]` for int types (int, int8, int16, int32, int64) +- `DefaultUnsignedIntegerType[T]()` - Returns `TypedHandler[T]` for uint types (uint, uint8, uint16, uint32, uint64) +- `DefaultFloatIntegerType[T]()` - Returns `TypedHandler[T]` for float types (float32, float64) +- `DefaultDurationType()` - Returns `TypedHandler[time.Duration]` + +These handlers already include tag-based validation (min, max, pattern). Use them as building blocks to add custom validation. + +#### Registering Custom Types + +Use `WithCustomType[T]()` to register a handler when loading config: +```go +err := goconfig.Load(ctx, &config, + goconfig.WithCustomType[APIKey](apiKeyHandler), + goconfig.WithCustomType[Status](statusHandler), +) +``` + +Or use `RegisterCustomType[T]()` to register globally (before Load): +```go +goconfig.RegisterCustomType[APIKey](apiKeyHandler) +// Now all APIKey fields will use this handler +``` + +**Important:** Handlers are registered by TYPE, not by field name. All fields of type T will use the same handler. + +## Architecture + +### Load Process + +1. User calls `Load(ctx, &config, options...)` +2. Options are applied (custom types, key stores) +3. The config struct is reflected to find all fields +4. For each field: + - Look up the handler in the type registry (by type) + - If not found, create a default handler for the type + - Build the processing pipeline (parser + validators) + - Read the value from the key store + - Parse and validate the value + - Set the field value +5. Collect and return all errors + +### Type Registry + +The type registry maps `reflect.Type` to `PipelineBuilder`. When you register a custom type with `WithCustomType[T]()`, it: +1. Gets the reflect.Type for T +2. Wraps the TypedHandler[T] in a `PipelineBuilder` adapter +3. Registers it in the type registry + +When processing a field, the system: +1. Gets the field's reflect.Type +2. Looks up the handler in the registry +3. Calls `Build(tags)` to create a `FieldProcessor[any]` for that field +4. Uses that processor to parse and validate the value + +## Migration from Old System + +### Old System (Deprecated) +```go +// Old: Field-based validators +err := goconfig.Load(ctx, &cfg, + goconfig.WithValidator("APIKey", func(value any) error { + key := value.(string) + // validation... + }), + goconfig.WithParser("DatabaseURL", func(value string) (any, error) { + // parsing... + }), +) +``` + +### New System (Building Block Approach) +```go +// New: Type-based handlers with building blocks +type APIKey string + +apiKeyHandler := goconfig.NewCustomType( + func(rawValue string) (APIKey, error) { + return APIKey(rawValue), nil + }, + func(value APIKey) error { + // validation... + }, +) + +err := goconfig.Load(ctx, &cfg, + goconfig.WithCustomType[APIKey](apiKeyHandler), +) +``` + +### Key Differences + +1. **Type-based vs Field-based**: Old system used field names ("APIKey"), new system uses types (APIKey) +2. **Type safety**: New system uses generics for compile-time type safety +3. **Reusability**: Custom types can be reused across multiple fields automatically +4. **Composability**: Building block architecture - compose handlers using `AddValidators()`, `CastCustomType()`, `AddDynamicValidation()`, etc. + +## Common Patterns + +### Custom String Types with Validation + +```go +type Email string + +// Building block approach: start with parser, add validators +emailHandler := goconfig.NewCustomType( + func(rawValue string) (Email, error) { + return Email(rawValue), nil + }, + func(value Email) error { + if !strings.Contains(string(value), "@") { + return errors.New("invalid email format") + } + return nil + }, +) + +type Config struct { + AdminEmail Email `key:"ADMIN_EMAIL" required:"true"` + UserEmail Email `key:"USER_EMAIL"` // Same handler applies +} + +err := goconfig.Load(ctx, &config, goconfig.WithCustomType[Email](emailHandler)) +``` + +### Enum Types + +```go +type LogLevel string +const ( + LogDebug LogLevel = "debug" + LogInfo LogLevel = "info" + LogWarn LogLevel = "warn" + LogError LogLevel = "error" +) + +type Config struct { + Level LogLevel `key:"LOG_LEVEL" default:"info"` +} + +// Use the specialized enum building block +err := goconfig.Load(ctx, &config, + goconfig.WithCustomType[LogLevel]( + goconfig.NewStringEnumType(LogDebug, LogInfo, LogWarn, LogError), + ), +) +``` + +### Adding Validation to Built-in Types (Composition) + +```go +// Building block approach: Take default int handler and add validators +baseHandler := goconfig.DefaultIntegerType[int]() +evenHandler := goconfig.AddValidators(baseHandler, func(v int) error { + if v%2 != 0 { + return errors.New("must be even") + } + return nil +}) + +type Config struct { + Port int `key:"PORT" default:"8080" min:"1024" max:"65535"` +} + +err := goconfig.Load(ctx, &config, + goconfig.WithCustomType[int](evenHandler), +) +// Now all int fields get tag validation (min/max) AND the even check +``` + +### Type Aliases with CastCustomType + +```go +type Port int + +// Building block: Cast the int64 handler to Port type +portHandler := goconfig.CastCustomType[int64, Port]( + goconfig.DefaultIntegerType[int64](), +) + +// Or add validators after casting +portWithValidation := goconfig.AddValidators(portHandler, func(p Port) error { + if int(p)%10 != 0 { + return errors.New("port must be multiple of 10") + } + return nil +}) + +type Config struct { + Port Port `key:"PORT" default:"8080" min:"1024" max:"65535"` +} + +err := goconfig.Load(ctx, &config, + goconfig.WithCustomType[Port](portWithValidation), +) +``` + +### Complex Custom Types + +```go +type ServerAddress struct { + Host string + Port int +} + +// Building block: Custom parser for complex type +addressHandler := goconfig.NewCustomType( + func(rawValue string) (ServerAddress, error) { + parts := strings.Split(rawValue, ":") + if len(parts) != 2 { + return ServerAddress{}, errors.New("invalid format: expected host:port") + } + port, err := strconv.Atoi(parts[1]) + if err != nil { + return ServerAddress{}, fmt.Errorf("invalid port: %w", err) + } + return ServerAddress{Host: parts[0], Port: port}, nil + }, + // Add validators as additional building blocks + func(addr ServerAddress) error { + if addr.Port < 1024 || addr.Port > 65535 { + return errors.New("port must be in range 1024-65535") + } + return nil + }, +) + +type Config struct { + Server ServerAddress `key:"SERVER_ADDR" default:"localhost:8080"` +} +``` + +## Testing + +Use in-memory key stores for testing: +```go +func TestConfig(t *testing.T) { + testStore := func(ctx context.Context, key string) (string, bool, error) { + data := map[string]string{ + "PORT": "8080", + "HOST": "localhost", + } + value, found := data[key] + return value, found, nil + } + + var cfg Config + err := goconfig.Load(context.Background(), &cfg, + goconfig.WithKeyStore(testStore), + ) +} +``` + +## Error Handling + +Errors are collected and returned together: +```go +err := goconfig.Load(ctx, &config) +if err != nil { + var configErrs *goconfig.ConfigErrors + if errors.As(err, &configErrs) { + // Multiple errors - all will be logged + goconfig.LogError(logger, err) + } +} +``` + +## Building Block Composition Patterns + +The power of the building block system is in **composition**. Here are common patterns: + +### Pattern 1: Start with Parser, Add Validators +```go +handler := goconfig.NewCustomType(parser, validator1, validator2, ...) +``` + +### Pattern 2: Extend Default Type with Validators +```go +handler := goconfig.AddValidators(goconfig.DefaultIntegerType[int](), myValidator) +``` + +### Pattern 3: Cast Then Validate +```go +handler := goconfig.AddValidators( + goconfig.CastCustomType[int64, Port](goconfig.DefaultIntegerType[int64]()), + portValidator, +) +``` + +### Pattern 4: Chain Multiple Wrappers +```go +handler := goconfig.AddDynamicValidation( + goconfig.AddValidators(baseHandler, validator1), + customWrapper, +) +``` + +## Key Files + +- `custom_types.go` - Public API for building blocks +- `internal/customtypes/parser.go` - Parser building block +- `internal/customtypes/validation_wrapper.go` - Validation wrapper building block +- `internal/customtypes/chain_handler.go` - Composition via chaining +- `internal/customtypes/transformer.go` - Type transformation building block +- `internal/customtypes/enum.go` - Enum building block +- `internal/readpipeline/typed_handler.go` - TypedHandler interface +- `internal/readpipeline/typeregistry.go` - Type registry +- `loadoptions.go` - WithCustomType() option +- `example/validation/main.go` - Example using custom types diff --git a/README.md b/README.md index ae7be29..4cf07e8 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,12 @@ A simple, type-safe Go library for loading configuration from environment variab ## Features - 🏷️ **Struct-based configuration** - Define config with Go structs and tags -- ✅ **Built-in validation** - `min`, `max`, and `pattern` tags plus custom validators ([docs](docs/validation.md) | [example](example/validation)) -- 🎯 **Type-safe** - Automatic conversion for primitives, durations, and JSON +- ✅ **Built-in validation** - `min`, `max`, and `pattern` tags plus custom type validators ([docs](docs/validation.md) | [example](example/validation)) +- 🎯 **Type-safe** - Automatic conversion for primitives, durations, and JSON with generic type handlers +- 🧱 **Building block architecture** - Compose custom types from simple, reusable components ([docs](docs/custom-types.md)) - 🔄 **Flexible defaults** - Struct tags or pre-initialized values ([docs](docs/defaulting.md)) - 🌳 **Nested structs** - Organize configuration hierarchically -- 🔧 **Extensible** - Custom parsers and key stores ([docs](docs/advanced.md)) +- 🔧 **Extensible** - Custom types and key stores ([docs](docs/advanced.md)) - 💬 **Clear errors** - Descriptive validation and missing field errors ## Installation @@ -113,21 +114,42 @@ Validation errors provide clear messages: invalid value for PORT: below minimum 1024 ``` -### Custom Validators +### Custom Types + +Define custom types with validation using the **building block architecture** - compose simple, reusable components: ```go -err := goconfig.Load(context.Background(), &cfg, - goconfig.WithValidator("APIKey", func(value any) error { - key := value.(string) - if !strings.HasPrefix(key, "sk-") { +type APIKey string + +type Config struct { + APIKey APIKey `key:"API_KEY" required:"true"` +} + +// Building block approach: parser + validators +apiKeyHandler := goconfig.NewCustomType( + func(rawValue string) (APIKey, error) { + return APIKey(rawValue), nil + }, + func(value APIKey) error { + if !strings.HasPrefix(string(value), "sk-") { return fmt.Errorf("API key must start with 'sk-'") } return nil - }), + }, +) + +err := goconfig.Load(context.Background(), &cfg, + goconfig.WithCustomType[APIKey](apiKeyHandler), ) ``` -📚 **[Full Validation Guide](docs/validation.md)** | **[Validation Example](example/validation)** +The building block system lets you compose handlers: +- `NewCustomType` - Start with parser and validators +- `AddValidators` - Add validators to existing handlers +- `CastCustomType` - Transform handlers for type aliases +- `NewStringEnumType` - Specialized enum builder + +📚 **[Custom Types Guide](docs/custom-types.md)** | **[Validation Guide](docs/validation.md)** | **[Example](example/validation)** ## JSON Configuration @@ -153,10 +175,11 @@ export MODEL_PARAMS='{"temperature":0.7,"max_tokens":1000}' ## Documentation - 📖 **[Documentation Index](docs/)** - Complete guides and reference -- 📋 **[Validation](docs/validation.md)** - Min/max, pattern, and custom validators +- 🧱 **[Custom Types Guide](docs/custom-types.md)** - Building block architecture for custom types +- 📋 **[Validation](docs/validation.md)** - Min/max, pattern, and custom type validators - ⚙️ **[Defaulting & Required Fields](docs/defaulting.md)** - How defaults and required work - 🔄 **[JSON Deserialization](docs/json.md)** - Working with JSON config -- 🔧 **[Advanced Features](docs/advanced.md)** - Custom parsers and key stores +- 🔧 **[Advanced Features](docs/advanced.md)** - Custom key stores and advanced patterns - 💡 **[Examples](example/)** - Working code examples ## Examples diff --git a/config.go b/config.go index 50d753f..324cb06 100644 --- a/config.go +++ b/config.go @@ -5,104 +5,9 @@ import ( "fmt" "reflect" - "github.com/m0rjc/goconfig/internal/process" + "github.com/m0rjc/goconfig/internal/readpipeline" ) -// Option is a functional option for configuring the Load function. -type Option func(*loadOptions) - -// WithValidatorFactory registers a custom validator factory. -// Validator factories inspect struct fields and automatically register validators -// based on field metadata (type, tags, name, etc.). -// -// Factories are called for each field during Load, allowing you to: -// - Add validation based on custom struct tags -// - Apply type-specific validation rules -// - Implement domain-specific validation patterns -// -// Example: Adding email validation via custom tag: -// -// factory := func(fieldType reflect.StructField, registry ValidatorRegistry) error { -// if fieldType.Tag.Get("email") == "true" { -// registry(func(value any) error { -// email := value.(string) -// if !strings.Contains(email, "@") { -// return fmt.Errorf("invalid email format") -// } -// return nil -// }) -// } -// return nil -// } -// Load(&cfg, WithValidatorFactory(factory)) -// -// Multiple factories can be registered and will be called in registration order. -// The builtin factory (for min, max, and pattern tags) is always registered first. -func WithValidatorFactory(factory ValidatorFactory) Option { - return func(opts *loadOptions) { - opts.addValidatorFactory(factory) - } -} - -// WithValidator registers a custom validator for a specific field path. -// Path uses dot notation for nested fields (e.g., "AI.APIKey", "WebHook.Timeout"). -// Multiple validators can be registered for the same field; all will be executed in order. -// -// The validator receives the converted value (after type conversion from the environment -// variable string) and should return an error if validation fails. -// -// Example: Validating a port is a multiple of 10: -// -// Load(&cfg, WithValidator("Port", func(value any) error { -// port := value.(int64) -// if port%10 != 0 { -// return fmt.Errorf("port must be multiple of 10") -// } -// return nil -// })) -// -// Use WithValidatorFactory instead if you want to apply validation based on -// field metadata (tags, type, name) rather than explicit field paths. -func WithValidator(path string, validator Validator) Option { - return func(opts *loadOptions) { - opts.addValidator(path, validator) - } -} - -// WithParser registers a custom parser at a given path. -func WithParser(path string, parser Parser) Option { - return func(opts *loadOptions) { - opts.addParser(path, parser) - } -} - -type Parser = process.FieldProcessor[any] - -// WithKeyStore replaces the environment variable keystore with an alternative. -// Use this to read from other sources such as a database or properties file. -func WithKeyStore(keyStore KeyStore) Option { - return func(opts *loadOptions) { - opts.keyStore = keyStore - } -} - -// newLoadOptions creates default load options. -func newLoadOptions() *loadOptions { - return &loadOptions{ - keyStore: EnvironmentKeyStore, - parsers: make(map[string]Parser), - validatorFactories: make([]ValidatorFactory, 0), - validators: make(map[string][]Validator), - } -} - -// applyOptions applies the given options to the load options. -func (opts *loadOptions) applyOptions(options []Option) { - for _, opt := range options { - opt(opts) - } -} - // Load populates the given configuration struct from environment variables // using the `key`, `default`, `required`, `min`, `max`, and `pattern` struct tags. // @@ -231,15 +136,9 @@ func loadStruct(ctx context.Context, v reflect.Value, fieldPath string, opts *lo } // Configure the processor, then run it - customParser := opts.getCustomParser(currentPath) - customValidators, err := opts.getCustomValidators(currentPath, fieldType) - if err != nil { - return fmt.Errorf("custom validators for field %s: %w", currentPath, err) - } - - processor, err := process.New(fieldType.Type, fieldType.Tag, customParser, customValidators) + processor, err := readpipeline.New(fieldType.Type, fieldType.Tag, opts.typeRegistry) if err != nil { - return fmt.Errorf("setting up field process %s: %v", currentPath, err) + return fmt.Errorf("setting up field readpipeline %s: %v", currentPath, err) } // Parse the configured value to produce a raw value diff --git a/config_test.go b/config_test.go index 7728dbc..8bd35ff 100644 --- a/config_test.go +++ b/config_test.go @@ -4,7 +4,7 @@ import ( "context" "errors" "os" - "reflect" + "strconv" "strings" "testing" ) @@ -212,13 +212,17 @@ func TestLoad_Options(t *testing.T) { } t.Run("Custom Parser", func(t *testing.T) { - var cfg Config - // Custom parser that adds 1 to the port - customParser := func(rawValue string) (any, error) { - return int64(9000), nil + type Port int + type Config struct { + Port Port `key:"PORT"` } + var cfg Config + // Custom parser for the custom Port type + handler := NewCustomType(func(rawValue string) (Port, error) { + return Port(9000), nil + }) - err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithParser("Port", customParser)) + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[Port](handler)) if err != nil { t.Fatalf("Load failed: %v", err) } @@ -228,16 +232,22 @@ func TestLoad_Options(t *testing.T) { }) t.Run("Custom Validator", func(t *testing.T) { + type Port int + type Config struct { + Port Port `key:"PORT"` + } var cfg Config - customValidator := func(value any) error { - v := value.(int64) - if v != 8080 { + handler := NewCustomType(func(rawValue string) (Port, error) { + v, err := strconv.Atoi(rawValue) + return Port(v), err + }, func(value Port) error { + if value != 8080 { return errors.New("wrong port") } return nil - } + }) - err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithValidator("Port", customValidator)) + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[Port](handler)) if err != nil { t.Fatalf("Load failed: %v", err) } @@ -266,7 +276,7 @@ func TestLoad_Errors(t *testing.T) { ctx := context.Background() t.Run("Failure to instantiate pipeline", func(t *testing.T) { - // Use a type that internal/process doesn't support (like a channel) + // Use a type that internal/readpipeline doesn't support (like a channel) type Config struct { Chan chan int `key:"CHAN"` } @@ -278,27 +288,32 @@ func TestLoad_Errors(t *testing.T) { if err == nil { t.Fatal("Expected error, got nil") } - if !strings.Contains(err.Error(), "setting up field process Chan") { + if !strings.Contains(err.Error(), "setting up field readpipeline Chan") { t.Errorf("Expected setup error, got: %v", err) } }) t.Run("Failure in getCustomValidators", func(t *testing.T) { - type Config struct { - Port int `key:"PORT"` - } - var cfg Config mockStore := func(ctx context.Context, key string) (string, bool, error) { return "8080", true, nil } - failingFactory := func(fieldType reflect.StructField, registry ValidatorRegistry) error { - return errors.New("factory failure") + // Validator factories are no longer supported in this way + // Instead we use custom types with handlers + type CustomPort int + type CustomConfig struct { + Port CustomPort `key:"PORT"` } - err := Load(ctx, &cfg, WithKeyStore(mockStore), WithValidatorFactory(failingFactory)) + var customCfg CustomConfig + + failingHandler := NewCustomType(func(rawValue string) (CustomPort, error) { + return 0, errors.New("factory failure") + }) + + err := Load(ctx, &customCfg, WithKeyStore(mockStore), WithCustomType[CustomPort](failingHandler)) if err == nil { t.Fatal("Expected error, got nil") } - if !strings.Contains(err.Error(), "custom validators for field Port: factory failure") { + if !strings.Contains(err.Error(), "factory failure") { t.Errorf("Expected factory error, got: %v", err) } }) @@ -345,7 +360,7 @@ func TestLoad_Errors(t *testing.T) { if err == nil { t.Fatal("Expected error, got nil") } - if !strings.Contains(err.Error(), "setting up field process Inner.Chan") { + if !strings.Contains(err.Error(), "setting up field readpipeline Inner.Chan") { t.Errorf("Expected setup error for nested field, got: %v", err) } }) diff --git a/custom_types.go b/custom_types.go new file mode 100644 index 0000000..ee30a33 --- /dev/null +++ b/custom_types.go @@ -0,0 +1,71 @@ +package goconfig + +import ( + "reflect" + "time" + + "github.com/m0rjc/goconfig/internal/customtypes" + "github.com/m0rjc/goconfig/internal/readpipeline" +) + +type FieldProcessor[T any] = readpipeline.FieldProcessor[T] + +type Validator[T any] = readpipeline.Validator[T] + +type Wrapper[T any] = readpipeline.Wrapper[T] + +type TypedHandler[T any] = readpipeline.TypedHandler[T] + +func RegisterCustomType[T any](handler TypedHandler[T]) { + readpipeline.RegisterType[T](handler) +} + +func NewCustomType[T any](customParser FieldProcessor[T], customValidators ...Validator[T]) TypedHandler[T] { + handler := customtypes.NewParser(customParser) + if customValidators != nil && len(customValidators) > 0 { + handler = customtypes.AddWrapper(handler, customtypes.NewValidatorWrapper(customValidators...)) + } + return handler +} + +func NewStringEnumType[T ~string](validValues ...T) TypedHandler[T] { + return customtypes.NewStringEnum(validValues...) +} + +func AddValidators[T any](baseHandler TypedHandler[T], customValidators ...Validator[T]) TypedHandler[T] { + if customValidators != nil && len(customValidators) > 0 { + return customtypes.AddWrapper(baseHandler, customtypes.NewValidatorWrapper(customValidators...)) + } + return baseHandler +} + +func AddDynamicValidation[T any](baseHandler TypedHandler[T], wrapper Wrapper[T]) TypedHandler[T] { + return customtypes.AddWrapper(baseHandler, wrapper) +} + +func CastCustomType[T, U any](baseHandler TypedHandler[T]) TypedHandler[U] { + return customtypes.NewTransformer[T, U](baseHandler) +} + +func DefaultStringType() TypedHandler[string] { + return readpipeline.NewTypedStringHandler() +} + +func DefaultIntegerType[T ~int | ~int8 | ~int16 | ~int32 | ~int64]() TypedHandler[T] { + t := reflect.TypeOf(T(0)) + return CastCustomType[int64, T](readpipeline.NewTypedIntHandler(t.Bits())) +} + +func DefaultUnsignedIntegerType[T ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64]() TypedHandler[T] { + t := reflect.TypeOf(T(0)) + return CastCustomType[uint64, T](readpipeline.NewTypedUintHandler(t.Bits())) +} + +func DefaultFloatIntegerType[T ~float32 | ~float64]() TypedHandler[T] { + t := reflect.TypeOf(T(0)) + return CastCustomType[float64, T](readpipeline.NewTypedFloatHandler(t.Bits())) +} + +func DefaultDurationType() TypedHandler[time.Duration] { + return readpipeline.NewTypedDurationHandler() +} diff --git a/custom_types_test.go b/custom_types_test.go new file mode 100644 index 0000000..122592a --- /dev/null +++ b/custom_types_test.go @@ -0,0 +1,302 @@ +package goconfig + +import ( + "context" + "errors" + "reflect" + "testing" + "time" +) + +func TestNewCustomType(t *testing.T) { + type CustomString string + type Config struct { + Val CustomString `key:"VAL"` + } + + handler := NewCustomType( + func(rawValue string) (CustomString, error) { + return CustomString("prefix-" + rawValue), nil + }, + func(value CustomString) error { + if len(value) < 10 { + return errors.New("too short") + } + return nil + }, + ) + + mockStore := func(ctx context.Context, key string) (string, bool, error) { + if key == "VAL" { + return "12345", true, nil + } + return "", false, nil + } + + var cfg Config + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[CustomString](handler)) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.Val != "prefix-12345" { + t.Errorf("Expected prefix-12345, got %s", cfg.Val) + } + + // Test validation failure + mockStoreFail := func(ctx context.Context, key string) (string, bool, error) { + if key == "VAL" { + return "1", true, nil + } + return "", false, nil + } + err = Load(context.Background(), &cfg, WithKeyStore(mockStoreFail), WithCustomType[CustomString](handler)) + if err == nil { + t.Fatal("Expected validation error, got nil") + } +} + +func TestNewStringEnumType(t *testing.T) { + type Mode string + const ( + ModeDev Mode = "dev" + ModeProd Mode = "prod" + ) + + type Config struct { + AppMode Mode `key:"MODE"` + } + + handler := NewStringEnumType(ModeDev, ModeProd) + + tests := []struct { + name string + val string + expectErr bool + }{ + {"valid dev", "dev", false}, + {"valid prod", "prod", false}, + {"invalid", "other", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return tt.val, true, nil + } + var cfg Config + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[Mode](handler)) + if tt.expectErr { + if err == nil { + t.Error("Expected error, got nil") + } + } else { + if err != nil { + t.Errorf("Load failed: %v", err) + } + if string(cfg.AppMode) != tt.val { + t.Errorf("Expected %s, got %s", tt.val, cfg.AppMode) + } + } + }) + } +} + +func TestAddValidators(t *testing.T) { + type Config struct { + Val int `key:"VAL"` + } + + baseHandler := DefaultIntegerType[int]() + handler := AddValidators(baseHandler, func(value int) error { + if value%2 != 0 { + return errors.New("must be even") + } + return nil + }) + + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "42", true, nil + } + + var cfg Config + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[int](handler)) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.Val != 42 { + t.Errorf("Expected 42, got %d", cfg.Val) + } + + mockStoreFail := func(ctx context.Context, key string) (string, bool, error) { + return "43", true, nil + } + err = Load(context.Background(), &cfg, WithKeyStore(mockStoreFail), WithCustomType[int](handler)) + if err == nil { + t.Fatal("Expected error for odd number, got nil") + } +} + +func TestAddDynamicValidation(t *testing.T) { + type Config struct { + Val string `key:"VAL" check:"true"` + } + + baseHandler := DefaultStringType() + handler := AddDynamicValidation(baseHandler, func(tags reflect.StructTag, inputProcess FieldProcessor[string]) (FieldProcessor[string], error) { + if tags.Get("check") == "true" { + return func(rawValue string) (string, error) { + val, err := inputProcess(rawValue) + if err != nil { + return val, err + } + if val == "forbidden" { + return val, errors.New("forbidden value") + } + return val, nil + }, nil + } + return inputProcess, nil + }) + + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "forbidden", true, nil + } + + var cfg Config + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[string](handler)) + if err == nil { + t.Fatal("Expected dynamic validation error, got nil") + } +} + +func TestCastCustomType(t *testing.T) { + type MyInt int + type Config struct { + Val MyInt `key:"VAL"` + } + + baseHandler := DefaultIntegerType[int]() + // Cast int handler to MyInt handler + handler := CastCustomType[int, MyInt](baseHandler) + + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "100", true, nil + } + + var cfg Config + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[MyInt](handler)) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.Val != 100 { + t.Errorf("Expected 100, got %d", cfg.Val) + } +} + +func TestDefaultTypeHandlers(t *testing.T) { + t.Run("String", func(t *testing.T) { + handler := DefaultStringType() + p, _ := handler.BuildPipeline("") + val, _ := p("hello") + if any(val).(string) != "hello" { + t.Errorf("Expected hello, got %s", val) + } + }) + + t.Run("Integer", func(t *testing.T) { + handler := DefaultIntegerType[int32]() + p, _ := handler.BuildPipeline("") + val, _ := p("123") + if any(val).(int32) != 123 { + t.Errorf("Expected 123, got %v", val) + } + }) + + t.Run("UnsignedInteger", func(t *testing.T) { + handler := DefaultUnsignedIntegerType[uint16]() + p, _ := handler.BuildPipeline("") + val, _ := p("456") + if any(val).(uint16) != 456 { + t.Errorf("Expected 456, got %v", val) + } + }) + + t.Run("Float", func(t *testing.T) { + handler := DefaultFloatIntegerType[float64]() + p, _ := handler.BuildPipeline("") + val, _ := p("1.23") + if any(val).(float64) != 1.23 { + t.Errorf("Expected 1.23, got %v", val) + } + }) + + t.Run("Duration", func(t *testing.T) { + handler := DefaultDurationType() + p, _ := handler.BuildPipeline("") + val, _ := p("10s") + if any(val).(time.Duration) != 10*time.Second { + t.Errorf("Expected 10s, got %v", val) + } + }) +} + +func TestRegisterCustomType(t *testing.T) { + type GlobalCustom string + type Config struct { + Val GlobalCustom `key:"VAL"` + } + + RegisterCustomType[GlobalCustom](NewCustomType(func(rawValue string) (GlobalCustom, error) { + return GlobalCustom("global-" + rawValue), nil + })) + + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "test", true, nil + } + + var cfg Config + err := Load(context.Background(), &cfg, WithKeyStore(mockStore)) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.Val != "global-test" { + t.Errorf("Expected global-test, got %s", cfg.Val) + } +} + +func TestDefaultIntegerType_Tags(t *testing.T) { + type Config struct { + Val int `key:"VAL" min:"10" max:"20"` + } + + handler := DefaultIntegerType[int]() + + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "15", true, nil + } + + var cfg Config + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[int](handler)) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + // Test min failure + mockStoreMin := func(ctx context.Context, key string) (string, bool, error) { + return "5", true, nil + } + err = Load(context.Background(), &cfg, WithKeyStore(mockStoreMin), WithCustomType[int](handler)) + if err == nil { + t.Fatal("Expected min validation error, got nil") + } + + // Test max failure + mockStoreMax := func(ctx context.Context, key string) (string, bool, error) { + return "25", true, nil + } + err = Load(context.Background(), &cfg, WithKeyStore(mockStoreMax), WithCustomType[int](handler)) + if err == nil { + t.Fatal("Expected max validation error, got nil") + } +} diff --git a/docs/README.md b/docs/README.md index b499f1a..7fd5724 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,13 +10,27 @@ Comprehensive guides for using goconfig. ## Documentation Guides +### 🧱 [Custom Types - Building Block Guide](custom-types.md) +Learn the building block architecture for creating custom types. + +**Topics covered:** +- Core building blocks (parser, validator, wrapper, handler, transformer) +- Building block functions (NewCustomType, AddValidators, CastCustomType, etc.) +- Composition patterns +- Complete examples (API keys, ports, URLs, complex types, enums) +- Advanced topics (global registration, reusable handlers, testing) + +**When to read:** Essential when you need custom types beyond built-in validation. Start here to understand the composable building block approach. + +--- + ### 📋 [Validation](validation.md) Learn how to validate configuration values using built-in and custom validators. **Topics covered:** - Min/max range validation for integers, floats, and durations - Pattern validation using regular expressions -- Custom validation functions +- Custom type validation with building blocks - Nested field validation - Combining multiple validators - Error messages and debugging @@ -97,8 +111,7 @@ Extend goconfig with custom behavior. // Load with options err := goconfig.Load(context.Background(), &config, goconfig.WithKeyStore(customStore), - goconfig.WithParser("Field", parserFunc), - goconfig.WithValidator("Field", validatorFunc), + goconfig.WithCustomType[APIKey](apiKeyHandler), ) ``` @@ -132,21 +145,34 @@ err := goconfig.Load(context.Background(), &cfg) See: [validation example](../example/validation), [validation.md](validation.md) -### With Custom Validators +### With Custom Types (Building Blocks) ```go -err := goconfig.Load(context.Background(), &cfg, - goconfig.WithValidator("APIKey", func(value any) error { - key := value.(string) - if !strings.HasPrefix(key, "sk-") { +type APIKey string + +// Building block: parser + validator +apiKeyHandler := goconfig.NewCustomType( + func(rawValue string) (APIKey, error) { + return APIKey(rawValue), nil + }, + func(key APIKey) error { + if !strings.HasPrefix(string(key), "sk-") { return fmt.Errorf("API key must start with 'sk-'") } return nil - }), + }, +) + +type Config struct { + APIKey APIKey `key:"API_KEY" required:"true"` +} + +err := goconfig.Load(context.Background(), &cfg, + goconfig.WithCustomType[APIKey](apiKeyHandler), ) ``` -See: [validation.md](validation.md#custom-validators) +See: [custom-types.md](custom-types.md), [validation.md](validation.md#custom-validators) ### JSON Configuration diff --git a/docs/advanced.md b/docs/advanced.md index 501eb31..63f2e0a 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -4,16 +4,16 @@ This guide covers advanced features for extending goconfig with custom behavior. ## Table of Contents -- [Custom Parsers](#custom-parsers) +- [Custom Types](#custom-types) - [Custom Key Stores](#custom-key-stores) - [Composite Key Stores](#composite-key-stores) -- [Error Handling](#error-handling) +- [Error Handling and Structured Logging](#error-handling) -## Custom Parsers +## Custom Types -Custom parsers allow you to define parsing logic for specific fields that need special handling beyond the built-in type conversions. +Custom types allow you to define parsing and validation logic for specific types that need special handling beyond the built-in type conversions. The type system uses Go generics for type safety. -### Basic Custom Parser +### Basic Custom Type ```go type Config struct { @@ -32,12 +32,12 @@ type CustomType struct { func main() { var cfg Config - err := goconfig.Load(context.Background(), &cfg, - goconfig.WithParser("SpecialValue", func(value string) (any, error) { + customHandler := goconfig.NewCustomHandler( + func(rawValue string) (CustomType, error) { // Parse the value however you need - parts := strings.Split(value, ":") + parts := strings.Split(rawValue, ":") if len(parts) != 2 { - return nil, fmt.Errorf("invalid format, expected key:value") + return CustomType{}, fmt.Errorf("invalid format, expected key:value") } return CustomType{ @@ -46,7 +46,11 @@ func main() { "key": parts[0], }, }, nil - }), + }, + ) + + err := goconfig.Load(context.Background(), &cfg, + goconfig.WithCustomType[CustomType](customHandler), ) if err != nil { @@ -55,52 +59,35 @@ func main() { } ``` -### Use Cases for Custom Parsers +### Example Use Cases for Custom Types #### Parsing URLs ```go +type DatabaseURL url.URL + type Config struct { - DatabaseURL *url.URL `key:"DATABASE_URL"` + DatabaseURL DatabaseURL `key:"DATABASE_URL"` } func main() { var cfg Config - err := goconfig.Load(context.Background(), &cfg, - goconfig.WithParser("DatabaseURL", func(value string) (any, error) { - parsedURL, err := url.Parse(value) + urlHandler := goconfig.NewCustomHandler( + func(rawValue string) (DatabaseURL, error) { + parsedURL, err := url.Parse(rawValue) if err != nil { - return nil, fmt.Errorf("invalid URL: %w", err) + return DatabaseURL{}, fmt.Errorf("invalid URL: %w", err) } if parsedURL.Scheme != "postgres" && parsedURL.Scheme != "postgresql" { - return nil, fmt.Errorf("unsupported database scheme: %s", parsedURL.Scheme) + return DatabaseURL{}, fmt.Errorf("unsupported database scheme: %s", parsedURL.Scheme) } - return parsedURL, nil - }), + return DatabaseURL(*parsedURL), nil + }, ) -} -``` - -#### Parsing Custom Time Formats - -```go -type Config struct { - Timestamp time.Time `key:"TIMESTAMP"` -} - -func main() { - var cfg Config err := goconfig.Load(context.Background(), &cfg, - goconfig.WithParser("Timestamp", func(value string) (any, error) { - // Parse RFC3339 format - t, err := time.Parse(time.RFC3339, value) - if err != nil { - return nil, fmt.Errorf("invalid timestamp format: %w", err) - } - return t, nil - }), + goconfig.WithCustomType[DatabaseURL](urlHandler), ) } ``` @@ -108,74 +95,61 @@ func main() { #### Parsing Lists with Custom Delimiters ```go +type HostList []string + type Config struct { - AllowedHosts []string `key:"ALLOWED_HOSTS"` + AllowedHosts HostList `key:"ALLOWED_HOSTS"` } func main() { var cfg Config - err := goconfig.Load(context.Background(), &cfg, - goconfig.WithParser("AllowedHosts", func(value string) (any, error) { + hostListHandler := goconfig.NewCustomHandler( + func(rawValue string) (HostList, error) { // Split on semicolons instead of commas - hosts := strings.Split(value, ";") + hosts := strings.Split(rawValue, ";") // Trim whitespace for i, host := range hosts { hosts[i] = strings.TrimSpace(host) } - return hosts, nil - }), + return HostList(hosts), nil + }, ) - // export ALLOWED_HOSTS="example.com; api.example.com; www.example.com" -} -``` - -#### Parsing Binary Data - -```go -type Config struct { - EncryptionKey []byte `key:"ENCRYPTION_KEY"` -} - -func main() { - var cfg Config - err := goconfig.Load(context.Background(), &cfg, - goconfig.WithParser("EncryptionKey", func(value string) (any, error) { - // Decode base64-encoded key - key, err := base64.StdEncoding.DecodeString(value) - if err != nil { - return nil, fmt.Errorf("invalid base64: %w", err) - } - if len(key) != 32 { - return nil, fmt.Errorf("encryption key must be 32 bytes, got %d", len(key)) - } - return key, nil - }), + goconfig.WithCustomType[HostList](hostListHandler), ) + + // export ALLOWED_HOSTS="example.com; api.example.com; www.example.com" } ``` + ### Parser Error Handling -Custom parsers should return descriptive errors: +Custom type parsers should return descriptive errors. For security best practice you should avoid returning the input value. +This avoids the like of `Failed to parse value 'Top Secret' for field 'API_KEY` appearing in logs. ```go -goconfig.WithParser("Field", func(value string) (any, error) { - // Good: Descriptive error - return nil, fmt.Errorf("invalid format: expected 'key=value', got '%s'", value) - - // Bad: Generic error - return nil, fmt.Errorf("parse error") -}) +customHandler := goconfig.NewCustomHandler( + func(rawValue string) (MyType, error) { + // Good: Descriptive error + return MyType{}, fmt.Errorf("invalid format: expected 'key=value'") + + // Bad: Generic error + return MyType{}, fmt.Errorf("parse error") + + // Bad: Potentially leaving secrets in logs + return MyType{}, fmt.Errorf("parse error: expected 'key=value', got '%s'", rawValue) + }, +) ``` The error will be wrapped with field context automatically: ``` -invalid value for FIELD: invalid format: expected 'key=value', got 'invalid-input' +invalid value for FIELD: invalid format: expected 'key=value' ``` ## Custom Key Stores @@ -194,179 +168,10 @@ type KeyStore func(ctx context.Context, key string) (value string, found bool, e - **found**: Whether the key was found (distinguishes "not found" from "found but empty") - **error**: Any error that occurred during lookup -### Reading from a File - -```go -func fileKeyStore(filename string) goconfig.KeyStore { - return func(ctx context.Context, key string) (string, bool, error) { - data, err := os.ReadFile(filename) - if err != nil { - return "", false, fmt.Errorf("failed to read config file: %w", err) - } - - // Parse simple KEY=VALUE format - lines := strings.Split(string(data), "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - parts := strings.SplitN(line, "=", 2) - if len(parts) == 2 && parts[0] == key { - return parts[1], true, nil - } - } - - return "", false, nil - } -} - -func main() { - var cfg Config - - err := goconfig.Load(context.Background(), &cfg, - goconfig.WithKeyStore(fileKeyStore("/etc/myapp/config")), - ) -} -``` - -### Reading from AWS Secrets Manager - -```go -func awsSecretsKeyStore(secretsClient *secretsmanager.Client, secretName string) goconfig.KeyStore { - return func(ctx context.Context, key string) (string, bool, error) { - result, err := secretsClient.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{ - SecretId: aws.String(secretName), - }) - if err != nil { - return "", false, fmt.Errorf("failed to get secret: %w", err) - } - - // Parse JSON secret - var secrets map[string]string - if err := json.Unmarshal([]byte(*result.SecretString), &secrets); err != nil { - return "", false, fmt.Errorf("failed to parse secret: %w", err) - } - - value, found := secrets[key] - return value, found, nil - } -} -``` - -### Reading from HashiCorp Vault - -```go -func vaultKeyStore(client *vault.Client, path string) goconfig.KeyStore { - return func(ctx context.Context, key string) (string, bool, error) { - secret, err := client.KVv2("secret").Get(ctx, path) - if err != nil { - return "", false, fmt.Errorf("failed to read from vault: %w", err) - } - - value, found := secret.Data[key].(string) - if !found { - return "", false, nil - } - - return value, true, nil - } -} -``` - -### In-Memory Key Store (for testing) - -```go -func mapKeyStore(data map[string]string) goconfig.KeyStore { - return func(ctx context.Context, key string) (string, bool, error) { - value, found := data[key] - return value, found, nil - } -} - -func TestConfig(t *testing.T) { - var cfg Config - - testData := map[string]string{ - "PORT": "8080", - "HOST": "localhost", - "API_KEY": "sk-test-key-12345678901234", - } - - err := goconfig.Load(context.Background(), &cfg, - goconfig.WithKeyStore(mapKeyStore(testData)), - ) - - if err != nil { - t.Fatalf("Failed to load config: %v", err) - } -} -``` - -## Composite Key Stores +### Composite Key Stores `CompositeStore` chains multiple key stores together, trying each in order until one returns a value. -### Environment Variables with File Fallback - -```go -func main() { - var cfg Config - - // Try environment first, then fall back to config file - store := goconfig.CompositeStore( - goconfig.EnvironmentKeyStore, - fileKeyStore("/etc/myapp/config"), - ) - - err := goconfig.Load(context.Background(), &cfg, - goconfig.WithKeyStore(store), - ) -} -``` - -### Secrets Manager with Environment Variable Override - -```go -func main() { - var cfg Config - - // Environment variables can override secrets manager - store := goconfig.CompositeStore( - goconfig.EnvironmentKeyStore, - awsSecretsKeyStore(secretsClient, "prod/myapp"), - ) - - err := goconfig.Load(context.Background(), &cfg, - goconfig.WithKeyStore(store), - ) -} -``` - -### Multi-Stage Fallback - -```go -func main() { - var cfg Config - - // Try multiple sources in order: - // 1. Environment variables (highest priority) - // 2. Vault secrets - // 3. Local config file - // 4. Default values in struct tags (automatic fallback) - store := goconfig.CompositeStore( - goconfig.EnvironmentKeyStore, - vaultKeyStore(vaultClient, "secret/myapp"), - fileKeyStore("/etc/myapp/config"), - ) - - err := goconfig.Load(context.Background(), &cfg, - goconfig.WithKeyStore(store), - ) -} -``` - ## Error Handling ### ConfigErrors Type @@ -391,28 +196,30 @@ if err != nil { ### Structured Logging -See `LogError` function in `errors.go` for an example of logging configuration errors to structured logs: +goconfig provides helper functions for structured logging with `slog`: ```go -func LogError(logger *slog.Logger, err error) { - var configErrs *ConfigErrors - if errors.As(err, &configErrs) { - for _, e := range configErrs.Unwrap() { - var fieldErr *FieldError - if errors.As(e, &fieldErr) { - logger.Error("configuration error", - "field", fieldErr.Field, - "key", fieldErr.Key, - "error", fieldErr.Err, - ) - } - } - } +logger := slog.New(slog.NewJSONHandler(os.Stderr, nil)) + +err := goconfig.Load(context.Background(), &config) +if err != nil { + // Log all the errors with a customized log message + goconfig.LogError(logger, err, goconfig.WithLogMessage("config_validation_failed")) +} +``` + +Alternatively, use `ConfigErrors.LogAll()` for more control: + +```go +if configErrs, ok := err.(*goconfig.ConfigErrors); ok { + configErrs.LogAll(logger, goconfig.WithLogMessage("configuration_error")) } ``` ### Checking Specific Error Types +The goconfig.ConfigErrors type provides the `Unwrap()` method, so implementing the `errors.Is`, `errors.As` contract. + ```go err := goconfig.Load(context.Background(), &config) if err != nil { @@ -450,28 +257,17 @@ func main() { vaultKeyStore(vaultClient, "secret/myapp"), )), - // Custom parsers - goconfig.WithParser("DatabaseURL", parseURL), - goconfig.WithParser("EncryptionKey", parseBase64Key), - - // Custom validators - goconfig.WithValidator("APIKey", validateAPIKey), - goconfig.WithValidator("Database.Host", validateProductionHost), + // Custom types + goconfig.WithCustomType[DatabaseURL](urlHandler), + goconfig.WithCustomType[EncryptionKey](keyHandler), + goconfig.WithCustomType[APIKey](apiKeyHandler), + goconfig.WithCustomType[DatabaseHost](hostHandler), ) if err != nil { - LogError(logger, err) + goconfig.LogError(logger, err) os.Exit(1) } } ``` -## Best Practices - -1. **Use CompositeStore for flexibility** - Allow environment overrides in production -2. **Cache key store results** - Avoid repeated API calls for the same key -3. **Handle context cancellation** - Respect context timeouts in custom key stores -4. **Return descriptive errors** - Help users understand what went wrong -5. **Test with in-memory stores** - Use `mapKeyStore` for unit tests -6. **Fail fast on key store errors** - Don't silently ignore lookup failures -7. **Document custom parsers** - Explain expected format and validation rules diff --git a/docs/custom-types.md b/docs/custom-types.md new file mode 100644 index 0000000..c1e85a5 --- /dev/null +++ b/docs/custom-types.md @@ -0,0 +1,579 @@ +# Custom Types - Building Block Guide + +This guide explains the building block architecture for custom types in goconfig. The system is designed around **composition** - you build complex type handlers by combining simple, reusable building blocks. + +## Table of Contents + +- [Overview](#overview) +- [Core Building Blocks](#core-building-blocks) +- [Building Block Functions](#building-block-functions) +- [Composition Patterns](#composition-patterns) +- [Complete Examples](#complete-examples) +- [Advanced Topics](#advanced-topics) + +## Overview + +The custom type system provides building blocks that you compose to create type handlers: + +1. **Parser** - Converts string to your type +2. **Validator** - Validates values of your type +3. **Wrapper** - Adds behavior to a processor (like validation) dynamically based on struct tags +4. **Handler** - Combines parser and wrappers into a complete type handler +5. **Transformer** - Converts between compatible types + +These building blocks can be combined in different ways to create exactly the behavior you need. + +## Core Building Blocks + +### FieldProcessor[T] + +A function that converts a string to type T: + +```go +type FieldProcessor[T any] func(rawValue string) (T, error) +``` + +This is the fundamental building block - everything ultimately produces a FieldProcessor. + +### Validator[T] + +A function that validates a value of type T: + +```go +type Validator[T any] func(value T) error +``` + +Validators are pure functions that check if a value is valid. + +### Wrapper[T] + +A factory that wraps a FieldProcessor to add behavior. The system allows dynamic behavior based on struct tags: + +```go +type Wrapper[T any] func(tags reflect.StructTag, inputProcess FieldProcessor[T]) (FieldProcessor[T], error) +``` + +Wrappers can: +- Add validation +- Read struct tags to customize behavior +- Transform values +- Add logging or other cross-cutting concerns (not intended, but possible) + +### TypedHandler[T] + +A handler that builds a FieldProcessor for a specific type: + +```go +type TypedHandler[T any] interface { + BuildPipeline(tags reflect.StructTag) (FieldProcessor[T], error) +} +``` + +This is the final building block - it combines everything and can read struct tags to customize behavior per field. + +## Building Block Functions + +### 1. NewCustomType - Start with Parser and Validators + +The most common way to create a custom type: + +```go +func NewCustomType[T any](parser FieldProcessor[T], validators ...Validator[T]) TypedHandler[T] +``` + +**Example:** +```go +type Email string + +emailHandler := goconfig.NewCustomType( + // Parser building block + func(rawValue string) (Email, error) { + return Email(rawValue), nil + }, + // Validator building blocks + func(value Email) error { + if !strings.Contains(string(value), "@") { + return errors.New("invalid email format") + } + return nil + }, + func(value Email) error { + if len(value) < 3 { + return errors.New("email too short") + } + return nil + }, +) +``` + +### 2. AddValidators - Add Validators to Existing Handler + +Extend any handler with additional validators: + +```go +func AddValidators[T any](handler TypedHandler[T], validators ...Validator[T]) TypedHandler[T] +``` + +**Example:** +```go +// Start with default int handler (has tag validation) +baseHandler := goconfig.DefaultIntegerType[int]() + +// Add custom validator building block +evenHandler := goconfig.AddValidators(baseHandler, + func(v int) error { + if v%2 != 0 { + return errors.New("must be even") + } + return nil + }, +) +// Now has BOTH tag validation AND even check +``` + +### 3. CastCustomType - Transform Between Compatible Types + +Transform a handler from type T to type U: + +```go +func CastCustomType[T, U any](handler TypedHandler[T]) TypedHandler[U] +``` + +This is essential for type aliases - it allows you to reuse handlers for similar types. + +**Example:** +```go +type Port int + +// Cast int handler to Port type +portHandler := goconfig.CastCustomType[int, Port]( + goconfig.DefaultIntegerType[int](), +) + +// Port now gets all int validation (min/max from tags) +``` + +### 4. NewStringEnumType - Specialized Enum Builder + +Create an enum validator for string-based types: + +```go +func NewStringEnumType[T ~string](validValues ...T) TypedHandler[T] +``` + +**Example:** +```go +type LogLevel string +const ( + LogDebug LogLevel = "debug" + LogInfo LogLevel = "info" + LogWarn LogLevel = "warn" +) + +handler := goconfig.NewStringEnumType(LogDebug, LogInfo, LogWarn) +``` + +### 5. AddDynamicValidation - Advanced Wrapper + +Add a wrapper that can read struct tags and customize behavior: + +```go +func AddDynamicValidation[T any](handler TypedHandler[T], wrapper Wrapper[T]) TypedHandler[T] +``` + +**Example:** +```go +// Custom wrapper that reads "allowed" tag +customWrapper := func(tags reflect.StructTag, processor FieldProcessor[string]) (FieldProcessor[string], error) { + allowed := tags.Get("allowed") + if allowed == "" { + return processor, nil + } + + allowedValues := strings.Split(allowed, ",") + return func(rawValue string) (string, error) { + value, err := processor(rawValue) + if err != nil { + return value, err + } + for _, allowed := range allowedValues { + if value == allowed { + return value, nil + } + } + return value, fmt.Errorf("not in allowed list: %v", allowedValues) + }, nil +} + +handler := goconfig.AddDynamicValidation( + goconfig.DefaultStringType(), + customWrapper, +) +``` + +### 6. Default Type Handlers - Building Blocks for Built-in Types + +Get handlers for built-in types that already include tag validation: + +```go +goconfig.DefaultStringType() // string with pattern validation +goconfig.DefaultIntegerType[int]() // int with min/max validation +goconfig.DefaultUnsignedIntegerType[uint]() // uint with min/max validation +goconfig.DefaultFloatIntegerType[float64]() // float64 with min/max validation +goconfig.DefaultDurationType() // time.Duration with min/max validation +``` + +These are perfect starting points for composition. + +## Composition Patterns + +The power of building blocks is in **composition**. Here are common patterns: + +### Pattern 1: Start Simple, Add Validators + +```go +// 1. Start with parser +handler := goconfig.NewCustomType(parser) + +// 2. Add validators later +handler = goconfig.AddValidators(handler, validator1, validator2) +``` + +### Pattern 2: Extend Built-in Types + +```go +// 1. Get default handler +base := goconfig.DefaultIntegerType[int]() + +// 2. Add custom validation +custom := goconfig.AddValidators(base, customValidator) +``` + +### Pattern 3: Cast Then Validate + +```go +type Port int + +// 1. Cast int handler to Port +handler := goconfig.CastCustomType[int, Port]( + goconfig.DefaultIntegerType[int](), +) + +// 2. Add Port-specific validation +handler = goconfig.AddValidators(handler, portValidator) +``` + +### Pattern 4: Chain Multiple Extensions + +```go +// Start with base +handler := goconfig.DefaultStringType() + +// Add validator +handler = goconfig.AddValidators(handler, validator1) + +// Add dynamic validation +handler = goconfig.AddDynamicValidation(handler, wrapper1) + +// Add more validators +handler = goconfig.AddValidators(handler, validator2) +``` + +## Complete Examples + +### Example 1: API Key with Multiple Validations + +```go +type APIKey string + +apiKeyHandler := goconfig.NewCustomType( + // Parser + func(rawValue string) (APIKey, error) { + return APIKey(rawValue), nil + }, + // Validator 1: Check prefix + func(key APIKey) error { + if !strings.HasPrefix(string(key), "sk-") { + return errors.New("API key must start with 'sk-'") + } + return nil + }, + // Validator 2: Check length + func(key APIKey) error { + if len(key) < 20 { + return errors.New("API key must be at least 20 characters") + } + return nil + }, + // Validator 3: Check format + func(key APIKey) error { + if !regexp.MustCompile(`^sk-[a-zA-Z0-9]+$`).MatchString(string(key)) { + return errors.New("API key contains invalid characters") + } + return nil + }, +) + +type Config struct { + APIKey APIKey `key:"API_KEY" required:"true"` +} + +err := goconfig.Load(ctx, &cfg, + goconfig.WithCustomType[APIKey](apiKeyHandler), +) +``` + +### Example 2: Port with Range and Multiple-of-10 Validation + +```go +type Port int + +// Compose building blocks +portHandler := goconfig.AddValidators( + // Start with int handler (gives us min/max from tags) + goconfig.CastCustomType[int, Port]( + goconfig.DefaultIntegerType[int](), + ), + // Add custom validation + func(port Port) error { + if int(port)%10 != 0 { + return errors.New("port must be a multiple of 10") + } + return nil + }, +) + +type Config struct { + HTTP Port `key:"HTTP_PORT" default:"8080" min:"1024" max:"65535"` + HTTPS Port `key:"HTTPS_PORT" default:"8443" min:"1024" max:"65535"` +} + +err := goconfig.Load(ctx, &cfg, + goconfig.WithCustomType[Port](portHandler), +) +// Both ports get min/max validation AND multiple-of-10 check +``` + +### Example 3: URL with Protocol Validation + +```go +type HTTPSURL string + +urlHandler := goconfig.NewCustomType( + // Parser with validation + func(rawValue string) (HTTPSURL, error) { + u, err := url.Parse(rawValue) + if err != nil { + return "", fmt.Errorf("invalid URL: %w", err) + } + return HTTPSURL(rawValue), nil + }, + // Validator 1: Must use HTTPS + func(urlStr HTTPSURL) error { + u, _ := url.Parse(string(urlStr)) + if u.Scheme != "https" { + return errors.New("URL must use HTTPS") + } + return nil + }, + // Validator 2: Must have host + func(urlStr HTTPSURL) error { + u, _ := url.Parse(string(urlStr)) + if u.Host == "" { + return errors.New("URL must have a host") + } + return nil + }, +) + +type Config struct { + APIEndpoint HTTPSURL `key:"API_ENDPOINT" required:"true"` +} +``` + +This sample is also achievable using the `pattern` tag validation. + +### Example 4: Complex Type - Server Address + +```go +type ServerAddress struct { + Host string + Port int +} + +addressHandler := goconfig.NewCustomType( + // Parser - converts "host:port" string to struct + func(rawValue string) (ServerAddress, error) { + parts := strings.Split(rawValue, ":") + if len(parts) != 2 { + return ServerAddress{}, errors.New("invalid format: expected host:port") + } + port, err := strconv.Atoi(parts[1]) + if err != nil { + return ServerAddress{}, fmt.Errorf("invalid port: %w", err) + } + return ServerAddress{Host: parts[0], Port: port}, nil + }, + // Validator 1: Port range + func(addr ServerAddress) error { + if addr.Port < 1024 || addr.Port > 65535 { + return errors.New("port must be in range 1024-65535") + } + return nil + }, + // Validator 2: Host not empty + func(addr ServerAddress) error { + if addr.Host == "" { + return errors.New("host cannot be empty") + } + return nil + }, + // Validator 3: Not localhost + func(addr ServerAddress) error { + if addr.Host == "localhost" || addr.Host == "127.0.0.1" { + return errors.New("production must use remote address") + } + return nil + }, +) + +type Config struct { + Database ServerAddress `key:"DB_ADDR" default:"db.example.com:5432"` + Cache ServerAddress `key:"CACHE_ADDR" default:"cache.example.com:6379"` +} +``` + +### Example 5: Enum with Validation + +```go +type Environment string +const ( + EnvDev Environment = "development" + EnvStage Environment = "staging" + EnvProd Environment = "production" +) + +// Use specialized enum building block +envHandler := goconfig.NewStringEnumType(EnvDev, EnvStage, EnvProd) + +type Config struct { + Env Environment `key:"ENVIRONMENT" default:"development"` +} + +err := goconfig.Load(ctx, &cfg, + goconfig.WithCustomType[Environment](envHandler), +) +``` + +## Advanced Topics + +### Global Registration + +Register a type handler globally so it applies to all future Load calls: + +```go +// Register once at package init or main +func init() { + goconfig.RegisterCustomType[APIKey](apiKeyHandler) + goconfig.RegisterCustomType[Port](portHandler) +} + +// Now Load automatically uses registered handlers +var cfg Config +err := goconfig.Load(ctx, &cfg) +// No need for WithCustomType options +``` + +### Per-Load Override + +Override global registration for a specific Load: + +```go +// Globally registered +goconfig.RegisterCustomType[Port](strictPortHandler) + +// Override for this load only +err := goconfig.Load(ctx, &cfg, + goconfig.WithCustomType[Port](lenientPortHandler), +) +``` + +### Reusing Handlers + +Handlers are composable - build complex handlers from simpler ones: + +```go +// Base validators +var ( + notEmpty = func(s string) error { + if s == "" { + return errors.New("cannot be empty") + } + return nil + } + + noSpaces = func(s string) error { + if strings.Contains(s, " ") { + return errors.New("cannot contain spaces") + } + return nil + } +) + +// Compose into handlers +usernameHandler := goconfig.AddValidators( + goconfig.DefaultStringType(), + notEmpty, + noSpaces, +) + +passwordHandler := goconfig.AddValidators( + goconfig.DefaultStringType(), + notEmpty, + func(s string) error { + if len(s) < 8 { + return errors.New("must be at least 8 characters") + } + return nil + }, +) +``` + +### Testing Custom Types + +Test your type handlers independently: + +```go +func TestAPIKeyHandler(t *testing.T) { + handler := createAPIKeyHandler() + + // Build pipeline with empty tags + processor, err := handler.BuildPipeline("") + require.NoError(t, err) + + // Test valid key + key, err := processor("sk-1234567890123456789") + assert.NoError(t, err) + assert.Equal(t, APIKey("sk-1234567890123456789"), key) + + // Test invalid prefix + _, err = processor("invalid-key") + assert.Error(t, err) + + // Test too short + _, err = processor("sk-123") + assert.Error(t, err) +} +``` + +## Summary + +The building block architecture provides: + +1. **Composability** - Build complex handlers from simple parts +2. **Reusability** - Share validators and handlers across types +3. **Type Safety** - Generic functions ensure compile-time safety +4. **Flexibility** - Mix and match building blocks as needed +5. **Testability** - Test each building block independently + +Start with the simple building blocks (`NewCustomType`, `AddValidators`, `CastCustomType`) and compose them to create exactly the behavior you need. diff --git a/docs/json.md b/docs/json.md index f1388ec..b828d54 100644 --- a/docs/json.md +++ b/docs/json.md @@ -250,50 +250,10 @@ Common JSON errors: - **Type mismatches** - JSON field type doesn't match struct field type - **Missing required fields** - JSON doesn't include fields without `omitempty` -## Best Practices - -1. **Use structs for known structure** - Better type safety and IDE support -2. **Use maps for dynamic configuration** - When structure isn't known at compile time -3. **Validate JSON types** - Ensure JSON field types match struct field types -4. **Use pointers for optional JSON** - Distinguish between "not set" and "set to zero value" -5. **Escape quotes in defaults** - Remember to escape quotes in `default` tags -6. **Consider external files for complex JSON** - For very large JSON, consider loading from files instead - -## Example: Feature Flags - -JSON is great for feature flag configuration: - -```go -type FeatureFlags struct { - EnableBetaFeatures bool `json:"enable_beta_features"` - AllowedUsers []string `json:"allowed_users"` - RolloutPercentage int `json:"rollout_percentage"` -} - -type Config struct { - Features FeatureFlags `key:"FEATURE_FLAGS" default:"{\"enable_beta_features\":false,\"allowed_users\":[],\"rollout_percentage\":0}"` -} - -func main() { - var config Config - - // export FEATURE_FLAGS='{"enable_beta_features":true,"allowed_users":["alice","bob"],"rollout_percentage":25}' - - if err := goconfig.Load(&config); err != nil { - log.Fatalf("Configuration error: %v", err) - } - - if config.Features.EnableBetaFeatures { - fmt.Println("Beta features enabled") - fmt.Printf("Allowed users: %v\n", config.Features.AllowedUsers) - fmt.Printf("Rollout: %d%%\n", config.Features.RolloutPercentage) - } -} -``` ## Combining with Other Features -JSON fields work with all other goconfig features: +JSON fields work with defaulting and required values. ```go type Config struct { @@ -308,4 +268,4 @@ type Config struct { } ``` -Note: Validation with `min`, `max`, and `pattern` tags applies to the JSON string itself, not the deserialized values. Use custom validators for validating deserialized JSON content. +There are no default validations such as `min`,`max`,`pattern` for JSON fields, apart from those provided by the JSON parser. \ No newline at end of file diff --git a/docs/security.md b/docs/security.md index 5ae1274..cd9caf1 100644 --- a/docs/security.md +++ b/docs/security.md @@ -25,11 +25,11 @@ When validation or parsing fails, these values could be exposed through: ### Implementation Requirements -1. **Parsing Errors** (config.go:242) +1. **Parsing Errors** - ❌ Bad: `"error parsing value %s: %w", configuredValue, err` - ✅ Good: `"error parsing value: %w", err` -2. **Validation Errors** (validation.go) +2. **Validation Errors** - ❌ Bad: `"value %s does not match pattern %s", actualValue, pattern` - ✅ Good: `"does not match pattern %s", pattern` - ❌ Bad: `"value %d is below minimum %d", actualValue, min` @@ -59,9 +59,3 @@ When adding new validation: 2. Ensure test expectations do NOT include actual values 3. Verify error messages only describe what's wrong, not what was provided -### Exception: Debugging Information - -Even in debug mode or verbose logging, actual configuration values should not be included in error messages. If debugging requires seeing values: -- Users should check their environment variables directly -- Application logs should be reviewed at the configuration loading stage (before validation) -- Never compromise security for convenience diff --git a/docs/validation.md b/docs/validation.md index 8d876a7..04898c8 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -95,48 +95,77 @@ type SecurityConfig struct { ## Custom Validators -Use the `WithValidator` option to add custom validation logic beyond what struct tags provide: +Use custom types with the `WithCustomType` option to add custom validation logic beyond what struct tags provide. The system uses a **building block architecture** where you compose handlers from simple, reusable components. + +### Building Block Basics + +The custom type system provides these building blocks: + +- **NewCustomType** - Create a handler from a parser and validators +- **AddValidators** - Add validators to an existing handler +- **CastCustomType** - Transform a handler to work with type aliases +- **NewStringEnumType** - Create an enum validator for string types +- **DefaultXxxType** - Get default handlers for built-in types + +### Basic Custom Types ```go +// Define custom types for fields that need special validation +type APIKey string +type Hostname string +type Port int + type Config struct { - APIKey string `key:"API_KEY" required:"true"` - Host string `key:"HOST" default:"localhost"` - Port int `key:"PORT" default:"8080" min:"1024" max:"65535"` + APIKey APIKey `key:"API_KEY" required:"true"` + Host Hostname `key:"HOST" default:"localhost"` + Port Port `key:"PORT" default:"8080" min:"1024" max:"65535"` } func main() { var cfg Config - err := goconfig.Load(&cfg, - // Validate API key format - goconfig.WithValidator("APIKey", func(value any) error { - key := value.(string) - if !strings.HasPrefix(key, "sk-") { - return fmt.Errorf("API key must start with 'sk-'") - } - if len(key) < 20 { - return fmt.Errorf("API key must be at least 20 characters long") - } - return nil - }), - - // Validate host is not an IP address - goconfig.WithValidator("Host", func(value any) error { - host := value.(string) - if net.ParseIP(host) != nil { - return fmt.Errorf("host must be a hostname, not an IP address") - } - return nil - }), - - // Additional port validation - goconfig.WithValidator("Port", func(value any) error { - port := value.(int64) - if port%10 != 0 { - return fmt.Errorf("port must be a multiple of 10") - } - return nil - }), + err := goconfig.Load(context.Background(), &cfg, + // Building block: parser + validators + goconfig.WithCustomType[APIKey](goconfig.NewCustomType( + func(rawValue string) (APIKey, error) { + return APIKey(rawValue), nil + }, + func(value APIKey) error { + key := string(value) + if !strings.HasPrefix(key, "sk-") { + return fmt.Errorf("API key must start with 'sk-'") + } + if len(key) < 20 { + return fmt.Errorf("API key must be at least 20 characters long") + } + return nil + }, + )), + + // Building block: parser + validator + goconfig.WithCustomType[Hostname](goconfig.NewCustomType( + func(rawValue string) (Hostname, error) { + return Hostname(rawValue), nil + }, + func(value Hostname) error { + host := string(value) + if net.ParseIP(host) != nil { + return fmt.Errorf("host must be a hostname, not an IP address") + } + return nil + }, + )), + + // Building block: cast int handler to Port, then add validator + goconfig.WithCustomType[Port](goconfig.AddValidators( + goconfig.CastCustomType[int, Port](goconfig.DefaultIntegerType[int]()), + func(value Port) error { + if int(value)%10 != 0 { + return fmt.Errorf("port must be a multiple of 10") + } + return nil + }, + )), ) if err != nil { @@ -145,57 +174,133 @@ func main() { } ``` -### Type Assertions in Custom Validators +### Type-Safe Validators + +Custom validators are type-safe - no type assertions needed: + +```go +// String-based custom type - building block approach +type Email string +emailHandler := goconfig.NewCustomType( + func(rawValue string) (Email, error) { + return Email(rawValue), nil + }, + func(value Email) error { // value is Email, not any + if !strings.Contains(string(value), "@") { + return errors.New("invalid email format") + } + return nil + }, +) + +// Type alias - use CastCustomType building block +type EvenPort int +evenPortHandler := goconfig.AddValidators( + goconfig.CastCustomType[int, EvenPort](goconfig.DefaultIntegerType[int]()), + func(value EvenPort) error { // value is EvenPort, not any + if int(value)%2 != 0 { + return errors.New("port must be even") + } + return nil + }, +) + +// Duration-based custom type - compose with default duration handler +type RequestTimeout time.Duration +timeoutHandler := goconfig.AddValidators( + goconfig.CastCustomType[time.Duration, RequestTimeout](goconfig.DefaultDurationType()), + func(value RequestTimeout) error { // value is RequestTimeout, not any + if value < RequestTimeout(100*time.Millisecond) { + return errors.New("timeout too short") + } + return nil + }, +) +``` + +### Enum Types -When writing custom validators, use the appropriate type assertion based on the field type: +For string-based enums, use the `NewStringEnumType` building block: + +```go +type LogLevel string -| Field Type | Validator Type Assertion | Example | -|------------|-------------------------|---------| -| `string` | `value.(string)` | `key := value.(string)` | -| `int`, `int8`, `int16`, `int32`, `int64` | `value.(int64)` | `port := value.(int64)` | -| `uint`, `uint8`, `uint16`, `uint32`, `uint64` | `value.(uint64)` | `count := value.(uint64)` | -| `float32`, `float64` | `value.(float64)` | `ratio := value.(float64)` | -| `bool` | `value.(bool)` | `enabled := value.(bool)` | -| `time.Duration` | `value.(time.Duration)` | `timeout := value.(time.Duration)` | +const ( + LogDebug LogLevel = "debug" + LogInfo LogLevel = "info" + LogWarn LogLevel = "warn" + LogError LogLevel = "error" +) + +type Config struct { + Level LogLevel `key:"LOG_LEVEL" default:"info"` +} + +func main() { + var cfg Config + + // Use the specialized enum building block + err := goconfig.Load(context.Background(), &cfg, + goconfig.WithCustomType[LogLevel]( + goconfig.NewStringEnumType(LogDebug, LogInfo, LogWarn, LogError), + ), + ) +} +``` + +The enum building block automatically validates that the value is one of the provided options and returns a clear error if not. ## Nested Field Validation -Validators work with nested structs using dot notation: +Custom type validators work with nested structs. Define custom types and they will be validated regardless of where they appear in the config structure: ```go +type DatabaseHost string +type APIEndpoint string + type Config struct { Database struct { - Host string `key:"DB_HOST" default:"localhost"` - Port int `key:"DB_PORT" default:"5432" min:"1024" max:"65535"` - Username string `key:"DB_USER" default:"postgres"` + Host DatabaseHost `key:"DB_HOST" default:"localhost"` + Port int `key:"DB_PORT" default:"5432" min:"1024" max:"65535"` + Username string `key:"DB_USER" default:"postgres"` } API struct { - Key string `key:"API_KEY" required:"true"` - Endpoint string `key:"API_ENDPOINT" default:"https://api.example.com"` + Key string `key:"API_KEY" required:"true"` + Endpoint APIEndpoint `key:"API_ENDPOINT" default:"https://api.example.com"` } } func main() { var cfg Config - err := goconfig.Load(&cfg, - // Validate database host - goconfig.WithValidator("Database.Host", func(value any) error { - host := value.(string) - if host == "localhost" { - return fmt.Errorf("production environments must use a remote database") - } - return nil - }), - - // Validate API endpoint uses HTTPS - goconfig.WithValidator("API.Endpoint", func(value any) error { - endpoint := value.(string) - if !strings.HasPrefix(endpoint, "https://") { - return fmt.Errorf("API endpoint must use HTTPS") - } - return nil - }), + err := goconfig.Load(context.Background(), &cfg, + // Building block: parser + validator + goconfig.WithCustomType[DatabaseHost](goconfig.NewCustomType( + func(rawValue string) (DatabaseHost, error) { + return DatabaseHost(rawValue), nil + }, + func(value DatabaseHost) error { + host := string(value) + if host == "localhost" { + return fmt.Errorf("production environments must use a remote database") + } + return nil + }, + )), + + // Building block: parser + validator + goconfig.WithCustomType[APIEndpoint](goconfig.NewCustomType( + func(rawValue string) (APIEndpoint, error) { + return APIEndpoint(rawValue), nil + }, + func(value APIEndpoint) error { + endpoint := string(value) + if !strings.HasPrefix(endpoint, "https://") { + return fmt.Errorf("API endpoint must use HTTPS") + } + return nil + }, + )), ) if err != nil { @@ -204,31 +309,106 @@ func main() { } ``` +**Note:** Custom type handlers are registered by TYPE, not by field path. All fields of the same type will use the same handler, regardless of nesting level. + ## Combining Validators -You can combine tag-based validation with custom validators. All validations must pass: +You can combine tag-based validation with custom type validators. All validations must pass: + +### Using Custom Types with Tag Validation ```go +type Port int + type Config struct { - Port int `key:"PORT" default:"8080" min:"1024" max:"65535"` + Port Port `key:"PORT" default:"8080" min:"1024" max:"65535"` } func main() { var cfg Config - err := goconfig.Load(&cfg, - // This runs AFTER min/max validation from the tag - goconfig.WithValidator("Port", func(value any) error { - port := value.(int64) - if port%10 != 0 { + // Building block: Cast int handler to Port, add custom validation + // Tag validation (min/max) runs first, then custom validators + portHandler := goconfig.AddValidators( + goconfig.CastCustomType[int, Port](goconfig.DefaultIntegerType[int]()), + func(value Port) error { + if int(value)%10 != 0 { return fmt.Errorf("port must be a multiple of 10") } return nil - }), + }, + ) + + err := goconfig.Load(context.Background(), &cfg, + goconfig.WithCustomType[Port](portHandler), + ) +} +``` + +### Adding Validators to Built-in Types + +Use the `AddValidators` building block to extend built-in types: + +```go +type Config struct { + Port int `key:"PORT" default:"8080" min:"1024" max:"65535"` +} + +func main() { + var cfg Config + + // Building block: Take default int handler and add validator + evenIntHandler := goconfig.AddValidators( + goconfig.DefaultIntegerType[int](), + func(v int) error { + if v%2 != 0 { + return errors.New("must be even") + } + return nil + }, + ) + + err := goconfig.Load(context.Background(), &cfg, + goconfig.WithCustomType[int](evenIntHandler), ) + // Now all int fields get tag validation (min/max) AND even check } ``` +### Multiple Validators + +You can pass multiple validators to `NewCustomType`: + +```go +type APIKey string + +// Building block: parser + multiple validators +apiKeyHandler := goconfig.NewCustomType( + func(rawValue string) (APIKey, error) { + return APIKey(rawValue), nil + }, + // Multiple validators - all must pass + func(value APIKey) error { + if !strings.HasPrefix(string(value), "sk-") { + return errors.New("must start with 'sk-'") + } + return nil + }, + func(value APIKey) error { + if len(value) < 20 { + return errors.New("must be at least 20 characters") + } + return nil + }, + func(value APIKey) error { + if !strings.HasSuffix(string(value), "==") { + return errors.New("must end with '=='") + } + return nil + }, +) +``` + ## Validation Order Validations are executed in this order: diff --git a/example/validation/main.go b/example/validation/main.go index f2c2f57..517a6ba 100644 --- a/example/validation/main.go +++ b/example/validation/main.go @@ -13,6 +13,11 @@ import ( "github.com/m0rjc/goconfig" ) +type APIKey string + +type APIEndpoint string +type DatabaseHost string + // ServerConfig demonstrates validation for server settings type ServerConfig struct { // Port must be in the unprivileged range @@ -44,7 +49,7 @@ type RateLimitConfig struct { // DatabaseConfig demonstrates pattern validation type DatabaseConfig struct { // Host can be hostname or IP - Host string `key:"DB_HOST" default:"localhost"` + Host DatabaseHost `key:"DB_HOST" default:"localhost"` // Port in standard database range Port int `key:"DB_PORT" default:"5432" min:"1024" max:"65535"` @@ -62,10 +67,10 @@ type DatabaseConfig struct { // APIConfig demonstrates custom validation type APIConfig struct { // API key with custom validation - APIKey string `key:"API_KEY" required:"true"` + APIKey APIKey `key:"API_KEY" required:"true"` // Endpoint URL with custom validation - Endpoint string `key:"API_ENDPOINT" default:"https://api.example.com"` + Endpoint APIEndpoint `key:"API_ENDPOINT" default:"https://api.example.com"` // Retry settings MaxRetries int `key:"API_MAX_RETRIES" default:"3" min:"0" max:"10"` @@ -92,36 +97,42 @@ func main() { // Load configuration with custom validators err := goconfig.Load(context.Background(), &config, // Validate API key format (must start with "sk-" and be at least 20 chars) - goconfig.WithValidator("API.APIKey", func(value any) error { - key := value.(string) - if !strings.HasPrefix(key, "sk-") { - return fmt.Errorf("API key must start with 'sk-'") - } - if len(key) < 20 { - return fmt.Errorf("API key must be at least 20 characters long") - } - return nil - }), + goconfig.WithCustomType[APIKey](goconfig.NewCustomType( + func(rawValue string) (APIKey, error) { return APIKey(rawValue), nil }, + func(value APIKey) error { + key := string(value) + if !strings.HasPrefix(key, "sk-") { + return fmt.Errorf("API key must start with 'sk-'") + } + if len(key) < 20 { + return fmt.Errorf("API key must be at least 20 characters long") + } + return nil + })), // Validate API endpoint is a valid URL with https - goconfig.WithValidator("API.Endpoint", func(value any) error { - endpoint := value.(string) - if !strings.HasPrefix(endpoint, "https://") { - return fmt.Errorf("API endpoint must use HTTPS") - } - return nil - }), + goconfig.WithCustomType[APIEndpoint](goconfig.NewCustomType( + func(rawValue string) (APIEndpoint, error) { return APIEndpoint(rawValue), nil }, + func(value APIEndpoint) error { + endpoint := string(value) + if !strings.HasPrefix(endpoint, "https://") { + return fmt.Errorf("API endpoint must use HTTPS") + } + return nil + })), // Validate database host is not a loopback address in production - goconfig.WithValidator("Database.Host", func(value any) error { - host := value.(string) - ip := net.ParseIP(host) - if ip != nil && ip.IsLoopback() { - // This is just an example - you might want to allow loopback in dev - fmt.Printf("Warning: Database host %s is a loopback address\n", host) - } - return nil - }), + goconfig.WithCustomType[DatabaseHost](goconfig.NewCustomType( + func(rawValue string) (DatabaseHost, error) { return DatabaseHost(rawValue), nil }, + func(value DatabaseHost) error { + host := string(value) + ip := net.ParseIP(host) + if ip != nil && ip.IsLoopback() { + // This is just an example - you might want to allow loopback in dev + fmt.Printf("Warning: Database host %s is a loopback address\n", host) + } + return nil + })), ) if err != nil { @@ -170,7 +181,7 @@ func printConfig(config Config) { fmt.Println() fmt.Println("API Configuration:") - fmt.Printf(" APIKey: %s (custom: must start with 'sk-', min 20 chars)\n", maskKey(config.API.APIKey)) + fmt.Printf(" APIKey: %s (custom: must start with 'sk-', min 20 chars)\n", maskKey(string(config.API.APIKey))) fmt.Printf(" Endpoint: %s (custom: must use HTTPS)\n", config.API.Endpoint) fmt.Printf(" MaxRetries: %d (range: 0-10)\n", config.API.MaxRetries) fmt.Printf(" RetryBackoff: %v (range: 100ms-30s)\n", config.API.RetryBackoff) diff --git a/internal/customtypes/chain_handler.go b/internal/customtypes/chain_handler.go new file mode 100644 index 0000000..84213ac --- /dev/null +++ b/internal/customtypes/chain_handler.go @@ -0,0 +1,32 @@ +package customtypes + +import ( + "reflect" + + "github.com/m0rjc/goconfig/internal/readpipeline" +) + +func AddWrapper[T any](prior readpipeline.TypedHandler[T], wrapper readpipeline.Wrapper[T]) readpipeline.TypedHandler[T] { + return &chainHandler[T]{Prior: prior, Wrapper: wrapper} +} + +// chainHandler creates a chained pipeline by appending wrappers +type chainHandler[T any] struct { + Prior readpipeline.TypedHandler[T] + Wrapper readpipeline.Wrapper[T] +} + +func (c *chainHandler[T]) BuildPipeline(tags reflect.StructTag) (readpipeline.FieldProcessor[T], error) { + pipeline, err := c.Prior.BuildPipeline(tags) + if err != nil { + return nil, err + } + if pipeline == nil { + return nil, nil + } + + if c.Wrapper != nil { + return c.Wrapper(tags, pipeline) + } + return pipeline, nil +} diff --git a/internal/customtypes/chain_handler_test.go b/internal/customtypes/chain_handler_test.go new file mode 100644 index 0000000..72b4818 --- /dev/null +++ b/internal/customtypes/chain_handler_test.go @@ -0,0 +1,72 @@ +package customtypes + +import ( + "errors" + "reflect" + "testing" + + "github.com/m0rjc/goconfig/internal/readpipeline" +) + +func TestAddWrapper(t *testing.T) { + parser := NewParser[int](func(rawValue string) (int, error) { + if rawValue == "1" { + return 1, nil + } + return 0, errors.New("parse error") + }) + + t.Run("Success", func(t *testing.T) { + wrapper := func(tags reflect.StructTag, input readpipeline.FieldProcessor[int]) (readpipeline.FieldProcessor[int], error) { + return func(rawValue string) (int, error) { + val, err := input(rawValue) + if err != nil { + return val, err + } + return val * 10, nil + }, nil + } + + handler := AddWrapper[int](parser, wrapper) + pipeline, err := handler.BuildPipeline("") + if err != nil { + t.Fatalf("BuildPipeline failed: %v", err) + } + + val, err := pipeline("1") + if err != nil { + t.Fatalf("pipeline failed: %v", err) + } + if val != 10 { + t.Errorf("expected 10, got %v", val) + } + }) + + t.Run("WrapperError", func(t *testing.T) { + wrapper := func(tags reflect.StructTag, input readpipeline.FieldProcessor[int]) (readpipeline.FieldProcessor[int], error) { + return nil, errors.New("wrapper build error") + } + + handler := AddWrapper[int](parser, wrapper) + _, err := handler.BuildPipeline("") + if err == nil { + t.Error("expected error from wrapper build, got nil") + } + }) + + t.Run("NilWrapper", func(t *testing.T) { + handler := AddWrapper[int](parser, nil) + pipeline, err := handler.BuildPipeline("") + if err != nil { + t.Fatalf("BuildPipeline failed: %v", err) + } + + val, err := pipeline("1") + if err != nil { + t.Fatalf("pipeline failed: %v", err) + } + if val != 1 { + t.Errorf("expected 1, got %v", val) + } + }) +} diff --git a/internal/customtypes/doc.go b/internal/customtypes/doc.go new file mode 100644 index 0000000..55474a9 --- /dev/null +++ b/internal/customtypes/doc.go @@ -0,0 +1,2 @@ +// Package customtypes provides the building blocks to make custom types for GoConfig. +package customtypes diff --git a/internal/customtypes/enum.go b/internal/customtypes/enum.go new file mode 100644 index 0000000..a1da39b --- /dev/null +++ b/internal/customtypes/enum.go @@ -0,0 +1,18 @@ +package customtypes + +import ( + "fmt" + + "github.com/m0rjc/goconfig/internal/readpipeline" +) + +func NewStringEnum[T ~string](validValues ...T) readpipeline.TypedHandler[T] { + return NewParser[T](func(rawValue string) (T, error) { + for _, validValue := range validValues { + if rawValue == string(validValue) { + return validValue, nil + } + } + return "", fmt.Errorf("invalid value: %s", rawValue) + }) +} diff --git a/internal/customtypes/enum_test.go b/internal/customtypes/enum_test.go new file mode 100644 index 0000000..78c0fbe --- /dev/null +++ b/internal/customtypes/enum_test.go @@ -0,0 +1,40 @@ +package customtypes + +import ( + "testing" +) + +type MyString string + +func TestNewStringEnum(t *testing.T) { + handler := NewStringEnum[MyString]("A", "B", "C") + pipeline, err := handler.BuildPipeline("") + if err != nil { + t.Fatalf("BuildPipeline failed: %v", err) + } + + tests := []struct { + input string + expected MyString + wantErr bool + }{ + {"A", "A", false}, + {"B", "B", false}, + {"C", "C", false}, + {"D", "", true}, + {"", "", true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + val, err := pipeline(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("pipeline(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + return + } + if val != tt.expected { + t.Errorf("pipeline(%q) = %v, want %v", tt.input, val, tt.expected) + } + }) + } +} diff --git a/internal/customtypes/parser.go b/internal/customtypes/parser.go new file mode 100644 index 0000000..9858f6e --- /dev/null +++ b/internal/customtypes/parser.go @@ -0,0 +1,19 @@ +package customtypes + +import ( + "reflect" + + "github.com/m0rjc/goconfig/internal/readpipeline" +) + +type customType[T any] struct { + Parser readpipeline.FieldProcessor[T] +} + +func NewParser[T any](parser readpipeline.FieldProcessor[T]) readpipeline.TypedHandler[T] { + return &customType[T]{Parser: parser} +} + +func (c *customType[T]) BuildPipeline(tags reflect.StructTag) (readpipeline.FieldProcessor[T], error) { + return c.Parser, nil +} diff --git a/internal/customtypes/parser_test.go b/internal/customtypes/parser_test.go new file mode 100644 index 0000000..f6d097b --- /dev/null +++ b/internal/customtypes/parser_test.go @@ -0,0 +1,55 @@ +package customtypes + +import ( + "reflect" + "testing" +) + +func TestNewParser(t *testing.T) { + parserFunc := func(rawValue string) (int, error) { + if rawValue == "42" { + return 42, nil + } + return 0, nil + } + + handler := NewParser[int](parserFunc) + if handler == nil { + t.Fatal("NewParser returned nil") + } + + pipeline, err := handler.BuildPipeline("") + if err != nil { + t.Fatalf("BuildPipeline failed: %v", err) + } + + val, err := pipeline("42") + if err != nil { + t.Fatalf("pipeline failed: %v", err) + } + if val != 42 { + t.Errorf("expected 42, got %v", val) + } +} + +func TestNewParser_BuildPipelineWithTags(t *testing.T) { + parserFunc := func(rawValue string) (string, error) { + return rawValue, nil + } + + handler := NewParser[string](parserFunc) + tags := reflect.StructTag(`key:"TEST_KEY"`) + + pipeline, err := handler.BuildPipeline(tags) + if err != nil { + t.Fatalf("BuildPipeline failed: %v", err) + } + + val, err := pipeline("hello") + if err != nil { + t.Fatalf("pipeline failed: %v", err) + } + if val != "hello" { + t.Errorf("expected hello, got %v", val) + } +} diff --git a/internal/customtypes/transformer.go b/internal/customtypes/transformer.go new file mode 100644 index 0000000..75a54ac --- /dev/null +++ b/internal/customtypes/transformer.go @@ -0,0 +1,64 @@ +package customtypes + +import ( + "fmt" + "reflect" + + "github.com/m0rjc/goconfig/internal/readpipeline" +) + +type transformer[T, U any] struct { + Prior readpipeline.TypedHandler[T] + Cast func(T) (U, error) +} + +func (t *transformer[T, U]) BuildPipeline(tags reflect.StructTag) (readpipeline.FieldProcessor[U], error) { + pipeline, err := t.Prior.BuildPipeline(tags) + if err != nil { + return nil, err + } + if pipeline == nil { + return nil, nil + } + + return func(rawValue string) (U, error) { + val, upstreamError := pipeline(rawValue) + if upstreamError != nil { + var zero U + return zero, upstreamError + } + return t.Cast(val) + }, nil +} + +// badTransformer holds on to an error that will be returned by BuildPipeline. +type badTransformer[T any] struct { + Err error +} + +func (b *badTransformer[T]) BuildPipeline(tags reflect.StructTag) (readpipeline.FieldProcessor[T], error) { + return nil, b.Err +} + +func NewTransformer[T, U any](handler readpipeline.TypedHandler[T]) readpipeline.TypedHandler[U] { + sourceType := reflect.TypeOf((*T)(nil)).Elem() + newType := reflect.TypeOf((*U)(nil)).Elem() + if !sourceType.ConvertibleTo(newType) { + return &badTransformer[U]{fmt.Errorf("incompatible type conversion: %s -> %s", sourceType, newType)} + } + + cast := func(value T) (U, error) { + reflected := reflect.ValueOf(value) + if !reflected.IsValid() { + var zero U + return zero, fmt.Errorf("invalid value in type conversion") + } + if !reflected.Type().ConvertibleTo(newType) { + var zero U + return zero, fmt.Errorf("cannot convert from %s to %s", reflected.Type(), newType) + } + return reflect.ValueOf(value).Convert(newType).Interface().(U), nil + } + + return &transformer[T, U]{Prior: handler, Cast: cast} +} diff --git a/internal/customtypes/transformer_test.go b/internal/customtypes/transformer_test.go new file mode 100644 index 0000000..e4a40d0 --- /dev/null +++ b/internal/customtypes/transformer_test.go @@ -0,0 +1,61 @@ +package customtypes + +import ( + "errors" + "testing" +) + +type Source string +type Target string + +func TestNewTransformer(t *testing.T) { + sourceHandler := NewParser[Source](func(rawValue string) (Source, error) { + return Source(rawValue), nil + }) + + t.Run("ValidConversion", func(t *testing.T) { + handler := NewTransformer[Source, Target](sourceHandler) + pipeline, err := handler.BuildPipeline("") + if err != nil { + t.Fatalf("BuildPipeline failed: %v", err) + } + + val, err := pipeline("hello") + if err != nil { + t.Fatalf("pipeline failed: %v", err) + } + if val != Target("hello") { + t.Errorf("expected Target(hello), got %v", val) + } + }) + + t.Run("IncompatibleTypes", func(t *testing.T) { + // int and string are not convertible directly via reflect.Value.Convert if they are not underlying types + // Actually, int to string conversion is allowed in Go but let's use something that is definitely not convertible + type Unrelated struct{ X int } + + handler := NewTransformer[Source, Unrelated](sourceHandler) + _, err := handler.BuildPipeline("") + if err == nil { + t.Error("expected error for incompatible types, got nil") + } + }) + + t.Run("UpstreamError", func(t *testing.T) { + errHandler := NewParser[Source](func(rawValue string) (Source, error) { + return "", errors.New("upstream error") + }) + handler := NewTransformer[Source, Target](errHandler) + pipeline, err := handler.BuildPipeline("") + if err != nil { + t.Fatalf("BuildPipeline failed: %v", err) + } + + _, err = pipeline("any") + if err == nil { + t.Error("expected upstream error, got nil") + } else if err.Error() != "upstream error" { + t.Errorf("expected 'upstream error', got %v", err) + } + }) +} diff --git a/internal/customtypes/validation_wrapper.go b/internal/customtypes/validation_wrapper.go new file mode 100644 index 0000000..b348d44 --- /dev/null +++ b/internal/customtypes/validation_wrapper.go @@ -0,0 +1,16 @@ +package customtypes + +import ( + "reflect" + + "github.com/m0rjc/goconfig/internal/readpipeline" +) + +func NewValidatorWrapper[T any](customValidators ...readpipeline.Validator[T]) readpipeline.Wrapper[T] { + return func(tags reflect.StructTag, inputProcess readpipeline.FieldProcessor[T]) (readpipeline.FieldProcessor[T], error) { + if customValidators != nil && len(customValidators) > 0 { + inputProcess = readpipeline.PipeMultiple(inputProcess, customValidators) + } + return inputProcess, nil + } +} diff --git a/internal/customtypes/validation_wrapper_test.go b/internal/customtypes/validation_wrapper_test.go new file mode 100644 index 0000000..a08986f --- /dev/null +++ b/internal/customtypes/validation_wrapper_test.go @@ -0,0 +1,80 @@ +package customtypes + +import ( + "errors" + "testing" +) + +func TestNewValidatorWrapper(t *testing.T) { + parser := func(rawValue string) (int, error) { + if rawValue == "1" { + return 1, nil + } + if rawValue == "2" { + return 2, nil + } + return 0, errors.New("parse error") + } + + t.Run("SingleValidator", func(t *testing.T) { + validator := func(val int) error { + if val == 1 { + return nil + } + return errors.New("must be 1") + } + + wrapper := NewValidatorWrapper(validator) + pipeline, err := wrapper("", parser) + if err != nil { + t.Fatalf("wrapper failed: %v", err) + } + + if _, err := pipeline("1"); err != nil { + t.Errorf("expected success for 1, got %v", err) + } + if _, err := pipeline("2"); err == nil { + t.Error("expected error for 2, got nil") + } + }) + + t.Run("MultipleValidators", func(t *testing.T) { + v1 := func(val int) error { + if val > 0 { + return nil + } + return errors.New("must be positive") + } + v2 := func(val int) error { + if val < 2 { + return nil + } + return errors.New("must be less than 2") + } + + wrapper := NewValidatorWrapper(v1, v2) + pipeline, err := wrapper("", parser) + if err != nil { + t.Fatalf("wrapper failed: %v", err) + } + + if _, err := pipeline("1"); err != nil { + t.Errorf("expected success for 1, got %v", err) + } + if _, err := pipeline("2"); err == nil { + t.Error("expected error for 2, got nil") + } + }) + + t.Run("No validators", func(t *testing.T) { + wrapper := NewValidatorWrapper[int]() + pipeline, err := wrapper("", parser) + if err != nil { + t.Fatalf("wrapper failed: %v", err) + } + + if val, err := pipeline("1"); err != nil || val != 1 { + t.Errorf("expected 1, got %v, %v", val, err) + } + }) +} diff --git a/internal/process/boolean_types.go b/internal/process/boolean_types.go deleted file mode 100644 index 53f4654..0000000 --- a/internal/process/boolean_types.go +++ /dev/null @@ -1,17 +0,0 @@ -package process - -import ( - "reflect" - "strconv" -) - -func NewBoolHandler(fieldType reflect.Type) Handler { - return TypeHandler[bool]{ - Parser: func(rawValue string) (bool, error) { - return strconv.ParseBool(rawValue) - }, - ValidationWrapper: func(tags reflect.StructTag, inputProcess FieldProcessor[bool]) (FieldProcessor[bool], error) { - return inputProcess, nil - }, - } -} diff --git a/internal/process/custom_types.go b/internal/process/custom_types.go deleted file mode 100644 index 052e7c5..0000000 --- a/internal/process/custom_types.go +++ /dev/null @@ -1,32 +0,0 @@ -package process - -import "reflect" - -// NewCustomHandler creates a custom handler that delegates to the custom parser. It will use the default handler -// for validation if present. customParser cannot be nil. defaultHandler can be nil. -func NewCustomHandler(customParser FieldProcessor[any], defaultHandler Handler) Handler { - return &customHandler{ - customParser: customParser, - defaultHandler: defaultHandler, - } -} - -type customHandler struct { - customParser FieldProcessor[any] - customValidators []Validator[any] - defaultHandler Handler -} - -// GetParser returns the custom parser. -func (c customHandler) GetParser() FieldProcessor[any] { - return c.customParser -} - -// AddValidatorsToPipeline will apply validators from the default handler if present. It allows wholly custom types -// to be a no-op -func (c customHandler) AddValidatorsToPipeline(tags reflect.StructTag, p FieldProcessor[any]) (FieldProcessor[any], error) { - if c.defaultHandler != nil { - return c.defaultHandler.AddValidatorsToPipeline(tags, p) - } - return p, nil -} diff --git a/internal/process/custom_types_test.go b/internal/process/custom_types_test.go deleted file mode 100644 index fc4cc37..0000000 --- a/internal/process/custom_types_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package process - -import ( - "errors" - "fmt" - "reflect" - "strconv" - "testing" -) - -func TestCustomParserAndValidators(t *testing.T) { - t.Run("Custom parser for built-in int with custom and tag validators", func(t *testing.T) { - // Custom parser that doubles the input value - customParser := func(rawValue string) (any, error) { - val, err := strconv.Atoi(rawValue) - if err != nil { - return nil, err - } - return int64(val * 2), nil - } - - // Custom validator that checks if even - customValidator := func(value any) error { - v := value.(int64) - if v%2 != 0 { - return errors.New("must be even") - } - return nil - } - - // Tag validators: min=10. Note: custom parser doubles this, so effective min is 20 - tags := reflect.StructTag(`key:"PORT" min:"10"`) - fieldType := reflect.TypeOf(int64(0)) - - p, err := New(fieldType, tags, customParser, []Validator[any]{customValidator}) - if err != nil { - t.Fatalf("Failed to create processor: %v", err) - } - - tests := []struct { - input string - want int64 - wantErr bool - }{ - {"11", 22, false}, // 11*2 = 22, which is >= 20 and even - {"6", 12, true}, // 6*2 = 12, which is < 20 (tag validator fails) - {"3", 6, true}, // 3*2 = 6, which is < 20 (tag validator fails) - {"foo", 0, true}, // Invalid input (parser fails) - } - - for _, tt := range tests { - got, err := p(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("p(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) - continue - } - if !tt.wantErr && got.(int64) != tt.want { - t.Errorf("p(%q) = %v, want %v", tt.input, got, tt.want) - } - } - }) - - t.Run("Custom parser for custom struct type", func(t *testing.T) { - type Point struct { - X, Y int - } - - // Custom parser "X,Y" - customParser := func(rawValue string) (any, error) { - var x, y int - _, err := fmt.Sscanf(rawValue, "%d,%d", &x, &y) - if err != nil { - return nil, errors.New("invalid point format") - } - return Point{X: x, Y: y}, nil - } - - // Custom validator: X must be positive - customValidator := func(value any) error { - p := value.(Point) - if p.X < 0 { - return errors.New("X must be positive") - } - return nil - } - - fieldType := reflect.TypeOf(Point{}) - p, err := New(fieldType, "", customParser, []Validator[any]{customValidator}) - if err != nil { - t.Fatalf("Failed to create processor: %v", err) - } - - tests := []struct { - input string - want Point - wantErr bool - }{ - {"1,2", Point{1, 2}, false}, - {"-1,2", Point{-1, 2}, true}, // Validator fails - {"bad", Point{}, true}, // Parser fails - } - - for _, tt := range tests { - got, err := p(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("p(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) - continue - } - if !tt.wantErr && got.(Point) != tt.want { - t.Errorf("p(%q) = %v, want %v", tt.input, got, tt.want) - } - } - }) - - t.Run("Custom validator against built-in int", func(t *testing.T) { - // No custom parser, use default int64 parser - customValidator := func(value any) error { - v := value.(int64) - if v == 42 { - return errors.New("42 is forbidden") - } - return nil - } - - fieldType := reflect.TypeOf(int64(0)) - p, err := New(fieldType, "", nil, []Validator[any]{customValidator}) - if err != nil { - t.Fatalf("Failed to create processor: %v", err) - } - - if _, err := p("42"); err == nil { - t.Error("Expected error for 42, got nil") - } - if got, err := p("10"); err != nil || got.(int64) != 10 { - t.Errorf("p(10) = %v, %v, want 10, nil", got, err) - } - }) - - t.Run("Custom parser for non-built-in type", func(t *testing.T) { - // Build a parser for a complex number and run it through the pipeline - customParser := func(rawValue string) (any, error) { - return complex(1, 2), nil - } - fieldType := reflect.TypeOf(complex(0, 0)) - p, err := New(fieldType, "", customParser, nil) - if err != nil { - t.Fatalf("Failed to create processor: %v", err) - } - - value, err := p("This value is ignored by the mock parser") - if err != nil { - t.Errorf("Unexpected error: %v", err) - } - if value != complex(1, 2) { - t.Errorf("Expected complex(1, 2), got %v", value) - } - }) -} diff --git a/internal/process/duration.go b/internal/process/duration.go deleted file mode 100644 index 37ce043..0000000 --- a/internal/process/duration.go +++ /dev/null @@ -1,10 +0,0 @@ -package process - -import "time" - -var durationTypeHandler = TypeHandler[time.Duration]{ - Parser: func(rawValue string) (time.Duration, error) { - return time.ParseDuration(rawValue) - }, - ValidationWrapper: WrapProcessUsingRangeTags[time.Duration], -} diff --git a/internal/process/number_types.go b/internal/process/number_types.go deleted file mode 100644 index 653a049..0000000 --- a/internal/process/number_types.go +++ /dev/null @@ -1,34 +0,0 @@ -package process - -import ( - "reflect" - "strconv" -) - -func NewIntHandler(fieldType reflect.Type) Handler { - return TypeHandler[int64]{ - Parser: func(rawValue string) (int64, error) { - // Use base 0 to allow input like 0xFF - return strconv.ParseInt(rawValue, 0, fieldType.Bits()) - }, - ValidationWrapper: WrapProcessUsingRangeTags[int64], - } -} - -func NewUintHandler(fieldType reflect.Type) Handler { - return TypeHandler[uint64]{ - Parser: func(rawValue string) (uint64, error) { - return strconv.ParseUint(rawValue, 0, fieldType.Bits()) - }, - ValidationWrapper: WrapProcessUsingRangeTags[uint64], - } -} - -func NewFloatHandler(fieldType reflect.Type) Handler { - return TypeHandler[float64]{ - Parser: func(rawValue string) (float64, error) { - return strconv.ParseFloat(rawValue, fieldType.Bits()) - }, - ValidationWrapper: WrapProcessUsingRangeTags[float64], - } -} diff --git a/internal/process/pointer_types_test.go b/internal/process/pointer_types_test.go deleted file mode 100644 index 9cb8f2c..0000000 --- a/internal/process/pointer_types_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package process - -import ( - "reflect" - "testing" -) - -func TestPointerTypes(t *testing.T) { - t.Run("PointerToInt", func(t *testing.T) { - var i *int - fieldType := reflect.TypeOf(i) - tags := reflect.StructTag(`min:"10"`) - - processor, err := New(fieldType, tags, nil, nil) - if err != nil { - t.Fatalf("Failed to create processor: %v", err) - } - - // Valid value - val, err := processor("15") - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - if val != int64(15) { - t.Errorf("Expected int64(15), got %v (%T)", val, val) - } - - // Below minimum - _, err = processor("5") - if err == nil { - t.Error("Expected error for value below minimum, got nil") - } - }) - - t.Run("PointerToString", func(t *testing.T) { - var s *string - fieldType := reflect.TypeOf(s) - tags := reflect.StructTag(`pattern:"^abc.*$"`) - - processor, err := New(fieldType, tags, nil, nil) - if err != nil { - t.Fatalf("Failed to create processor: %v", err) - } - - // Valid value - val, err := processor("abcdef") - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - if val != "abcdef" { - t.Errorf("Expected 'abcdef', got %v", val) - } - - // Invalid pattern - _, err = processor("ghijk") - if err == nil { - t.Error("Expected error for pattern mismatch, got nil") - } - }) - - t.Run("PointerToStruct", func(t *testing.T) { - type MyStruct struct { - Name string `json:"name"` - } - var ms *MyStruct - fieldType := reflect.TypeOf(ms) - - processor, err := New(fieldType, "", nil, nil) - if err != nil { - t.Fatalf("Failed to create processor: %v", err) - } - - // Valid JSON - val, err := processor(`{"name": "test"}`) - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - expected := MyStruct{Name: "test"} - if !reflect.DeepEqual(val, expected) { - t.Errorf("Expected %v, got %v", expected, val) - } - }) - - t.Run("PointerToCustomTypeWithCustomParser", func(t *testing.T) { - type Point struct { - X, Y int - } - var p *Point - fieldType := reflect.TypeOf(p) - - customParser := func(rawValue string) (any, error) { - // Dummy parser for "1,2" - return Point{X: 1, Y: 2}, nil - } - - processor, err := New(fieldType, "", customParser, nil) - if err != nil { - t.Fatalf("Failed to create processor: %v", err) - } - - val, err := processor("anything") - if err != nil { - t.Errorf("Expected no error, got %v", err) - } - - expected := Point{X: 1, Y: 2} - if !reflect.DeepEqual(val, expected) { - t.Errorf("Expected %v, got %v", expected, val) - } - }) -} diff --git a/internal/process/process.go b/internal/process/process.go deleted file mode 100644 index 3b38335..0000000 --- a/internal/process/process.go +++ /dev/null @@ -1,44 +0,0 @@ -package process - -import ( - "fmt" - "reflect" -) - -// New creates a FieldProcessor for the given type. It reads struct tags to instantiate required -// validators. It allows override using custom parsing and validation if supplied. Default validation is -// applied before custom validators. -// If the target type is a pointer, it will be unboxed before processing. The output of the process chain is the value. -// The caller is responsible for assigning the value to the struct field, dealing with pointers as needed. -func New(fieldType reflect.Type, tags reflect.StructTag, customParser FieldProcessor[any], customValidators []Validator[any]) (FieldProcessor[any], error) { - var err error - targetType := fieldType - isPointer := fieldType.Kind() == reflect.Ptr - - if isPointer { - // Pointer writing is handled by the setFieldValue side of the process - // in config.go - targetType = targetType.Elem() - } - - handler := handlerFor(targetType) - if customParser != nil { - handler = NewCustomHandler(customParser, handler) - } - if handler == nil { - return nil, fmt.Errorf("no handler for type %s", fieldType) - } - - pipeline := handler.GetParser() - - if customValidators != nil { - pipeline = PipeMultiple(pipeline, customValidators) - } - - pipeline, err = handler.AddValidatorsToPipeline(tags, pipeline) - if err != nil { - return nil, err - } - - return pipeline, nil -} diff --git a/internal/process/string_types.go b/internal/process/string_types.go deleted file mode 100644 index 16dca89..0000000 --- a/internal/process/string_types.go +++ /dev/null @@ -1,16 +0,0 @@ -package process - -import ( - "reflect" -) - -// NewStringHandler returns a Handler that simply returns the raw value. -// Strings support the min and max tags for lexical ordering and the pattern tag for regex -func NewStringHandler(_ reflect.Type) Handler { - return TypeHandler[string]{ - Parser: func(rawValue string) (value string, err error) { - return rawValue, nil - }, - ValidationWrapper: NewCompositeWrapper(WrapProcessUsingPatternTag, WrapProcessUsingRangeTags[string]), - } -} diff --git a/internal/process/typeregistry.go b/internal/process/typeregistry.go deleted file mode 100644 index e375b05..0000000 --- a/internal/process/typeregistry.go +++ /dev/null @@ -1,90 +0,0 @@ -package process - -import ( - "reflect" - "time" -) - -// TypeHandler is the strongly typed handler for the given pipeline. -// It implements the typeless Handler interface for the pipeline by boxing and unboxing the value as required. -type TypeHandler[T any] struct { - Parser FieldProcessor[T] - ValidationWrapper Wrapper[T] -} - -// Handler is the typeless interface used to build the read pipeline. -type Handler interface { - // GetParser returns a FieldProcessor[any] that is used to read the raw value and start the read pipeline - GetParser() FieldProcessor[any] - // AddValidatorsToPipeline adds validators to the pipeline based on tags found in the StructTag for the target field. - AddValidatorsToPipeline(tags reflect.StructTag, p FieldProcessor[any]) (FieldProcessor[any], error) -} - -func (h TypeHandler[T]) GetParser() FieldProcessor[any] { - // This wrapper function converts from the strongly typed world of the TypeHandler to the weak type world of the process pipeline. - return func(rawValue string) (any, error) { - return h.Parser(rawValue) - } -} - -func (h TypeHandler[T]) AddValidatorsToPipeline(tags reflect.StructTag, p FieldProcessor[any]) (FieldProcessor[any], error) { - // Convert FieldProcessor[any] back to FieldProcessor[T] safely - typedP := func(s string) (T, error) { - val, err := p(s) - if err != nil { - var zero T - return zero, err - } - return val.(T), nil - } - - wrapped, err := h.ValidationWrapper(tags, typedP) - if err != nil { - return nil, err - } - - // Erase type again for the pipeline - return func(s string) (any, error) { - return wrapped(s) - }, nil -} - -// Specific type overrides (Duration, etc.) -var specialTypeParsers = map[reflect.Type]Handler{ - reflect.TypeOf(time.Duration(0)): durationTypeHandler, -} - -// Category-based parsers -var kindParsers = map[reflect.Kind]func(t reflect.Type) Handler{ - reflect.Int: NewIntHandler, - reflect.Int8: NewIntHandler, - reflect.Int16: NewIntHandler, - reflect.Int32: NewIntHandler, - reflect.Int64: NewIntHandler, - reflect.Uint: NewUintHandler, - reflect.Uint8: NewUintHandler, - reflect.Uint16: NewUintHandler, - reflect.Uint32: NewUintHandler, - reflect.Uint64: NewUintHandler, - reflect.Struct: NewJsonHandler, - reflect.Map: NewJsonHandler, - reflect.String: NewStringHandler, - reflect.Bool: NewBoolHandler, - reflect.Float32: NewFloatHandler, - reflect.Float64: NewFloatHandler, -} - -// handlerFor returns the Handler for the given type, or nil if none is registered. -func handlerFor(t reflect.Type) Handler { - // 1. Check for specific type overrides (The "Duration" check) - if p, ok := specialTypeParsers[t]; ok { - return p - } - - // 2. Fall back to category-based logic - if factory, ok := kindParsers[t.Kind()]; ok { - return factory(t) - } - - return nil -} diff --git a/internal/readpipeline/boolean_types.go b/internal/readpipeline/boolean_types.go new file mode 100644 index 0000000..b4adf89 --- /dev/null +++ b/internal/readpipeline/boolean_types.go @@ -0,0 +1,22 @@ +package readpipeline + +import ( + "reflect" + "strconv" +) + +func NewBoolHandler(_ reflect.Type) TypedHandler[bool] { + return NewTypedBoolHandler() +} + +// NewTypedBoolHandler returns a TypedHandler[bool] that uses standard bool parsing and validation. +func NewTypedBoolHandler() TypedHandler[bool] { + return &typeHandlerImpl[bool]{ + Parser: func(rawValue string) (bool, error) { + return strconv.ParseBool(rawValue) + }, + ValidationWrapper: func(tags reflect.StructTag, inputProcess FieldProcessor[bool]) (FieldProcessor[bool], error) { + return inputProcess, nil + }, + } +} diff --git a/internal/process/boolean_types_test.go b/internal/readpipeline/boolean_types_test.go similarity index 91% rename from internal/process/boolean_types_test.go rename to internal/readpipeline/boolean_types_test.go index dbb98f0..d712277 100644 --- a/internal/process/boolean_types_test.go +++ b/internal/readpipeline/boolean_types_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" @@ -46,9 +46,10 @@ func TestBoolTypes(t *testing.T) { }, } + registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := New(tt.fieldType, tt.tags, nil, nil) + proc, err := New(tt.fieldType, tt.tags, registry) if err != nil { t.Fatalf("New() error = %v", err) } diff --git a/internal/readpipeline/duration.go b/internal/readpipeline/duration.go new file mode 100644 index 0000000..ed5c501 --- /dev/null +++ b/internal/readpipeline/duration.go @@ -0,0 +1,15 @@ +package readpipeline + +import "time" + +var durationTypeHandler = NewTypedDurationHandler() + +// NewTypedDurationHandler returns a TypedHandler[time.Duration] that uses standard duration parsing and validation. +func NewTypedDurationHandler() TypedHandler[time.Duration] { + return &typeHandlerImpl[time.Duration]{ + Parser: func(rawValue string) (time.Duration, error) { + return time.ParseDuration(rawValue) + }, + ValidationWrapper: WrapProcessUsingRangeTags[time.Duration], + } +} diff --git a/internal/process/duration_test.go b/internal/readpipeline/duration_test.go similarity index 92% rename from internal/process/duration_test.go rename to internal/readpipeline/duration_test.go index da3bb31..0326281 100644 --- a/internal/process/duration_test.go +++ b/internal/readpipeline/duration_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" @@ -43,9 +43,10 @@ func TestDurationTypes(t *testing.T) { }, } + registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := New(tt.fieldType, tt.tags, nil, nil) + proc, err := New(tt.fieldType, tt.tags, registry) if err != nil { t.Fatalf("New() error = %v", err) } diff --git a/internal/process/invalid_types_test.go b/internal/readpipeline/invalid_types_test.go similarity index 86% rename from internal/process/invalid_types_test.go rename to internal/readpipeline/invalid_types_test.go index 45dc0ac..3125ddc 100644 --- a/internal/process/invalid_types_test.go +++ b/internal/readpipeline/invalid_types_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" @@ -7,11 +7,12 @@ import ( func TestInvalidTypes(t *testing.T) { // This test will need to be removed or replaced if we ever support complex numbers + registry := NewTypeRegistry() t.Run("Complex128", func(t *testing.T) { fieldType := reflect.TypeOf(complex128(0)) tags := reflect.StructTag("") - _, err := New(fieldType, tags, nil, nil) + _, err := New(fieldType, tags, registry) if err == nil { t.Fatal("Expected error for complex128 type, but got nil") } @@ -27,7 +28,7 @@ func TestInvalidTypes(t *testing.T) { fieldType := reflect.TypeOf(&i).Elem() tags := reflect.StructTag("") - _, err := New(fieldType, tags, nil, nil) + _, err := New(fieldType, tags, registry) if err == nil { t.Fatal("Expected error for interface type, but got nil") } diff --git a/internal/process/json_types.go b/internal/readpipeline/json_types.go similarity index 75% rename from internal/process/json_types.go rename to internal/readpipeline/json_types.go index 7a15862..bd2d96c 100644 --- a/internal/process/json_types.go +++ b/internal/readpipeline/json_types.go @@ -1,12 +1,12 @@ -package process +package readpipeline import ( "encoding/json" "reflect" ) -func NewJsonHandler(targetType reflect.Type) Handler { - return TypeHandler[any]{ +func NewJsonPipelineBuilder(targetType reflect.Type) TypedHandler[any] { + return &typeHandlerImpl[any]{ Parser: func(rawValue string) (any, error) { ptr := reflect.New(targetType).Interface() err := json.Unmarshal([]byte(rawValue), ptr) @@ -15,7 +15,7 @@ func NewJsonHandler(targetType reflect.Type) Handler { return nil, err } - // Dereference the value to maintain consistency with the maxim "Pipelines always process values" + // Dereference the value to maintain consistency with the maxim "Pipelines always readpipeline values" return reflect.ValueOf(ptr).Elem().Interface(), nil }, diff --git a/internal/process/json_types_test.go b/internal/readpipeline/json_types_test.go similarity index 91% rename from internal/process/json_types_test.go rename to internal/readpipeline/json_types_test.go index 1802f9a..f8609d4 100644 --- a/internal/process/json_types_test.go +++ b/internal/readpipeline/json_types_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" @@ -39,9 +39,10 @@ func TestJsonTypes(t *testing.T) { }, } + registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := New(tt.fieldType, tt.tags, nil, nil) + proc, err := New(tt.fieldType, tt.tags, registry) if err != nil { t.Fatalf("New() error = %v", err) } diff --git a/internal/readpipeline/number_types.go b/internal/readpipeline/number_types.go new file mode 100644 index 0000000..5fe0b03 --- /dev/null +++ b/internal/readpipeline/number_types.go @@ -0,0 +1,48 @@ +package readpipeline + +import ( + "reflect" + "strconv" +) + +func NewIntHandler(fieldType reflect.Type) TypedHandler[int64] { + return NewTypedIntHandler(fieldType.Bits()) +} + +func NewUintHandler(fieldType reflect.Type) TypedHandler[uint64] { + return NewTypedUintHandler(fieldType.Bits()) +} + +func NewFloatHandler(fieldType reflect.Type) TypedHandler[float64] { + return NewTypedFloatHandler(fieldType.Bits()) +} + +// NewTypedIntHandler returns a TypedHandler[int64] that uses standard int parsing and validation. +func NewTypedIntHandler(bits int) TypedHandler[int64] { + return &typeHandlerImpl[int64]{ + Parser: func(rawValue string) (int64, error) { + return strconv.ParseInt(rawValue, 0, bits) + }, + ValidationWrapper: WrapProcessUsingRangeTags[int64], + } +} + +// NewTypedUintHandler returns a TypedHandler[uint64] that uses standard uint parsing and validation. +func NewTypedUintHandler(bits int) TypedHandler[uint64] { + return &typeHandlerImpl[uint64]{ + Parser: func(rawValue string) (uint64, error) { + return strconv.ParseUint(rawValue, 0, bits) + }, + ValidationWrapper: WrapProcessUsingRangeTags[uint64], + } +} + +// NewTypedFloatHandler returns a TypedHandler[float64] that uses standard float parsing and validation. +func NewTypedFloatHandler(bits int) TypedHandler[float64] { + return &typeHandlerImpl[float64]{ + Parser: func(rawValue string) (float64, error) { + return strconv.ParseFloat(rawValue, bits) + }, + ValidationWrapper: WrapProcessUsingRangeTags[float64], + } +} diff --git a/internal/process/number_types_test.go b/internal/readpipeline/number_types_test.go similarity index 94% rename from internal/process/number_types_test.go rename to internal/readpipeline/number_types_test.go index 93d78de..cd4c8cd 100644 --- a/internal/process/number_types_test.go +++ b/internal/readpipeline/number_types_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" @@ -109,8 +109,9 @@ func TestIntTypes(t *testing.T) { }, } + registry := NewTypeRegistry() t.Run("invalid min tag", func(t *testing.T) { - _, err := New(reflect.TypeOf(int(0)), `min:"foo"`, nil, nil) + _, err := New(reflect.TypeOf(int(0)), `min:"foo"`, registry) if err == nil { t.Error("expected error for invalid min tag, got nil") } @@ -118,7 +119,7 @@ func TestIntTypes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := New(tt.fieldType, tt.tags, nil, nil) + proc, err := New(tt.fieldType, tt.tags, registry) if err != nil { t.Fatalf("New() error = %v", err) } @@ -213,9 +214,10 @@ func TestUintTypes(t *testing.T) { }, } + registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := New(tt.fieldType, tt.tags, nil, nil) + proc, err := New(tt.fieldType, tt.tags, registry) if err != nil { t.Fatalf("New() error = %v", err) } @@ -262,9 +264,10 @@ func TestFloatTypes(t *testing.T) { }, } + registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := New(tt.fieldType, tt.tags, nil, nil) + proc, err := New(tt.fieldType, tt.tags, registry) if err != nil { t.Fatalf("New() error = %v", err) } diff --git a/internal/process/ordered_validators.go b/internal/readpipeline/ordered_validators.go similarity index 91% rename from internal/process/ordered_validators.go rename to internal/readpipeline/ordered_validators.go index 4c1ff85..4616767 100644 --- a/internal/process/ordered_validators.go +++ b/internal/readpipeline/ordered_validators.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "cmp" @@ -12,7 +12,7 @@ type orderedValidator[T cmp.Ordered] func(value T) error func newMinValidator[T cmp.Ordered](minimum T) orderedValidator[T] { return func(value T) error { if value < minimum { - return fmt.Errorf("below mininum %v", minimum) + return fmt.Errorf("below minimum %v", minimum) } return nil } @@ -21,7 +21,7 @@ func newMinValidator[T cmp.Ordered](minimum T) orderedValidator[T] { func newMaxValidator[T cmp.Ordered](maximum T) orderedValidator[T] { return func(value T) error { if value > maximum { - return fmt.Errorf("above maxinum %v", maximum) + return fmt.Errorf("above maximum %v", maximum) } return nil } @@ -36,7 +36,7 @@ func newRangeValidator[T cmp.Ordered](minimum, maximum T) orderedValidator[T] { } } -// WrapProcessUsingRangeTags applies the min and max tags to an ordered process. +// WrapProcessUsingRangeTags applies the min and max tags to an ordered readpipeline. func WrapProcessUsingRangeTags[T cmp.Ordered](tags reflect.StructTag, processor FieldProcessor[T]) (FieldProcessor[T], error) { minTag, hasMin := tags.Lookup("min") maxTag, hasMax := tags.Lookup("max") diff --git a/internal/process/pattern_validator.go b/internal/readpipeline/pattern_validator.go similarity index 96% rename from internal/process/pattern_validator.go rename to internal/readpipeline/pattern_validator.go index c6a4f70..74c7330 100644 --- a/internal/process/pattern_validator.go +++ b/internal/readpipeline/pattern_validator.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "fmt" diff --git a/internal/process/pattern_validator_test.go b/internal/readpipeline/pattern_validator_test.go similarity index 98% rename from internal/process/pattern_validator_test.go rename to internal/readpipeline/pattern_validator_test.go index b7f14a4..9f375c1 100644 --- a/internal/process/pattern_validator_test.go +++ b/internal/readpipeline/pattern_validator_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" diff --git a/internal/readpipeline/process.go b/internal/readpipeline/process.go new file mode 100644 index 0000000..f60bf13 --- /dev/null +++ b/internal/readpipeline/process.go @@ -0,0 +1,35 @@ +package readpipeline + +import ( + "fmt" + "reflect" +) + +// New creates a FieldProcessor for the given type. It reads struct tags to instantiate required +// validators. +// If the target type is a pointer, it will be unboxed before processing. The output of the readpipeline chain is the value. +// The caller is responsible for assigning the value to the struct field, dealing with pointers as needed. +func New(fieldType reflect.Type, tags reflect.StructTag, registry TypeRegistry) (FieldProcessor[any], error) { + targetType := fieldType + isPointer := fieldType.Kind() == reflect.Ptr + + if isPointer { + // Pointer writing is handled by the setFieldValue side of the readpipeline + // in config.go + targetType = targetType.Elem() + } + + handler := registry.HandlerFor(targetType) + if handler == nil { + return nil, fmt.Errorf("no handler for type %s", targetType) + } + + pipeline, err := handler.Build(tags) + if err != nil { + return nil, err + } + if pipeline == nil { + return nil, fmt.Errorf("no parser for type %s", targetType) + } + return pipeline, nil +} diff --git a/internal/readpipeline/process_test.go b/internal/readpipeline/process_test.go new file mode 100644 index 0000000..dcc413b --- /dev/null +++ b/internal/readpipeline/process_test.go @@ -0,0 +1,188 @@ +package readpipeline + +import ( + "errors" + "reflect" + "testing" +) + +// mockPipelineBuilder is a mock implementation of PipelineBuilder for testing. +type mockPipelineBuilder struct { + buildFunc func(tags reflect.StructTag) (FieldProcessor[any], error) +} + +func (m *mockPipelineBuilder) Build(tags reflect.StructTag) (FieldProcessor[any], error) { + return m.buildFunc(tags) +} + +func TestNew(t *testing.T) { + t.Run("BareType", func(t *testing.T) { + registry := &rootTypeRegistry{ + specialTypeHandlers: make(map[reflect.Type]PipelineBuilder), + kindHandlers: make(map[reflect.Kind]HandlerFactory), + } + + expectedValue := "success" + mockBuilder := &mockPipelineBuilder{ + buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { + return func(rawValue string) (any, error) { + return expectedValue, nil + }, nil + }, + } + + stringType := reflect.TypeOf("") + registry.RegisterType(stringType, mockBuilder) + + processor, err := New(stringType, "", registry) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + val, err := processor("input") + if err != nil { + t.Fatalf("Processor failed: %v", err) + } + + if val != expectedValue { + t.Errorf("Expected %v, got %v", expectedValue, val) + } + }) + + t.Run("PointerType", func(t *testing.T) { + registry := &rootTypeRegistry{ + specialTypeHandlers: make(map[reflect.Type]PipelineBuilder), + kindHandlers: make(map[reflect.Kind]HandlerFactory), + } + + expectedValue := 42 + mockBuilder := &mockPipelineBuilder{ + buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { + return func(rawValue string) (any, error) { + return expectedValue, nil + }, nil + }, + } + + intType := reflect.TypeOf(0) + registry.RegisterType(intType, mockBuilder) + + // Pass *int to New + ptrIntType := reflect.TypeOf((*int)(nil)) + processor, err := New(ptrIntType, "", registry) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + val, err := processor("input") + if err != nil { + t.Fatalf("Processor failed: %v", err) + } + + if val != expectedValue { + t.Errorf("Expected %v, got %v", expectedValue, val) + } + }) + + t.Run("NoHandlerError", func(t *testing.T) { + registry := &rootTypeRegistry{ + specialTypeHandlers: make(map[reflect.Type]PipelineBuilder), + kindHandlers: make(map[reflect.Kind]HandlerFactory), + } + + _, err := New(reflect.TypeOf(0), "", registry) + if err == nil { + t.Fatal("Expected error for missing handler, got nil") + } + + expectedErr := "no handler for type int" + if err.Error() != expectedErr { + t.Errorf("Expected error %q, got %q", expectedErr, err.Error()) + } + }) + + t.Run("BuildError", func(t *testing.T) { + registry := &rootTypeRegistry{ + specialTypeHandlers: make(map[reflect.Type]PipelineBuilder), + kindHandlers: make(map[reflect.Kind]HandlerFactory), + } + + expectedBuildErr := errors.New("build failed") + mockBuilder := &mockPipelineBuilder{ + buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { + return nil, expectedBuildErr + }, + } + + intType := reflect.TypeOf(0) + registry.RegisterType(intType, mockBuilder) + + _, err := New(intType, "", registry) + if err == nil { + t.Fatal("Expected error from Build, got nil") + } + + if !errors.Is(err, expectedBuildErr) { + t.Errorf("Expected error %v, got %v", expectedBuildErr, err) + } + }) + + t.Run("NilPipelineError", func(t *testing.T) { + registry := &rootTypeRegistry{ + specialTypeHandlers: make(map[reflect.Type]PipelineBuilder), + kindHandlers: make(map[reflect.Kind]HandlerFactory), + } + + mockBuilder := &mockPipelineBuilder{ + buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { + return nil, nil + }, + } + + intType := reflect.TypeOf(0) + registry.RegisterType(intType, mockBuilder) + + _, err := New(intType, "", registry) + if err == nil { + t.Fatal("Expected error for nil pipeline, got nil") + } + + expectedErr := "no parser for type int" + if err.Error() != expectedErr { + t.Errorf("Expected error %q, got %q", expectedErr, err.Error()) + } + }) + + t.Run("ProcessorError", func(t *testing.T) { + registry := &rootTypeRegistry{ + specialTypeHandlers: make(map[reflect.Type]PipelineBuilder), + kindHandlers: make(map[reflect.Kind]HandlerFactory), + } + + expectedProcErr := errors.New("processing failed") + mockBuilder := &mockPipelineBuilder{ + buildFunc: func(tags reflect.StructTag) (FieldProcessor[any], error) { + return func(rawValue string) (any, error) { + return nil, expectedProcErr + }, nil + }, + } + + intType := reflect.TypeOf(0) + registry.RegisterType(intType, mockBuilder) + + processor, err := New(intType, "", registry) + if err != nil { + t.Fatalf("New failed: %v", err) + } + + _, err = processor("input") + if err == nil { + t.Fatal("Expected error from processor, got nil") + } + + if !errors.Is(err, expectedProcErr) { + t.Errorf("Expected error %v, got %v", expectedProcErr, err) + } + }) +} diff --git a/internal/readpipeline/string_types.go b/internal/readpipeline/string_types.go new file mode 100644 index 0000000..cf2e6e2 --- /dev/null +++ b/internal/readpipeline/string_types.go @@ -0,0 +1,21 @@ +package readpipeline + +import ( + "reflect" +) + +// NewStringHandler returns a TypedHandler[string] that simply returns the raw value. +// Strings support the min and max tags for lexical ordering and the pattern tag for regex +func NewStringHandler(_ reflect.Type) TypedHandler[string] { + return NewTypedStringHandler() +} + +// NewTypedStringHandler returns a TypedHandler[string] that uses standard string parsing and validation. +func NewTypedStringHandler() TypedHandler[string] { + return &typeHandlerImpl[string]{ + Parser: func(rawValue string) (string, error) { + return rawValue, nil + }, + ValidationWrapper: NewCompositeWrapper(WrapProcessUsingPatternTag, WrapProcessUsingRangeTags[string]), + } +} diff --git a/internal/process/string_types_test.go b/internal/readpipeline/string_types_test.go similarity index 95% rename from internal/process/string_types_test.go rename to internal/readpipeline/string_types_test.go index 77df058..af676f0 100644 --- a/internal/process/string_types_test.go +++ b/internal/readpipeline/string_types_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" @@ -85,9 +85,10 @@ func TestStringTypes(t *testing.T) { }, } + registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - proc, err := New(tt.fieldType, tt.tags, nil, nil) + proc, err := New(tt.fieldType, tt.tags, registry) if err != nil { t.Fatalf("New() error = %v", err) } diff --git a/internal/readpipeline/typed_handler.go b/internal/readpipeline/typed_handler.go new file mode 100644 index 0000000..20a4af0 --- /dev/null +++ b/internal/readpipeline/typed_handler.go @@ -0,0 +1,30 @@ +package readpipeline + +import "reflect" + +// typeHandlerImpl is the strongly typed handler for the given pipeline. +// It implements the typeless PipelineBuilder interface for the pipeline by boxing and unboxing the value as required. +type typeHandlerImpl[T any] struct { + // Parser is the strongly typed version of the FieldProcessor that acts as input for this readpipeline + Parser FieldProcessor[T] + // ValidationWrapper is a factory that wraps the FieldProcessor with validation stages + ValidationWrapper Wrapper[T] +} + +func (h *typeHandlerImpl[T]) BuildPipeline(tags reflect.StructTag) (FieldProcessor[T], error) { + pipeline := h.Parser + if pipeline == nil { + return nil, nil + } + + wrapper := h.ValidationWrapper + if wrapper != nil { + var err error + pipeline, err = wrapper(tags, pipeline) + if err != nil { + return nil, err + } + } + + return pipeline, nil +} diff --git a/internal/readpipeline/typeregistry.go b/internal/readpipeline/typeregistry.go new file mode 100644 index 0000000..716cd6c --- /dev/null +++ b/internal/readpipeline/typeregistry.go @@ -0,0 +1,125 @@ +package readpipeline + +import ( + "reflect" + "time" +) + +// HandlerFactory is a function that returns a PipelineBuilder for a given type. +type HandlerFactory func(t reflect.Type) PipelineBuilder + +// TypedHandlerFactory is a function that returns a TypedHandler for a given type. +type TypedHandlerFactory[T any] func(t reflect.Type) TypedHandler[any] + +type TypeRegistry interface { + RegisterType(t reflect.Type, handler PipelineBuilder) + HandlerFor(t reflect.Type) PipelineBuilder +} + +// NewTypeRegistry creates a new TypeRegistry with the default handlers. +// Types registered here will override the default handlers for this registry instance only +func NewTypeRegistry() TypeRegistry { + return &localTypeRegistry{ + parent: rootRegistry, + specialTypeHandlers: map[reflect.Type]PipelineBuilder{}, + } +} + +// RegisterType registers a custom PipelineBuilder for a given type in the root registry. +func RegisterType[T any](handler TypedHandler[T]) { + handlerType := reflect.TypeOf((*T)(nil)).Elem() + wrapper := WrapTypedHandler(handler) + rootRegistry.RegisterType(handlerType, wrapper) +} + +type localTypeRegistry struct { + parent TypeRegistry + specialTypeHandlers map[reflect.Type]PipelineBuilder +} + +func (r *localTypeRegistry) RegisterType(t reflect.Type, handler PipelineBuilder) { + r.specialTypeHandlers[t] = handler +} + +func (r *localTypeRegistry) HandlerFor(t reflect.Type) PipelineBuilder { + if p, ok := r.specialTypeHandlers[t]; ok { + return p + } + return r.parent.HandlerFor(t) +} + +// BaseTypeRegistry is a registry of Handlers factories for specific types. +// Handlers can be registered for specific types or for a category of types keyed on Kind. +// If a handler is registered for a specific type, it will be used instead of the category handler. +// If a handler is registered for a category, a factory method is called to instantiate the handler given the type. +type rootTypeRegistry struct { + specialTypeHandlers map[reflect.Type]PipelineBuilder + kindHandlers map[reflect.Kind]HandlerFactory +} + +// RegisterType registers a custom PipelineBuilder for a given type. +func (r *rootTypeRegistry) RegisterType(t reflect.Type, handler PipelineBuilder) { + r.specialTypeHandlers[t] = handler +} + +// HandlerFor returns the PipelineBuilder for the given type, or nil if none is registered. +func (r *rootTypeRegistry) HandlerFor(t reflect.Type) PipelineBuilder { + // 1. Check for specific type overrides (The "Duration" check) + if p, ok := r.specialTypeHandlers[t]; ok { + return p + } + + // 2. Fall back to category-based logic + if factory, ok := r.kindHandlers[t.Kind()]; ok { + return factory(t) + } + + return nil +} + +// typedHandlerAdapter adapts a TypedHandler[T] to a PipelineBuilder. +type typedHandlerAdapter[T any] struct { + Handler TypedHandler[T] +} + +func (a typedHandlerAdapter[T]) Build(tags reflect.StructTag) (FieldProcessor[any], error) { + pipeline, err := a.Handler.BuildPipeline(tags) + if err != nil { + return nil, err + } + if pipeline == nil { + return nil, nil // Return nil if no parser is provided (modification handler) + } + return func(rawValue string) (any, error) { + return pipeline(rawValue) + }, nil +} + +// WrapTypedHandler wraps a TypedHandler[T] as a PipelineBuilder. +func WrapTypedHandler[T any](handler TypedHandler[T]) PipelineBuilder { + return typedHandlerAdapter[T]{Handler: handler} +} + +var rootRegistry = &rootTypeRegistry{ + specialTypeHandlers: map[reflect.Type]PipelineBuilder{ + reflect.TypeOf(time.Duration(0)): WrapTypedHandler(durationTypeHandler), + }, + kindHandlers: map[reflect.Kind]HandlerFactory{ + reflect.Int: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewIntHandler(t)) }, + reflect.Int8: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewIntHandler(t)) }, + reflect.Int16: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewIntHandler(t)) }, + reflect.Int32: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewIntHandler(t)) }, + reflect.Int64: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewIntHandler(t)) }, + reflect.Uint: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewUintHandler(t)) }, + reflect.Uint8: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewUintHandler(t)) }, + reflect.Uint16: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewUintHandler(t)) }, + reflect.Uint32: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewUintHandler(t)) }, + reflect.Uint64: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewUintHandler(t)) }, + reflect.Struct: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewJsonPipelineBuilder(t)) }, + reflect.Map: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewJsonPipelineBuilder(t)) }, + reflect.String: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewStringHandler(t)) }, + reflect.Bool: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewBoolHandler(t)) }, + reflect.Float32: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewFloatHandler(t)) }, + reflect.Float64: func(t reflect.Type) PipelineBuilder { return WrapTypedHandler(NewFloatHandler(t)) }, + }, +} diff --git a/internal/readpipeline/typeregistry_test.go b/internal/readpipeline/typeregistry_test.go new file mode 100644 index 0000000..65f2f49 --- /dev/null +++ b/internal/readpipeline/typeregistry_test.go @@ -0,0 +1,274 @@ +package readpipeline + +import ( + "errors" + "reflect" + "testing" + "time" +) + +// mockTypedHandler is a simple implementation of TypedHandler for testing +type mockTypedHandler[T any] struct { + buildPipelineFunc func(tags reflect.StructTag) (FieldProcessor[T], error) +} + +func (m *mockTypedHandler[T]) BuildPipeline(tags reflect.StructTag) (FieldProcessor[T], error) { + if m.buildPipelineFunc != nil { + return m.buildPipelineFunc(tags) + } + return nil, nil +} + +// registryMockPipelineBuilder is a simple implementation of PipelineBuilder for testing +type registryMockPipelineBuilder struct { + buildFunc func(tags reflect.StructTag) (FieldProcessor[any], error) +} + +func (m *registryMockPipelineBuilder) Build(tags reflect.StructTag) (FieldProcessor[any], error) { + if m.buildFunc != nil { + return m.buildFunc(tags) + } + return nil, nil +} + +func TestLocalTypeRegistry(t *testing.T) { + parent := &localTypeRegistry{ + specialTypeHandlers: map[reflect.Type]PipelineBuilder{}, + } + registry := &localTypeRegistry{ + parent: parent, + specialTypeHandlers: map[reflect.Type]PipelineBuilder{}, + } + + intType := reflect.TypeOf(0) + stringType := reflect.TypeOf("") + + handler1 := ®istryMockPipelineBuilder{} + handler2 := ®istryMockPipelineBuilder{} + + t.Run("RegisterAndRetrieve", func(t *testing.T) { + registry.RegisterType(intType, handler1) + got := registry.HandlerFor(intType) + if got != handler1 { + t.Errorf("expected handler1, got %v", got) + } + }) + + t.Run("FallbackToParent", func(t *testing.T) { + parent.RegisterType(stringType, handler2) + got := registry.HandlerFor(stringType) + if got != handler2 { + t.Errorf("expected handler2 from parent, got %v", got) + } + }) + + t.Run("OverrideParent", func(t *testing.T) { + registry.RegisterType(stringType, handler1) + got := registry.HandlerFor(stringType) + if got != handler1 { + t.Errorf("expected handler1 (override), got %v", got) + } + }) + + t.Run("NotFound", func(t *testing.T) { + boolType := reflect.TypeOf(true) + // We need a parent that returns nil if not found + root := &rootTypeRegistry{ + specialTypeHandlers: map[reflect.Type]PipelineBuilder{}, + kindHandlers: map[reflect.Kind]HandlerFactory{}, + } + regWithRoot := &localTypeRegistry{ + parent: root, + specialTypeHandlers: map[reflect.Type]PipelineBuilder{}, + } + got := regWithRoot.HandlerFor(boolType) + if got != nil { + t.Errorf("expected nil, got %v", got) + } + }) +} + +func TestRootTypeRegistry(t *testing.T) { + registry := &rootTypeRegistry{ + specialTypeHandlers: map[reflect.Type]PipelineBuilder{}, + kindHandlers: map[reflect.Kind]HandlerFactory{}, + } + + intType := reflect.TypeOf(0) + handler1 := ®istryMockPipelineBuilder{} + + t.Run("SpecialTypeHandler", func(t *testing.T) { + registry.RegisterType(intType, handler1) + got := registry.HandlerFor(intType) + if got != handler1 { + t.Errorf("expected handler1, got %v", got) + } + }) + + t.Run("KindHandler", func(t *testing.T) { + stringType := reflect.TypeOf("") + handler2 := ®istryMockPipelineBuilder{} + registry.kindHandlers[reflect.String] = func(t reflect.Type) PipelineBuilder { + return handler2 + } + + got := registry.HandlerFor(stringType) + if got != handler2 { + t.Errorf("expected handler2 from kind factory, got %v", got) + } + }) + + t.Run("NotFound", func(t *testing.T) { + boolType := reflect.TypeOf(true) + got := registry.HandlerFor(boolType) + if got != nil { + t.Errorf("expected nil, got %v", got) + } + }) +} + +func TestTypedHandlerAdapter(t *testing.T) { + t.Run("Success", func(t *testing.T) { + inner := &mockTypedHandler[int]{ + buildPipelineFunc: func(tags reflect.StructTag) (FieldProcessor[int], error) { + return func(rawValue string) (int, error) { + return 42, nil + }, nil + }, + } + + adapter := WrapTypedHandler[int](inner) + pipeline, err := adapter.Build("") + if err != nil { + t.Fatalf("Build failed: %v", err) + } + + val, err := pipeline("anything") + if err != nil { + t.Fatalf("pipeline failed: %v", err) + } + if val != 42 { + t.Errorf("expected 42, got %v", val) + } + }) + + t.Run("NilPipeline", func(t *testing.T) { + inner := &mockTypedHandler[int]{ + buildPipelineFunc: func(tags reflect.StructTag) (FieldProcessor[int], error) { + return nil, nil + }, + } + + adapter := WrapTypedHandler[int](inner) + pipeline, err := adapter.Build("") + if err != nil { + t.Fatalf("Build failed: %v", err) + } + if pipeline != nil { + t.Error("expected nil pipeline") + } + }) + + t.Run("BuildError", func(t *testing.T) { + inner := &mockTypedHandler[int]{ + buildPipelineFunc: func(tags reflect.StructTag) (FieldProcessor[int], error) { + return nil, errors.New("build error") + }, + } + + adapter := WrapTypedHandler[int](inner) + _, err := adapter.Build("") + if err == nil { + t.Error("expected build error, got nil") + } + }) +} + +func TestNewTypeRegistry(t *testing.T) { + registry := NewTypeRegistry() + if registry == nil { + t.Fatal("NewTypeRegistry returned nil") + } + + local, ok := registry.(*localTypeRegistry) + if !ok { + t.Fatalf("expected *localTypeRegistry, got %T", registry) + } + + if local.parent != rootRegistry { + t.Error("expected parent to be rootRegistry") + } +} + +func TestRegisterTypeGlobal(t *testing.T) { + type CustomType struct { + Value string + } + + handler := &mockTypedHandler[CustomType]{ + buildPipelineFunc: func(tags reflect.StructTag) (FieldProcessor[CustomType], error) { + return func(rawValue string) (CustomType, error) { + return CustomType{Value: rawValue}, nil + }, nil + }, + } + + RegisterType[CustomType](handler) + + // Verify it's in rootRegistry + customType := reflect.TypeOf(CustomType{}) + pb := rootRegistry.HandlerFor(customType) + if pb == nil { + t.Fatal("expected handler to be registered in rootRegistry") + } + + pipeline, err := pb.Build("") + if err != nil { + t.Fatalf("Build failed: %v", err) + } + + val, err := pipeline("hello") + if err != nil { + t.Fatalf("pipeline failed: %v", err) + } + + expected := CustomType{Value: "hello"} + if val != expected { + t.Errorf("expected %v, got %v", expected, val) + } +} + +func TestDefaultHandlers(t *testing.T) { + tests := []struct { + name string + val any + }{ + {"Int", 0}, + {"Int8", int8(0)}, + {"Int16", int16(0)}, + {"Int32", int32(0)}, + {"Int64", int64(0)}, + {"Uint", uint(0)}, + {"Uint8", uint8(0)}, + {"Uint16", uint16(0)}, + {"Uint32", uint32(0)}, + {"Uint64", uint64(0)}, + {"String", ""}, + {"Bool", true}, + {"Float32", float32(0)}, + {"Float64", float64(0)}, + {"Duration", time.Duration(0)}, + {"Struct", struct{ X int }{}}, + {"Map", map[string]int{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + typ := reflect.TypeOf(tt.val) + handler := rootRegistry.HandlerFor(typ) + if handler == nil { + t.Errorf("no default handler for %v", typ) + } + }) + } +} diff --git a/internal/process/types.go b/internal/readpipeline/types.go similarity index 77% rename from internal/process/types.go rename to internal/readpipeline/types.go index 9d90a79..56d0bae 100644 --- a/internal/process/types.go +++ b/internal/readpipeline/types.go @@ -1,8 +1,6 @@ -package process +package readpipeline -import ( - "reflect" -) +import "reflect" // FieldProcessor takes the user input string and outputs the final value to be set on the struct field. // Any parsing or validation errors are returned as an error @@ -15,6 +13,21 @@ type FieldProcessor[T any] func(rawValue string) (T, error) // at the last minute (before assignment) type Validator[T any] func(value T) error +// TypedHandler is the strongly typed version of the PipelineBuilder interface. +type TypedHandler[T any] interface { + // BuildPipeline creates the final FieldProcessor[T] for the given tags. + BuildPipeline(tags reflect.StructTag) (FieldProcessor[T], error) +} + +// PipelineBuilder is the typeless interface used to build the read pipeline. +type PipelineBuilder interface { + // Build creates the final FieldProcessor[any] for the given tags. + Build(tags reflect.StructTag) (FieldProcessor[any], error) +} + +// Wrapper is a factory that wraps a FieldProcessor according to tags present on the target field +type Wrapper[T any] func(tags reflect.StructTag, inputProcess FieldProcessor[T]) (FieldProcessor[T], error) + // Pipe combines a processor and a Validator, adding validation to the processor func Pipe[T any](processor FieldProcessor[T], validator Validator[T]) FieldProcessor[T] { return func(rawValue string) (T, error) { @@ -48,9 +61,7 @@ func PipeMultiple[T any](processor FieldProcessor[T], validators []Validator[T]) }) } -// Wrapper is a factory that wraps a FieldProcessor according to tags present on the target field -type Wrapper[T any] func(tags reflect.StructTag, inputProcess FieldProcessor[T]) (FieldProcessor[T], error) - +// NewCompositeWrapper creates a Wrapper that applies a sequence of wrappers to a FieldProcessor func NewCompositeWrapper[T any](wrappers ...Wrapper[T]) Wrapper[T] { return func(tags reflect.StructTag, inputProcess FieldProcessor[T]) (FieldProcessor[T], error) { var wrapped FieldProcessor[T] = inputProcess diff --git a/loadoptions.go b/loadoptions.go index f331cf4..eed1d1d 100644 --- a/loadoptions.go +++ b/loadoptions.go @@ -1,61 +1,51 @@ package goconfig -import "reflect" +import ( + "reflect" -// loadOptions holds the configuration options for Load. -type loadOptions struct { - // parsers allows the parser for a given key to be overridden - parsers map[string]Parser // key is fieldPath - // keyStore reads the values. Default to os.GetEnv() - keyStore KeyStore - // validatorFactories provide validators for a field - validatorFactories []ValidatorFactory - // validators maps field paths to their validator functions - validators map[string][]Validator -} + "github.com/m0rjc/goconfig/internal/readpipeline" +) -func (opts *loadOptions) addValidator(fieldPath string, validator Validator) { - if opts.validators == nil { - opts.validators = make(map[string][]Validator) - } - opts.validators[fieldPath] = append(opts.validators[fieldPath], validator) -} +// Option is a functional option for configuring the Load function. +type Option func(*loadOptions) -func (opts *loadOptions) addValidatorFactory(factory ValidatorFactory) { - if opts.validatorFactories == nil { - opts.validatorFactories = make([]ValidatorFactory, 0, 1) +// WithKeyStore replaces the environment variable keystore with an alternative. +// Use this to read from other sources such as a database or properties file. +func WithKeyStore(keyStore KeyStore) Option { + return func(opts *loadOptions) { + opts.keyStore = keyStore } - opts.validatorFactories = append(opts.validatorFactories, factory) } -func (opts *loadOptions) addParser(path string, parser Parser) { - if opts.parsers == nil { - opts.parsers = make(map[string]Parser) +// WithCustomType registers a custom type handler for a given type. +func WithCustomType[T any](handler TypedHandler[T]) Option { + var typedNil *T + t := reflect.TypeOf(typedNil).Elem() + + return func(opts *loadOptions) { + opts.typeRegistry.RegisterType(t, readpipeline.WrapTypedHandler(handler)) } - opts.parsers[path] = parser } -func (opts *loadOptions) getCustomParser(path string) Parser { - return opts.parsers[path] +// loadOptions holds the configuration options for Load. +type loadOptions struct { + // keyStore reads the values. Default to os.GetEnv() + keyStore KeyStore + // typeRegistry holds the handlers for specific types + typeRegistry readpipeline.TypeRegistry } -func (opts *loadOptions) getCustomValidators(path string, fieldType reflect.StructField) ([]Validator, error) { - validators := make([]Validator, 0) - supplied, ok := opts.validators[path] - if ok { - validators = append(validators, supplied...) +// newLoadOptions creates default load options. +func newLoadOptions() *loadOptions { + return &loadOptions{ + keyStore: EnvironmentKeyStore, + typeRegistry: readpipeline.NewTypeRegistry(), } +} - if opts.validatorFactories != nil { - registry := func(v Validator) { - validators = append(validators, v) - } - for _, factory := range opts.validatorFactories { - if err := factory(fieldType, registry); err != nil { - return nil, err - } - } +// applyOptions applies the given options to the load options. +func (opts *loadOptions) applyOptions(options []Option) { + for _, opt := range options { + opt(opts) } - - return validators, nil } diff --git a/validation.go b/validation.go deleted file mode 100644 index 1a36738..0000000 --- a/validation.go +++ /dev/null @@ -1,50 +0,0 @@ -package goconfig - -import ( - "reflect" - - "github.com/m0rjc/goconfig/internal/process" -) - -// ValidatorRegistry is the callback to add a validator to the current field. -// Validator factories call this function to register validators for a field. -// The registry handles associating the validator with the current field path. -type ValidatorRegistry func(validator Validator) - -// ValidatorFactory inspects a struct field and registers appropriate validators. -// Factories can examine the field's type, tags, and name to determine which validators to add. -// The registry parameter is used to register validators for the current field. -// -// Example: A factory that validates email fields based on a custom tag: -// -// func emailValidatorFactory(fieldType reflect.StructField, registry ValidatorRegistry) error { -// if fieldType.Tag.Get("email") == "true" { -// registry(func(value any) error { -// email := value.(string) -// if !strings.Contains(email, "@") { -// return fmt.Errorf("invalid email format") -// } -// return nil -// }) -// } -// return nil -// } -// -// Factories are called during configuration loading for each field that has a key tag. -// They are called before values are loaded, so they only have access to field metadata. -type ValidatorFactory func(fieldType reflect.StructField, registry ValidatorRegistry) error - -// Validator validates a field's value after type conversion. -// The validator receives the converted value and returns an error if validation fails. -// Validators are called after the environment variable or default value is converted -// to the field's type but before it is assigned to the struct field. -// -// The value parameter type depends on the field type: -// - int types receive int64 -// - uint types receive uint64 -// - float types receive float64 -// - string types receive string -// - bool types receive bool -// - time.Duration types receive time.Duration -// - Other types, such as Struct and Map, receive the value as a value not a pointer. -type Validator = process.Validator[any]