Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions form/decode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ import (
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"testing"
"time"

"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{}
Expand Down Expand Up @@ -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())
}
})
}
32 changes: 25 additions & 7 deletions form/decoders.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down
Loading