diff --git a/pkg/jsonschema/jsonschema.go b/pkg/jsonschema/jsonschema.go index 509bd92..2a1b6e4 100644 --- a/pkg/jsonschema/jsonschema.go +++ b/pkg/jsonschema/jsonschema.go @@ -421,6 +421,10 @@ func applySpecialTypeSchema(s *upstream.Schema, t reflect.Type) bool { resetToScalarStringSchema(s, "json") return true } + if isDurationLikeType(t) { + resetToScalarStringSchema(s, "duration") + return true + } return false } } @@ -437,6 +441,22 @@ func isJSONBytesType(t reflect.Type) bool { return t != dataType && t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 && t.Implements(jsonMarshalerType) } +// isDurationLikeType reports whether t is a named type derived from +// time.Duration (e.g. `type Foo time.Duration`) that provides its own JSON +// encoding - many packages define a local duration type this way rather +// than exposing time.Duration on the wire directly, typically to render it +// as a duration string via a custom MarshalJSON. Requiring both the exact +// underlying kind and a custom Marshaler (rather than just Kind==Int64, +// which any unrelated int64 type would trivially satisfy via +// ConvertibleTo) keeps this from misfiring on arbitrary int64 fields that +// have nothing to do with durations. +func isDurationLikeType(t reflect.Type) bool { + return t != durationType && + t.Kind() == reflect.Int64 && + t.ConvertibleTo(durationType) && + t.Implements(jsonMarshalerType) +} + func resetToScalarStringSchema(s *upstream.Schema, format string) { s.Type = "string" s.Types = nil @@ -474,8 +494,10 @@ func marshalDefault(t reflect.Type, val string) json.RawMessage { for t.Kind() == reflect.Pointer { t = t.Elem() } - // time.Duration defaults are stored as duration strings (e.g. "5s"), not integers. - if t == durationType { + // time.Duration (and duration-like named types, e.g. `type Foo + // time.Duration` with its own MarshalJSON) defaults are stored as + // duration strings (e.g. "5s"), not integers. + if t == durationType || isDurationLikeType(t) { if b, err := json.Marshal(val); err == nil { return b } diff --git a/pkg/jsonschema/jsonschema_test.go b/pkg/jsonschema/jsonschema_test.go index ad7382c..c299b84 100644 --- a/pkg/jsonschema/jsonschema_test.go +++ b/pkg/jsonschema/jsonschema_test.go @@ -173,6 +173,21 @@ type customJSONBytesStruct struct { Payload customJSONBytes `json:"payload"` } +// customDuration is a named type derived from time.Duration with its own +// MarshalJSON, matching the shape of a package-local "type Foo +// time.Duration" that renders itself as a duration string - the case +// isDurationLikeType exists to detect, since it's a different reflect.Type +// than time.Duration itself and so isn't caught by the exact-type switch. +type customDuration time.Duration + +func (d customDuration) MarshalJSON() ([]byte, error) { + return json.Marshal(time.Duration(d).String()) +} + +type customDurationStruct struct { + Timeout customDuration `json:"timeout"` +} + type embeddedSpecialStruct struct { Format customJSONBytes `json:"format,omitempty"` Data []byte `json:"data,omitempty"` @@ -1076,6 +1091,47 @@ func TestFor_TimeDuration_StructField(t *testing.T) { } } +// Regression test: a named type derived from time.Duration (e.g. `type Foo +// time.Duration` with its own MarshalJSON) used to fall through to the +// generic int64 handling - rendering as "integer" instead of a duration +// string - because the switch in applySpecialTypeSchema compared by exact +// reflect.Type identity, which time.Duration-derived types never match. +func TestFor_CustomDuration_TopLevel(t *testing.T) { + s, err := For[customDuration]() + if err != nil { + t.Fatal(err) + } + if s.Type != "string" { + t.Fatalf("type: got %q, want \"string\"", s.Type) + } + if s.Format != "duration" { + t.Fatalf("format: got %q, want \"duration\"", s.Format) + } + if s.Items != nil { + t.Fatalf("items: got %v, want nil", s.Items) + } +} + +func TestFor_CustomDuration_StructField(t *testing.T) { + s, err := For[customDurationStruct]() + if err != nil { + t.Fatal(err) + } + prop := s.Properties["timeout"] + if prop == nil { + t.Fatal("expected property 'timeout'") + } + if prop.Type != "string" && !sliceContains(prop.Types, "string") { + t.Fatalf("timeout type: got Type=%q Types=%v, want string", prop.Type, prop.Types) + } + if prop.Format != "duration" { + t.Fatalf("timeout format: got %q, want \"duration\"", prop.Format) + } + if prop.Items != nil { + t.Fatalf("timeout items: got %v, want nil", prop.Items) + } +} + func TestFor_JSONRawMessage_TopLevel(t *testing.T) { s, err := For[json.RawMessage]() if err != nil {