Skip to content
Open
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
27 changes: 27 additions & 0 deletions jsonschema/infer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
78 changes: 78 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 @@ -132,6 +133,7 @@ func TestFor(t *testing.T) {
},
Required: []string{"f", "G", "P", "PT"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"f", "G", "P", "PT", "NoSkip"},
},
},
{
Expand All @@ -145,6 +147,7 @@ func TestFor(t *testing.T) {
},
Required: []string{"X", "Y"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"X", "Y"},
},
},
{
Expand All @@ -163,6 +166,7 @@ func TestFor(t *testing.T) {
},
Required: []string{"B"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"B"},
},
"B": {
Type: "integer",
Expand All @@ -171,6 +175,7 @@ func TestFor(t *testing.T) {
},
Required: []string{"A", "B"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"A", "B"},
},
},
}
Expand Down Expand Up @@ -205,6 +210,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 @@ -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) {
Expand Down
87 changes: 86 additions & 1 deletion jsonschema/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions jsonschema/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions jsonschema/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ func TestStructEmbedding(t *testing.T) {
},
Required: []string{"id", "name", "extra"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"id", "name", "extra"},
},
},
validInstance: []Banana{
Expand All @@ -525,6 +526,7 @@ func TestStructEmbedding(t *testing.T) {
},
Required: []string{"id", "name", "extra"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"id", "name", "extra"},
},
},
validInstance: []Durian{
Expand All @@ -546,6 +548,7 @@ func TestStructEmbedding(t *testing.T) {
},
Required: []string{"id", "name", "extra"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"id", "name", "extra"},
},
},
validInstance: []Fig{
Expand All @@ -567,6 +570,7 @@ func TestStructEmbedding(t *testing.T) {
},
Required: []string{"id", "name", "extra"},
AdditionalProperties: falseSchema(),
PropertyOrder: []string{"id", "name", "extra"},
},
},
validInstance: []Honeyberry{
Expand All @@ -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},
},
Expand Down