From 6567231d0ae1c4cd08411949e89fe0d129ff57d2 Mon Sep 17 00:00:00 2001 From: Richard Corfield Date: Mon, 22 Dec 2025 20:08:54 +0000 Subject: [PATCH 01/10] Replace the old custom parser and validator system with the new custom type system. Perform some exploratory tests to verify that the custom type system can do anything the old parser/validators could. It can! The system is now so much simpler! This is a checkpoint commit, the end of manual work, before allow the AI tool, Junie, to fix up the tests. --- .junie/guidelines.md | 4 +- config.go | 105 +---------- custom_types_test.go | 163 ++++++++++++++++++ example/custom_type/README.md | 7 + internal/process/custom_types.go | 32 ---- internal/process/process.go | 44 ----- internal/process/typeregistry.go | 90 ---------- loadoptions.go | 78 ++++----- .../process => process}/boolean_types.go | 0 .../process => process}/boolean_types_test.go | 0 process/custom_types.go | 17 ++ .../process => process}/custom_types_test.go | 0 {internal/process => process}/duration.go | 0 .../process => process}/duration_test.go | 0 .../process => process}/invalid_types_test.go | 0 {internal/process => process}/json_types.go | 0 .../process => process}/json_types_test.go | 0 {internal/process => process}/number_types.go | 0 .../process => process}/number_types_test.go | 0 .../process => process}/ordered_validators.go | 0 .../process => process}/pattern_validator.go | 0 .../pattern_validator_test.go | 0 .../process => process}/pointer_types_test.go | 0 process/process.go | 34 ++++ {internal/process => process}/string_types.go | 0 .../process => process}/string_types_test.go | 0 process/typeregistry.go | 70 ++++++++ {internal/process => process}/types.go | 51 ++++++ validation.go | 2 +- 29 files changed, 383 insertions(+), 314 deletions(-) create mode 100644 custom_types_test.go create mode 100644 example/custom_type/README.md delete mode 100644 internal/process/custom_types.go delete mode 100644 internal/process/process.go delete mode 100644 internal/process/typeregistry.go rename {internal/process => process}/boolean_types.go (100%) rename {internal/process => process}/boolean_types_test.go (100%) create mode 100644 process/custom_types.go rename {internal/process => process}/custom_types_test.go (100%) rename {internal/process => process}/duration.go (100%) rename {internal/process => process}/duration_test.go (100%) rename {internal/process => process}/invalid_types_test.go (100%) rename {internal/process => process}/json_types.go (100%) rename {internal/process => process}/json_types_test.go (100%) rename {internal/process => process}/number_types.go (100%) rename {internal/process => process}/number_types_test.go (100%) rename {internal/process => process}/ordered_validators.go (100%) rename {internal/process => process}/pattern_validator.go (100%) rename {internal/process => process}/pattern_validator_test.go (100%) rename {internal/process => process}/pointer_types_test.go (100%) create mode 100644 process/process.go rename {internal/process => process}/string_types.go (100%) rename {internal/process => process}/string_types_test.go (100%) create mode 100644 process/typeregistry.go rename {internal/process => process}/types.go (54%) diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 75932c8..24fbb1e 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) } diff --git a/config.go b/config.go index 50d753f..8d980b6 100644 --- a/config.go +++ b/config.go @@ -5,104 +5,9 @@ import ( "fmt" "reflect" - "github.com/m0rjc/goconfig/internal/process" + "github.com/m0rjc/goconfig/process" ) -// 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,13 +136,7 @@ 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 := process.New(fieldType.Type, fieldType.Tag, opts.typeRegistry) if err != nil { return fmt.Errorf("setting up field process %s: %v", currentPath, err) } diff --git a/custom_types_test.go b/custom_types_test.go new file mode 100644 index 0000000..3d7b33e --- /dev/null +++ b/custom_types_test.go @@ -0,0 +1,163 @@ +package goconfig + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/m0rjc/goconfig/process" +) + +// Explore what can be done with custom types +func TestLoad_WithCustomTypes(t *testing.T) { + t.Run("A type handler can be registered for a custom struct", func(t *testing.T) { + type CustomStruct struct { + Field1 string + } + customStructType := reflect.TypeOf(CustomStruct{}) + mockStore := func(ctx context.Context, key string) (string, bool, error) { + if key == "CUSTOM_STRUCT" { + return "--Marker--", true, nil + } + return "", false, nil + } + mockParser := func(value string) (any, error) { + return CustomStruct{Field1: value}, nil + } + mockHandler := process.NewCustomHandler(mockParser) + + t.Run("struct as value", func(t *testing.T) { + type Config struct { + Value CustomStruct `key:"CUSTOM_STRUCT"` + } + + config := Config{Value: CustomStruct{Field1: ""}} + err := Load(context.Background(), &config, + WithCustomType(customStructType, mockHandler), + WithKeyStore(mockStore)) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if config.Value.Field1 != "--Marker--" { + t.Errorf("Expected Field1 to be set to --Marker--, got %s", config.Value.Field1) + } + }) + + t.Run("struct as pointer", func(t *testing.T) { + type Config struct { + Value *CustomStruct `key:"CUSTOM_STRUCT"` + } + + config := Config{Value: nil} + err := Load(context.Background(), &config, + WithCustomType(customStructType, mockHandler), + WithKeyStore(mockStore)) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if config.Value == nil { + t.Fatal("Expected config.Value to be non-nil") + } + if config.Value.Field1 != "--Marker--" { + t.Errorf("Expected Field1 to be set to --Marker--, got %s", config.Value.Field1) + } + }) + }) + + t.Run("Custom type can be registered for a custom enum (string kind)", func(t *testing.T) { + type CustomEnum string + const ( + CustomEnum1 CustomEnum = "--Marker--1--" + CustomEnum2 CustomEnum = "--Marker--2--" + ) + customEnumType := reflect.TypeOf(CustomEnum("")) + mockStore := func(ctx context.Context, key string) (string, bool, error) { + if key == "CUSTOM_ENUM_1" { + return string(CustomEnum1), true, nil + } + if key == "CUSTOM_ENUM_2" { + return string(CustomEnum2), true, nil + } + if key == "SOME_OTHER_KEY" { + return "foo", true, nil + } + return "", false, nil + } + expectedError := errors.New("expected CustomEnum") + mockHandler := process.NewCustomHandler(func(value string) (CustomEnum, error) { + return CustomEnum(value), nil + }, func(value CustomEnum) error { + if value != CustomEnum1 && value != CustomEnum2 { + return expectedError + } + return nil + }) + + t.Run("string enum as value", func(t *testing.T) { + type Config struct { + Value CustomEnum `key:"CUSTOM_ENUM_1"` + Value2 CustomEnum `key:"CUSTOM_ENUM_2"` + Other string `key:"SOME_OTHER_KEY"` + } + + config := Config{} + err := Load(context.Background(), &config, + WithCustomType(customEnumType, mockHandler), + WithKeyStore(mockStore)) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if config.Value != CustomEnum1 { + t.Errorf("Expected Field1 to be set to %s, got %s", CustomEnum1, config.Value) + } + if config.Value2 != CustomEnum2 { + t.Errorf("Expected Field1 to be set to %s, got %s", CustomEnum2, config.Value) + } + if config.Other != "foo" { + t.Errorf("Expected Other to be set to foo, got %s", config.Other) + } + }) + + t.Run("the validator is called", func(t *testing.T) { + type Config struct { + Value CustomEnum `key:"SOME_OTHER_KEY"` + } + + config := Config{} + err := Load(context.Background(), &config, + WithCustomType(customEnumType, mockHandler), + WithKeyStore(mockStore)) + if err == nil { + t.Fatal("Load should have failed") + } + if !errors.Is(err, expectedError) { + t.Errorf("Expected validator error, got: %v", err) + } + }) + + t.Run("string enum as pointer", func(t *testing.T) { + type Config struct { + Value *CustomEnum `key:"CUSTOM_ENUM_1"` + Other string `key:"SOME_OTHER_KEY"` + } + + config := Config{} + err := Load(context.Background(), &config, + WithCustomType(customEnumType, mockHandler), + WithKeyStore(mockStore)) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if config.Value == nil { + t.Fatal("Expected config.Value to be non-nil") + } + if *config.Value != CustomEnum1 { + t.Errorf("Expected Field1 to be set to %s, got %s", CustomEnum1, *config.Value) + } + if config.Other != "foo" { + t.Errorf("Expected Other to be set to foo, got %s", config.Other) + } + }) + }) +} diff --git a/example/custom_type/README.md b/example/custom_type/README.md new file mode 100644 index 0000000..88925b9 --- /dev/null +++ b/example/custom_type/README.md @@ -0,0 +1,7 @@ +# Custom Type Demo + +_Just because you can, doesn't mean you should!_ + +This sample demonstrates the flexibility of the system by defining a custom type which is a +running web server. The type handler recurses into the server struct having decorated the +key store to apply a prefix to the keys. 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/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/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/loadoptions.go b/loadoptions.go index f331cf4..9c3d54c 100644 --- a/loadoptions.go +++ b/loadoptions.go @@ -1,61 +1,55 @@ 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/process" +) + +// Option is a functional option for configuring the Load function. +type Option func(*loadOptions) -func (opts *loadOptions) addValidator(fieldPath string, validator Validator) { - if opts.validators == nil { - opts.validators = make(map[string][]Validator) +// 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.validators[fieldPath] = append(opts.validators[fieldPath], validator) } -func (opts *loadOptions) addValidatorFactory(factory ValidatorFactory) { - if opts.validatorFactories == nil { - opts.validatorFactories = make([]ValidatorFactory, 0, 1) +// WithCustomType registers a custom type handler for a given type. +func WithCustomType(t reflect.Type, handler process.Handler) Option { + return func(opts *loadOptions) { + opts.typeRegistry.RegisterType(t, handler) } - opts.validatorFactories = append(opts.validatorFactories, factory) } -func (opts *loadOptions) addParser(path string, parser Parser) { - if opts.parsers == nil { - opts.parsers = make(map[string]Parser) +// WithCustomKind registers a custom type handler for a given kind. +func WithCustomKind(t reflect.Kind, handler process.HandlerFactory) Option { + return func(opts *loadOptions) { + opts.typeRegistry.RegisterKind(t, 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 *process.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: process.NewDefaultTypeRegistry(), } +} - 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/internal/process/boolean_types.go b/process/boolean_types.go similarity index 100% rename from internal/process/boolean_types.go rename to process/boolean_types.go diff --git a/internal/process/boolean_types_test.go b/process/boolean_types_test.go similarity index 100% rename from internal/process/boolean_types_test.go rename to process/boolean_types_test.go diff --git a/process/custom_types.go b/process/custom_types.go new file mode 100644 index 0000000..0bcdf33 --- /dev/null +++ b/process/custom_types.go @@ -0,0 +1,17 @@ +package process + +import "reflect" + +// NewCustomHandler creates a new handler that uses the custom parser and validators. +// The custom parser cannot be nil. +func NewCustomHandler[T any](customParser FieldProcessor[T], customValidators ...Validator[T]) Handler { + return &TypeHandler[T]{ + Parser: customParser, + ValidationWrapper: func(tags reflect.StructTag, inputProcess FieldProcessor[T]) (FieldProcessor[T], error) { + if customValidators != nil && len(customValidators) > 0 { + inputProcess = PipeMultiple(inputProcess, customValidators) + } + return inputProcess, nil + }, + } +} diff --git a/internal/process/custom_types_test.go b/process/custom_types_test.go similarity index 100% rename from internal/process/custom_types_test.go rename to process/custom_types_test.go diff --git a/internal/process/duration.go b/process/duration.go similarity index 100% rename from internal/process/duration.go rename to process/duration.go diff --git a/internal/process/duration_test.go b/process/duration_test.go similarity index 100% rename from internal/process/duration_test.go rename to process/duration_test.go diff --git a/internal/process/invalid_types_test.go b/process/invalid_types_test.go similarity index 100% rename from internal/process/invalid_types_test.go rename to process/invalid_types_test.go diff --git a/internal/process/json_types.go b/process/json_types.go similarity index 100% rename from internal/process/json_types.go rename to process/json_types.go diff --git a/internal/process/json_types_test.go b/process/json_types_test.go similarity index 100% rename from internal/process/json_types_test.go rename to process/json_types_test.go diff --git a/internal/process/number_types.go b/process/number_types.go similarity index 100% rename from internal/process/number_types.go rename to process/number_types.go diff --git a/internal/process/number_types_test.go b/process/number_types_test.go similarity index 100% rename from internal/process/number_types_test.go rename to process/number_types_test.go diff --git a/internal/process/ordered_validators.go b/process/ordered_validators.go similarity index 100% rename from internal/process/ordered_validators.go rename to process/ordered_validators.go diff --git a/internal/process/pattern_validator.go b/process/pattern_validator.go similarity index 100% rename from internal/process/pattern_validator.go rename to process/pattern_validator.go diff --git a/internal/process/pattern_validator_test.go b/process/pattern_validator_test.go similarity index 100% rename from internal/process/pattern_validator_test.go rename to process/pattern_validator_test.go diff --git a/internal/process/pointer_types_test.go b/process/pointer_types_test.go similarity index 100% rename from internal/process/pointer_types_test.go rename to process/pointer_types_test.go diff --git a/process/process.go b/process/process.go new file mode 100644 index 0000000..ccf8435 --- /dev/null +++ b/process/process.go @@ -0,0 +1,34 @@ +package process + +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 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, registry *TypeRegistry) (FieldProcessor[any], 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 := registry.HandlerFor(targetType) + if handler == nil { + return nil, fmt.Errorf("no handler for type %s", targetType) + } + + // This remains two distinct steps so that the Custom Types Decorator can intercept the process + pipeline := handler.GetParser() + if pipeline == nil { + return nil, fmt.Errorf("no parser for type %s", targetType) + } + + return handler.AddValidatorsToPipeline(tags, pipeline) +} diff --git a/internal/process/string_types.go b/process/string_types.go similarity index 100% rename from internal/process/string_types.go rename to process/string_types.go diff --git a/internal/process/string_types_test.go b/process/string_types_test.go similarity index 100% rename from internal/process/string_types_test.go rename to process/string_types_test.go diff --git a/process/typeregistry.go b/process/typeregistry.go new file mode 100644 index 0000000..d2bf639 --- /dev/null +++ b/process/typeregistry.go @@ -0,0 +1,70 @@ +package process + +import ( + "reflect" + "time" +) + +// HandlerFactory is a function that returns a Handler for a given type. +type HandlerFactory func(t reflect.Type) Handler + +// TypeRegistry 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 TypeRegistry struct { + specialTypeHandlers map[reflect.Type]Handler + kindHandlers map[reflect.Kind]HandlerFactory +} + +// NewDefaultTypeRegistry creates a new TypeRegistry with the default handlers. +func NewDefaultTypeRegistry() *TypeRegistry { + return &TypeRegistry{ + specialTypeHandlers: map[reflect.Type]Handler{ + reflect.TypeOf(time.Duration(0)): durationTypeHandler, + }, + kindHandlers: map[reflect.Kind]HandlerFactory{ + 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, + }, + } +} + +// RegisterKind registers a factory function for a given kind. +func (r *TypeRegistry) RegisterKind(kind reflect.Kind, factory func(t reflect.Type) Handler) { + r.kindHandlers[kind] = factory +} + +// RegisterType registers a custom Handler for a given type. +func (r *TypeRegistry) RegisterType(t reflect.Type, handler Handler) { + r.specialTypeHandlers[t] = handler +} + +// HandlerFor returns the Handler for the given type, or nil if none is registered. +func (r *TypeRegistry) HandlerFor(t reflect.Type) Handler { + // 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 +} diff --git a/internal/process/types.go b/process/types.go similarity index 54% rename from internal/process/types.go rename to process/types.go index 9d90a79..d9d090f 100644 --- a/internal/process/types.go +++ b/process/types.go @@ -15,6 +15,56 @@ type FieldProcessor[T any] func(rawValue string) (T, error) // at the last minute (before assignment) type Validator[T any] func(value T) error +// 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 is the strongly typed version of the FieldProcessor that acts as input for this process + Parser FieldProcessor[T] + // ValidationWrapper is a factory that wraps the FieldProcessor with validation stages + 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) { + if h.ValidationWrapper == nil { + return p, nil + } + + // 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 +} + // 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) { @@ -51,6 +101,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/validation.go b/validation.go index 1a36738..c9712c8 100644 --- a/validation.go +++ b/validation.go @@ -3,7 +3,7 @@ package goconfig import ( "reflect" - "github.com/m0rjc/goconfig/internal/process" + "github.com/m0rjc/goconfig/process" ) // ValidatorRegistry is the callback to add a validator to the current field. From dc34bcd454f84dd0cb05c77eaeb2b02e417425a3 Mon Sep 17 00:00:00 2001 From: Richard Corfield Date: Mon, 22 Dec 2025 22:46:04 +0000 Subject: [PATCH 02/10] Improving the type system --- config_test.go | 55 +++++++----- custom_types_test.go | 73 ++++++++++++++-- example/validation/main.go | 71 +++++++++------- loadoptions.go | 12 +-- process/boolean_types.go | 9 +- process/boolean_types_test.go | 3 +- process/custom_types.go | 110 +++++++++++++++++++++--- process/custom_types_test.go | 156 ++++++++++++++++++++++++++++++++-- process/duration.go | 15 ++-- process/duration_test.go | 3 +- process/invalid_types_test.go | 5 +- process/json_types.go | 4 +- process/json_types_test.go | 3 +- process/number_types.go | 34 +++++--- process/number_types_test.go | 11 ++- process/pointer_types_test.go | 14 +-- process/process.go | 9 +- process/string_types.go | 13 ++- process/string_types_test.go | 3 +- process/typed_handler.go | 95 +++++++++++++++++++++ process/typeregistry.go | 18 ++-- process/types.go | 113 ++++-------------------- 22 files changed, 596 insertions(+), 233 deletions(-) create mode 100644 process/typed_handler.go diff --git a/config_test.go b/config_test.go index 7728dbc..3c4166f 100644 --- a/config_test.go +++ b/config_test.go @@ -4,9 +4,11 @@ import ( "context" "errors" "os" - "reflect" + "strconv" "strings" "testing" + + "github.com/m0rjc/goconfig/process" ) func TestLoad_Basic(t *testing.T) { @@ -212,13 +214,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 := process.NewCustomHandler(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 +234,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 := process.NewCustomHandler(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) } @@ -284,21 +296,26 @@ func TestLoad_Errors(t *testing.T) { }) 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 := process.NewCustomHandler(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) } }) diff --git a/custom_types_test.go b/custom_types_test.go index 3d7b33e..a95cd6c 100644 --- a/custom_types_test.go +++ b/custom_types_test.go @@ -15,17 +15,16 @@ func TestLoad_WithCustomTypes(t *testing.T) { type CustomStruct struct { Field1 string } - customStructType := reflect.TypeOf(CustomStruct{}) mockStore := func(ctx context.Context, key string) (string, bool, error) { if key == "CUSTOM_STRUCT" { return "--Marker--", true, nil } return "", false, nil } - mockParser := func(value string) (any, error) { + mockParser := func(value string) (CustomStruct, error) { return CustomStruct{Field1: value}, nil } - mockHandler := process.NewCustomHandler(mockParser) + mockHandler := process.NewCustomHandler[CustomStruct](mockParser) t.Run("struct as value", func(t *testing.T) { type Config struct { @@ -34,7 +33,7 @@ func TestLoad_WithCustomTypes(t *testing.T) { config := Config{Value: CustomStruct{Field1: ""}} err := Load(context.Background(), &config, - WithCustomType(customStructType, mockHandler), + WithCustomType[CustomStruct](mockHandler), WithKeyStore(mockStore)) if err != nil { t.Fatalf("Load failed: %v", err) @@ -51,7 +50,7 @@ func TestLoad_WithCustomTypes(t *testing.T) { config := Config{Value: nil} err := Load(context.Background(), &config, - WithCustomType(customStructType, mockHandler), + WithCustomType[CustomStruct](mockHandler), WithKeyStore(mockStore)) if err != nil { t.Fatalf("Load failed: %v", err) @@ -71,7 +70,6 @@ func TestLoad_WithCustomTypes(t *testing.T) { CustomEnum1 CustomEnum = "--Marker--1--" CustomEnum2 CustomEnum = "--Marker--2--" ) - customEnumType := reflect.TypeOf(CustomEnum("")) mockStore := func(ctx context.Context, key string) (string, bool, error) { if key == "CUSTOM_ENUM_1" { return string(CustomEnum1), true, nil @@ -103,7 +101,7 @@ func TestLoad_WithCustomTypes(t *testing.T) { config := Config{} err := Load(context.Background(), &config, - WithCustomType(customEnumType, mockHandler), + WithCustomType[CustomEnum](mockHandler), WithKeyStore(mockStore)) if err != nil { t.Fatalf("Load failed: %v", err) @@ -126,7 +124,7 @@ func TestLoad_WithCustomTypes(t *testing.T) { config := Config{} err := Load(context.Background(), &config, - WithCustomType(customEnumType, mockHandler), + WithCustomType[CustomEnum](mockHandler), WithKeyStore(mockStore)) if err == nil { t.Fatal("Load should have failed") @@ -144,7 +142,7 @@ func TestLoad_WithCustomTypes(t *testing.T) { config := Config{} err := Load(context.Background(), &config, - WithCustomType(customEnumType, mockHandler), + WithCustomType[CustomEnum](mockHandler), WithKeyStore(mockStore)) if err != nil { t.Fatalf("Load failed: %v", err) @@ -160,4 +158,61 @@ func TestLoad_WithCustomTypes(t *testing.T) { } }) }) + + t.Run("WithCustomType can add validation to an existing type", func(t *testing.T) { + type Config struct { + Port int `key:"PORT" min:"1000"` + } + + mockStore := func(ctx context.Context, key string) (string, bool, error) { + if key == "PORT" { + return "1024", true, nil + } + return "", false, nil + } + + // Modification: must be even + t.Run("Adding validator to int", func(t *testing.T) { + // Reuse the standard int handler logic and add a validator + base := process.NewTypedIntHandler(reflect.TypeOf(int(0)).Bits()) + mod := process.NewCustomHandler[int](func(s string) (int, error) { + v, err := base.GetParser()(s) + return int(v), err + }, func(v int) error { + if v%2 != 0 { + return errors.New("must be even") + } + return nil + }) + + var cfg Config + err := Load(context.Background(), &cfg, + WithKeyStore(mockStore), + WithCustomType[int](mod)) + + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.Port != 1024 { + t.Errorf("Expected 1024, got %d", cfg.Port) + } + + // Test failure + mockStoreOdd := func(ctx context.Context, key string) (string, bool, error) { + return "1025", true, nil + } + err = Load(context.Background(), &cfg, + WithKeyStore(mockStoreOdd), + WithCustomType[int](mod)) + if err == nil || !reflect.TypeOf(err).AssignableTo(reflect.TypeOf(&ConfigErrors{})) { + t.Fatalf("Expected ConfigErrors, got %v", err) + } + if !errors.Is(err, errors.New("must be even")) { + // ConfigErrors.Error() contains the string + if !reflect.ValueOf(err).MethodByName("HasErrors").Call(nil)[0].Bool() { + t.Fatal("Expected errors") + } + } + }) + }) } diff --git a/example/validation/main.go b/example/validation/main.go index f2c2f57..c05b680 100644 --- a/example/validation/main.go +++ b/example/validation/main.go @@ -11,8 +11,13 @@ import ( "time" "github.com/m0rjc/goconfig" + "github.com/m0rjc/goconfig/process" ) +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](process.NewCustomHandler( + 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](process.NewCustomHandler( + 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](process.NewCustomHandler( + 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/loadoptions.go b/loadoptions.go index 9c3d54c..e839e7a 100644 --- a/loadoptions.go +++ b/loadoptions.go @@ -18,16 +18,12 @@ func WithKeyStore(keyStore KeyStore) Option { } // WithCustomType registers a custom type handler for a given type. -func WithCustomType(t reflect.Type, handler process.Handler) Option { - return func(opts *loadOptions) { - opts.typeRegistry.RegisterType(t, handler) - } -} +func WithCustomType[T any](handler process.TypedHandler[T]) Option { + var typedNil *T + t := reflect.TypeOf(typedNil).Elem() -// WithCustomKind registers a custom type handler for a given kind. -func WithCustomKind(t reflect.Kind, handler process.HandlerFactory) Option { return func(opts *loadOptions) { - opts.typeRegistry.RegisterKind(t, handler) + opts.typeRegistry.RegisterType(t, handler) } } diff --git a/process/boolean_types.go b/process/boolean_types.go index 53f4654..e84aaf9 100644 --- a/process/boolean_types.go +++ b/process/boolean_types.go @@ -5,8 +5,13 @@ import ( "strconv" ) -func NewBoolHandler(fieldType reflect.Type) Handler { - return TypeHandler[bool]{ +func NewBoolHandler(_ reflect.Type) PipelineBuilder { + 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) }, diff --git a/process/boolean_types_test.go b/process/boolean_types_test.go index dbb98f0..12362a9 100644 --- a/process/boolean_types_test.go +++ b/process/boolean_types_test.go @@ -46,9 +46,10 @@ func TestBoolTypes(t *testing.T) { }, } + registry := NewDefaultTypeRegistry() 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/process/custom_types.go b/process/custom_types.go index 0bcdf33..913f6c5 100644 --- a/process/custom_types.go +++ b/process/custom_types.go @@ -1,17 +1,107 @@ package process -import "reflect" +import ( + "fmt" + "reflect" +) // NewCustomHandler creates a new handler that uses the custom parser and validators. -// The custom parser cannot be nil. -func NewCustomHandler[T any](customParser FieldProcessor[T], customValidators ...Validator[T]) Handler { - return &TypeHandler[T]{ - Parser: customParser, - ValidationWrapper: func(tags reflect.StructTag, inputProcess FieldProcessor[T]) (FieldProcessor[T], error) { - if customValidators != nil && len(customValidators) > 0 { - inputProcess = PipeMultiple(inputProcess, customValidators) +func NewCustomHandler[T any](customParser FieldProcessor[T], customValidators ...Validator[T]) TypedHandler[T] { + return typeHandlerImpl[T]{ + Parser: customParser, + ValidationWrapper: newCustomValidatorWrapper(customValidators), + } +} + +func NewEnumHandler[T ~string](validValues ...T) TypedHandler[T] { + return NewCustomHandler[T](func(rawValue string) (T, error) { + for _, validValue := range validValues { + if rawValue == string(validValue) { + return validValue, nil } - return inputProcess, nil - }, + } + return "", fmt.Errorf("invalid value: %s", rawValue) + }) +} + +func newCustomValidatorWrapper[T any](customValidators []Validator[T]) Wrapper[T] { + return func(tags reflect.StructTag, inputProcess FieldProcessor[T]) (FieldProcessor[T], error) { + if customValidators != nil && len(customValidators) > 0 { + inputProcess = PipeMultiple(inputProcess, customValidators) + } + return inputProcess, nil + } +} + +func ReplaceParser[B, T any](baseHandler TypedHandler[B], customParser FieldProcessor[T]) (TypedHandler[T], error) { + adaptedWrapper := castWrapper[B, T](baseHandler.GetWrapper()) + + return typeHandlerImpl[T]{ + Parser: customParser, + ValidationWrapper: adaptedWrapper, + }, nil +} + +func PrependValidators[B, T any](baseHandler TypedHandler[B], customValidators ...Validator[T]) (TypedHandler[T], error) { + parser, err := castPipeline[B, T](baseHandler.GetParser()) + if err != nil { + return nil, err + } + + adaptedWrapper := castWrapper[B, T](baseHandler.GetWrapper()) + + return typeHandlerImpl[T]{ + Parser: parser, + ValidationWrapper: NewCompositeWrapper[T](adaptedWrapper, newCustomValidatorWrapper(customValidators)), + }, nil +} + +func castPipeline[B, T any](parser FieldProcessor[B]) (FieldProcessor[T], error) { + if parser == nil { + return nil, nil + } + + baseType := reflect.TypeOf((*B)(nil)).Elem() + newType := reflect.TypeOf((*T)(nil)).Elem() + if !baseType.ConvertibleTo(newType) { + return nil, fmt.Errorf("incompatible type conversion: %s -> %s", baseType, newType) + } + + return func(rawValue string) (T, error) { + val, err := parser(rawValue) + if err != nil { + var zero T + return zero, err + } + // Convert B to T (e.g., string to Foo) + return reflect.ValueOf(val).Convert(newType).Interface().(T), nil + }, nil +} + +func castWrapper[B, T any](wrapper Wrapper[B]) Wrapper[T] { + if wrapper == nil { + return nil + } + + return func(tags reflect.StructTag, inputProcess FieldProcessor[T]) (FieldProcessor[T], error) { + // 1. Down-convert the inputProcess (T -> B) so the base wrapper can use it + inputPipeline, err := castPipeline[T, B](inputProcess) + if err != nil { + return nil, fmt.Errorf("input conversion for validators: %w", err) + } + + // 2. Run the base wrapper logic + wrappedBase, err := wrapper(tags, inputPipeline) + if err != nil { + return nil, err + } + + // 3. Up-convert the result (B -> T) for the final pipeline + outputPipeline, err := castPipeline[B, T](wrappedBase) + if err != nil { + return nil, fmt.Errorf("output conversion for validators: %w", err) + } + + return outputPipeline, nil } } diff --git a/process/custom_types_test.go b/process/custom_types_test.go index fc4cc37..e4f5d89 100644 --- a/process/custom_types_test.go +++ b/process/custom_types_test.go @@ -5,6 +5,7 @@ import ( "fmt" "reflect" "strconv" + "strings" "testing" ) @@ -20,8 +21,7 @@ func TestCustomParserAndValidators(t *testing.T) { } // Custom validator that checks if even - customValidator := func(value any) error { - v := value.(int64) + customValidator := func(v int64) error { if v%2 != 0 { return errors.New("must be even") } @@ -32,7 +32,24 @@ func TestCustomParserAndValidators(t *testing.T) { tags := reflect.StructTag(`key:"PORT" min:"10"`) fieldType := reflect.TypeOf(int64(0)) - p, err := New(fieldType, tags, customParser, []Validator[any]{customValidator}) + registry := NewDefaultTypeRegistry() + registry.RegisterType(fieldType, typeHandlerImpl[int64]{ + Parser: func(s string) (int64, error) { + v, err := customParser(s) + if err != nil { + return 0, err + } + return v.(int64), nil + }, + ValidationWrapper: NewCompositeWrapper( + func(tags reflect.StructTag, inputProcess FieldProcessor[int64]) (FieldProcessor[int64], error) { + return Pipe(inputProcess, customValidator), nil + }, + WrapProcessUsingRangeTags[int64], + ), + }) + + p, err := New(fieldType, tags, registry) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -85,7 +102,17 @@ func TestCustomParserAndValidators(t *testing.T) { } fieldType := reflect.TypeOf(Point{}) - p, err := New(fieldType, "", customParser, []Validator[any]{customValidator}) + registry := NewDefaultTypeRegistry() + registry.RegisterType(fieldType, NewCustomHandler(func(s string) (Point, error) { + v, err := customParser(s) + if err != nil { + return Point{}, err + } + return v.(Point), nil + }, func(v Point) error { + return customValidator(v) + })) + p, err := New(fieldType, "", registry) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -114,16 +141,23 @@ func TestCustomParserAndValidators(t *testing.T) { 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 { + customValidator := func(value int64) error { + if value == 42 { return errors.New("42 is forbidden") } return nil } fieldType := reflect.TypeOf(int64(0)) - p, err := New(fieldType, "", nil, []Validator[any]{customValidator}) + registry := NewDefaultTypeRegistry() + // Since we want to use the default parser but add a custom validator, we can prepend it + baseHandler := NewTypedIntHandler(64) + handler, err := PrependValidators(baseHandler, customValidator) + if err != nil { + t.Fatalf("Failed to prepend validator: %v", err) + } + registry.RegisterType(fieldType, handler) + p, err := New(fieldType, "", registry) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -142,7 +176,12 @@ func TestCustomParserAndValidators(t *testing.T) { return complex(1, 2), nil } fieldType := reflect.TypeOf(complex(0, 0)) - p, err := New(fieldType, "", customParser, nil) + registry := NewDefaultTypeRegistry() + registry.RegisterType(fieldType, NewCustomHandler(func(s string) (complex128, error) { + v, err := customParser(s) + return v.(complex128), err + })) + p, err := New(fieldType, "", registry) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -155,4 +194,103 @@ func TestCustomParserAndValidators(t *testing.T) { t.Errorf("Expected complex(1, 2), got %v", value) } }) + + t.Run("ReplaceParser and PrependValidators", func(t *testing.T) { + baseHandler := NewTypedIntHandler(64) + + t.Run("Parser override via ReplaceParser", func(t *testing.T) { + // Replace the parser with one that always returns 42 + decorated, err := ReplaceParser(baseHandler, func(s string) (int64, error) { + return 42, nil + }) + if err != nil { + t.Fatalf("ReplaceParser failed: %v", err) + } + + p, err := decorated.Build("") + if err != nil { + t.Fatalf("Build failed: %v", err) + } + val, err := p("any value") + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if val.(int64) != 42 { + t.Errorf("Expected 42, got %v", val) + } + }) + + t.Run("Validator prepending via PrependValidators", func(t *testing.T) { + // Base has range validation (via NewTypedIntHandler) + // Prepend a check for even numbers + decorated, err := PrependValidators(baseHandler, func(v int64) error { + if v%2 != 0 { + return errors.New("must be even") + } + return nil + }) + if err != nil { + t.Fatalf("PrependValidators failed: %v", err) + } + + // tags with min=10 + tags := reflect.StructTag(`min:"10"`) + p, err := decorated.Build(tags) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + tests := []struct { + input string + wantErr string + }{ + {"12", ""}, // Pass: >= 10 and even + {"11", "must be even"}, // Fail: >= 10 but odd (prepended validator fails) + {"8", "below mininum 10"}, // Fail: < 10 (base validator fails) + } + + for _, tt := range tests { + _, err := p(tt.input) + if tt.wantErr == "" { + if err != nil { + t.Errorf("input %s: unexpected error %v", tt.input, err) + } + } else { + if err == nil { + t.Errorf("input %s: expected error %q, got nil", tt.input, tt.wantErr) + } else if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("input %s: expected error to contain %q, got %q", tt.input, tt.wantErr, err.Error()) + } + } + } + }) + + t.Run("Multiple prepended validators", func(t *testing.T) { + // Prepend "must be even" + handler1, _ := PrependValidators(baseHandler, func(v int64) error { + if v%2 != 0 { + return errors.New("must be even") + } + return nil + }) + // Prepend "must be positive" + handler2, _ := PrependValidators(handler1, func(v int64) error { + if v <= 0 { + return errors.New("must be positive") + } + return nil + }) + + p, _ := handler2.Build("") + if _, err := p("-2"); err == nil || !strings.Contains(err.Error(), "must be positive") { + t.Errorf("expected positive error, got %v", err) + } + if _, err := p("3"); err == nil || !strings.Contains(err.Error(), "must be even") { + t.Errorf("expected even error, got %v", err) + } + if v, err := p("4"); err != nil || v.(int64) != 4 { + t.Errorf("expected 4, got %v (err: %v)", v, err) + } + }) + }) } diff --git a/process/duration.go b/process/duration.go index 37ce043..4467cbb 100644 --- a/process/duration.go +++ b/process/duration.go @@ -2,9 +2,14 @@ package process import "time" -var durationTypeHandler = TypeHandler[time.Duration]{ - Parser: func(rawValue string) (time.Duration, error) { - return time.ParseDuration(rawValue) - }, - ValidationWrapper: WrapProcessUsingRangeTags[time.Duration], +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/process/duration_test.go b/process/duration_test.go index da3bb31..3cbc486 100644 --- a/process/duration_test.go +++ b/process/duration_test.go @@ -43,9 +43,10 @@ func TestDurationTypes(t *testing.T) { }, } + registry := NewDefaultTypeRegistry() 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/process/invalid_types_test.go b/process/invalid_types_test.go index 45dc0ac..dea781e 100644 --- a/process/invalid_types_test.go +++ b/process/invalid_types_test.go @@ -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 := NewDefaultTypeRegistry() 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/process/json_types.go b/process/json_types.go index 7a15862..db77c3f 100644 --- a/process/json_types.go +++ b/process/json_types.go @@ -5,8 +5,8 @@ import ( "reflect" ) -func NewJsonHandler(targetType reflect.Type) Handler { - return TypeHandler[any]{ +func NewJsonHandler(targetType reflect.Type) PipelineBuilder { + return typeHandlerImpl[any]{ Parser: func(rawValue string) (any, error) { ptr := reflect.New(targetType).Interface() err := json.Unmarshal([]byte(rawValue), ptr) diff --git a/process/json_types_test.go b/process/json_types_test.go index 1802f9a..dd03fef 100644 --- a/process/json_types_test.go +++ b/process/json_types_test.go @@ -39,9 +39,10 @@ func TestJsonTypes(t *testing.T) { }, } + registry := NewDefaultTypeRegistry() 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/process/number_types.go b/process/number_types.go index 653a049..b4d5bf6 100644 --- a/process/number_types.go +++ b/process/number_types.go @@ -5,29 +5,43 @@ import ( "strconv" ) -func NewIntHandler(fieldType reflect.Type) Handler { - return TypeHandler[int64]{ +func NewIntHandler(fieldType reflect.Type) PipelineBuilder { + return NewTypedIntHandler(fieldType.Bits()) +} + +func NewUintHandler(fieldType reflect.Type) PipelineBuilder { + return NewTypedUintHandler(fieldType.Bits()) +} + +func NewFloatHandler(fieldType reflect.Type) PipelineBuilder { + 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) { - // Use base 0 to allow input like 0xFF - return strconv.ParseInt(rawValue, 0, fieldType.Bits()) + return strconv.ParseInt(rawValue, 0, bits) }, ValidationWrapper: WrapProcessUsingRangeTags[int64], } } -func NewUintHandler(fieldType reflect.Type) Handler { - return TypeHandler[uint64]{ +// 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, fieldType.Bits()) + return strconv.ParseUint(rawValue, 0, bits) }, ValidationWrapper: WrapProcessUsingRangeTags[uint64], } } -func NewFloatHandler(fieldType reflect.Type) Handler { - return TypeHandler[float64]{ +// 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, fieldType.Bits()) + return strconv.ParseFloat(rawValue, bits) }, ValidationWrapper: WrapProcessUsingRangeTags[float64], } diff --git a/process/number_types_test.go b/process/number_types_test.go index 93d78de..0e4b5a2 100644 --- a/process/number_types_test.go +++ b/process/number_types_test.go @@ -109,8 +109,9 @@ func TestIntTypes(t *testing.T) { }, } + registry := NewDefaultTypeRegistry() 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 := NewDefaultTypeRegistry() 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 := NewDefaultTypeRegistry() 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/process/pointer_types_test.go b/process/pointer_types_test.go index 9cb8f2c..5eaa779 100644 --- a/process/pointer_types_test.go +++ b/process/pointer_types_test.go @@ -6,12 +6,13 @@ import ( ) func TestPointerTypes(t *testing.T) { + registry := NewDefaultTypeRegistry() 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) + processor, err := New(fieldType, tags, registry) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -37,7 +38,7 @@ func TestPointerTypes(t *testing.T) { fieldType := reflect.TypeOf(s) tags := reflect.StructTag(`pattern:"^abc.*$"`) - processor, err := New(fieldType, tags, nil, nil) + processor, err := New(fieldType, tags, registry) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -65,7 +66,7 @@ func TestPointerTypes(t *testing.T) { var ms *MyStruct fieldType := reflect.TypeOf(ms) - processor, err := New(fieldType, "", nil, nil) + processor, err := New(fieldType, "", registry) if err != nil { t.Fatalf("Failed to create processor: %v", err) } @@ -89,12 +90,15 @@ func TestPointerTypes(t *testing.T) { var p *Point fieldType := reflect.TypeOf(p) - customParser := func(rawValue string) (any, error) { + customParser := func(rawValue string) (Point, error) { // Dummy parser for "1,2" return Point{X: 1, Y: 2}, nil } - processor, err := New(fieldType, "", customParser, nil) + registry := NewDefaultTypeRegistry() + registry.RegisterType(reflect.TypeOf(Point{}), NewCustomHandler(customParser)) + + processor, err := New(fieldType, "", registry) if err != nil { t.Fatalf("Failed to create processor: %v", err) } diff --git a/process/process.go b/process/process.go index ccf8435..6bd3ce5 100644 --- a/process/process.go +++ b/process/process.go @@ -24,11 +24,12 @@ func New(fieldType reflect.Type, tags reflect.StructTag, registry *TypeRegistry) return nil, fmt.Errorf("no handler for type %s", targetType) } - // This remains two distinct steps so that the Custom Types Decorator can intercept the process - pipeline := handler.GetParser() + 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 handler.AddValidatorsToPipeline(tags, pipeline) + return pipeline, nil } diff --git a/process/string_types.go b/process/string_types.go index 16dca89..78a6ca1 100644 --- a/process/string_types.go +++ b/process/string_types.go @@ -4,11 +4,16 @@ import ( "reflect" ) -// NewStringHandler returns a Handler that simply returns the raw value. +// NewStringHandler returns a PipelineBuilder 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) { +func NewStringHandler(_ reflect.Type) PipelineBuilder { + 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/process/string_types_test.go b/process/string_types_test.go index 77df058..a597b16 100644 --- a/process/string_types_test.go +++ b/process/string_types_test.go @@ -85,9 +85,10 @@ func TestStringTypes(t *testing.T) { }, } + registry := NewDefaultTypeRegistry() 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/process/typed_handler.go b/process/typed_handler.go new file mode 100644 index 0000000..4f556a4 --- /dev/null +++ b/process/typed_handler.go @@ -0,0 +1,95 @@ +package process + +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 process + Parser FieldProcessor[T] + // ValidationWrapper is a factory that wraps the FieldProcessor with validation stages + ValidationWrapper Wrapper[T] +} + +func (h typeHandlerImpl[T]) GetParser() FieldProcessor[T] { + return h.Parser +} + +func (h typeHandlerImpl[T]) GetWrapper() Wrapper[T] { + return h.ValidationWrapper +} + +func (h typeHandlerImpl[T]) Build(tags reflect.StructTag) (FieldProcessor[any], error) { + pipeline := h.GetParser() + if pipeline == nil { + return nil, nil // Return nil if no parser is provided (modification handler) + } + + wrapper := h.GetWrapper() + if wrapper != nil { + var err error + pipeline, err = wrapper(tags, pipeline) + if err != nil { + return nil, err + } + } + return typedToUntypedPipeline(pipeline), nil +} + +// typedToUntypedPipeline converts from the strongly typed world of the typeHandlerImpl to the weak type world of the process pipeline. +func typedToUntypedPipeline[T any](parser FieldProcessor[T]) FieldProcessor[any] { + if parser == nil { + return nil + } + return func(rawValue string) (any, error) { + return parser(rawValue) + } +} + +// 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) { + value, err := processor(rawValue) + if err != nil { + return value, err + } + + if err := validator(value); err != nil { + return value, err + } + + return value, nil + } +} + +// PipeMultiple combines a processor and a slice of Validators, adding validation to the processor +// This creates a single validator that runs all the other validators to reduce stack depth +func PipeMultiple[T any](processor FieldProcessor[T], validators []Validator[T]) FieldProcessor[T] { + if len(validators) == 0 { + return processor + } + // Create a single validator that runs all the other validators to reduce stack depth and closure debugging issues + return Pipe(processor, func(value T) error { + for _, validator := range validators { + if err := validator(value); err != nil { + return err + } + } + return nil + }) +} + +// 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 + for _, wrapper := range wrappers { + var err error + wrapped, err = wrapper(tags, wrapped) + if err != nil { + return nil, err + } + } + return wrapped, nil + } +} diff --git a/process/typeregistry.go b/process/typeregistry.go index d2bf639..dca54d3 100644 --- a/process/typeregistry.go +++ b/process/typeregistry.go @@ -5,22 +5,22 @@ import ( "time" ) -// HandlerFactory is a function that returns a Handler for a given type. -type HandlerFactory func(t reflect.Type) Handler +// HandlerFactory is a function that returns a PipelineBuilder for a given type. +type HandlerFactory func(t reflect.Type) PipelineBuilder // TypeRegistry 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 TypeRegistry struct { - specialTypeHandlers map[reflect.Type]Handler + specialTypeHandlers map[reflect.Type]PipelineBuilder kindHandlers map[reflect.Kind]HandlerFactory } // NewDefaultTypeRegistry creates a new TypeRegistry with the default handlers. func NewDefaultTypeRegistry() *TypeRegistry { return &TypeRegistry{ - specialTypeHandlers: map[reflect.Type]Handler{ + specialTypeHandlers: map[reflect.Type]PipelineBuilder{ reflect.TypeOf(time.Duration(0)): durationTypeHandler, }, kindHandlers: map[reflect.Kind]HandlerFactory{ @@ -45,17 +45,17 @@ func NewDefaultTypeRegistry() *TypeRegistry { } // RegisterKind registers a factory function for a given kind. -func (r *TypeRegistry) RegisterKind(kind reflect.Kind, factory func(t reflect.Type) Handler) { +func (r *TypeRegistry) RegisterKind(kind reflect.Kind, factory func(t reflect.Type) PipelineBuilder) { r.kindHandlers[kind] = factory } -// RegisterType registers a custom Handler for a given type. -func (r *TypeRegistry) RegisterType(t reflect.Type, handler Handler) { +// RegisterType registers a custom PipelineBuilder for a given type. +func (r *TypeRegistry) RegisterType(t reflect.Type, handler PipelineBuilder) { r.specialTypeHandlers[t] = handler } -// HandlerFor returns the Handler for the given type, or nil if none is registered. -func (r *TypeRegistry) HandlerFor(t reflect.Type) Handler { +// HandlerFor returns the PipelineBuilder for the given type, or nil if none is registered. +func (r *TypeRegistry) HandlerFor(t reflect.Type) PipelineBuilder { // 1. Check for specific type overrides (The "Duration" check) if p, ok := r.specialTypeHandlers[t]; ok { return p diff --git a/process/types.go b/process/types.go index d9d090f..692542e 100644 --- a/process/types.go +++ b/process/types.go @@ -1,8 +1,6 @@ package process -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,103 +13,24 @@ type FieldProcessor[T any] func(rawValue string) (T, error) // at the last minute (before assignment) type Validator[T any] func(value T) error -// 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 is the strongly typed version of the FieldProcessor that acts as input for this process - Parser FieldProcessor[T] - // ValidationWrapper is a factory that wraps the FieldProcessor with validation stages - ValidationWrapper Wrapper[T] +// TypedHandler is the strongly typed version of the PipelineBuilder interface. +type TypedHandler[T any] interface { + // GetParser returns a FieldProcessor[T] that is used to read the raw value and start the read pipeline. + // It can return nil if this handler doesn't provide a parser (e.g. it's a modification). + GetParser() FieldProcessor[T] + // GetWrapper returns a Wrapper[T] that adds validators to the pipeline based on tags. + // It can return nil if no validation is needed. + GetWrapper() Wrapper[T] + // Build creates the final FieldProcessor[any] for the given tags. + // This causes any TypedHandler to implement the untyped PipelineBuilder interface. + Build(tags reflect.StructTag) (FieldProcessor[any], error) } -// 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) { - if h.ValidationWrapper == nil { - return p, nil - } - - // 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 -} - -// 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) { - value, err := processor(rawValue) - if err != nil { - return value, err - } - - if err := validator(value); err != nil { - return value, err - } - - return value, nil - } -} - -// PipeMultiple combines a processor and a slice of Validators, adding validation to the processor -// This creates a single validator that runs all the other validators to reduce stack depth -func PipeMultiple[T any](processor FieldProcessor[T], validators []Validator[T]) FieldProcessor[T] { - if len(validators) == 0 { - return processor - } - // Create a single validator that runs all the other validators to reduce stack depth and closure debugging issues - return Pipe(processor, func(value T) error { - for _, validator := range validators { - if err := validator(value); err != nil { - return err - } - } - return nil - }) +// 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) - -// 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 - for _, wrapper := range wrappers { - var err error - wrapped, err = wrapper(tags, wrapped) - if err != nil { - return nil, err - } - } - return wrapped, nil - } -} From 07dbcad3d84b027a6af5bea86893da75dee272d1 Mon Sep 17 00:00:00 2001 From: Richard Corfield Date: Mon, 22 Dec 2025 23:22:06 +0000 Subject: [PATCH 03/10] Moved the pipeline code back to internal and exposed only parts of it --- config.go | 6 +- config_test.go | 14 ++-- custom_types.go | 72 +++++++++++++++++++ custom_types_test.go | 32 ++++----- example/validation/main.go | 7 +- .../readpipeline}/boolean_types.go | 2 +- .../readpipeline}/boolean_types_test.go | 2 +- .../readpipeline}/custom_types.go | 16 ++++- .../readpipeline}/custom_types_test.go | 2 +- .../readpipeline}/duration.go | 2 +- .../readpipeline}/duration_test.go | 2 +- .../readpipeline}/invalid_types_test.go | 2 +- .../readpipeline}/json_types.go | 4 +- .../readpipeline}/json_types_test.go | 2 +- .../readpipeline}/number_types.go | 2 +- .../readpipeline}/number_types_test.go | 2 +- .../readpipeline}/ordered_validators.go | 4 +- .../readpipeline}/pattern_validator.go | 2 +- .../readpipeline}/pattern_validator_test.go | 2 +- .../readpipeline}/pointer_types_test.go | 2 +- {process => internal/readpipeline}/process.go | 6 +- .../readpipeline}/string_types.go | 2 +- .../readpipeline}/string_types_test.go | 2 +- .../readpipeline}/typed_handler.go | 6 +- .../readpipeline}/typeregistry.go | 2 +- {process => internal/readpipeline}/types.go | 2 +- loadoptions.go | 8 +-- validation.go | 50 ------------- 28 files changed, 143 insertions(+), 114 deletions(-) create mode 100644 custom_types.go rename {process => internal/readpipeline}/boolean_types.go (96%) rename {process => internal/readpipeline}/boolean_types_test.go (98%) rename {process => internal/readpipeline}/custom_types.go (90%) rename {process => internal/readpipeline}/custom_types_test.go (99%) rename {process => internal/readpipeline}/duration.go (95%) rename {process => internal/readpipeline}/duration_test.go (98%) rename {process => internal/readpipeline}/invalid_types_test.go (97%) rename {process => internal/readpipeline}/json_types.go (90%) rename {process => internal/readpipeline}/json_types_test.go (98%) rename {process => internal/readpipeline}/number_types.go (98%) rename {process => internal/readpipeline}/number_types_test.go (99%) rename {process => internal/readpipeline}/ordered_validators.go (97%) rename {process => internal/readpipeline}/pattern_validator.go (96%) rename {process => internal/readpipeline}/pattern_validator_test.go (98%) rename {process => internal/readpipeline}/pointer_types_test.go (99%) rename {process => internal/readpipeline}/process.go (90%) rename {process => internal/readpipeline}/string_types.go (96%) rename {process => internal/readpipeline}/string_types_test.go (99%) rename {process => internal/readpipeline}/typed_handler.go (95%) rename {process => internal/readpipeline}/typeregistry.go (99%) rename {process => internal/readpipeline}/types.go (98%) delete mode 100644 validation.go diff --git a/config.go b/config.go index 8d980b6..324cb06 100644 --- a/config.go +++ b/config.go @@ -5,7 +5,7 @@ import ( "fmt" "reflect" - "github.com/m0rjc/goconfig/process" + "github.com/m0rjc/goconfig/internal/readpipeline" ) // Load populates the given configuration struct from environment variables @@ -136,9 +136,9 @@ func loadStruct(ctx context.Context, v reflect.Value, fieldPath string, opts *lo } // Configure the processor, then run it - processor, err := process.New(fieldType.Type, fieldType.Tag, opts.typeRegistry) + 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 3c4166f..94722d1 100644 --- a/config_test.go +++ b/config_test.go @@ -7,8 +7,6 @@ import ( "strconv" "strings" "testing" - - "github.com/m0rjc/goconfig/process" ) func TestLoad_Basic(t *testing.T) { @@ -220,7 +218,7 @@ func TestLoad_Options(t *testing.T) { } var cfg Config // Custom parser for the custom Port type - handler := process.NewCustomHandler(func(rawValue string) (Port, error) { + handler := NewCustomHandler(func(rawValue string) (Port, error) { return Port(9000), nil }) @@ -239,7 +237,7 @@ func TestLoad_Options(t *testing.T) { Port Port `key:"PORT"` } var cfg Config - handler := process.NewCustomHandler(func(rawValue string) (Port, error) { + handler := NewCustomHandler(func(rawValue string) (Port, error) { v, err := strconv.Atoi(rawValue) return Port(v), err }, func(value Port) error { @@ -278,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"` } @@ -290,7 +288,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 Chan") { + if !strings.Contains(err.Error(), "setting up field readpipeline Chan") { t.Errorf("Expected setup error, got: %v", err) } }) @@ -307,7 +305,7 @@ func TestLoad_Errors(t *testing.T) { } var customCfg CustomConfig - failingHandler := process.NewCustomHandler(func(rawValue string) (CustomPort, error) { + failingHandler := NewCustomHandler(func(rawValue string) (CustomPort, error) { return 0, errors.New("factory failure") }) @@ -362,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..08a819c --- /dev/null +++ b/custom_types.go @@ -0,0 +1,72 @@ +package goconfig + +import ( + "reflect" + "time" + + "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] interface { + readpipeline.TypedHandler[T] +} + +func NewCustomHandler[T any](customParser FieldProcessor[T], customValidators ...Validator[T]) TypedHandler[T] { + return readpipeline.NewCustomHandler(customParser, customValidators...) +} + +func NewEnumHandler[T ~string](validValues ...T) TypedHandler[T] { + return readpipeline.NewEnumHandler(validValues...) +} + +func ReplaceParser[B, T any](baseHandler TypedHandler[B], customParser FieldProcessor[T]) (TypedHandler[T], error) { + return readpipeline.ReplaceParser(baseHandler, customParser) +} + +func PrependValidators[B, T any](baseHandler TypedHandler[B], customValidators ...Validator[T]) (TypedHandler[T], error) { + return readpipeline.PrependValidators(baseHandler, customValidators...) +} + +func NewTypedStringHandler() TypedHandler[string] { + return readpipeline.NewTypedStringHandler() +} + +func NewTypedIntHandler[T ~int | ~int8 | ~int16 | ~int32 | ~int64]() TypedHandler[T] { + t := reflect.TypeOf(T(0)) + cast, err := readpipeline.CastHandler[int64, T](readpipeline.NewTypedIntHandler(t.Bits())) + if err != nil { + // Should never happen + panic(err) + } + return cast +} + +func NewTypedUintHandler[T ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64]() TypedHandler[T] { + t := reflect.TypeOf(T(0)) + cast, err := readpipeline.CastHandler[uint64, T](readpipeline.NewTypedUintHandler(t.Bits())) + if err != nil { + // Should never happen + panic(err) + } + return cast +} + +func NewTypedFloatHandler[T ~float32 | ~float64]() TypedHandler[T] { + t := reflect.TypeOf(T(0)) + cast, err := readpipeline.CastHandler[float64, T](readpipeline.NewTypedFloatHandler(t.Bits())) + if err != nil { + // Should never happen + panic(err) + } + return cast +} + +func NewTypedDurationHandler() TypedHandler[time.Duration] { + return readpipeline.NewTypedDurationHandler() +} diff --git a/custom_types_test.go b/custom_types_test.go index a95cd6c..b3fde02 100644 --- a/custom_types_test.go +++ b/custom_types_test.go @@ -3,10 +3,8 @@ package goconfig import ( "context" "errors" - "reflect" + "strings" "testing" - - "github.com/m0rjc/goconfig/process" ) // Explore what can be done with custom types @@ -24,7 +22,7 @@ func TestLoad_WithCustomTypes(t *testing.T) { mockParser := func(value string) (CustomStruct, error) { return CustomStruct{Field1: value}, nil } - mockHandler := process.NewCustomHandler[CustomStruct](mockParser) + mockHandler := NewCustomHandler[CustomStruct](mockParser) t.Run("struct as value", func(t *testing.T) { type Config struct { @@ -83,7 +81,7 @@ func TestLoad_WithCustomTypes(t *testing.T) { return "", false, nil } expectedError := errors.New("expected CustomEnum") - mockHandler := process.NewCustomHandler(func(value string) (CustomEnum, error) { + mockHandler := NewCustomHandler(func(value string) (CustomEnum, error) { return CustomEnum(value), nil }, func(value CustomEnum) error { if value != CustomEnum1 && value != CustomEnum2 { @@ -174,19 +172,20 @@ func TestLoad_WithCustomTypes(t *testing.T) { // Modification: must be even t.Run("Adding validator to int", func(t *testing.T) { // Reuse the standard int handler logic and add a validator - base := process.NewTypedIntHandler(reflect.TypeOf(int(0)).Bits()) - mod := process.NewCustomHandler[int](func(s string) (int, error) { - v, err := base.GetParser()(s) - return int(v), err - }, func(v int) error { + base := NewTypedIntHandler[int]() + + mod, err := PrependValidators(base, func(v int) error { if v%2 != 0 { return errors.New("must be even") } return nil }) + if err != nil { + t.Fatalf("Failed to create modified handler: %v", err) + } var cfg Config - err := Load(context.Background(), &cfg, + err = Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[int](mod)) @@ -204,14 +203,11 @@ func TestLoad_WithCustomTypes(t *testing.T) { err = Load(context.Background(), &cfg, WithKeyStore(mockStoreOdd), WithCustomType[int](mod)) - if err == nil || !reflect.TypeOf(err).AssignableTo(reflect.TypeOf(&ConfigErrors{})) { - t.Fatalf("Expected ConfigErrors, got %v", err) + if err == nil { + t.Fatal("Expected error") } - if !errors.Is(err, errors.New("must be even")) { - // ConfigErrors.Error() contains the string - if !reflect.ValueOf(err).MethodByName("HasErrors").Call(nil)[0].Bool() { - t.Fatal("Expected errors") - } + if !strings.Contains(err.Error(), "must be even") { + t.Errorf("Expected error to contain 'must be even', got %v", err) } }) }) diff --git a/example/validation/main.go b/example/validation/main.go index c05b680..97f79dd 100644 --- a/example/validation/main.go +++ b/example/validation/main.go @@ -11,7 +11,6 @@ import ( "time" "github.com/m0rjc/goconfig" - "github.com/m0rjc/goconfig/process" ) type APIKey string @@ -97,7 +96,7 @@ 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.WithCustomType[APIKey](process.NewCustomHandler( + goconfig.WithCustomType[APIKey](goconfig.NewCustomHandler( func(rawValue string) (APIKey, error) { return APIKey(rawValue), nil }, func(value APIKey) error { key := string(value) @@ -111,7 +110,7 @@ func main() { })), // Validate API endpoint is a valid URL with https - goconfig.WithCustomType[APIEndpoint](process.NewCustomHandler( + goconfig.WithCustomType[APIEndpoint](goconfig.NewCustomHandler( func(rawValue string) (APIEndpoint, error) { return APIEndpoint(rawValue), nil }, func(value APIEndpoint) error { endpoint := string(value) @@ -122,7 +121,7 @@ func main() { })), // Validate database host is not a loopback address in production - goconfig.WithCustomType[DatabaseHost](process.NewCustomHandler( + goconfig.WithCustomType[DatabaseHost](goconfig.NewCustomHandler( func(rawValue string) (DatabaseHost, error) { return DatabaseHost(rawValue), nil }, func(value DatabaseHost) error { host := string(value) diff --git a/process/boolean_types.go b/internal/readpipeline/boolean_types.go similarity index 96% rename from process/boolean_types.go rename to internal/readpipeline/boolean_types.go index e84aaf9..da98c85 100644 --- a/process/boolean_types.go +++ b/internal/readpipeline/boolean_types.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" diff --git a/process/boolean_types_test.go b/internal/readpipeline/boolean_types_test.go similarity index 98% rename from process/boolean_types_test.go rename to internal/readpipeline/boolean_types_test.go index 12362a9..edc6bdd 100644 --- a/process/boolean_types_test.go +++ b/internal/readpipeline/boolean_types_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" diff --git a/process/custom_types.go b/internal/readpipeline/custom_types.go similarity index 90% rename from process/custom_types.go rename to internal/readpipeline/custom_types.go index 913f6c5..d22a101 100644 --- a/process/custom_types.go +++ b/internal/readpipeline/custom_types.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "fmt" @@ -56,6 +56,20 @@ func PrependValidators[B, T any](baseHandler TypedHandler[B], customValidators . }, nil } +func CastHandler[B, T any](handler TypedHandler[B]) (TypedHandler[T], error) { + parser, err := castPipeline[B, T](handler.GetParser()) + if err != nil { + return nil, err + } + + wrapper := castWrapper[B, T](handler.GetWrapper()) + + return typeHandlerImpl[T]{ + Parser: parser, + ValidationWrapper: wrapper, + }, nil +} + func castPipeline[B, T any](parser FieldProcessor[B]) (FieldProcessor[T], error) { if parser == nil { return nil, nil diff --git a/process/custom_types_test.go b/internal/readpipeline/custom_types_test.go similarity index 99% rename from process/custom_types_test.go rename to internal/readpipeline/custom_types_test.go index e4f5d89..c3db11a 100644 --- a/process/custom_types_test.go +++ b/internal/readpipeline/custom_types_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "errors" diff --git a/process/duration.go b/internal/readpipeline/duration.go similarity index 95% rename from process/duration.go rename to internal/readpipeline/duration.go index 4467cbb..bd2f77c 100644 --- a/process/duration.go +++ b/internal/readpipeline/duration.go @@ -1,4 +1,4 @@ -package process +package readpipeline import "time" diff --git a/process/duration_test.go b/internal/readpipeline/duration_test.go similarity index 98% rename from process/duration_test.go rename to internal/readpipeline/duration_test.go index 3cbc486..133c451 100644 --- a/process/duration_test.go +++ b/internal/readpipeline/duration_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" diff --git a/process/invalid_types_test.go b/internal/readpipeline/invalid_types_test.go similarity index 97% rename from process/invalid_types_test.go rename to internal/readpipeline/invalid_types_test.go index dea781e..03c3ffc 100644 --- a/process/invalid_types_test.go +++ b/internal/readpipeline/invalid_types_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" diff --git a/process/json_types.go b/internal/readpipeline/json_types.go similarity index 90% rename from process/json_types.go rename to internal/readpipeline/json_types.go index db77c3f..636aaa6 100644 --- a/process/json_types.go +++ b/internal/readpipeline/json_types.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "encoding/json" @@ -15,7 +15,7 @@ func NewJsonHandler(targetType reflect.Type) PipelineBuilder { 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/process/json_types_test.go b/internal/readpipeline/json_types_test.go similarity index 98% rename from process/json_types_test.go rename to internal/readpipeline/json_types_test.go index dd03fef..7190fd8 100644 --- a/process/json_types_test.go +++ b/internal/readpipeline/json_types_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" diff --git a/process/number_types.go b/internal/readpipeline/number_types.go similarity index 98% rename from process/number_types.go rename to internal/readpipeline/number_types.go index b4d5bf6..3afce91 100644 --- a/process/number_types.go +++ b/internal/readpipeline/number_types.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" diff --git a/process/number_types_test.go b/internal/readpipeline/number_types_test.go similarity index 99% rename from process/number_types_test.go rename to internal/readpipeline/number_types_test.go index 0e4b5a2..588f310 100644 --- a/process/number_types_test.go +++ b/internal/readpipeline/number_types_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" diff --git a/process/ordered_validators.go b/internal/readpipeline/ordered_validators.go similarity index 97% rename from process/ordered_validators.go rename to internal/readpipeline/ordered_validators.go index 4c1ff85..a7e0d6f 100644 --- a/process/ordered_validators.go +++ b/internal/readpipeline/ordered_validators.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "cmp" @@ -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/process/pattern_validator.go b/internal/readpipeline/pattern_validator.go similarity index 96% rename from process/pattern_validator.go rename to internal/readpipeline/pattern_validator.go index c6a4f70..74c7330 100644 --- a/process/pattern_validator.go +++ b/internal/readpipeline/pattern_validator.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "fmt" diff --git a/process/pattern_validator_test.go b/internal/readpipeline/pattern_validator_test.go similarity index 98% rename from process/pattern_validator_test.go rename to internal/readpipeline/pattern_validator_test.go index b7f14a4..9f375c1 100644 --- a/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/process/pointer_types_test.go b/internal/readpipeline/pointer_types_test.go similarity index 99% rename from process/pointer_types_test.go rename to internal/readpipeline/pointer_types_test.go index 5eaa779..feba5bd 100644 --- a/process/pointer_types_test.go +++ b/internal/readpipeline/pointer_types_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" diff --git a/process/process.go b/internal/readpipeline/process.go similarity index 90% rename from process/process.go rename to internal/readpipeline/process.go index 6bd3ce5..b6c1884 100644 --- a/process/process.go +++ b/internal/readpipeline/process.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "fmt" @@ -7,14 +7,14 @@ import ( // 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 process chain is the value. +// 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 process + // Pointer writing is handled by the setFieldValue side of the readpipeline // in config.go targetType = targetType.Elem() } diff --git a/process/string_types.go b/internal/readpipeline/string_types.go similarity index 96% rename from process/string_types.go rename to internal/readpipeline/string_types.go index 78a6ca1..830331d 100644 --- a/process/string_types.go +++ b/internal/readpipeline/string_types.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" diff --git a/process/string_types_test.go b/internal/readpipeline/string_types_test.go similarity index 99% rename from process/string_types_test.go rename to internal/readpipeline/string_types_test.go index a597b16..59ea128 100644 --- a/process/string_types_test.go +++ b/internal/readpipeline/string_types_test.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" diff --git a/process/typed_handler.go b/internal/readpipeline/typed_handler.go similarity index 95% rename from process/typed_handler.go rename to internal/readpipeline/typed_handler.go index 4f556a4..ee5cf58 100644 --- a/process/typed_handler.go +++ b/internal/readpipeline/typed_handler.go @@ -1,11 +1,11 @@ -package process +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 process + // 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] @@ -36,7 +36,7 @@ func (h typeHandlerImpl[T]) Build(tags reflect.StructTag) (FieldProcessor[any], return typedToUntypedPipeline(pipeline), nil } -// typedToUntypedPipeline converts from the strongly typed world of the typeHandlerImpl to the weak type world of the process pipeline. +// typedToUntypedPipeline converts from the strongly typed world of the typeHandlerImpl to the weak type world of the readpipeline pipeline. func typedToUntypedPipeline[T any](parser FieldProcessor[T]) FieldProcessor[any] { if parser == nil { return nil diff --git a/process/typeregistry.go b/internal/readpipeline/typeregistry.go similarity index 99% rename from process/typeregistry.go rename to internal/readpipeline/typeregistry.go index dca54d3..f2561a4 100644 --- a/process/typeregistry.go +++ b/internal/readpipeline/typeregistry.go @@ -1,4 +1,4 @@ -package process +package readpipeline import ( "reflect" diff --git a/process/types.go b/internal/readpipeline/types.go similarity index 98% rename from process/types.go rename to internal/readpipeline/types.go index 692542e..7de957d 100644 --- a/process/types.go +++ b/internal/readpipeline/types.go @@ -1,4 +1,4 @@ -package process +package readpipeline import "reflect" diff --git a/loadoptions.go b/loadoptions.go index e839e7a..babbf8a 100644 --- a/loadoptions.go +++ b/loadoptions.go @@ -3,7 +3,7 @@ package goconfig import ( "reflect" - "github.com/m0rjc/goconfig/process" + "github.com/m0rjc/goconfig/internal/readpipeline" ) // Option is a functional option for configuring the Load function. @@ -18,7 +18,7 @@ func WithKeyStore(keyStore KeyStore) Option { } // WithCustomType registers a custom type handler for a given type. -func WithCustomType[T any](handler process.TypedHandler[T]) Option { +func WithCustomType[T any](handler TypedHandler[T]) Option { var typedNil *T t := reflect.TypeOf(typedNil).Elem() @@ -32,14 +32,14 @@ type loadOptions struct { // keyStore reads the values. Default to os.GetEnv() keyStore KeyStore // typeRegistry holds the handlers for specific types - typeRegistry *process.TypeRegistry + typeRegistry *readpipeline.TypeRegistry } // newLoadOptions creates default load options. func newLoadOptions() *loadOptions { return &loadOptions{ keyStore: EnvironmentKeyStore, - typeRegistry: process.NewDefaultTypeRegistry(), + typeRegistry: readpipeline.NewDefaultTypeRegistry(), } } diff --git a/validation.go b/validation.go deleted file mode 100644 index c9712c8..0000000 --- a/validation.go +++ /dev/null @@ -1,50 +0,0 @@ -package goconfig - -import ( - "reflect" - - "github.com/m0rjc/goconfig/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] From 8bdc7b3fc09717de8fbc3f8815f177dabb0e8030 Mon Sep 17 00:00:00 2001 From: Richard Corfield Date: Mon, 22 Dec 2025 23:33:36 +0000 Subject: [PATCH 04/10] Added tests for the exposed custom type support --- custom_types_test.go | 189 ++++++++++++++++++++ internal/readpipeline/custom_types_test.go | 2 +- internal/readpipeline/ordered_validators.go | 4 +- 3 files changed, 192 insertions(+), 3 deletions(-) diff --git a/custom_types_test.go b/custom_types_test.go index b3fde02..ad86d7b 100644 --- a/custom_types_test.go +++ b/custom_types_test.go @@ -3,10 +3,24 @@ package goconfig import ( "context" "errors" + "reflect" + "strconv" "strings" "testing" + "time" ) +// NonConvertibleHandler is a TypedHandler[string] that returns an int from Build +type NonConvertibleHandler struct{} + +func (n NonConvertibleHandler) GetParser() FieldProcessor[string] { return nil } +func (n NonConvertibleHandler) GetWrapper() Wrapper[string] { return nil } +func (n NonConvertibleHandler) Build(tags reflect.StructTag) (FieldProcessor[any], error) { + return func(rawValue string) (any, error) { + return struct{ A int }{A: 123}, nil // Returns a struct instead of string + }, nil +} + // Explore what can be done with custom types func TestLoad_WithCustomTypes(t *testing.T) { t.Run("A type handler can be registered for a custom struct", func(t *testing.T) { @@ -210,5 +224,180 @@ func TestLoad_WithCustomTypes(t *testing.T) { t.Errorf("Expected error to contain 'must be even', got %v", err) } }) + t.Run("NewEnumHandler provides validation for string enums", func(t *testing.T) { + type MyEnum string + const ( + ValA MyEnum = "A" + ValB MyEnum = "B" + ) + handler := NewEnumHandler(ValA, ValB) + + type Config struct { + Value MyEnum `key:"ENUM"` + } + + t.Run("Valid value", func(t *testing.T) { + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "A", true, nil + } + var cfg Config + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[MyEnum](handler)) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.Value != ValA { + t.Errorf("Expected A, got %s", cfg.Value) + } + }) + + t.Run("Invalid value", func(t *testing.T) { + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "C", true, nil + } + var cfg Config + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[MyEnum](handler)) + if err == nil { + t.Fatal("Expected error") + } + if !strings.Contains(err.Error(), "invalid value: C") { + t.Errorf("Expected error message to contain 'invalid value: C', got %v", err) + } + }) + }) + + t.Run("ReplaceParser can change the parsing logic while keeping validators", func(t *testing.T) { + // Base handler for int with a range validator (via tag or manual) + // We'll use NewTypedIntHandler which has range validation support + base := NewTypedIntHandler[int]() + + // Replace parser to multiply input by 2 + mod, err := ReplaceParser(base, func(rawValue string) (int, error) { + v, err := strconv.Atoi(rawValue) + if err != nil { + return 0, err + } + return v * 2, nil + }) + if err != nil { + t.Fatalf("ReplaceParser failed: %v", err) + } + + type Config struct { + Value int `key:"VAL" max:"10"` + } + + t.Run("Success", func(t *testing.T) { + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "4", true, nil // 4 * 2 = 8, which is <= 10 + } + var cfg Config + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[int](mod)) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.Value != 8 { + t.Errorf("Expected 8, got %d", cfg.Value) + } + }) + + t.Run("Validator still works", func(t *testing.T) { + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "11", true, nil // 11 * 2 = 22, which is > 10 * 2 = 20 + } + var cfg Config + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[int](mod)) + if err == nil { + t.Fatal("Expected error") + } + if !strings.Contains(err.Error(), "above maximum") { + t.Errorf("Expected range validation error, got %v", err) + } + }) + }) + + t.Run("Triggering type conversion error at assignment", func(t *testing.T) { + // This test demonstrates how it is possible to trigger the error at line 193 in config.go + // by providing a custom TypedHandler that returns an incorrect type from its Build method. + type Config struct { + Value string `key:"VAL"` + } + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "anything", true, nil + } + + var cfg Config + err := Load(context.Background(), &cfg, + WithKeyStore(mockStore), + WithCustomType[string](NonConvertibleHandler{})) + + if err == nil { + t.Fatal("Expected error") + } + if !strings.Contains(err.Error(), "cannot be converted to string") { + t.Errorf("Expected conversion error, got: %v", err) + } + }) + + t.Run("Standard typed handlers", func(t *testing.T) { + t.Run("String handler with pattern", func(t *testing.T) { + handler := NewTypedStringHandler() + type Config struct { + Value string `key:"S" pattern:"^foo.*$"` + } + var cfg Config + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "bar", true, nil + } + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[string](handler)) + if err == nil { + t.Fatal("Expected error") + } + }) + + t.Run("Uint handler", func(t *testing.T) { + handler := NewTypedUintHandler[uint32]() + type Config struct { + Value uint32 `key:"U" max:"100"` + } + var cfg Config + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "101", true, nil + } + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[uint32](handler)) + if err == nil { + t.Fatal("Expected error") + } + }) + + t.Run("Float handler", func(t *testing.T) { + handler := NewTypedFloatHandler[float64]() + type Config struct { + Value float64 `key:"F" min:"0.5"` + } + var cfg Config + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "0.4", true, nil + } + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[float64](handler)) + if err == nil { + t.Fatal("Expected error") + } + }) + + t.Run("Duration handler", func(t *testing.T) { + handler := NewTypedDurationHandler() + type Config struct { + Value time.Duration `key:"D" min:"1s"` + } + var cfg Config + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "500ms", true, nil + } + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[time.Duration](handler)) + if err == nil { + t.Fatal("Expected error") + } + }) + }) }) } diff --git a/internal/readpipeline/custom_types_test.go b/internal/readpipeline/custom_types_test.go index c3db11a..0b80840 100644 --- a/internal/readpipeline/custom_types_test.go +++ b/internal/readpipeline/custom_types_test.go @@ -246,7 +246,7 @@ func TestCustomParserAndValidators(t *testing.T) { }{ {"12", ""}, // Pass: >= 10 and even {"11", "must be even"}, // Fail: >= 10 but odd (prepended validator fails) - {"8", "below mininum 10"}, // Fail: < 10 (base validator fails) + {"8", "below minimum 10"}, // Fail: < 10 (base validator fails) } for _, tt := range tests { diff --git a/internal/readpipeline/ordered_validators.go b/internal/readpipeline/ordered_validators.go index a7e0d6f..4616767 100644 --- a/internal/readpipeline/ordered_validators.go +++ b/internal/readpipeline/ordered_validators.go @@ -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 } From c810b927097a3751550cd738be3543f715323818 Mon Sep 17 00:00:00 2001 From: Richard Corfield Date: Mon, 22 Dec 2025 23:41:45 +0000 Subject: [PATCH 05/10] Removed the Build method from TypedHandler so removing coupling to the pipeline registry and process system and removing a chance for user error --- custom_types.go | 4 +-- custom_types_test.go | 38 +++------------------ internal/readpipeline/boolean_types.go | 2 +- internal/readpipeline/custom_types_test.go | 20 +++++------ internal/readpipeline/duration.go | 2 +- internal/readpipeline/json_types.go | 4 +-- internal/readpipeline/number_types.go | 6 ++-- internal/readpipeline/pointer_types_test.go | 2 +- internal/readpipeline/string_types.go | 2 +- internal/readpipeline/typed_handler.go | 26 +++++++------- internal/readpipeline/types.go | 3 -- loadoptions.go | 2 +- 12 files changed, 39 insertions(+), 72 deletions(-) diff --git a/custom_types.go b/custom_types.go index 08a819c..29832a7 100644 --- a/custom_types.go +++ b/custom_types.go @@ -13,9 +13,7 @@ type Validator[T any] = readpipeline.Validator[T] type Wrapper[T any] = readpipeline.Wrapper[T] -type TypedHandler[T any] interface { - readpipeline.TypedHandler[T] -} +type TypedHandler[T any] = readpipeline.TypedHandler[T] func NewCustomHandler[T any](customParser FieldProcessor[T], customValidators ...Validator[T]) TypedHandler[T] { return readpipeline.NewCustomHandler(customParser, customValidators...) diff --git a/custom_types_test.go b/custom_types_test.go index ad86d7b..a695239 100644 --- a/custom_types_test.go +++ b/custom_types_test.go @@ -3,23 +3,16 @@ package goconfig import ( "context" "errors" - "reflect" "strconv" "strings" "testing" "time" ) -// NonConvertibleHandler is a TypedHandler[string] that returns an int from Build -type NonConvertibleHandler struct{} - -func (n NonConvertibleHandler) GetParser() FieldProcessor[string] { return nil } -func (n NonConvertibleHandler) GetWrapper() Wrapper[string] { return nil } -func (n NonConvertibleHandler) Build(tags reflect.StructTag) (FieldProcessor[any], error) { - return func(rawValue string) (any, error) { - return struct{ A int }{A: 123}, nil // Returns a struct instead of string - }, nil -} +// NonConvertibleHandler can no longer trigger the error by returning an incorrect type from Build +// because Build is now handled by the library's adapter which always calls the strongly-typed parser. +// If we want to test that the conversion error can STILL happen (e.g. if internal code is buggy), +// we would need a different way, but the goal was to PREVENT it by removing Build from the public interface. // Explore what can be done with custom types func TestLoad_WithCustomTypes(t *testing.T) { @@ -315,29 +308,6 @@ func TestLoad_WithCustomTypes(t *testing.T) { }) }) - t.Run("Triggering type conversion error at assignment", func(t *testing.T) { - // This test demonstrates how it is possible to trigger the error at line 193 in config.go - // by providing a custom TypedHandler that returns an incorrect type from its Build method. - type Config struct { - Value string `key:"VAL"` - } - mockStore := func(ctx context.Context, key string) (string, bool, error) { - return "anything", true, nil - } - - var cfg Config - err := Load(context.Background(), &cfg, - WithKeyStore(mockStore), - WithCustomType[string](NonConvertibleHandler{})) - - if err == nil { - t.Fatal("Expected error") - } - if !strings.Contains(err.Error(), "cannot be converted to string") { - t.Errorf("Expected conversion error, got: %v", err) - } - }) - t.Run("Standard typed handlers", func(t *testing.T) { t.Run("String handler with pattern", func(t *testing.T) { handler := NewTypedStringHandler() diff --git a/internal/readpipeline/boolean_types.go b/internal/readpipeline/boolean_types.go index da98c85..c52a1df 100644 --- a/internal/readpipeline/boolean_types.go +++ b/internal/readpipeline/boolean_types.go @@ -6,7 +6,7 @@ import ( ) func NewBoolHandler(_ reflect.Type) PipelineBuilder { - return NewTypedBoolHandler() + return WrapTypedHandler(NewTypedBoolHandler()) } // NewTypedBoolHandler returns a TypedHandler[bool] that uses standard bool parsing and validation. diff --git a/internal/readpipeline/custom_types_test.go b/internal/readpipeline/custom_types_test.go index 0b80840..a6c1a6f 100644 --- a/internal/readpipeline/custom_types_test.go +++ b/internal/readpipeline/custom_types_test.go @@ -33,7 +33,7 @@ func TestCustomParserAndValidators(t *testing.T) { fieldType := reflect.TypeOf(int64(0)) registry := NewDefaultTypeRegistry() - registry.RegisterType(fieldType, typeHandlerImpl[int64]{ + registry.RegisterType(fieldType, WrapTypedHandler(typeHandlerImpl[int64]{ Parser: func(s string) (int64, error) { v, err := customParser(s) if err != nil { @@ -47,7 +47,7 @@ func TestCustomParserAndValidators(t *testing.T) { }, WrapProcessUsingRangeTags[int64], ), - }) + })) p, err := New(fieldType, tags, registry) if err != nil { @@ -103,7 +103,7 @@ func TestCustomParserAndValidators(t *testing.T) { fieldType := reflect.TypeOf(Point{}) registry := NewDefaultTypeRegistry() - registry.RegisterType(fieldType, NewCustomHandler(func(s string) (Point, error) { + registry.RegisterType(fieldType, WrapTypedHandler(NewCustomHandler(func(s string) (Point, error) { v, err := customParser(s) if err != nil { return Point{}, err @@ -111,7 +111,7 @@ func TestCustomParserAndValidators(t *testing.T) { return v.(Point), nil }, func(v Point) error { return customValidator(v) - })) + }))) p, err := New(fieldType, "", registry) if err != nil { t.Fatalf("Failed to create processor: %v", err) @@ -156,7 +156,7 @@ func TestCustomParserAndValidators(t *testing.T) { if err != nil { t.Fatalf("Failed to prepend validator: %v", err) } - registry.RegisterType(fieldType, handler) + registry.RegisterType(fieldType, WrapTypedHandler(handler)) p, err := New(fieldType, "", registry) if err != nil { t.Fatalf("Failed to create processor: %v", err) @@ -177,10 +177,10 @@ func TestCustomParserAndValidators(t *testing.T) { } fieldType := reflect.TypeOf(complex(0, 0)) registry := NewDefaultTypeRegistry() - registry.RegisterType(fieldType, NewCustomHandler(func(s string) (complex128, error) { + registry.RegisterType(fieldType, WrapTypedHandler(NewCustomHandler(func(s string) (complex128, error) { v, err := customParser(s) return v.(complex128), err - })) + }))) p, err := New(fieldType, "", registry) if err != nil { t.Fatalf("Failed to create processor: %v", err) @@ -207,7 +207,7 @@ func TestCustomParserAndValidators(t *testing.T) { t.Fatalf("ReplaceParser failed: %v", err) } - p, err := decorated.Build("") + p, err := WrapTypedHandler(decorated).Build("") if err != nil { t.Fatalf("Build failed: %v", err) } @@ -235,7 +235,7 @@ func TestCustomParserAndValidators(t *testing.T) { // tags with min=10 tags := reflect.StructTag(`min:"10"`) - p, err := decorated.Build(tags) + p, err := WrapTypedHandler(decorated).Build(tags) if err != nil { t.Fatalf("Unexpected error: %v", err) } @@ -281,7 +281,7 @@ func TestCustomParserAndValidators(t *testing.T) { return nil }) - p, _ := handler2.Build("") + p, _ := WrapTypedHandler(handler2).Build("") if _, err := p("-2"); err == nil || !strings.Contains(err.Error(), "must be positive") { t.Errorf("expected positive error, got %v", err) } diff --git a/internal/readpipeline/duration.go b/internal/readpipeline/duration.go index bd2f77c..bde2af8 100644 --- a/internal/readpipeline/duration.go +++ b/internal/readpipeline/duration.go @@ -2,7 +2,7 @@ package readpipeline import "time" -var durationTypeHandler = NewTypedDurationHandler() +var durationTypeHandler = WrapTypedHandler(NewTypedDurationHandler()) // NewTypedDurationHandler returns a TypedHandler[time.Duration] that uses standard duration parsing and validation. func NewTypedDurationHandler() TypedHandler[time.Duration] { diff --git a/internal/readpipeline/json_types.go b/internal/readpipeline/json_types.go index 636aaa6..47726d1 100644 --- a/internal/readpipeline/json_types.go +++ b/internal/readpipeline/json_types.go @@ -6,7 +6,7 @@ import ( ) func NewJsonHandler(targetType reflect.Type) PipelineBuilder { - return typeHandlerImpl[any]{ + return WrapTypedHandler(typeHandlerImpl[any]{ Parser: func(rawValue string) (any, error) { ptr := reflect.New(targetType).Interface() err := json.Unmarshal([]byte(rawValue), ptr) @@ -22,5 +22,5 @@ func NewJsonHandler(targetType reflect.Type) PipelineBuilder { ValidationWrapper: func(tags reflect.StructTag, inputProcess FieldProcessor[any]) (FieldProcessor[any], error) { return inputProcess, nil }, - } + }) } diff --git a/internal/readpipeline/number_types.go b/internal/readpipeline/number_types.go index 3afce91..c2b6258 100644 --- a/internal/readpipeline/number_types.go +++ b/internal/readpipeline/number_types.go @@ -6,15 +6,15 @@ import ( ) func NewIntHandler(fieldType reflect.Type) PipelineBuilder { - return NewTypedIntHandler(fieldType.Bits()) + return WrapTypedHandler(NewTypedIntHandler(fieldType.Bits())) } func NewUintHandler(fieldType reflect.Type) PipelineBuilder { - return NewTypedUintHandler(fieldType.Bits()) + return WrapTypedHandler(NewTypedUintHandler(fieldType.Bits())) } func NewFloatHandler(fieldType reflect.Type) PipelineBuilder { - return NewTypedFloatHandler(fieldType.Bits()) + return WrapTypedHandler(NewTypedFloatHandler(fieldType.Bits())) } // NewTypedIntHandler returns a TypedHandler[int64] that uses standard int parsing and validation. diff --git a/internal/readpipeline/pointer_types_test.go b/internal/readpipeline/pointer_types_test.go index feba5bd..f783b07 100644 --- a/internal/readpipeline/pointer_types_test.go +++ b/internal/readpipeline/pointer_types_test.go @@ -96,7 +96,7 @@ func TestPointerTypes(t *testing.T) { } registry := NewDefaultTypeRegistry() - registry.RegisterType(reflect.TypeOf(Point{}), NewCustomHandler(customParser)) + registry.RegisterType(reflect.TypeOf(Point{}), WrapTypedHandler(NewCustomHandler(customParser))) processor, err := New(fieldType, "", registry) if err != nil { diff --git a/internal/readpipeline/string_types.go b/internal/readpipeline/string_types.go index 830331d..40737fa 100644 --- a/internal/readpipeline/string_types.go +++ b/internal/readpipeline/string_types.go @@ -7,7 +7,7 @@ import ( // NewStringHandler returns a PipelineBuilder 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) PipelineBuilder { - return NewTypedStringHandler() + return WrapTypedHandler(NewTypedStringHandler()) } // NewTypedStringHandler returns a TypedHandler[string] that uses standard string parsing and validation. diff --git a/internal/readpipeline/typed_handler.go b/internal/readpipeline/typed_handler.go index ee5cf58..679039e 100644 --- a/internal/readpipeline/typed_handler.go +++ b/internal/readpipeline/typed_handler.go @@ -19,13 +19,18 @@ func (h typeHandlerImpl[T]) GetWrapper() Wrapper[T] { return h.ValidationWrapper } -func (h typeHandlerImpl[T]) Build(tags reflect.StructTag) (FieldProcessor[any], error) { - pipeline := h.GetParser() +// 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 := a.Handler.GetParser() if pipeline == nil { return nil, nil // Return nil if no parser is provided (modification handler) } - wrapper := h.GetWrapper() + wrapper := a.Handler.GetWrapper() if wrapper != nil { var err error pipeline, err = wrapper(tags, pipeline) @@ -33,17 +38,14 @@ func (h typeHandlerImpl[T]) Build(tags reflect.StructTag) (FieldProcessor[any], return nil, err } } - return typedToUntypedPipeline(pipeline), nil + return func(rawValue string) (any, error) { + return pipeline(rawValue) + }, nil } -// typedToUntypedPipeline converts from the strongly typed world of the typeHandlerImpl to the weak type world of the readpipeline pipeline. -func typedToUntypedPipeline[T any](parser FieldProcessor[T]) FieldProcessor[any] { - if parser == nil { - return nil - } - return func(rawValue string) (any, error) { - return parser(rawValue) - } +// WrapTypedHandler wraps a TypedHandler[T] as a PipelineBuilder. +func WrapTypedHandler[T any](handler TypedHandler[T]) PipelineBuilder { + return typedHandlerAdapter[T]{Handler: handler} } // Pipe combines a processor and a Validator, adding validation to the processor diff --git a/internal/readpipeline/types.go b/internal/readpipeline/types.go index 7de957d..68d737d 100644 --- a/internal/readpipeline/types.go +++ b/internal/readpipeline/types.go @@ -21,9 +21,6 @@ type TypedHandler[T any] interface { // GetWrapper returns a Wrapper[T] that adds validators to the pipeline based on tags. // It can return nil if no validation is needed. GetWrapper() Wrapper[T] - // Build creates the final FieldProcessor[any] for the given tags. - // This causes any TypedHandler to implement the untyped PipelineBuilder interface. - Build(tags reflect.StructTag) (FieldProcessor[any], error) } // PipelineBuilder is the typeless interface used to build the read pipeline. diff --git a/loadoptions.go b/loadoptions.go index babbf8a..cba2e59 100644 --- a/loadoptions.go +++ b/loadoptions.go @@ -23,7 +23,7 @@ func WithCustomType[T any](handler TypedHandler[T]) Option { t := reflect.TypeOf(typedNil).Elem() return func(opts *loadOptions) { - opts.typeRegistry.RegisterType(t, handler) + opts.typeRegistry.RegisterType(t, readpipeline.WrapTypedHandler(handler)) } } From 46aae74184a1685eeec6f781b4786881400d6d43 Mon Sep 17 00:00:00 2001 From: Richard Corfield Date: Tue, 23 Dec 2025 00:02:55 +0000 Subject: [PATCH 06/10] Asked Claude to rewrite the docs --- CLAUDE.md | 345 ++++++++++++++++++++++++++++++++++ README.md | 36 ++-- docs/advanced.md | 151 +++++++++------ docs/validation.md | 320 ++++++++++++++++++++++++------- example/custom_type/README.md | 7 - 5 files changed, 711 insertions(+), 148 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 example/custom_type/README.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c4d72e5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,345 @@ +# 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 + +The new type system (replaced the old parser/validator system) uses typed handlers registered per type. + +#### Key Types + +**TypedHandler[T]** - A handler that knows how to parse and validate values of type T +- Has a parser: `FieldProcessor[T]` (converts string to T) +- Has a validation wrapper: `Wrapper[T]` (adds validation stages) + +**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 with validation: +```go +type Wrapper[T any] func(tags reflect.StructTag, inputProcess FieldProcessor[T]) (FieldProcessor[T], error) +``` + +#### Creating Custom Type Handlers + +**NewCustomHandler[T]()** - Create a handler with custom parsing and validation: +```go +handler := goconfig.NewCustomHandler( + 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 + }, +) +``` + +**NewEnumHandler[T]()** - Create a handler for enum types: +```go +type Status string +const ( + StatusActive Status = "active" + StatusInactive Status = "inactive" +) +handler := goconfig.NewEnumHandler(StatusActive, StatusInactive) +``` + +**ReplaceParser()** - Replace the parser while keeping validators: +```go +baseHandler := goconfig.NewTypedIntHandler[int]() +customHandler, err := goconfig.ReplaceParser(baseHandler, func(rawValue string) (int, error) { + v, err := strconv.Atoi(rawValue) + return v * 2, err // Example: multiply by 2 +}) +``` + +**PrependValidators()** - Add validators to an existing handler: +```go +baseHandler := goconfig.NewTypedIntHandler[int]() +validatedHandler, err := goconfig.PrependValidators(baseHandler, func(v int) error { + if v%2 != 0 { + return errors.New("must be even") + } + return nil +}) +``` + +#### Standard Type Handlers + +For extending built-in types with custom validation: +- `NewTypedStringHandler()` - Returns `TypedHandler[string]` +- `NewTypedIntHandler[T]()` - Returns `TypedHandler[T]` for int types +- `NewTypedUintHandler[T]()` - Returns `TypedHandler[T]` for uint types +- `NewTypedFloatHandler[T]()` - Returns `TypedHandler[T]` for float types +- `NewTypedDurationHandler()` - Returns `TypedHandler[time.Duration]` + +#### Registering Custom Types + +Use `WithCustomType[T]()` to register a handler: +```go +err := goconfig.Load(ctx, &config, + goconfig.WithCustomType[APIKey](apiKeyHandler), + goconfig.WithCustomType[Status](statusHandler), +) +``` + +**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 +```go +// New: Type-based handlers +type APIKey string + +apiKeyHandler := goconfig.NewCustomHandler( + 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**: New system provides `ReplaceParser()`, `PrependValidators()`, etc. for composing handlers + +## Common Patterns + +### Custom String Types with Validation + +```go +type Email string + +emailHandler := goconfig.NewCustomHandler( + 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"` +} + +err := goconfig.Load(ctx, &config, + goconfig.WithCustomType[LogLevel]( + goconfig.NewEnumHandler(LogDebug, LogInfo, LogWarn, LogError), + ), +) +``` + +### Adding Validation to Built-in Types + +```go +// Make all ints even +baseHandler := goconfig.NewTypedIntHandler[int]() +evenHandler, _ := goconfig.PrependValidators(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), +) +``` + +### Complex Custom Types + +```go +type ServerAddress struct { + Host string + Port int +} + +addressHandler := goconfig.NewCustomHandler( + 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 + }, +) + +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) + } +} +``` + +## Key Files + +- `custom_types.go` - Public API for custom types +- `internal/readpipeline/custom_types.go` - Custom type implementation +- `internal/readpipeline/typed_handler.go` - TypedHandler interface and implementation +- `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..7e0bb8c 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,11 @@ 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 - 🔄 **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,17 +113,31 @@ Validation errors provide clear messages: invalid value for PORT: below minimum 1024 ``` -### Custom Validators +### Custom Types + +Define custom types with validation using the type-safe handler system: ```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"` +} + +apiKeyHandler := goconfig.NewCustomHandler( + 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), ) ``` @@ -153,10 +167,10 @@ 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 +- 📋 **[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 types and key stores - 💡 **[Examples](example/)** - Working code examples ## Examples diff --git a/docs/advanced.md b/docs/advanced.md index 501eb31..813ee4b 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) -## 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,29 +59,35 @@ func main() { } ``` -### Use Cases for Custom Parsers +### 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 + }, + ) + + err := goconfig.Load(context.Background(), &cfg, + goconfig.WithCustomType[DatabaseURL](urlHandler), ) } ``` @@ -85,22 +95,28 @@ func main() { #### Parsing Custom Time Formats ```go +type Timestamp time.Time + type Config struct { - Timestamp time.Time `key:"TIMESTAMP"` + Timestamp Timestamp `key:"TIMESTAMP"` } func main() { var cfg Config - err := goconfig.Load(context.Background(), &cfg, - goconfig.WithParser("Timestamp", func(value string) (any, error) { + timestampHandler := goconfig.NewCustomHandler( + func(rawValue string) (Timestamp, error) { // Parse RFC3339 format - t, err := time.Parse(time.RFC3339, value) + t, err := time.Parse(time.RFC3339, rawValue) if err != nil { - return nil, fmt.Errorf("invalid timestamp format: %w", err) + return Timestamp{}, fmt.Errorf("invalid timestamp format: %w", err) } - return t, nil - }), + return Timestamp(t), nil + }, + ) + + err := goconfig.Load(context.Background(), &cfg, + goconfig.WithCustomType[Timestamp](timestampHandler), ) } ``` @@ -108,25 +124,31 @@ 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 + }, + ) + + err := goconfig.Load(context.Background(), &cfg, + goconfig.WithCustomType[HostList](hostListHandler), ) // export ALLOWED_HOSTS="example.com; api.example.com; www.example.com" @@ -136,41 +158,49 @@ func main() { #### Parsing Binary Data ```go +type EncryptionKey []byte + type Config struct { - EncryptionKey []byte `key:"ENCRYPTION_KEY"` + EncryptionKey EncryptionKey `key:"ENCRYPTION_KEY"` } func main() { var cfg Config - err := goconfig.Load(context.Background(), &cfg, - goconfig.WithParser("EncryptionKey", func(value string) (any, error) { + keyHandler := goconfig.NewCustomHandler( + func(rawValue string) (EncryptionKey, error) { // Decode base64-encoded key - key, err := base64.StdEncoding.DecodeString(value) + key, err := base64.StdEncoding.DecodeString(rawValue) 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 - }), + return EncryptionKey(key), nil + }, + ) + + err := goconfig.Load(context.Background(), &cfg, + goconfig.WithCustomType[EncryptionKey](keyHandler), ) } ``` ### Parser Error Handling -Custom parsers should return descriptive errors: +Custom type parsers should return descriptive errors: ```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', got '%s'", rawValue) + + // Bad: Generic error + return MyType{}, fmt.Errorf("parse error") + }, +) ``` The error will be wrapped with field context automatically: @@ -450,17 +480,15 @@ 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) } } @@ -468,10 +496,13 @@ func main() { ## 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 +1. **Use custom types for domain validation** - Create custom types (e.g., `APIKey`, `Email`) instead of using raw strings for fields that need validation +2. **Leverage type safety** - Use the type system's generics for compile-time type safety +3. **Reuse type handlers** - Define handlers once and use them across multiple fields of the same type +4. **Use CompositeStore for flexibility** - Allow environment overrides in production +5. **Cache key store results** - Avoid repeated API calls for the same key +6. **Handle context cancellation** - Respect context timeouts in custom key stores +7. **Return descriptive errors** - Help users understand what went wrong +8. **Test with in-memory stores** - Use `mapKeyStore` for unit tests +9. **Fail fast on key store errors** - Don't silently ignore lookup failures +10. **Use `NewEnumHandler` for enums** - Automatic validation for string-based enum types diff --git a/docs/validation.md b/docs/validation.md index 8d876a7..b90696e 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -95,48 +95,70 @@ 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 new type system uses Go generics for type-safe validation. + +### 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, + err := goconfig.Load(context.Background(), &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 - }), + goconfig.WithCustomType[APIKey](goconfig.NewCustomHandler( + 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 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 - }), + goconfig.WithCustomType[Hostname](goconfig.NewCustomHandler( + 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 + }, + )), + + // Additional port validation (even numbers only) + goconfig.WithCustomType[Port](goconfig.NewCustomHandler( + func(rawValue string) (Port, error) { + v, err := strconv.Atoi(rawValue) + return Port(v), err + }, + 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 +167,137 @@ func main() { } ``` -### Type Assertions in Custom Validators +### Type-Safe Validators + +Custom validators are type-safe - no type assertions needed: + +```go +// String-based custom type +type Email string +emailHandler := goconfig.NewCustomHandler( + 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 + }, +) + +// Int-based custom type +type EvenPort int +evenPortHandler := goconfig.NewCustomHandler( + func(rawValue string) (EvenPort, error) { + v, err := strconv.Atoi(rawValue) + return EvenPort(v), err + }, + 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 +type RequestTimeout time.Duration +timeoutHandler := goconfig.NewCustomHandler( + func(rawValue string) (RequestTimeout, error) { + d, err := time.ParseDuration(rawValue) + return RequestTimeout(d), err + }, + 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 + +For string-based enums, use `NewEnumHandler` for automatic validation: + +```go +type LogLevel string + +const ( + LogDebug LogLevel = "debug" + LogInfo LogLevel = "info" + LogWarn LogLevel = "warn" + LogError LogLevel = "error" +) -When writing custom validators, use the appropriate type assertion based on the field type: +type Config struct { + Level LogLevel `key:"LOG_LEVEL" default:"info"` +} -| 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)` | +func main() { + var cfg Config + + err := goconfig.Load(context.Background(), &cfg, + goconfig.WithCustomType[LogLevel]( + goconfig.NewEnumHandler(LogDebug, LogInfo, LogWarn, LogError), + ), + ) +} +``` + +The enum handler will automatically validate that the value is one of the provided options and return 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, + err := goconfig.Load(context.Background(), &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 - }), + goconfig.WithCustomType[DatabaseHost](goconfig.NewCustomHandler( + 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 + }, + )), // 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 - }), + goconfig.WithCustomType[APIEndpoint](goconfig.NewCustomHandler( + 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 +306,109 @@ 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 { + // Create handler with custom validation that runs AFTER min/max from tags + portHandler := goconfig.NewCustomHandler( + func(rawValue string) (Port, error) { + v, err := strconv.Atoi(rawValue) + return Port(v), err + }, + 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 `PrependValidators()` to add custom validation to built-in types while keeping their tag validation: + +```go +type Config struct { + Port int `key:"PORT" default:"8080" min:"1024" max:"65535"` +} + +func main() { + var cfg Config + + // Get the standard int handler + baseHandler := goconfig.NewTypedIntHandler[int]() + + // Add custom validator (port must be even) + evenIntHandler, err := goconfig.PrependValidators(baseHandler, func(v int) error { + if v%2 != 0 { + return errors.New("must be even") + } + return nil + }) + if err != nil { + log.Fatal(err) + } + + err = goconfig.Load(context.Background(), &cfg, + goconfig.WithCustomType[int](evenIntHandler), ) } ``` +### Multiple Validators + +You can pass multiple validators to `NewCustomHandler`: + +```go +type APIKey string + +apiKeyHandler := goconfig.NewCustomHandler( + func(rawValue string) (APIKey, error) { + return APIKey(rawValue), nil + }, + // Multiple validators + 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/custom_type/README.md b/example/custom_type/README.md deleted file mode 100644 index 88925b9..0000000 --- a/example/custom_type/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Custom Type Demo - -_Just because you can, doesn't mean you should!_ - -This sample demonstrates the flexibility of the system by defining a custom type which is a -running web server. The type handler recurses into the server struct having decorated the -key store to apply a prefix to the keys. From ef268b0333d61439f390f64f3b067e30adca6ec1 Mon Sep 17 00:00:00 2001 From: Richard Corfield Date: Tue, 23 Dec 2025 00:05:49 +0000 Subject: [PATCH 07/10] Asked Junie to update its guidelines. Interestingly it read CLAUDE.md, which is Claude's take on the system but talks of the old pre-type system. --- .junie/guidelines.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 24fbb1e..d442f83 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -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 `PrependValidators` 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). From c8815ebac7e2c5b8a51adfe276d99987aa76ed50 Mon Sep 17 00:00:00 2001 From: Richard Corfield Date: Tue, 23 Dec 2025 00:33:07 +0000 Subject: [PATCH 08/10] Reduced some of Claudes samples from docs --- docs/advanced.md | 289 +++++------------------------------------------ docs/json.md | 44 +------- docs/security.md | 10 +- 3 files changed, 31 insertions(+), 312 deletions(-) diff --git a/docs/advanced.md b/docs/advanced.md index 813ee4b..63f2e0a 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -7,7 +7,7 @@ This guide covers advanced features for extending goconfig with custom behavior. - [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 Types @@ -59,7 +59,7 @@ func main() { } ``` -### Use Cases for Custom Types +### Example Use Cases for Custom Types #### Parsing URLs @@ -92,35 +92,6 @@ func main() { } ``` -#### Parsing Custom Time Formats - -```go -type Timestamp time.Time - -type Config struct { - Timestamp Timestamp `key:"TIMESTAMP"` -} - -func main() { - var cfg Config - - timestampHandler := goconfig.NewCustomHandler( - func(rawValue string) (Timestamp, error) { - // Parse RFC3339 format - t, err := time.Parse(time.RFC3339, rawValue) - if err != nil { - return Timestamp{}, fmt.Errorf("invalid timestamp format: %w", err) - } - return Timestamp(t), nil - }, - ) - - err := goconfig.Load(context.Background(), &cfg, - goconfig.WithCustomType[Timestamp](timestampHandler), - ) -} -``` - #### Parsing Lists with Custom Delimiters ```go @@ -155,57 +126,30 @@ func main() { } ``` -#### Parsing Binary Data - -```go -type EncryptionKey []byte - -type Config struct { - EncryptionKey EncryptionKey `key:"ENCRYPTION_KEY"` -} - -func main() { - var cfg Config - - keyHandler := goconfig.NewCustomHandler( - func(rawValue string) (EncryptionKey, error) { - // Decode base64-encoded key - key, err := base64.StdEncoding.DecodeString(rawValue) - 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 EncryptionKey(key), nil - }, - ) - - err := goconfig.Load(context.Background(), &cfg, - goconfig.WithCustomType[EncryptionKey](keyHandler), - ) -} -``` ### Parser Error Handling -Custom type 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 customHandler := goconfig.NewCustomHandler( func(rawValue string) (MyType, error) { // Good: Descriptive error - return MyType{}, fmt.Errorf("invalid format: expected 'key=value', got '%s'", rawValue) + 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 @@ -224,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 @@ -421,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 { @@ -494,15 +271,3 @@ func main() { } ``` -## Best Practices - -1. **Use custom types for domain validation** - Create custom types (e.g., `APIKey`, `Email`) instead of using raw strings for fields that need validation -2. **Leverage type safety** - Use the type system's generics for compile-time type safety -3. **Reuse type handlers** - Define handlers once and use them across multiple fields of the same type -4. **Use CompositeStore for flexibility** - Allow environment overrides in production -5. **Cache key store results** - Avoid repeated API calls for the same key -6. **Handle context cancellation** - Respect context timeouts in custom key stores -7. **Return descriptive errors** - Help users understand what went wrong -8. **Test with in-memory stores** - Use `mapKeyStore` for unit tests -9. **Fail fast on key store errors** - Don't silently ignore lookup failures -10. **Use `NewEnumHandler` for enums** - Automatic validation for string-based enum types 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 From a98318b57a95d4598cff79b77b1539110f49714b Mon Sep 17 00:00:00 2001 From: Richard Corfield Date: Tue, 23 Dec 2025 10:22:45 +0000 Subject: [PATCH 09/10] Central type registry and building block based custom types --- .junie/guidelines.md | 2 +- CLAUDE.md | 8 +- config_test.go | 6 +- custom_types.go | 63 +- custom_types_test.go | 599 ++++++++---------- docs/validation.md | 4 +- example/validation/main.go | 7 +- internal/customtypes/chain_handler.go | 32 + internal/customtypes/chain_handler_test.go | 72 +++ internal/customtypes/doc.go | 2 + internal/customtypes/enum.go | 18 + internal/customtypes/enum_test.go | 40 ++ internal/customtypes/parser.go | 19 + internal/customtypes/parser_test.go | 55 ++ internal/customtypes/transformer.go | 64 ++ internal/customtypes/transformer_test.go | 61 ++ internal/customtypes/validation_wrapper.go | 16 + .../customtypes/validation_wrapper_test.go | 80 +++ internal/readpipeline/boolean_types.go | 6 +- internal/readpipeline/boolean_types_test.go | 2 +- internal/readpipeline/custom_types.go | 121 ---- internal/readpipeline/custom_types_test.go | 296 --------- internal/readpipeline/duration.go | 4 +- internal/readpipeline/duration_test.go | 2 +- internal/readpipeline/invalid_types_test.go | 2 +- internal/readpipeline/json_types.go | 6 +- internal/readpipeline/json_types_test.go | 2 +- internal/readpipeline/number_types.go | 18 +- internal/readpipeline/number_types_test.go | 6 +- internal/readpipeline/pointer_types_test.go | 116 ---- internal/readpipeline/process.go | 2 +- internal/readpipeline/process_test.go | 188 ++++++ internal/readpipeline/string_types.go | 8 +- internal/readpipeline/string_types_test.go | 2 +- internal/readpipeline/typed_handler.go | 77 +-- internal/readpipeline/typeregistry.go | 125 +++- internal/readpipeline/typeregistry_test.go | 274 ++++++++ internal/readpipeline/types.go | 56 +- loadoptions.go | 4 +- 39 files changed, 1408 insertions(+), 1057 deletions(-) create mode 100644 internal/customtypes/chain_handler.go create mode 100644 internal/customtypes/chain_handler_test.go create mode 100644 internal/customtypes/doc.go create mode 100644 internal/customtypes/enum.go create mode 100644 internal/customtypes/enum_test.go create mode 100644 internal/customtypes/parser.go create mode 100644 internal/customtypes/parser_test.go create mode 100644 internal/customtypes/transformer.go create mode 100644 internal/customtypes/transformer_test.go create mode 100644 internal/customtypes/validation_wrapper.go create mode 100644 internal/customtypes/validation_wrapper_test.go delete mode 100644 internal/readpipeline/custom_types.go delete mode 100644 internal/readpipeline/custom_types_test.go delete mode 100644 internal/readpipeline/pointer_types_test.go create mode 100644 internal/readpipeline/process_test.go create mode 100644 internal/readpipeline/typeregistry_test.go diff --git a/.junie/guidelines.md b/.junie/guidelines.md index d442f83..e657c15 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -59,7 +59,7 @@ func TestExampleLoad(t *testing.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 `PrependValidators` are available. +- **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. diff --git a/CLAUDE.md b/CLAUDE.md index c4d72e5..6fed786 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,10 +100,10 @@ customHandler, err := goconfig.ReplaceParser(baseHandler, func(rawValue string) }) ``` -**PrependValidators()** - Add validators to an existing handler: +**AddValidators()** - Add validators to an existing handler: ```go baseHandler := goconfig.NewTypedIntHandler[int]() -validatedHandler, err := goconfig.PrependValidators(baseHandler, func(v int) error { +validatedHandler, err := goconfig.AddValidators(baseHandler, func(v int) error { if v%2 != 0 { return errors.New("must be even") } @@ -201,7 +201,7 @@ err := goconfig.Load(ctx, &cfg, 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**: New system provides `ReplaceParser()`, `PrependValidators()`, etc. for composing handlers +4. **Composability**: New system provides `ReplaceParser()`, `AddValidators()`, etc. for composing handlers ## Common Patterns @@ -257,7 +257,7 @@ err := goconfig.Load(ctx, &config, ```go // Make all ints even baseHandler := goconfig.NewTypedIntHandler[int]() -evenHandler, _ := goconfig.PrependValidators(baseHandler, func(v int) error { +evenHandler, _ := goconfig.AddValidators(baseHandler, func(v int) error { if v%2 != 0 { return errors.New("must be even") } diff --git a/config_test.go b/config_test.go index 94722d1..8bd35ff 100644 --- a/config_test.go +++ b/config_test.go @@ -218,7 +218,7 @@ func TestLoad_Options(t *testing.T) { } var cfg Config // Custom parser for the custom Port type - handler := NewCustomHandler(func(rawValue string) (Port, error) { + handler := NewCustomType(func(rawValue string) (Port, error) { return Port(9000), nil }) @@ -237,7 +237,7 @@ func TestLoad_Options(t *testing.T) { Port Port `key:"PORT"` } var cfg Config - handler := NewCustomHandler(func(rawValue string) (Port, error) { + handler := NewCustomType(func(rawValue string) (Port, error) { v, err := strconv.Atoi(rawValue) return Port(v), err }, func(value Port) error { @@ -305,7 +305,7 @@ func TestLoad_Errors(t *testing.T) { } var customCfg CustomConfig - failingHandler := NewCustomHandler(func(rawValue string) (CustomPort, error) { + failingHandler := NewCustomType(func(rawValue string) (CustomPort, error) { return 0, errors.New("factory failure") }) diff --git a/custom_types.go b/custom_types.go index 29832a7..ee30a33 100644 --- a/custom_types.go +++ b/custom_types.go @@ -4,6 +4,7 @@ import ( "reflect" "time" + "github.com/m0rjc/goconfig/internal/customtypes" "github.com/m0rjc/goconfig/internal/readpipeline" ) @@ -15,56 +16,56 @@ type Wrapper[T any] = readpipeline.Wrapper[T] type TypedHandler[T any] = readpipeline.TypedHandler[T] -func NewCustomHandler[T any](customParser FieldProcessor[T], customValidators ...Validator[T]) TypedHandler[T] { - return readpipeline.NewCustomHandler(customParser, customValidators...) +func RegisterCustomType[T any](handler TypedHandler[T]) { + readpipeline.RegisterType[T](handler) } -func NewEnumHandler[T ~string](validValues ...T) TypedHandler[T] { - return readpipeline.NewEnumHandler(validValues...) +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 ReplaceParser[B, T any](baseHandler TypedHandler[B], customParser FieldProcessor[T]) (TypedHandler[T], error) { - return readpipeline.ReplaceParser(baseHandler, customParser) +func NewStringEnumType[T ~string](validValues ...T) TypedHandler[T] { + return customtypes.NewStringEnum(validValues...) } -func PrependValidators[B, T any](baseHandler TypedHandler[B], customValidators ...Validator[T]) (TypedHandler[T], error) { - return readpipeline.PrependValidators(baseHandler, customValidators...) +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 NewTypedStringHandler() TypedHandler[string] { +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 NewTypedIntHandler[T ~int | ~int8 | ~int16 | ~int32 | ~int64]() TypedHandler[T] { +func DefaultIntegerType[T ~int | ~int8 | ~int16 | ~int32 | ~int64]() TypedHandler[T] { t := reflect.TypeOf(T(0)) - cast, err := readpipeline.CastHandler[int64, T](readpipeline.NewTypedIntHandler(t.Bits())) - if err != nil { - // Should never happen - panic(err) - } - return cast + return CastCustomType[int64, T](readpipeline.NewTypedIntHandler(t.Bits())) } -func NewTypedUintHandler[T ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64]() TypedHandler[T] { +func DefaultUnsignedIntegerType[T ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64]() TypedHandler[T] { t := reflect.TypeOf(T(0)) - cast, err := readpipeline.CastHandler[uint64, T](readpipeline.NewTypedUintHandler(t.Bits())) - if err != nil { - // Should never happen - panic(err) - } - return cast + return CastCustomType[uint64, T](readpipeline.NewTypedUintHandler(t.Bits())) } -func NewTypedFloatHandler[T ~float32 | ~float64]() TypedHandler[T] { +func DefaultFloatIntegerType[T ~float32 | ~float64]() TypedHandler[T] { t := reflect.TypeOf(T(0)) - cast, err := readpipeline.CastHandler[float64, T](readpipeline.NewTypedFloatHandler(t.Bits())) - if err != nil { - // Should never happen - panic(err) - } - return cast + return CastCustomType[float64, T](readpipeline.NewTypedFloatHandler(t.Bits())) } -func NewTypedDurationHandler() TypedHandler[time.Duration] { +func DefaultDurationType() TypedHandler[time.Duration] { return readpipeline.NewTypedDurationHandler() } diff --git a/custom_types_test.go b/custom_types_test.go index a695239..122592a 100644 --- a/custom_types_test.go +++ b/custom_types_test.go @@ -3,371 +3,300 @@ package goconfig import ( "context" "errors" - "strconv" - "strings" + "reflect" "testing" "time" ) -// NonConvertibleHandler can no longer trigger the error by returning an incorrect type from Build -// because Build is now handled by the library's adapter which always calls the strongly-typed parser. -// If we want to test that the conversion error can STILL happen (e.g. if internal code is buggy), -// we would need a different way, but the goal was to PREVENT it by removing Build from the public interface. +func TestNewCustomType(t *testing.T) { + type CustomString string + type Config struct { + Val CustomString `key:"VAL"` + } -// Explore what can be done with custom types -func TestLoad_WithCustomTypes(t *testing.T) { - t.Run("A type handler can be registered for a custom struct", func(t *testing.T) { - type CustomStruct struct { - Field1 string - } - mockStore := func(ctx context.Context, key string) (string, bool, error) { - if key == "CUSTOM_STRUCT" { - return "--Marker--", true, nil + 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 "", false, nil + return nil + }, + ) + + mockStore := func(ctx context.Context, key string) (string, bool, error) { + if key == "VAL" { + return "12345", true, nil } - mockParser := func(value string) (CustomStruct, error) { - return CustomStruct{Field1: value}, 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 } - mockHandler := NewCustomHandler[CustomStruct](mockParser) - - t.Run("struct as value", func(t *testing.T) { - type Config struct { - Value CustomStruct `key:"CUSTOM_STRUCT"` - } + return "", false, nil + } + err = Load(context.Background(), &cfg, WithKeyStore(mockStoreFail), WithCustomType[CustomString](handler)) + if err == nil { + t.Fatal("Expected validation error, got nil") + } +} - config := Config{Value: CustomStruct{Field1: ""}} - err := Load(context.Background(), &config, - WithCustomType[CustomStruct](mockHandler), - WithKeyStore(mockStore)) - if err != nil { - t.Fatalf("Load failed: %v", err) +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 } - if config.Value.Field1 != "--Marker--" { - t.Errorf("Expected Field1 to be set to --Marker--, got %s", config.Value.Field1) + 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) + } } }) + } +} - t.Run("struct as pointer", func(t *testing.T) { - type Config struct { - Value *CustomStruct `key:"CUSTOM_STRUCT"` - } - - config := Config{Value: nil} - err := Load(context.Background(), &config, - WithCustomType[CustomStruct](mockHandler), - WithKeyStore(mockStore)) - if err != nil { - t.Fatalf("Load failed: %v", err) - } - if config.Value == nil { - t.Fatal("Expected config.Value to be non-nil") - } - if config.Value.Field1 != "--Marker--" { - t.Errorf("Expected Field1 to be set to --Marker--, got %s", config.Value.Field1) - } - }) - }) +func TestAddValidators(t *testing.T) { + type Config struct { + Val int `key:"VAL"` + } - t.Run("Custom type can be registered for a custom enum (string kind)", func(t *testing.T) { - type CustomEnum string - const ( - CustomEnum1 CustomEnum = "--Marker--1--" - CustomEnum2 CustomEnum = "--Marker--2--" - ) - mockStore := func(ctx context.Context, key string) (string, bool, error) { - if key == "CUSTOM_ENUM_1" { - return string(CustomEnum1), true, nil - } - if key == "CUSTOM_ENUM_2" { - return string(CustomEnum2), true, nil - } - if key == "SOME_OTHER_KEY" { - return "foo", true, nil - } - return "", false, nil + baseHandler := DefaultIntegerType[int]() + handler := AddValidators(baseHandler, func(value int) error { + if value%2 != 0 { + return errors.New("must be even") } - expectedError := errors.New("expected CustomEnum") - mockHandler := NewCustomHandler(func(value string) (CustomEnum, error) { - return CustomEnum(value), nil - }, func(value CustomEnum) error { - if value != CustomEnum1 && value != CustomEnum2 { - return expectedError - } - return nil - }) - - t.Run("string enum as value", func(t *testing.T) { - type Config struct { - Value CustomEnum `key:"CUSTOM_ENUM_1"` - Value2 CustomEnum `key:"CUSTOM_ENUM_2"` - Other string `key:"SOME_OTHER_KEY"` - } - - config := Config{} - err := Load(context.Background(), &config, - WithCustomType[CustomEnum](mockHandler), - WithKeyStore(mockStore)) - if err != nil { - t.Fatalf("Load failed: %v", err) - } - if config.Value != CustomEnum1 { - t.Errorf("Expected Field1 to be set to %s, got %s", CustomEnum1, config.Value) - } - if config.Value2 != CustomEnum2 { - t.Errorf("Expected Field1 to be set to %s, got %s", CustomEnum2, config.Value) - } - if config.Other != "foo" { - t.Errorf("Expected Other to be set to foo, got %s", config.Other) - } - }) - - t.Run("the validator is called", func(t *testing.T) { - type Config struct { - Value CustomEnum `key:"SOME_OTHER_KEY"` - } - - config := Config{} - err := Load(context.Background(), &config, - WithCustomType[CustomEnum](mockHandler), - WithKeyStore(mockStore)) - if err == nil { - t.Fatal("Load should have failed") - } - if !errors.Is(err, expectedError) { - t.Errorf("Expected validator error, got: %v", err) - } - }) - - t.Run("string enum as pointer", func(t *testing.T) { - type Config struct { - Value *CustomEnum `key:"CUSTOM_ENUM_1"` - Other string `key:"SOME_OTHER_KEY"` - } - - config := Config{} - err := Load(context.Background(), &config, - WithCustomType[CustomEnum](mockHandler), - WithKeyStore(mockStore)) - if err != nil { - t.Fatalf("Load failed: %v", err) - } - if config.Value == nil { - t.Fatal("Expected config.Value to be non-nil") - } - if *config.Value != CustomEnum1 { - t.Errorf("Expected Field1 to be set to %s, got %s", CustomEnum1, *config.Value) - } - if config.Other != "foo" { - t.Errorf("Expected Other to be set to foo, got %s", config.Other) - } - }) + return nil }) - t.Run("WithCustomType can add validation to an existing type", func(t *testing.T) { - type Config struct { - Port int `key:"PORT" min:"1000"` - } - - mockStore := func(ctx context.Context, key string) (string, bool, error) { - if key == "PORT" { - return "1024", true, nil - } - return "", false, nil - } - - // Modification: must be even - t.Run("Adding validator to int", func(t *testing.T) { - // Reuse the standard int handler logic and add a validator - base := NewTypedIntHandler[int]() - - mod, err := PrependValidators(base, func(v int) error { - if v%2 != 0 { - return errors.New("must be even") - } - return nil - }) - if err != nil { - t.Fatalf("Failed to create modified handler: %v", err) - } - - var cfg Config - err = Load(context.Background(), &cfg, - WithKeyStore(mockStore), - WithCustomType[int](mod)) - - if err != nil { - t.Fatalf("Load failed: %v", err) - } - if cfg.Port != 1024 { - t.Errorf("Expected 1024, got %d", cfg.Port) - } + 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") + } +} - // Test failure - mockStoreOdd := func(ctx context.Context, key string) (string, bool, error) { - return "1025", true, nil - } - err = Load(context.Background(), &cfg, - WithKeyStore(mockStoreOdd), - WithCustomType[int](mod)) - if err == nil { - t.Fatal("Expected error") - } - if !strings.Contains(err.Error(), "must be even") { - t.Errorf("Expected error to contain 'must be even', got %v", err) - } - }) - t.Run("NewEnumHandler provides validation for string enums", func(t *testing.T) { - type MyEnum string - const ( - ValA MyEnum = "A" - ValB MyEnum = "B" - ) - handler := NewEnumHandler(ValA, ValB) - - type Config struct { - Value MyEnum `key:"ENUM"` - } +func TestAddDynamicValidation(t *testing.T) { + type Config struct { + Val string `key:"VAL" check:"true"` + } - t.Run("Valid value", func(t *testing.T) { - mockStore := func(ctx context.Context, key string) (string, bool, error) { - return "A", true, nil - } - var cfg Config - err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[MyEnum](handler)) + 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 { - t.Fatalf("Load failed: %v", err) + return val, err } - if cfg.Value != ValA { - t.Errorf("Expected A, got %s", cfg.Value) + if val == "forbidden" { + return val, errors.New("forbidden value") } - }) + return val, nil + }, nil + } + return inputProcess, nil + }) - t.Run("Invalid value", func(t *testing.T) { - mockStore := func(ctx context.Context, key string) (string, bool, error) { - return "C", true, nil - } - var cfg Config - err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[MyEnum](handler)) - if err == nil { - t.Fatal("Expected error") - } - if !strings.Contains(err.Error(), "invalid value: C") { - t.Errorf("Expected error message to contain 'invalid value: C', got %v", err) - } - }) - }) + mockStore := func(ctx context.Context, key string) (string, bool, error) { + return "forbidden", true, nil + } - t.Run("ReplaceParser can change the parsing logic while keeping validators", func(t *testing.T) { - // Base handler for int with a range validator (via tag or manual) - // We'll use NewTypedIntHandler which has range validation support - base := NewTypedIntHandler[int]() + var cfg Config + err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[string](handler)) + if err == nil { + t.Fatal("Expected dynamic validation error, got nil") + } +} - // Replace parser to multiply input by 2 - mod, err := ReplaceParser(base, func(rawValue string) (int, error) { - v, err := strconv.Atoi(rawValue) - if err != nil { - return 0, err - } - return v * 2, nil - }) - if err != nil { - t.Fatalf("ReplaceParser failed: %v", err) - } +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) + } +} - type Config struct { - Value int `key:"VAL" max:"10"` - } +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("Success", func(t *testing.T) { - mockStore := func(ctx context.Context, key string) (string, bool, error) { - return "4", true, nil // 4 * 2 = 8, which is <= 10 - } - var cfg Config - err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[int](mod)) - if err != nil { - t.Fatalf("Load failed: %v", err) - } - if cfg.Value != 8 { - t.Errorf("Expected 8, got %d", cfg.Value) - } - }) + 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("Validator still works", func(t *testing.T) { - mockStore := func(ctx context.Context, key string) (string, bool, error) { - return "11", true, nil // 11 * 2 = 22, which is > 10 * 2 = 20 - } - var cfg Config - err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[int](mod)) - if err == nil { - t.Fatal("Expected error") - } - if !strings.Contains(err.Error(), "above maximum") { - t.Errorf("Expected range validation error, got %v", err) - } - }) - }) + 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("Standard typed handlers", func(t *testing.T) { - t.Run("String handler with pattern", func(t *testing.T) { - handler := NewTypedStringHandler() - type Config struct { - Value string `key:"S" pattern:"^foo.*$"` - } - var cfg Config - mockStore := func(ctx context.Context, key string) (string, bool, error) { - return "bar", true, nil - } - err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[string](handler)) - if err == nil { - t.Fatal("Expected error") - } - }) + 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("Uint handler", func(t *testing.T) { - handler := NewTypedUintHandler[uint32]() - type Config struct { - Value uint32 `key:"U" max:"100"` - } - var cfg Config - mockStore := func(ctx context.Context, key string) (string, bool, error) { - return "101", true, nil - } - err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[uint32](handler)) - if err == nil { - t.Fatal("Expected error") - } - }) + 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) + } + }) +} - t.Run("Float handler", func(t *testing.T) { - handler := NewTypedFloatHandler[float64]() - type Config struct { - Value float64 `key:"F" min:"0.5"` - } - var cfg Config - mockStore := func(ctx context.Context, key string) (string, bool, error) { - return "0.4", true, nil - } - err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[float64](handler)) - if err == nil { - t.Fatal("Expected error") - } - }) +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) + } +} - t.Run("Duration handler", func(t *testing.T) { - handler := NewTypedDurationHandler() - type Config struct { - Value time.Duration `key:"D" min:"1s"` - } - var cfg Config - mockStore := func(ctx context.Context, key string) (string, bool, error) { - return "500ms", true, nil - } - err := Load(context.Background(), &cfg, WithKeyStore(mockStore), WithCustomType[time.Duration](handler)) - if err == nil { - t.Fatal("Expected error") - } - }) - }) - }) +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/validation.md b/docs/validation.md index b90696e..c3bd275 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -346,7 +346,7 @@ func main() { ### Adding Validators to Built-in Types -Use `PrependValidators()` to add custom validation to built-in types while keeping their tag validation: +Use `AddValidators()` to add custom validation to built-in types while keeping their tag validation: ```go type Config struct { @@ -360,7 +360,7 @@ func main() { baseHandler := goconfig.NewTypedIntHandler[int]() // Add custom validator (port must be even) - evenIntHandler, err := goconfig.PrependValidators(baseHandler, func(v int) error { + evenIntHandler, err := goconfig.AddValidators(baseHandler, func(v int) error { if v%2 != 0 { return errors.New("must be even") } diff --git a/example/validation/main.go b/example/validation/main.go index 97f79dd..517a6ba 100644 --- a/example/validation/main.go +++ b/example/validation/main.go @@ -14,6 +14,7 @@ import ( ) type APIKey string + type APIEndpoint string type DatabaseHost string @@ -96,7 +97,7 @@ 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.WithCustomType[APIKey](goconfig.NewCustomHandler( + goconfig.WithCustomType[APIKey](goconfig.NewCustomType( func(rawValue string) (APIKey, error) { return APIKey(rawValue), nil }, func(value APIKey) error { key := string(value) @@ -110,7 +111,7 @@ func main() { })), // Validate API endpoint is a valid URL with https - goconfig.WithCustomType[APIEndpoint](goconfig.NewCustomHandler( + goconfig.WithCustomType[APIEndpoint](goconfig.NewCustomType( func(rawValue string) (APIEndpoint, error) { return APIEndpoint(rawValue), nil }, func(value APIEndpoint) error { endpoint := string(value) @@ -121,7 +122,7 @@ func main() { })), // Validate database host is not a loopback address in production - goconfig.WithCustomType[DatabaseHost](goconfig.NewCustomHandler( + goconfig.WithCustomType[DatabaseHost](goconfig.NewCustomType( func(rawValue string) (DatabaseHost, error) { return DatabaseHost(rawValue), nil }, func(value DatabaseHost) error { host := string(value) 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/readpipeline/boolean_types.go b/internal/readpipeline/boolean_types.go index c52a1df..b4adf89 100644 --- a/internal/readpipeline/boolean_types.go +++ b/internal/readpipeline/boolean_types.go @@ -5,13 +5,13 @@ import ( "strconv" ) -func NewBoolHandler(_ reflect.Type) PipelineBuilder { - return WrapTypedHandler(NewTypedBoolHandler()) +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]{ + return &typeHandlerImpl[bool]{ Parser: func(rawValue string) (bool, error) { return strconv.ParseBool(rawValue) }, diff --git a/internal/readpipeline/boolean_types_test.go b/internal/readpipeline/boolean_types_test.go index edc6bdd..d712277 100644 --- a/internal/readpipeline/boolean_types_test.go +++ b/internal/readpipeline/boolean_types_test.go @@ -46,7 +46,7 @@ func TestBoolTypes(t *testing.T) { }, } - registry := NewDefaultTypeRegistry() + registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { proc, err := New(tt.fieldType, tt.tags, registry) diff --git a/internal/readpipeline/custom_types.go b/internal/readpipeline/custom_types.go deleted file mode 100644 index d22a101..0000000 --- a/internal/readpipeline/custom_types.go +++ /dev/null @@ -1,121 +0,0 @@ -package readpipeline - -import ( - "fmt" - "reflect" -) - -// NewCustomHandler creates a new handler that uses the custom parser and validators. -func NewCustomHandler[T any](customParser FieldProcessor[T], customValidators ...Validator[T]) TypedHandler[T] { - return typeHandlerImpl[T]{ - Parser: customParser, - ValidationWrapper: newCustomValidatorWrapper(customValidators), - } -} - -func NewEnumHandler[T ~string](validValues ...T) TypedHandler[T] { - return NewCustomHandler[T](func(rawValue string) (T, error) { - for _, validValue := range validValues { - if rawValue == string(validValue) { - return validValue, nil - } - } - return "", fmt.Errorf("invalid value: %s", rawValue) - }) -} - -func newCustomValidatorWrapper[T any](customValidators []Validator[T]) Wrapper[T] { - return func(tags reflect.StructTag, inputProcess FieldProcessor[T]) (FieldProcessor[T], error) { - if customValidators != nil && len(customValidators) > 0 { - inputProcess = PipeMultiple(inputProcess, customValidators) - } - return inputProcess, nil - } -} - -func ReplaceParser[B, T any](baseHandler TypedHandler[B], customParser FieldProcessor[T]) (TypedHandler[T], error) { - adaptedWrapper := castWrapper[B, T](baseHandler.GetWrapper()) - - return typeHandlerImpl[T]{ - Parser: customParser, - ValidationWrapper: adaptedWrapper, - }, nil -} - -func PrependValidators[B, T any](baseHandler TypedHandler[B], customValidators ...Validator[T]) (TypedHandler[T], error) { - parser, err := castPipeline[B, T](baseHandler.GetParser()) - if err != nil { - return nil, err - } - - adaptedWrapper := castWrapper[B, T](baseHandler.GetWrapper()) - - return typeHandlerImpl[T]{ - Parser: parser, - ValidationWrapper: NewCompositeWrapper[T](adaptedWrapper, newCustomValidatorWrapper(customValidators)), - }, nil -} - -func CastHandler[B, T any](handler TypedHandler[B]) (TypedHandler[T], error) { - parser, err := castPipeline[B, T](handler.GetParser()) - if err != nil { - return nil, err - } - - wrapper := castWrapper[B, T](handler.GetWrapper()) - - return typeHandlerImpl[T]{ - Parser: parser, - ValidationWrapper: wrapper, - }, nil -} - -func castPipeline[B, T any](parser FieldProcessor[B]) (FieldProcessor[T], error) { - if parser == nil { - return nil, nil - } - - baseType := reflect.TypeOf((*B)(nil)).Elem() - newType := reflect.TypeOf((*T)(nil)).Elem() - if !baseType.ConvertibleTo(newType) { - return nil, fmt.Errorf("incompatible type conversion: %s -> %s", baseType, newType) - } - - return func(rawValue string) (T, error) { - val, err := parser(rawValue) - if err != nil { - var zero T - return zero, err - } - // Convert B to T (e.g., string to Foo) - return reflect.ValueOf(val).Convert(newType).Interface().(T), nil - }, nil -} - -func castWrapper[B, T any](wrapper Wrapper[B]) Wrapper[T] { - if wrapper == nil { - return nil - } - - return func(tags reflect.StructTag, inputProcess FieldProcessor[T]) (FieldProcessor[T], error) { - // 1. Down-convert the inputProcess (T -> B) so the base wrapper can use it - inputPipeline, err := castPipeline[T, B](inputProcess) - if err != nil { - return nil, fmt.Errorf("input conversion for validators: %w", err) - } - - // 2. Run the base wrapper logic - wrappedBase, err := wrapper(tags, inputPipeline) - if err != nil { - return nil, err - } - - // 3. Up-convert the result (B -> T) for the final pipeline - outputPipeline, err := castPipeline[B, T](wrappedBase) - if err != nil { - return nil, fmt.Errorf("output conversion for validators: %w", err) - } - - return outputPipeline, nil - } -} diff --git a/internal/readpipeline/custom_types_test.go b/internal/readpipeline/custom_types_test.go deleted file mode 100644 index a6c1a6f..0000000 --- a/internal/readpipeline/custom_types_test.go +++ /dev/null @@ -1,296 +0,0 @@ -package readpipeline - -import ( - "errors" - "fmt" - "reflect" - "strconv" - "strings" - "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(v int64) error { - 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)) - - registry := NewDefaultTypeRegistry() - registry.RegisterType(fieldType, WrapTypedHandler(typeHandlerImpl[int64]{ - Parser: func(s string) (int64, error) { - v, err := customParser(s) - if err != nil { - return 0, err - } - return v.(int64), nil - }, - ValidationWrapper: NewCompositeWrapper( - func(tags reflect.StructTag, inputProcess FieldProcessor[int64]) (FieldProcessor[int64], error) { - return Pipe(inputProcess, customValidator), nil - }, - WrapProcessUsingRangeTags[int64], - ), - })) - - p, err := New(fieldType, tags, registry) - 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{}) - registry := NewDefaultTypeRegistry() - registry.RegisterType(fieldType, WrapTypedHandler(NewCustomHandler(func(s string) (Point, error) { - v, err := customParser(s) - if err != nil { - return Point{}, err - } - return v.(Point), nil - }, func(v Point) error { - return customValidator(v) - }))) - p, err := New(fieldType, "", registry) - 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 int64) error { - if value == 42 { - return errors.New("42 is forbidden") - } - return nil - } - - fieldType := reflect.TypeOf(int64(0)) - registry := NewDefaultTypeRegistry() - // Since we want to use the default parser but add a custom validator, we can prepend it - baseHandler := NewTypedIntHandler(64) - handler, err := PrependValidators(baseHandler, customValidator) - if err != nil { - t.Fatalf("Failed to prepend validator: %v", err) - } - registry.RegisterType(fieldType, WrapTypedHandler(handler)) - p, err := New(fieldType, "", registry) - 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)) - registry := NewDefaultTypeRegistry() - registry.RegisterType(fieldType, WrapTypedHandler(NewCustomHandler(func(s string) (complex128, error) { - v, err := customParser(s) - return v.(complex128), err - }))) - p, err := New(fieldType, "", registry) - 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) - } - }) - - t.Run("ReplaceParser and PrependValidators", func(t *testing.T) { - baseHandler := NewTypedIntHandler(64) - - t.Run("Parser override via ReplaceParser", func(t *testing.T) { - // Replace the parser with one that always returns 42 - decorated, err := ReplaceParser(baseHandler, func(s string) (int64, error) { - return 42, nil - }) - if err != nil { - t.Fatalf("ReplaceParser failed: %v", err) - } - - p, err := WrapTypedHandler(decorated).Build("") - if err != nil { - t.Fatalf("Build failed: %v", err) - } - val, err := p("any value") - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - if val.(int64) != 42 { - t.Errorf("Expected 42, got %v", val) - } - }) - - t.Run("Validator prepending via PrependValidators", func(t *testing.T) { - // Base has range validation (via NewTypedIntHandler) - // Prepend a check for even numbers - decorated, err := PrependValidators(baseHandler, func(v int64) error { - if v%2 != 0 { - return errors.New("must be even") - } - return nil - }) - if err != nil { - t.Fatalf("PrependValidators failed: %v", err) - } - - // tags with min=10 - tags := reflect.StructTag(`min:"10"`) - p, err := WrapTypedHandler(decorated).Build(tags) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - tests := []struct { - input string - wantErr string - }{ - {"12", ""}, // Pass: >= 10 and even - {"11", "must be even"}, // Fail: >= 10 but odd (prepended validator fails) - {"8", "below minimum 10"}, // Fail: < 10 (base validator fails) - } - - for _, tt := range tests { - _, err := p(tt.input) - if tt.wantErr == "" { - if err != nil { - t.Errorf("input %s: unexpected error %v", tt.input, err) - } - } else { - if err == nil { - t.Errorf("input %s: expected error %q, got nil", tt.input, tt.wantErr) - } else if !strings.Contains(err.Error(), tt.wantErr) { - t.Errorf("input %s: expected error to contain %q, got %q", tt.input, tt.wantErr, err.Error()) - } - } - } - }) - - t.Run("Multiple prepended validators", func(t *testing.T) { - // Prepend "must be even" - handler1, _ := PrependValidators(baseHandler, func(v int64) error { - if v%2 != 0 { - return errors.New("must be even") - } - return nil - }) - // Prepend "must be positive" - handler2, _ := PrependValidators(handler1, func(v int64) error { - if v <= 0 { - return errors.New("must be positive") - } - return nil - }) - - p, _ := WrapTypedHandler(handler2).Build("") - if _, err := p("-2"); err == nil || !strings.Contains(err.Error(), "must be positive") { - t.Errorf("expected positive error, got %v", err) - } - if _, err := p("3"); err == nil || !strings.Contains(err.Error(), "must be even") { - t.Errorf("expected even error, got %v", err) - } - if v, err := p("4"); err != nil || v.(int64) != 4 { - t.Errorf("expected 4, got %v (err: %v)", v, err) - } - }) - }) -} diff --git a/internal/readpipeline/duration.go b/internal/readpipeline/duration.go index bde2af8..ed5c501 100644 --- a/internal/readpipeline/duration.go +++ b/internal/readpipeline/duration.go @@ -2,11 +2,11 @@ package readpipeline import "time" -var durationTypeHandler = WrapTypedHandler(NewTypedDurationHandler()) +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]{ + return &typeHandlerImpl[time.Duration]{ Parser: func(rawValue string) (time.Duration, error) { return time.ParseDuration(rawValue) }, diff --git a/internal/readpipeline/duration_test.go b/internal/readpipeline/duration_test.go index 133c451..0326281 100644 --- a/internal/readpipeline/duration_test.go +++ b/internal/readpipeline/duration_test.go @@ -43,7 +43,7 @@ func TestDurationTypes(t *testing.T) { }, } - registry := NewDefaultTypeRegistry() + registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { proc, err := New(tt.fieldType, tt.tags, registry) diff --git a/internal/readpipeline/invalid_types_test.go b/internal/readpipeline/invalid_types_test.go index 03c3ffc..3125ddc 100644 --- a/internal/readpipeline/invalid_types_test.go +++ b/internal/readpipeline/invalid_types_test.go @@ -7,7 +7,7 @@ import ( func TestInvalidTypes(t *testing.T) { // This test will need to be removed or replaced if we ever support complex numbers - registry := NewDefaultTypeRegistry() + registry := NewTypeRegistry() t.Run("Complex128", func(t *testing.T) { fieldType := reflect.TypeOf(complex128(0)) tags := reflect.StructTag("") diff --git a/internal/readpipeline/json_types.go b/internal/readpipeline/json_types.go index 47726d1..bd2d96c 100644 --- a/internal/readpipeline/json_types.go +++ b/internal/readpipeline/json_types.go @@ -5,8 +5,8 @@ import ( "reflect" ) -func NewJsonHandler(targetType reflect.Type) PipelineBuilder { - return WrapTypedHandler(typeHandlerImpl[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) @@ -22,5 +22,5 @@ func NewJsonHandler(targetType reflect.Type) PipelineBuilder { ValidationWrapper: func(tags reflect.StructTag, inputProcess FieldProcessor[any]) (FieldProcessor[any], error) { return inputProcess, nil }, - }) + } } diff --git a/internal/readpipeline/json_types_test.go b/internal/readpipeline/json_types_test.go index 7190fd8..f8609d4 100644 --- a/internal/readpipeline/json_types_test.go +++ b/internal/readpipeline/json_types_test.go @@ -39,7 +39,7 @@ func TestJsonTypes(t *testing.T) { }, } - registry := NewDefaultTypeRegistry() + registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { proc, err := New(tt.fieldType, tt.tags, registry) diff --git a/internal/readpipeline/number_types.go b/internal/readpipeline/number_types.go index c2b6258..5fe0b03 100644 --- a/internal/readpipeline/number_types.go +++ b/internal/readpipeline/number_types.go @@ -5,21 +5,21 @@ import ( "strconv" ) -func NewIntHandler(fieldType reflect.Type) PipelineBuilder { - return WrapTypedHandler(NewTypedIntHandler(fieldType.Bits())) +func NewIntHandler(fieldType reflect.Type) TypedHandler[int64] { + return NewTypedIntHandler(fieldType.Bits()) } -func NewUintHandler(fieldType reflect.Type) PipelineBuilder { - return WrapTypedHandler(NewTypedUintHandler(fieldType.Bits())) +func NewUintHandler(fieldType reflect.Type) TypedHandler[uint64] { + return NewTypedUintHandler(fieldType.Bits()) } -func NewFloatHandler(fieldType reflect.Type) PipelineBuilder { - return WrapTypedHandler(NewTypedFloatHandler(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]{ + return &typeHandlerImpl[int64]{ Parser: func(rawValue string) (int64, error) { return strconv.ParseInt(rawValue, 0, bits) }, @@ -29,7 +29,7 @@ func NewTypedIntHandler(bits int) TypedHandler[int64] { // NewTypedUintHandler returns a TypedHandler[uint64] that uses standard uint parsing and validation. func NewTypedUintHandler(bits int) TypedHandler[uint64] { - return typeHandlerImpl[uint64]{ + return &typeHandlerImpl[uint64]{ Parser: func(rawValue string) (uint64, error) { return strconv.ParseUint(rawValue, 0, bits) }, @@ -39,7 +39,7 @@ func NewTypedUintHandler(bits int) TypedHandler[uint64] { // NewTypedFloatHandler returns a TypedHandler[float64] that uses standard float parsing and validation. func NewTypedFloatHandler(bits int) TypedHandler[float64] { - return typeHandlerImpl[float64]{ + return &typeHandlerImpl[float64]{ Parser: func(rawValue string) (float64, error) { return strconv.ParseFloat(rawValue, bits) }, diff --git a/internal/readpipeline/number_types_test.go b/internal/readpipeline/number_types_test.go index 588f310..cd4c8cd 100644 --- a/internal/readpipeline/number_types_test.go +++ b/internal/readpipeline/number_types_test.go @@ -109,7 +109,7 @@ func TestIntTypes(t *testing.T) { }, } - registry := NewDefaultTypeRegistry() + registry := NewTypeRegistry() t.Run("invalid min tag", func(t *testing.T) { _, err := New(reflect.TypeOf(int(0)), `min:"foo"`, registry) if err == nil { @@ -214,7 +214,7 @@ func TestUintTypes(t *testing.T) { }, } - registry := NewDefaultTypeRegistry() + registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { proc, err := New(tt.fieldType, tt.tags, registry) @@ -264,7 +264,7 @@ func TestFloatTypes(t *testing.T) { }, } - registry := NewDefaultTypeRegistry() + registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { proc, err := New(tt.fieldType, tt.tags, registry) diff --git a/internal/readpipeline/pointer_types_test.go b/internal/readpipeline/pointer_types_test.go deleted file mode 100644 index f783b07..0000000 --- a/internal/readpipeline/pointer_types_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package readpipeline - -import ( - "reflect" - "testing" -) - -func TestPointerTypes(t *testing.T) { - registry := NewDefaultTypeRegistry() - t.Run("PointerToInt", func(t *testing.T) { - var i *int - fieldType := reflect.TypeOf(i) - tags := reflect.StructTag(`min:"10"`) - - processor, err := New(fieldType, tags, registry) - 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, registry) - 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, "", registry) - 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) (Point, error) { - // Dummy parser for "1,2" - return Point{X: 1, Y: 2}, nil - } - - registry := NewDefaultTypeRegistry() - registry.RegisterType(reflect.TypeOf(Point{}), WrapTypedHandler(NewCustomHandler(customParser))) - - processor, err := New(fieldType, "", registry) - 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/readpipeline/process.go b/internal/readpipeline/process.go index b6c1884..f60bf13 100644 --- a/internal/readpipeline/process.go +++ b/internal/readpipeline/process.go @@ -9,7 +9,7 @@ import ( // 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) { +func New(fieldType reflect.Type, tags reflect.StructTag, registry TypeRegistry) (FieldProcessor[any], error) { targetType := fieldType isPointer := fieldType.Kind() == reflect.Ptr 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 index 40737fa..cf2e6e2 100644 --- a/internal/readpipeline/string_types.go +++ b/internal/readpipeline/string_types.go @@ -4,15 +4,15 @@ import ( "reflect" ) -// NewStringHandler returns a PipelineBuilder that simply returns the raw value. +// 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) PipelineBuilder { - return WrapTypedHandler(NewTypedStringHandler()) +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]{ + return &typeHandlerImpl[string]{ Parser: func(rawValue string) (string, error) { return rawValue, nil }, diff --git a/internal/readpipeline/string_types_test.go b/internal/readpipeline/string_types_test.go index 59ea128..af676f0 100644 --- a/internal/readpipeline/string_types_test.go +++ b/internal/readpipeline/string_types_test.go @@ -85,7 +85,7 @@ func TestStringTypes(t *testing.T) { }, } - registry := NewDefaultTypeRegistry() + registry := NewTypeRegistry() for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { proc, err := New(tt.fieldType, tt.tags, registry) diff --git a/internal/readpipeline/typed_handler.go b/internal/readpipeline/typed_handler.go index 679039e..20a4af0 100644 --- a/internal/readpipeline/typed_handler.go +++ b/internal/readpipeline/typed_handler.go @@ -11,26 +11,13 @@ type typeHandlerImpl[T any] struct { ValidationWrapper Wrapper[T] } -func (h typeHandlerImpl[T]) GetParser() FieldProcessor[T] { - return h.Parser -} - -func (h typeHandlerImpl[T]) GetWrapper() Wrapper[T] { - return h.ValidationWrapper -} - -// 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 := a.Handler.GetParser() +func (h *typeHandlerImpl[T]) BuildPipeline(tags reflect.StructTag) (FieldProcessor[T], error) { + pipeline := h.Parser if pipeline == nil { - return nil, nil // Return nil if no parser is provided (modification handler) + return nil, nil } - wrapper := a.Handler.GetWrapper() + wrapper := h.ValidationWrapper if wrapper != nil { var err error pipeline, err = wrapper(tags, pipeline) @@ -38,60 +25,6 @@ func (a typedHandlerAdapter[T]) Build(tags reflect.StructTag) (FieldProcessor[an return nil, err } } - 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} -} - -// 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) { - value, err := processor(rawValue) - if err != nil { - return value, err - } - if err := validator(value); err != nil { - return value, err - } - - return value, nil - } -} - -// PipeMultiple combines a processor and a slice of Validators, adding validation to the processor -// This creates a single validator that runs all the other validators to reduce stack depth -func PipeMultiple[T any](processor FieldProcessor[T], validators []Validator[T]) FieldProcessor[T] { - if len(validators) == 0 { - return processor - } - // Create a single validator that runs all the other validators to reduce stack depth and closure debugging issues - return Pipe(processor, func(value T) error { - for _, validator := range validators { - if err := validator(value); err != nil { - return err - } - } - return nil - }) -} - -// 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 - for _, wrapper := range wrappers { - var err error - wrapped, err = wrapper(tags, wrapped) - if err != nil { - return nil, err - } - } - return wrapped, nil - } + return pipeline, nil } diff --git a/internal/readpipeline/typeregistry.go b/internal/readpipeline/typeregistry.go index f2561a4..716cd6c 100644 --- a/internal/readpipeline/typeregistry.go +++ b/internal/readpipeline/typeregistry.go @@ -8,54 +8,62 @@ import ( // HandlerFactory is a function that returns a PipelineBuilder for a given type. type HandlerFactory func(t reflect.Type) PipelineBuilder -// TypeRegistry 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 TypeRegistry struct { +// 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 - kindHandlers map[reflect.Kind]HandlerFactory } -// NewDefaultTypeRegistry creates a new TypeRegistry with the default handlers. -func NewDefaultTypeRegistry() *TypeRegistry { - return &TypeRegistry{ - specialTypeHandlers: map[reflect.Type]PipelineBuilder{ - reflect.TypeOf(time.Duration(0)): durationTypeHandler, - }, - kindHandlers: map[reflect.Kind]HandlerFactory{ - 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, - }, +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) } -// RegisterKind registers a factory function for a given kind. -func (r *TypeRegistry) RegisterKind(kind reflect.Kind, factory func(t reflect.Type) PipelineBuilder) { - r.kindHandlers[kind] = factory +// 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 *TypeRegistry) RegisterType(t reflect.Type, handler PipelineBuilder) { +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 *TypeRegistry) HandlerFor(t reflect.Type) PipelineBuilder { +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 @@ -68,3 +76,50 @@ func (r *TypeRegistry) HandlerFor(t reflect.Type) PipelineBuilder { 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/readpipeline/types.go b/internal/readpipeline/types.go index 68d737d..56d0bae 100644 --- a/internal/readpipeline/types.go +++ b/internal/readpipeline/types.go @@ -15,12 +15,8 @@ type Validator[T any] func(value T) error // TypedHandler is the strongly typed version of the PipelineBuilder interface. type TypedHandler[T any] interface { - // GetParser returns a FieldProcessor[T] that is used to read the raw value and start the read pipeline. - // It can return nil if this handler doesn't provide a parser (e.g. it's a modification). - GetParser() FieldProcessor[T] - // GetWrapper returns a Wrapper[T] that adds validators to the pipeline based on tags. - // It can return nil if no validation is needed. - GetWrapper() Wrapper[T] + // 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. @@ -31,3 +27,51 @@ type PipelineBuilder interface { // 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) { + value, err := processor(rawValue) + if err != nil { + return value, err + } + + if err := validator(value); err != nil { + return value, err + } + + return value, nil + } +} + +// PipeMultiple combines a processor and a slice of Validators, adding validation to the processor +// This creates a single validator that runs all the other validators to reduce stack depth +func PipeMultiple[T any](processor FieldProcessor[T], validators []Validator[T]) FieldProcessor[T] { + if len(validators) == 0 { + return processor + } + // Create a single validator that runs all the other validators to reduce stack depth and closure debugging issues + return Pipe(processor, func(value T) error { + for _, validator := range validators { + if err := validator(value); err != nil { + return err + } + } + return nil + }) +} + +// 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 + for _, wrapper := range wrappers { + var err error + wrapped, err = wrapper(tags, wrapped) + if err != nil { + return nil, err + } + } + return wrapped, nil + } +} diff --git a/loadoptions.go b/loadoptions.go index cba2e59..eed1d1d 100644 --- a/loadoptions.go +++ b/loadoptions.go @@ -32,14 +32,14 @@ type loadOptions struct { // keyStore reads the values. Default to os.GetEnv() keyStore KeyStore // typeRegistry holds the handlers for specific types - typeRegistry *readpipeline.TypeRegistry + typeRegistry readpipeline.TypeRegistry } // newLoadOptions creates default load options. func newLoadOptions() *loadOptions { return &loadOptions{ keyStore: EnvironmentKeyStore, - typeRegistry: readpipeline.NewDefaultTypeRegistry(), + typeRegistry: readpipeline.NewTypeRegistry(), } } From 77dcae591d9458bab58ee07e00e59f85932e6854 Mon Sep 17 00:00:00 2001 From: Richard Corfield Date: Tue, 23 Dec 2025 10:37:29 +0000 Subject: [PATCH 10/10] Custom type documentation --- CLAUDE.md | 197 +++++++++------ README.md | 17 +- docs/README.md | 46 +++- docs/custom-types.md | 579 +++++++++++++++++++++++++++++++++++++++++++ docs/validation.md | 112 ++++----- 5 files changed, 810 insertions(+), 141 deletions(-) create mode 100644 docs/custom-types.md diff --git a/CLAUDE.md b/CLAUDE.md index 6fed786..85c03e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,15 +39,13 @@ The library has built-in support for: - Pointers: All above types as pointers - Nested structs -### 3. Custom Type System +### 3. Custom Type System - Building Block Approach -The new type system (replaced the old parser/validator system) uses typed handlers registered per type. +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. -#### Key Types +#### Core Building Blocks -**TypedHandler[T]** - A handler that knows how to parse and validate values of type T -- Has a parser: `FieldProcessor[T]` (converts string to T) -- Has a validation wrapper: `Wrapper[T]` (adds validation stages) +**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 @@ -59,70 +57,49 @@ type FieldProcessor[T any] func(rawValue string) (T, error) type Validator[T any] func(value T) error ``` -**Wrapper[T]** - A factory that wraps a FieldProcessor with validation: +**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) ``` -#### Creating Custom Type Handlers +#### Building Block Functions -**NewCustomHandler[T]()** - Create a handler with custom parsing and validation: -```go -handler := goconfig.NewCustomHandler( - 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 - }, -) -``` +These are the core building blocks you compose to create custom types: -**NewEnumHandler[T]()** - Create a handler for enum types: -```go -type Status string -const ( - StatusActive Status = "active" - StatusInactive Status = "inactive" -) -handler := goconfig.NewEnumHandler(StatusActive, StatusInactive) -``` +**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 -**ReplaceParser()** - Replace the parser while keeping validators: -```go -baseHandler := goconfig.NewTypedIntHandler[int]() -customHandler, err := goconfig.ReplaceParser(baseHandler, func(rawValue string) (int, error) { - v, err := strconv.Atoi(rawValue) - return v * 2, err // Example: multiply by 2 -}) -``` +**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 -**AddValidators()** - Add validators to an existing handler: -```go -baseHandler := goconfig.NewTypedIntHandler[int]() -validatedHandler, err := goconfig.AddValidators(baseHandler, func(v int) error { - if v%2 != 0 { - return errors.New("must be even") - } - return nil -}) -``` +**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`) -#### Standard Type Handlers +**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 -For extending built-in types with custom validation: -- `NewTypedStringHandler()` - Returns `TypedHandler[string]` -- `NewTypedIntHandler[T]()` - Returns `TypedHandler[T]` for int types -- `NewTypedUintHandler[T]()` - Returns `TypedHandler[T]` for uint types -- `NewTypedFloatHandler[T]()` - Returns `TypedHandler[T]` for float types -- `NewTypedDurationHandler()` - Returns `TypedHandler[time.Duration]` +#### 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: +Use `WithCustomType[T]()` to register a handler when loading config: ```go err := goconfig.Load(ctx, &config, goconfig.WithCustomType[APIKey](apiKeyHandler), @@ -130,6 +107,12 @@ err := goconfig.Load(ctx, &config, ) ``` +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 @@ -177,12 +160,12 @@ err := goconfig.Load(ctx, &cfg, ) ``` -### New System +### New System (Building Block Approach) ```go -// New: Type-based handlers +// New: Type-based handlers with building blocks type APIKey string -apiKeyHandler := goconfig.NewCustomHandler( +apiKeyHandler := goconfig.NewCustomType( func(rawValue string) (APIKey, error) { return APIKey(rawValue), nil }, @@ -201,7 +184,7 @@ err := goconfig.Load(ctx, &cfg, 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**: New system provides `ReplaceParser()`, `AddValidators()`, etc. for composing handlers +4. **Composability**: Building block architecture - compose handlers using `AddValidators()`, `CastCustomType()`, `AddDynamicValidation()`, etc. ## Common Patterns @@ -210,7 +193,8 @@ err := goconfig.Load(ctx, &cfg, ```go type Email string -emailHandler := goconfig.NewCustomHandler( +// Building block approach: start with parser, add validators +emailHandler := goconfig.NewCustomType( func(rawValue string) (Email, error) { return Email(rawValue), nil }, @@ -245,19 +229,20 @@ 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.NewEnumHandler(LogDebug, LogInfo, LogWarn, LogError), + goconfig.NewStringEnumType(LogDebug, LogInfo, LogWarn, LogError), ), ) ``` -### Adding Validation to Built-in Types +### Adding Validation to Built-in Types (Composition) ```go -// Make all ints even -baseHandler := goconfig.NewTypedIntHandler[int]() -evenHandler, _ := goconfig.AddValidators(baseHandler, func(v int) error { +// 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") } @@ -271,6 +256,34 @@ type Config struct { 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 @@ -281,7 +294,8 @@ type ServerAddress struct { Port int } -addressHandler := goconfig.NewCustomHandler( +// Building block: Custom parser for complex type +addressHandler := goconfig.NewCustomType( func(rawValue string) (ServerAddress, error) { parts := strings.Split(rawValue, ":") if len(parts) != 2 { @@ -293,6 +307,13 @@ addressHandler := goconfig.NewCustomHandler( } 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 { @@ -335,11 +356,45 @@ if err != nil { } ``` +## 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 custom types -- `internal/readpipeline/custom_types.go` - Custom type implementation -- `internal/readpipeline/typed_handler.go` - TypedHandler interface and implementation +- `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 7e0bb8c..4cf07e8 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A simple, type-safe Go library for loading configuration from environment variab - 🏷️ **Struct-based configuration** - Define config with Go structs and tags - ✅ **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 types and key stores ([docs](docs/advanced.md)) @@ -115,7 +116,7 @@ invalid value for PORT: below minimum 1024 ### Custom Types -Define custom types with validation using the type-safe handler system: +Define custom types with validation using the **building block architecture** - compose simple, reusable components: ```go type APIKey string @@ -124,7 +125,8 @@ type Config struct { APIKey APIKey `key:"API_KEY" required:"true"` } -apiKeyHandler := goconfig.NewCustomHandler( +// Building block approach: parser + validators +apiKeyHandler := goconfig.NewCustomType( func(rawValue string) (APIKey, error) { return APIKey(rawValue), nil }, @@ -141,7 +143,13 @@ err := goconfig.Load(context.Background(), &cfg, ) ``` -📚 **[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 @@ -167,10 +175,11 @@ export MODEL_PARAMS='{"temperature":0.7,"max_tokens":1000}' ## Documentation - 📖 **[Documentation Index](docs/)** - Complete guides and reference +- 🧱 **[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 types and key stores +- 🔧 **[Advanced Features](docs/advanced.md)** - Custom key stores and advanced patterns - 💡 **[Examples](example/)** - Working code examples ## Examples 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/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/validation.md b/docs/validation.md index c3bd275..04898c8 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -95,7 +95,17 @@ type SecurityConfig struct { ## Custom Validators -Use custom types with the `WithCustomType` option to add custom validation logic beyond what struct tags provide. The new type system uses Go generics for type-safe validation. +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 @@ -115,8 +125,8 @@ func main() { var cfg Config err := goconfig.Load(context.Background(), &cfg, - // Validate API key format - goconfig.WithCustomType[APIKey](goconfig.NewCustomHandler( + // Building block: parser + validators + goconfig.WithCustomType[APIKey](goconfig.NewCustomType( func(rawValue string) (APIKey, error) { return APIKey(rawValue), nil }, @@ -132,8 +142,8 @@ func main() { }, )), - // Validate host is not an IP address - goconfig.WithCustomType[Hostname](goconfig.NewCustomHandler( + // Building block: parser + validator + goconfig.WithCustomType[Hostname](goconfig.NewCustomType( func(rawValue string) (Hostname, error) { return Hostname(rawValue), nil }, @@ -146,12 +156,9 @@ func main() { }, )), - // Additional port validation (even numbers only) - goconfig.WithCustomType[Port](goconfig.NewCustomHandler( - func(rawValue string) (Port, error) { - v, err := strconv.Atoi(rawValue) - return Port(v), err - }, + // 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") @@ -172,9 +179,9 @@ func main() { Custom validators are type-safe - no type assertions needed: ```go -// String-based custom type +// String-based custom type - building block approach type Email string -emailHandler := goconfig.NewCustomHandler( +emailHandler := goconfig.NewCustomType( func(rawValue string) (Email, error) { return Email(rawValue), nil }, @@ -186,13 +193,10 @@ emailHandler := goconfig.NewCustomHandler( }, ) -// Int-based custom type +// Type alias - use CastCustomType building block type EvenPort int -evenPortHandler := goconfig.NewCustomHandler( - func(rawValue string) (EvenPort, error) { - v, err := strconv.Atoi(rawValue) - return EvenPort(v), err - }, +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") @@ -201,13 +205,10 @@ evenPortHandler := goconfig.NewCustomHandler( }, ) -// Duration-based custom type +// Duration-based custom type - compose with default duration handler type RequestTimeout time.Duration -timeoutHandler := goconfig.NewCustomHandler( - func(rawValue string) (RequestTimeout, error) { - d, err := time.ParseDuration(rawValue) - return RequestTimeout(d), err - }, +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") @@ -215,10 +216,11 @@ timeoutHandler := goconfig.NewCustomHandler( return nil }, ) +``` ### Enum Types -For string-based enums, use `NewEnumHandler` for automatic validation: +For string-based enums, use the `NewStringEnumType` building block: ```go type LogLevel string @@ -237,15 +239,16 @@ type Config struct { func main() { var cfg Config + // Use the specialized enum building block err := goconfig.Load(context.Background(), &cfg, goconfig.WithCustomType[LogLevel]( - goconfig.NewEnumHandler(LogDebug, LogInfo, LogWarn, LogError), + goconfig.NewStringEnumType(LogDebug, LogInfo, LogWarn, LogError), ), ) } ``` -The enum handler will automatically validate that the value is one of the provided options and return a clear error if not. +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 @@ -271,8 +274,8 @@ func main() { var cfg Config err := goconfig.Load(context.Background(), &cfg, - // Validate database host - goconfig.WithCustomType[DatabaseHost](goconfig.NewCustomHandler( + // Building block: parser + validator + goconfig.WithCustomType[DatabaseHost](goconfig.NewCustomType( func(rawValue string) (DatabaseHost, error) { return DatabaseHost(rawValue), nil }, @@ -285,8 +288,8 @@ func main() { }, )), - // Validate API endpoint uses HTTPS - goconfig.WithCustomType[APIEndpoint](goconfig.NewCustomHandler( + // Building block: parser + validator + goconfig.WithCustomType[APIEndpoint](goconfig.NewCustomType( func(rawValue string) (APIEndpoint, error) { return APIEndpoint(rawValue), nil }, @@ -324,12 +327,10 @@ type Config struct { func main() { var cfg Config - // Create handler with custom validation that runs AFTER min/max from tags - portHandler := goconfig.NewCustomHandler( - func(rawValue string) (Port, error) { - v, err := strconv.Atoi(rawValue) - return Port(v), err - }, + // 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") @@ -346,7 +347,7 @@ func main() { ### Adding Validators to Built-in Types -Use `AddValidators()` to add custom validation to built-in types while keeping their tag validation: +Use the `AddValidators` building block to extend built-in types: ```go type Config struct { @@ -356,38 +357,37 @@ type Config struct { func main() { var cfg Config - // Get the standard int handler - baseHandler := goconfig.NewTypedIntHandler[int]() - - // Add custom validator (port must be even) - evenIntHandler, err := goconfig.AddValidators(baseHandler, func(v int) error { - if v%2 != 0 { - return errors.New("must be even") - } - return nil - }) - if err != nil { - log.Fatal(err) - } + // 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, + 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 `NewCustomHandler`: +You can pass multiple validators to `NewCustomType`: ```go type APIKey string -apiKeyHandler := goconfig.NewCustomHandler( +// Building block: parser + multiple validators +apiKeyHandler := goconfig.NewCustomType( func(rawValue string) (APIKey, error) { return APIKey(rawValue), nil }, - // Multiple validators + // Multiple validators - all must pass func(value APIKey) error { if !strings.HasPrefix(string(value), "sk-") { return errors.New("must start with 'sk-'")