From e722f72ceac7d50a0b458a2bff14a13ee8a44aed Mon Sep 17 00:00:00 2001 From: tatang26 Date: Sat, 28 Feb 2026 16:34:19 -0500 Subject: [PATCH] feat: make RegisterCustomTypeFunc generic with type inference Replace the old (func(string) (any, error), any) signature with a generic [T any] version that infers the target type from the function return type. Supports concrete types, interface types, and backward-compatible any+hint registration. Includes tests for all paths. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- form/decode_test.go | 148 ++++++++++++++++++++++++++++++++++++++++++++ form/decoders.go | 32 +++++++--- 2 files changed, 173 insertions(+), 7 deletions(-) diff --git a/form/decode_test.go b/form/decode_test.go index 4829737..5e11162 100644 --- a/form/decode_test.go +++ b/form/decode_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "slices" + "strconv" "strings" "testing" "time" @@ -14,6 +15,12 @@ import ( "go.leapkit.dev/core/form" ) +type customStringer string + +func (cs customStringer) String() string { + return string(cs) +} + func TestDecode(t *testing.T) { t.Run("correct happy path", func(t *testing.T) { data := url.Values{} @@ -849,4 +856,145 @@ func TestDecode(t *testing.T) { t.Fatal("expected error for invalid time field value") } }) + + t.Run("correct RegisterCustomTypeFunc with concrete type", func(t *testing.T) { + type CustomType1 int + + form.RegisterCustomTypeFunc(func(s string) (CustomType1, error) { + n, err := strconv.Atoi(s) + return CustomType1(n), err + }) + + vals := url.Values{} + vals.Set("field_a", "42") + + req, _ := http.NewRequest("POST", "/", strings.NewReader(vals.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var s struct { + FieldA CustomType1 `form:"field_a"` + } + + if err := form.Decode(req, &s); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if s.FieldA != 42 { + t.Errorf("expected FieldA to be 42, got %v", s.FieldA) + } + }) + + t.Run("correct RegisterCustomTypeFunc with pointer field", func(t *testing.T) { + type CustomType1 int + + form.RegisterCustomTypeFunc(func(s string) (CustomType1, error) { + n, err := strconv.Atoi(s) + return CustomType1(n), err + }) + + vals := url.Values{} + vals.Set("present", "7") + + req, _ := http.NewRequest("POST", "/", strings.NewReader(vals.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var s struct { + Present *CustomType1 `form:"present"` + Absent *CustomType1 `form:"absent"` + } + + if err := form.Decode(req, &s); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if s.Present == nil || *s.Present != 7 { + t.Errorf("expected Present to be pointer to 7, got %v", s.Present) + } + + if s.Absent != nil { + t.Errorf("expected Absent to be nil, got %v", s.Absent) + } + }) + + t.Run("correct RegisterCustomTypeFunc with any and customType hint", func(t *testing.T) { + type CustomType2 struct{ V int } + + form.RegisterCustomTypeFunc(func(s string) (any, error) { + n, err := strconv.Atoi(s) + return CustomType2{V: n}, err + }, CustomType2{}) + + vals := url.Values{} + vals.Set("field", "10") + + req, _ := http.NewRequest("POST", "/", strings.NewReader(vals.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var s struct { + Field CustomType2 `form:"field"` + } + + if err := form.Decode(req, &s); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if s.Field.V != 10 { + t.Errorf("expected Field.V to be 10, got %v", s.Field.V) + } + }) + + t.Run("correct RegisterCustomTypeFunc with interface type", func(t *testing.T) { + form.RegisterCustomTypeFunc(func(s string) (fmt.Stringer, error) { + return customStringer(s), nil + }) + + vals := url.Values{} + vals.Set("field", "hello") + + req, _ := http.NewRequest("POST", "/", strings.NewReader(vals.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var s struct { + Field fmt.Stringer `form:"field"` + } + + if err := form.Decode(req, &s); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if s.Field == nil { + t.Fatal("expected Field to not be nil") + } + + if s.Field.String() != "hello" { + t.Errorf("expected Field.String() to be %q, got %q", "hello", s.Field.String()) + } + }) + + t.Run("incorrect RegisterCustomTypeFunc decoder error", func(t *testing.T) { + type CustomType3 int + + form.RegisterCustomTypeFunc(func(s string) (CustomType3, error) { + return 0, fmt.Errorf("custom decode error: %q", s) + }) + + vals := url.Values{} + vals.Set("field", "bad") + + req, _ := http.NewRequest("POST", "/", strings.NewReader(vals.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + var s struct { + Field CustomType3 `form:"field"` + } + + err := form.Decode(req, &s) + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "custom decode error") { + t.Errorf("expected error to contain %q, got %q", "custom decode error", err.Error()) + } + }) } diff --git a/form/decoders.go b/form/decoders.go index ff811a3..35487c7 100644 --- a/form/decoders.go +++ b/form/decoders.go @@ -14,7 +14,7 @@ var ( // customDecoders holds custom decoders for specific types. // The default map includes a decoder for time.Time. customDecoders = map[reflect.Type]func(string) (any, error){ - reflect.TypeOf(time.Time{}): func(value string) (any, error) { + reflect.TypeFor[time.Time](): func(value string) (any, error) { layouts := []string{ time.Layout, time.ANSIC, @@ -51,15 +51,33 @@ var ( } ) -// RegisterCustomTypeFunc registers a custom decoder function for a specific type. -// The decoder function should accept a string and return the decoded value or an error. -// This allows Decode to handle custom types beyond the built-in decoders. -func RegisterCustomTypeFunc(fn func(string) (any, error), customType any) { +// RegisterCustomTypeFunc registers a custom decoder function for type T, +// allowing Decode to handle types beyond the built-in decoders. +// +// The type to register is inferred from the return type of fn: +// +// form.RegisterCustomTypeFunc(func(s string) (MyType, error) { ... }) +// +// For interface return types like fmt.Stringer, the decoder is registered +// under the interface type itself: +// +// form.RegisterCustomTypeFunc(func(s string) (fmt.Stringer, error) { ... }) +// +// For backward compatibility, when fn returns any, pass a zero-value instance +// of the target type via customType so the decoder is registered under +// the concrete type rather than interface{}: +// +// form.RegisterCustomTypeFunc(func(s string) (any, error) { ... }, MyType{}) +func RegisterCustomTypeFunc[T any](fn func(string) (T, error), customType ...any) { mu.Lock() defer mu.Unlock() - t := reflect.TypeOf(customType) - customDecoders[t] = fn + rt := reflect.TypeFor[T]() + if rt.Kind() == reflect.Interface && len(customType) > 0 { + rt = reflect.TypeOf(customType[0]) + } + + customDecoders[rt] = func(s string) (any, error) { return fn(s) } } // builtInDecoders provides decoding functions for Go's basic kinds (bool, int, float, string, etc).