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
2 changes: 1 addition & 1 deletion jsonschema/draft07_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ func TestDraft07Marshalling(t *testing.T) {
{
name: "draft-07 dependencies marshalling complex",
input: `{"dependencies": {"name": ["first", "last"],"billing_address": {"required": ["shipping_address"],"properties": {"user_role": { "enum": ["preferred", "standard"] }}}}}`,
expected: `{"dependencies":{"billing_address":{"required":["shipping_address"],"properties":{"user_role":{"enum":["preferred","standard"]}}},"name":["first","last"]}}`,
expected: `{"dependencies":{"billing_address":{"properties":{"user_role":{"enum":["preferred","standard"]}},"required":["shipping_address"]},"name":["first","last"]}}`,
},
{
name: "draft-07 definitions marshalling",
Expand Down
44 changes: 39 additions & 5 deletions jsonschema/infer.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type ForOptions struct {
// ensure uniqueness).
// Types in this map override the default translations, as described
// in [For]'s documentation.
// PropertyOrder defined in these schemas will not be used in [For]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or [ForType].

TypeSchemas map[reflect.Type]*Schema
}

Expand All @@ -58,6 +59,7 @@ type ForOptions struct {
// Their properties are derived from exported struct fields, using the
// struct field JSON name. Fields that are marked "omitempty" or "omitzero" are
// considered optional; all other fields become required properties.
// For structs, the PropertyOrder will be set to the field order.
// - Some types in the standard library that implement json.Marshaler
// translate to schemas that match the values to which they marshal.
// For example, [time.Time] translates to the schema for strings.
Expand Down Expand Up @@ -250,6 +252,9 @@ func forType(t reflect.Type, seen map[reflect.Type]bool, ignore bool, schemas ma
// schema has been replaced by a known schema.
var skipPath []int
for _, field := range reflect.VisibleFields(t) {
if s.Properties == nil {
s.Properties = make(map[string]*Schema)
}
if field.Anonymous {
override := schemas[field.Type]
if override != nil {
Expand All @@ -271,8 +276,16 @@ func forType(t reflect.Type, seen map[reflect.Type]bool, ignore bool, schemas ma
}

skipPath = field.Index
for name, prop := range override.Properties {
s.Properties[name] = prop.CloneSchemas()
keys := make([]string, 0, len(override.Properties))
for k := range override.Properties {
keys = append(keys, k)
}
slices.Sort(keys)
for _, name := range keys {
if _, ok := s.Properties[name]; !ok {
s.Properties[name] = override.Properties[name].CloneSchemas()
s.PropertyOrder = append(s.PropertyOrder, name)
}
}
}
continue
Expand Down Expand Up @@ -306,9 +319,6 @@ func forType(t reflect.Type, seen map[reflect.Type]bool, ignore bool, schemas ma
if info.omit {
continue
}
if s.Properties == nil {
s.Properties = make(map[string]*Schema)
}
fs, err := forType(field.Type, seen, ignore, schemas)
if err != nil {
return nil, err
Expand All @@ -327,11 +337,35 @@ func forType(t reflect.Type, seen map[reflect.Type]bool, ignore bool, schemas ma
fs.Description = tag
}
s.Properties[info.name] = fs

s.PropertyOrder = append(s.PropertyOrder, info.name)

if !info.settings["omitempty"] && !info.settings["omitzero"] {
s.Required = append(s.Required, info.name)
}
}

// Remove PropertyOrder duplicates, keeping the last occurrence
if len(s.PropertyOrder) > 1 {
seen := make(map[string]bool)
// Create a slice to hold the cleaned order (capacity = current length)
cleaned := make([]string, 0, len(s.PropertyOrder))

// Iterate backwards
for i := len(s.PropertyOrder) - 1; i >= 0; i-- {
name := s.PropertyOrder[i]
if !seen[name] {
cleaned = append(cleaned, name)
seen[name] = true
}
}

// Since we collected them backwards, we need to reverse the result
// to restore the correct order.
slices.Reverse(cleaned)
s.PropertyOrder = cleaned
}

default:
if ignore {
// Ignore.
Expand Down
243 changes: 243 additions & 0 deletions jsonschema/infer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package jsonschema_test

import (
"encoding/json"
"log/slog"
"math"
"math/big"
Expand Down Expand Up @@ -134,6 +135,7 @@ func TestFor(t *testing.T) {
},
Required: []string{"f", "G", "P", "PT"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"f", "G", "P", "PT", "NoSkip"},
},
},
{
Expand All @@ -147,6 +149,7 @@ func TestFor(t *testing.T) {
},
Required: []string{"X", "Y"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"X", "Y"},
},
},
{
Expand All @@ -165,6 +168,7 @@ func TestFor(t *testing.T) {
},
Required: []string{"B"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"B"},
},
"B": {
Type: "integer",
Expand All @@ -173,6 +177,7 @@ func TestFor(t *testing.T) {
},
Required: []string{"A", "B"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"A", "B"},
},
},
}
Expand Down Expand Up @@ -207,6 +212,7 @@ func TestFor(t *testing.T) {
},
Required: []string{"A"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"A"},
},
})
t.Run("lax", func(t *testing.T) {
Expand Down Expand Up @@ -277,10 +283,247 @@ func TestForType(t *testing.T) {
},
Required: []string{"I", "C", "P", "PP", "B", "M1", "PM1", "M2", "PM2"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"I", "C", "P", "PP", "G", "B", "M1", "PM1", "M2", "PM2"},
}
if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(schema{})); diff != "" {
t.Fatalf("ForType mismatch (-want +got):\n%s", diff)
}

gotBytes, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
wantStr := `{"type":"object","properties":{"I":{"type":"integer"},"C":{"type":"custom"},"P":{"type":["null","custom"]},"PP":{"type":["null","custom"]},"G":{"type":"integer"}` +
`,"B":{"type":"boolean"},"M1":{"type":["custom1","custom2"]},"PM1":{"type":["null","custom1","custom2"]},"M2":{"type":["null","custom3","custom4"]},` +
`"PM2":{"type":["null","custom3","custom4"]}},"required":["I","C","P","PP","B","M1","PM1","M2","PM2"],"additionalProperties":false}`
if diff := cmp.Diff(wantStr, string(gotBytes), cmpopts.IgnoreUnexported(schema{})); diff != "" {
t.Fatalf("ForType mismatch (-want +got):\n%s", diff)
}
}

func TestForTypeWithDifferentOrder(t *testing.T) {
// This tests embedded structs with a custom schema in addition to ForType.
type schema = jsonschema.Schema

type E struct {
G float64 // promoted into S
B int // hidden by S.B
}

type S struct {
I int
F func()
C custom
B bool
E
}

opts := &jsonschema.ForOptions{
IgnoreInvalidTypes: true,
TypeSchemas: map[reflect.Type]*schema{
reflect.TypeFor[custom](): {Type: "custom"},
reflect.TypeFor[E](): {
Type: "object",
Properties: map[string]*schema{
"G": {Type: "integer"},
"B": {Type: "integer"},
},
},
},
}
got, err := jsonschema.ForType(reflect.TypeOf(S{}), opts)
if err != nil {
t.Fatal(err)
}
want := &schema{
Type: "object",
Properties: map[string]*schema{
"I": {Type: "integer"},
"C": {Type: "custom"},
"G": {Type: "integer"},
"B": {Type: "boolean"},
},
Required: []string{"I", "C", "B"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"I", "C", "B", "G"},
}
if diff := cmp.Diff(want, got, cmpopts.IgnoreUnexported(schema{})); diff != "" {
t.Fatalf("ForType mismatch (-want +got):\n%s", diff)
}

gotBytes, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
wantStr := `{"type":"object","properties":{"I":{"type":"integer"},"C":{"type":"custom"},"B":{"type":"boolean"},"G":{"type":"integer"}},"required":["I","C","B"],"additionalProperties":false}`
if diff := cmp.Diff(wantStr, string(gotBytes), cmpopts.IgnoreUnexported(schema{})); diff != "" {
t.Fatalf("ForType mismatch (-want +got):\n%s", diff)
}
}

func TestForTypeWithEmbeddedStruct(t *testing.T) {
// This tests embedded structs with a custom schema in addition to ForType.
type schema = jsonschema.Schema

type E struct {
G float64 // promoted into S
B int // promoted into S
I int // promoted into S
}

type S struct {
F func()
C custom
E
}

type S1 struct {
F func()
C custom
E
M int
}

type test struct {
name string
convertType reflect.Type
opts *jsonschema.ForOptions
want *jsonschema.Schema
wantStr string
}

tests := []test{
{
name: "Embedded without override",
convertType: reflect.TypeOf(S{}),
opts: &jsonschema.ForOptions{
IgnoreInvalidTypes: true,
TypeSchemas: map[reflect.Type]*schema{
reflect.TypeFor[custom](): {Type: "custom"},
},
},
want: &schema{
Type: "object",
Properties: map[string]*schema{
"C": {Type: "custom"},
"G": {Type: "number"},
"B": {Type: "integer"},
"I": {Type: "integer"},
},
Required: []string{"C", "G", "B", "I"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"C", "G", "B", "I"},
},
wantStr: `{"type":"object","properties":{"C":{"type":"custom"},"G":{"type":"number"},"B":{"type":"integer"},"I":{"type":"integer"}},"required":["C","G","B","I"],"additionalProperties":false}`,
},
{
name: "Embedded with overwrite",
convertType: reflect.TypeOf(S{}),
opts: &jsonschema.ForOptions{
IgnoreInvalidTypes: true,
TypeSchemas: map[reflect.Type]*schema{
reflect.TypeFor[custom](): {Type: "custom"},
reflect.TypeFor[E](): {
Type: "object",
Properties: map[string]*schema{
"G": {Type: "integer"},
"B": {Type: "integer"},
"I": {Type: "integer"},
},
},
},
},
want: &schema{
Type: "object",
Properties: map[string]*schema{
"C": {Type: "custom"},
"G": {Type: "integer"},
"B": {Type: "integer"},
"I": {Type: "integer"},
},
Required: []string{"C"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"C", "B", "G", "I"},
},
wantStr: `{"type":"object","properties":{"C":{"type":"custom"},"B":{"type":"integer"},"G":{"type":"integer"},"I":{"type":"integer"}},"required":["C"],"additionalProperties":false}`,
},
{
name: "Embedded in the middle without overwrite",
convertType: reflect.TypeOf(S1{}),
opts: &jsonschema.ForOptions{
IgnoreInvalidTypes: true,
TypeSchemas: map[reflect.Type]*schema{
reflect.TypeFor[custom](): {Type: "custom"},
},
},
want: &schema{
Type: "object",
Properties: map[string]*schema{
"C": {Type: "custom"},
"G": {Type: "number"},
"B": {Type: "integer"},
"I": {Type: "integer"},
"M": {Type: "integer"},
},
Required: []string{"C", "G", "B", "I", "M"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"C", "G", "B", "I", "M"},
},
wantStr: `{"type":"object","properties":{"C":{"type":"custom"},"G":{"type":"number"},"B":{"type":"integer"},"I":{"type":"integer"},"M":{"type":"integer"}},"required":["C","G","B","I","M"],"additionalProperties":false}`,
},
{
name: "Embedded in the middle with overwrite",
convertType: reflect.TypeOf(S1{}),
opts: &jsonschema.ForOptions{
IgnoreInvalidTypes: true,
TypeSchemas: map[reflect.Type]*schema{
reflect.TypeFor[custom](): {Type: "custom"},
reflect.TypeFor[E](): {
Type: "object",
Properties: map[string]*schema{
"G": {Type: "integer"},
"B": {Type: "integer"},
"I": {Type: "integer"},
},
},
},
},
want: &schema{
Type: "object",
Properties: map[string]*schema{
"C": {Type: "custom"},
"G": {Type: "integer"},
"B": {Type: "integer"},
"I": {Type: "integer"},
"M": {Type: "integer"},
},
Required: []string{"C", "M"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"C", "B", "G", "I", "M"},
},
wantStr: `{"type":"object","properties":{"C":{"type":"custom"},"B":{"type":"integer"},"G":{"type":"integer"},"I":{"type":"integer"},"M":{"type":"integer"}},"required":["C","M"],"additionalProperties":false}`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := jsonschema.ForType(tt.convertType, tt.opts)
if err != nil {
t.Fatal(err)
}

if diff := cmp.Diff(tt.want, got, cmpopts.IgnoreUnexported(schema{})); diff != "" {
t.Fatalf("ForType mismatch (-want +got):\n%s", diff)
}
gotBytes, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(tt.wantStr, string(gotBytes), cmpopts.IgnoreUnexported(schema{})); diff != "" {
t.Fatalf("ForType mismatch (-want +got):\n%s", diff)
}
})
}
}

func TestCustomEmbeddedError(t *testing.T) {
Expand Down
Loading