diff --git a/go.mod b/go.mod index 11bc6d02..09d57e98 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/getkin/kin-openapi go 1.20 +replace github.com/invopop/yaml => github.com/diamondburned/invopop-yaml v0.3.2-0.20240812084936-33aae275be98 + require ( github.com/go-openapi/jsonpointer v0.21.0 github.com/gorilla/mux v1.8.0 diff --git a/go.sum b/go.sum index 6b91d0dc..d513eb9e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ 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/invopop-yaml v0.3.2-0.20240812084936-33aae275be98 h1:Z+YbYkmppSq0GA/nr1d8+VVf3UpALBQSyFYixw7gb44= +github.com/diamondburned/invopop-yaml v0.3.2-0.20240812084936-33aae275be98/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= @@ -7,8 +9,6 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso= -github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/openapi3/marsh.go b/openapi3/marsh.go index daa93755..72694eb8 100644 --- a/openapi3/marsh.go +++ b/openapi3/marsh.go @@ -1,8 +1,10 @@ package openapi3 import ( + "bytes" "encoding/json" "fmt" + "log" "strings" "github.com/invopop/yaml" @@ -32,3 +34,41 @@ func unmarshal(data []byte, v any) error { // If both unmarshaling attempts fail, return a new error that includes both errors return fmt.Errorf("failed to unmarshal data: json error: %v, yaml error: %v", jsonErr, yamlErr) } + +// 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/openapi3/marsh_test.go b/openapi3/marsh_test.go index 4ddc4fa9..2fc34045 100644 --- a/openapi3/marsh_test.go +++ b/openapi3/marsh_test.go @@ -76,3 +76,14 @@ paths: err = doc.Validate(sl.Context) require.NoError(t, err) } + +func TestExtractObjectKeys(t *testing.T) { + const j = `{ + "z_hello": "world", + "a_foo": "bar", + }` + + keys, err := extractObjectKeys([]byte(j)) + require.NoError(t, err) + require.Equal(t, []string{"z_hello", "a_foo"}, keys) +} diff --git a/openapi3/schema.go b/openapi3/schema.go index 7be6bd38..6a32834c 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -131,6 +131,8 @@ type Schema struct { MaxProps *uint64 `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` AdditionalProperties AdditionalProperties `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"` Discriminator *Discriminator `json:"discriminator,omitempty" yaml:"discriminator,omitempty"` + // list of keys in Properties with preserved order + propertyKeys []string } type Types []string @@ -410,6 +412,16 @@ func (schema *Schema) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(data, &x); err != nil { return unmarshalError(err) } + + if x.Properties != nil { + var rawProperties struct { + Properties json.RawMessage `json:"properties"` + } + if err := json.Unmarshal(data, &rawProperties); err == nil { + x.propertyKeys, _ = extractObjectKeys(rawProperties.Properties) + } + } + _ = json.Unmarshal(data, &x.Extensions) delete(x.Extensions, "oneOf") @@ -476,6 +488,24 @@ func (schema *Schema) UnmarshalJSON(data []byte) error { 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 +} + // JSONLookup implements https://pkg.go.dev/github.com/go-openapi/jsonpointer#JSONPointable func (schema Schema) JSONLookup(token string) (any, error) { switch token { diff --git a/openapi3/schema_test.go b/openapi3/schema_test.go index d678361b..a05f1711 100644 --- a/openapi3/schema_test.go +++ b/openapi3/schema_test.go @@ -1490,3 +1490,30 @@ func TestIssue751(t *testing.T) { require.NoError(t, schema.VisitJSON(validData)) require.ErrorContains(t, schema.VisitJSON(invalidData), "duplicate items found") } + +func TestSchemaOrderedProperties(t *testing.T) { + const api = ` +openapi: "3.0.1" +components: + schemas: + Pet: + properties: + z_name: + type: string + description: Diamond + a_ownerName: + not: + type: boolean + type: object +` + s, err := NewLoader().LoadFromData([]byte(api)) + require.NoError(t, err) + require.NotNil(t, s) + + pet := s.Components.Schemas["Pet"].Value + require.Equal(t, []string{"z_name", "a_ownerName"}, pet.propertyKeys) + require.Equal(t, []string{"z_name", "a_ownerName"}, pet.OrderedPropertyKeys()) + + pet.propertyKeys = nil + require.Equal(t, []string{"a_ownerName", "z_name"}, pet.OrderedPropertyKeys()) +}