From f9deb571c5397fecc58c65452aec4a3a9eeacb43 Mon Sep 17 00:00:00 2001 From: diamondburned Date: Mon, 12 Aug 2024 14:05:30 +0700 Subject: [PATCH] Add openapi3.Schema.OrderedPropertyKeys This commit adds the `OrderedPropertyKeys` method to the `openapi3.Schema`: OrderedPropertyKeys returns the keys of the properties in the order they were defined. This is useful for generating code that needs to iterate over the properties in a consistent order. If the keys could not be extracted for some reason, then this method automatically sorts the keys to be deterministic. This is done via a temporary fork of the YAML-to-JSON transformation library. It will not be ready until these PRs are merged in this order: - https://github.com/silasdavis/yaml/pull/1 - https://github.com/ghodss/yaml/pull/62 --- go.mod | 4 ++++ go.sum | 5 ++++ jsoninfo/orderedmap.go | 46 +++++++++++++++++++++++++++++++++++++ jsoninfo/orderedmap_test.go | 23 +++++++++++++++++++ openapi3/loader.go | 2 +- openapi3/schema.go | 44 +++++++++++++++++++++++++++++++++-- openapi3/schema_test.go | 20 ++++++++++++++++ 7 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 jsoninfo/orderedmap.go create mode 100644 jsoninfo/orderedmap_test.go diff --git a/go.mod b/go.mod index 9da0ae552..9f69c2f58 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,14 @@ module github.com/getkin/kin-openapi go 1.14 +replace github.com/ghodss/yaml/v2 => github.com/diamondburned/yaml/v2 v2.0.0-20240812065612-baf990d70122 + require ( github.com/ghodss/yaml v1.0.0 + github.com/ghodss/yaml/v2 v2.0.0-00010101000000-000000000000 github.com/go-openapi/jsonpointer v0.19.5 github.com/gorilla/mux v1.8.0 github.com/stretchr/testify v1.5.1 gopkg.in/yaml.v2 v2.3.0 + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2b289d716..4b8a8bd07 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/diamondburned/yaml/v2 v2.0.0-20240812065612-baf990d70122 h1:hOA7Z6xhY5sn50zMsuY9JhA0A1QMiO0z/Ltx7ZcqUCM= +github.com/diamondburned/yaml/v2 v2.0.0-20240812065612-baf990d70122/go.mod h1:KkR1H6NtyEqVsGChMAaRwn4BkIX0dG683i7NgqX947Y= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= @@ -29,3 +31,6 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/jsoninfo/orderedmap.go b/jsoninfo/orderedmap.go new file mode 100644 index 000000000..f7a7a9c06 --- /dev/null +++ b/jsoninfo/orderedmap.go @@ -0,0 +1,46 @@ +package jsoninfo + +import ( + "bytes" + "encoding/json" + "fmt" + "log" +) + +// ExtractObjectKeys extracts the keys of an object in a JSON string. The keys +// are returned in the order they appear in the JSON string. +func ExtractObjectKeys(b []byte) ([]string, error) { + if !bytes.HasPrefix(b, []byte{'{'}) { + return nil, fmt.Errorf("expected '{' at start of JSON object") + } + + dec := json.NewDecoder(bytes.NewReader(b)) + var keys []string + + for dec.More() { + // Read prop name + t, err := dec.Token() + if err != nil { + log.Printf("Err: %v", err) + break + } + + name, ok := t.(string) + if !ok { + continue // May be a delimeter + } + + keys = append(keys, name) + + var whatever nullMessage + dec.Decode(&whatever) + } + + return keys, nil +} + +// nullMessage implements json.Unmarshaler and does nothing with the given +// value. +type nullMessage struct{} + +func (*nullMessage) UnmarshalJSON(data []byte) error { return nil } diff --git a/jsoninfo/orderedmap_test.go b/jsoninfo/orderedmap_test.go new file mode 100644 index 000000000..3987b9acf --- /dev/null +++ b/jsoninfo/orderedmap_test.go @@ -0,0 +1,23 @@ +package jsoninfo + +import ( + "reflect" + "testing" +) + +func TestExtractObjectKeys(t *testing.T) { + const j = `{ + "foo": {"bar": 1}, + "baz": "qux", + "quux": "quuz" + }` + + keys, err := ExtractObjectKeys([]byte(j)) + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(keys, []string{"foo", "baz", "quux"}) { + t.Fatalf("expected %v, got %v", []string{"foo", "baz", "quux"}, keys) + } +} diff --git a/openapi3/loader.go b/openapi3/loader.go index 0b8d0e1cc..208ad2192 100644 --- a/openapi3/loader.go +++ b/openapi3/loader.go @@ -14,7 +14,7 @@ import ( "strconv" "strings" - "github.com/ghodss/yaml" + "github.com/ghodss/yaml/v2" ) func foundUnresolvedRef(ref string) error { diff --git a/openapi3/schema.go b/openapi3/schema.go index 443f71980..8e2f4de06 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -9,6 +9,7 @@ import ( "math" "math/big" "regexp" + "sort" "strconv" "unicode/utf16" @@ -150,6 +151,7 @@ type Schema struct { // Object Required []string `json:"required,omitempty" yaml:"required,omitempty"` Properties Schemas `json:"properties,omitempty" yaml:"properties,omitempty"` + propertyKeys []string // order kept MinProps uint64 `json:"minProperties,omitempty" yaml:"minProperties,omitempty"` MaxProps *uint64 `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` AdditionalPropertiesAllowed *bool `multijson:"additionalProperties,omitempty" json:"-" yaml:"-"` // In this order... @@ -157,7 +159,10 @@ type Schema struct { Discriminator *Discriminator `json:"discriminator,omitempty" yaml:"discriminator,omitempty"` } -var _ jsonpointer.JSONPointable = (*Schema)(nil) +var ( + _ jsonpointer.JSONPointable = (*Schema)(nil) + _ json.Unmarshaler = (*Schema)(nil) +) func NewSchema() *Schema { return &Schema{} @@ -168,7 +173,42 @@ func (schema *Schema) MarshalJSON() ([]byte, error) { } func (schema *Schema) UnmarshalJSON(data []byte) error { - return jsoninfo.UnmarshalStrictStruct(data, schema) + if err := jsoninfo.UnmarshalStrictStruct(data, schema); err != nil { + return err + } + + var rawProperties struct { + Properties json.RawMessage `json:"properties"` + } + + if err := json.Unmarshal(data, &rawProperties); err != nil { + return fmt.Errorf("failed to extract raw schema properties: %w", err) + } + + if schema.Type == "object" && rawProperties.Properties != nil { + keys, _ := jsoninfo.ExtractObjectKeys(rawProperties.Properties) + schema.propertyKeys = keys + } + + return nil +} + +// OrderedPropertyKeys returns the keys of the properties in the order they were +// defined. This is useful for generating code that needs to iterate over the +// properties in a consistent order. If the keys could not be extracted for some +// reason, then this method automatically sorts the keys to be deterministic. +func (schema Schema) OrderedPropertyKeys() []string { + if schema.propertyKeys != nil { + return schema.propertyKeys + } + + keys := make([]string, 0, len(schema.Properties)) + for k := range schema.Properties { + keys = append(keys, k) + } + + sort.Strings(keys) + return keys } func (schema Schema) JSONLookup(token string) (interface{}, error) { diff --git a/openapi3/schema_test.go b/openapi3/schema_test.go index f724f08e2..33751dcd5 100644 --- a/openapi3/schema_test.go +++ b/openapi3/schema_test.go @@ -1220,6 +1220,26 @@ components: require.Contains(t, err.Error(), `Error at "/ownerName": Doesn't match schema "not"`) } +func TestSchemaOrderedProperties(t *testing.T) { + const api = ` +openapi: "3.0.1" +components: + schemas: + Pet: + properties: + z_name: + type: string + a_ownerName: + not: + type: boolean + type: object +` + s, err := NewLoader().LoadFromData([]byte(api)) + require.NoError(t, err) + require.NotNil(t, s) + require.Equal(t, []string{"z_name", "a_ownerName"}, s.Components.Schemas["Pet"].Value.propertyKeys) +} + func TestValidationFailsOnInvalidPattern(t *testing.T) { schema := Schema{ Pattern: "[",