diff --git a/huma_internal_test.go b/huma_internal_test.go index aa0ae6b8..2789ccea 100644 --- a/huma_internal_test.go +++ b/huma_internal_test.go @@ -1,7 +1,10 @@ package huma import ( + "encoding" + "reflect" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -14,3 +17,75 @@ func TestCollectionPath(t *testing.T) { assert.Panics(t, func() { collectionPath([]int{2}) }) assert.Panics(t, func() { collectionPath(nil) }) } + +type issue680Duration struct { + time.Duration +} + +func (d *issue680Duration) UnmarshalText(data []byte) error { + v, err := time.ParseDuration(string(data)) + if err != nil { + return err + } + d.Duration = v + return nil +} + +var _ encoding.TextUnmarshaler = (*issue680Duration)(nil) + +func TestConvertTypeTextUnmarshalerDefault(t *testing.T) { + got := convertType("Duration", reflect.TypeOf(issue680Duration{}), "10s") + d, ok := got.(issue680Duration) + if !ok { + t.Fatalf("got %T %#v", got, got) + } + if d.Duration != 10*time.Second { + t.Fatalf("duration=%v", d.Duration) + } +} + +func TestFindDefaultsTextUnmarshaler(t *testing.T) { + type Input struct { + Duration issue680Duration `json:"duration" default:"10s"` + } + r := NewMapRegistry("#/components/schemas/", DefaultSchemaNamer) + defaults := findDefaults(r, reflect.TypeOf(Input{})) + var in Input + v := reflect.ValueOf(&in).Elem() + setDefaults(v, defaults) + if in.Duration.Duration != 10*time.Second { + t.Fatalf("default not applied: %v", in.Duration) + } +} + +func TestSchemaFromFieldTextUnmarshalerDefault(t *testing.T) { + type Input struct { + Duration issue680Duration `json:"duration" default:"10s"` + } + r := NewMapRegistry("#/components/schemas/", DefaultSchemaNamer) + s := SchemaFromType(r, reflect.TypeOf(Input{})) + def := s.Properties["duration"].Default + if def != "10s" { + t.Fatalf("schema default=%#v want %q", def, "10s") + } +} + +func TestConvertTypeTextUnmarshalerPointerDefault(t *testing.T) { + got := convertType("Duration", reflect.TypeOf((*issue680Duration)(nil)), "10s") + d, ok := got.(*issue680Duration) + if !ok || d == nil { + t.Fatalf("got %T %#v", got, got) + } + if d.Duration != 10*time.Second { + t.Fatalf("duration=%v", d.Duration) + } +} + +func TestConvertTypeTextUnmarshalerDefaultInvalid(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic on invalid duration default") + } + }() + _ = convertType("Duration", reflect.TypeOf(issue680Duration{}), "not-a-duration") +} diff --git a/schema.go b/schema.go index b4bd3ad9..b8ba52a3 100644 --- a/schema.go +++ b/schema.go @@ -504,6 +504,19 @@ func convertType(fieldName string, t reflect.Type, v any) any { return tmp.Interface() } + target := deref(t) + targetPtr := reflect.PointerTo(target) + if s, ok := v.(string); ok && (target.Implements(textUnmarshalerType) || targetPtr.Implements(textUnmarshalerType)) { + converted := reflect.New(target) + if err := converted.Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(s)); err != nil { + panic(fmt.Errorf("unable to convert %v to %v for field '%s': %v: %w", tv, t, fieldName, err, ErrSchemaInvalid)) + } + if t.Kind() == reflect.Pointer { + return converted.Interface() + } + return converted.Elem().Interface() + } + if !tv.ConvertibleTo(deref(t)) { panic(fmt.Errorf("unable to convert %v to %v for field '%s': %w", tv, t, fieldName, ErrSchemaInvalid)) } @@ -557,7 +570,14 @@ func jsonTagValue(r Registry, fieldName string, s *Schema, value string) any { func jsonTag(r Registry, f reflect.StructField, s *Schema, name string) any { t := f.Type if value := f.Tag.Get(name); value != "" { - return convertType(f.Name, t, jsonTagValue(r, f.Name, s, value)) + parsed := jsonTagValue(r, f.Name, s, value) + base := deref(t) + // Keep schema defaults in wire form for TextUnmarshaler string types so + // OpenAPI stays a string (e.g. "10s") while findDefaults still converts. + if base.Implements(textUnmarshalerType) || reflect.PointerTo(base).Implements(textUnmarshalerType) { + return parsed + } + return convertType(f.Name, t, parsed) } return nil } diff --git a/schema_test.go b/schema_test.go index c0598fdc..9e024508 100644 --- a/schema_test.go +++ b/schema_test.go @@ -571,6 +571,23 @@ func TestSchema(t *testing.T) { "required": ["value"] }`, }, + { + name: "field-default-text-unmarshaler", + input: struct { + Value MyDate `json:"value" default:"2020-01-02"` + }{}, + expected: `{ + "type": "object", + "properties": { + "value": { + "type": "string", + "default": "2020-01-02" + } + }, + "additionalProperties": false, + "required": ["value"] + }`, + }, { name: "field-optional-without-name", input: struct {