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
26 changes: 24 additions & 2 deletions pkg/jsonschema/jsonschema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
56 changes: 56 additions & 0 deletions pkg/jsonschema/jsonschema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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 {
Expand Down
Loading