diff --git a/jsonschema/infer.go b/jsonschema/infer.go index d12e556..ea232a4 100644 --- a/jsonschema/infer.go +++ b/jsonschema/infer.go @@ -265,6 +265,7 @@ 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() + s.PropertyOrder = append(s.PropertyOrder, name) } } continue @@ -319,11 +320,37 @@ 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. + for i, j := 0, len(cleaned)-1; i < j; i, j = i+1, j-1 { + cleaned[i], cleaned[j] = cleaned[j], cleaned[i] + } + s.PropertyOrder = cleaned + } + default: if ignore { // Ignore. diff --git a/jsonschema/infer_test.go b/jsonschema/infer_test.go index 64a18f4..ceda83c 100644 --- a/jsonschema/infer_test.go +++ b/jsonschema/infer_test.go @@ -5,6 +5,7 @@ package jsonschema_test import ( + "encoding/json" "log/slog" "math" "math/big" @@ -132,6 +133,7 @@ func TestFor(t *testing.T) { }, Required: []string{"f", "G", "P", "PT"}, AdditionalProperties: falseSchema(), + PropertyOrder: []string{"f", "G", "P", "PT", "NoSkip"}, }, }, { @@ -145,6 +147,7 @@ func TestFor(t *testing.T) { }, Required: []string{"X", "Y"}, AdditionalProperties: falseSchema(), + PropertyOrder: []string{"X", "Y"}, }, }, { @@ -163,6 +166,7 @@ func TestFor(t *testing.T) { }, Required: []string{"B"}, AdditionalProperties: falseSchema(), + PropertyOrder: []string{"B"}, }, "B": { Type: "integer", @@ -171,6 +175,7 @@ func TestFor(t *testing.T) { }, Required: []string{"A", "B"}, AdditionalProperties: falseSchema(), + PropertyOrder: []string{"A", "B"}, }, }, } @@ -205,6 +210,7 @@ func TestFor(t *testing.T) { }, Required: []string{"A"}, AdditionalProperties: falseSchema(), + PropertyOrder: []string{"A"}, }, }) t.Run("lax", func(t *testing.T) { @@ -275,10 +281,82 @@ 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: "integer"}, + }, + Required: []string{"I", "C", "B"}, + AdditionalProperties: falseSchema(), + PropertyOrder: []string{"I", "C", "G", "B"}, + } + 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"},"G":{"type":"integer"},"B":{"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 TestCustomEmbeddedError(t *testing.T) { diff --git a/jsonschema/schema.go b/jsonschema/schema.go index 3b4db9a..9cd1cc3 100644 --- a/jsonschema/schema.go +++ b/jsonschema/schema.go @@ -125,6 +125,17 @@ type Schema struct { // Extra allows for additional keywords beyond those specified. Extra map[string]any `json:"-"` + + // PropertyOrder records the ordering of properties for JSON rendering. + // + // During [For], PropertyOrder is set to the field order, + // if the type used for inference is a struct. + // + // If PropertyOrder is set, it controls the relative ordering of properties in [Schema.MarshalJSON]. + // The rendered JSON first lists any properties that appear in the PropertyOrder slice in the order + // they appear, followed by any properties that do not appear in the PropertyOrder slice in an + // undefined but deterministic order. + PropertyOrder []string `json:"-"` } // falseSchema returns a new Schema tree that fails to validate any value. @@ -212,12 +223,20 @@ func (s *Schema) MarshalJSON() ([]byte, error) { typ = s.Types } ms := struct { - Type any `json:"type,omitempty"` + Type any `json:"type,omitempty"` + Properties json.Marshaler `json:"properties,omitempty"` *schemaWithoutMethods }{ Type: typ, schemaWithoutMethods: (*schemaWithoutMethods)(s), } + if len(s.Properties) > 0 { + ms.Properties = orderedProperties{ + props: s.Properties, + order: s.PropertyOrder, + } + } + bs, err := marshalStructWithMap(&ms, "Extra") if err != nil { return nil, err @@ -233,6 +252,72 @@ func (s *Schema) MarshalJSON() ([]byte, error) { return bs, nil } +// orderedProperties is a helper to marshal the properties map in a specific order. +type orderedProperties struct { + props map[string]*Schema + order []string +} + +func (op orderedProperties) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + buf.WriteByte('{') + + first := true + processed := make(map[string]bool, len(op.props)) + + // Helper closure to write "key": value + writeEntry := func(key string, val *Schema) error { + if !first { + buf.WriteByte(',') + } + first = false + + // Marshal the Key + keyBytes, err := json.Marshal(key) + if err != nil { + return err + } + buf.Write(keyBytes) + + buf.WriteByte(':') + + // Marshal the Value + valBytes, err := json.Marshal(val) + if err != nil { + return err + } + buf.Write(valBytes) + return nil + } + + // Write keys explicitly listed in PropertyOrder + for _, name := range op.order { + if prop, ok := op.props[name]; ok { + if err := writeEntry(name, prop); err != nil { + return nil, err + } + processed[name] = true + } + } + + // Write any remaining keys + var remaining []string + for name := range op.props { + if !processed[name] { + remaining = append(remaining, name) + } + } + + for _, name := range remaining { + if err := writeEntry(name, op.props[name]); err != nil { + return nil, err + } + } + + buf.WriteByte('}') + return buf.Bytes(), nil +} + func (s *Schema) UnmarshalJSON(data []byte) error { // A JSON boolean is a valid schema. var b bool diff --git a/jsonschema/schema_test.go b/jsonschema/schema_test.go index 486d157..6401877 100644 --- a/jsonschema/schema_test.go +++ b/jsonschema/schema_test.go @@ -10,6 +10,9 @@ import ( "math" "regexp" "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" ) func TestGoRoundTrip(t *testing.T) { @@ -118,6 +121,42 @@ func TestUnmarshalErrors(t *testing.T) { } } +func TestMarshalOrder(t *testing.T) { + for _, tt := range []struct { + order []string + want string + }{ + {[]string{"A", "B", "C", "D"}, + `{"type":"object","properties":{"A":{"type":"integer"},"B":{"type":"integer"},"C":{"type":"integer"},"D":{"type":"integer"}}}`}, + {[]string{"A", "C", "B", "D"}, + `{"type":"object","properties":{"A":{"type":"integer"},"C":{"type":"integer"},"B":{"type":"integer"},"D":{"type":"integer"}}}`}, + {[]string{"D", "C", "B", "A"}, + `{"type":"object","properties":{"D":{"type":"integer"},"C":{"type":"integer"},"B":{"type":"integer"},"A":{"type":"integer"}}}`}, + {[]string{"A", "B", "C"}, + `{"type":"object","properties":{"A":{"type":"integer"},"B":{"type":"integer"},"C":{"type":"integer"},"D":{"type":"integer"}}}`}, + {[]string{"A", "B", "C", "D", "D"}, + `{"type":"object","properties":{"A":{"type":"integer"},"B":{"type":"integer"},"C":{"type":"integer"},"D":{"type":"integer"},"D":{"type":"integer"}}}`}, + } { + s := &Schema{ + Type: "object", + Properties: map[string]*Schema{ + "A": {Type: "integer"}, + "B": {Type: "integer"}, + "C": {Type: "integer"}, + "D": {Type: "integer"}, + }, + } + s.PropertyOrder = tt.order + gotBytes, err := json.Marshal(s) + if err != nil { + t.Fatal(err) + } + if diff := cmp.Diff(tt.want, string(gotBytes), cmpopts.IgnoreUnexported(Schema{})); diff != "" { + t.Fatalf("ForType mismatch (-want +got):\n%s", diff) + } + } +} + func mustUnmarshal(t *testing.T, data []byte, ptr any) { t.Helper() if err := json.Unmarshal(data, ptr); err != nil { diff --git a/jsonschema/validate_test.go b/jsonschema/validate_test.go index 0808f4c..8ea9e8b 100644 --- a/jsonschema/validate_test.go +++ b/jsonschema/validate_test.go @@ -504,6 +504,7 @@ func TestStructEmbedding(t *testing.T) { }, Required: []string{"id", "name", "extra"}, AdditionalProperties: falseSchema(), + PropertyOrder: []string{"id", "name", "extra"}, }, }, validInstance: []Banana{ @@ -525,6 +526,7 @@ func TestStructEmbedding(t *testing.T) { }, Required: []string{"id", "name", "extra"}, AdditionalProperties: falseSchema(), + PropertyOrder: []string{"id", "name", "extra"}, }, }, validInstance: []Durian{ @@ -546,6 +548,7 @@ func TestStructEmbedding(t *testing.T) { }, Required: []string{"id", "name", "extra"}, AdditionalProperties: falseSchema(), + PropertyOrder: []string{"id", "name", "extra"}, }, }, validInstance: []Fig{ @@ -567,6 +570,7 @@ func TestStructEmbedding(t *testing.T) { }, Required: []string{"id", "name", "extra"}, AdditionalProperties: falseSchema(), + PropertyOrder: []string{"id", "name", "extra"}, }, }, validInstance: []Honeyberry{ @@ -587,6 +591,7 @@ func TestStructEmbedding(t *testing.T) { }, Required: []string{"inner_only", "conflict_field"}, AdditionalProperties: falseSchema(), + PropertyOrder: []string{"inner_only", "conflict_field"}, }, validInstance: Outer{Inner: &Inner{InnerOnly: "data"}, Conflict: 123}, },