-
Notifications
You must be signed in to change notification settings - Fork 18
Add PropertyOrder #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe there should be no duplicates after the above change. |
||
| 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 { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (but I don't think you'll need it) |
||
| cleaned[i], cleaned[j] = cleaned[j], cleaned[i] | ||
| } | ||
| s.PropertyOrder = cleaned | ||
| } | ||
|
|
||
| default: | ||
| if ignore { | ||
| // Ignore. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also test with E before B. |
||
| } | ||
|
|
||
| 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"}, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should be bool. The top-level field takes precedence. |
||
| }, | ||
| Required: []string{"I", "C", "B"}, | ||
| AdditionalProperties: falseSchema(), | ||
| PropertyOrder: []string{"I", "C", "G", "B"}, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should be 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"},"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) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Doc this in |
||
| // 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "followed by all other properties..." |
||
| // 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"}, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add "E", make sure it's at the end. |
||
| "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 { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unrelated bug: if an anonymous field is first, s.Properties will be nil.