From 1835b939e0aa2bc1e92d4f74aa9908733d00b54a Mon Sep 17 00:00:00 2001 From: diamondburned Date: Mon, 12 Aug 2024 16:07:46 +0700 Subject: [PATCH 1/2] Add openapi3.Schema.PropertyKeys for deterministic order This commit adds the `PropertyKeys` property to the type `openapi3.Schema` which contains the keys of the `Properties` map in the order that they appear in the original YAML file. This is useful to guarantee deterministic code generation. This is done via a temporary fork of the YAML-to-JSON transformation library. It will not be ready until invopop/yaml#13 is merged. --- go.mod | 2 ++ go.sum | 4 ++-- openapi3/loader_circular_test.go | 1 + openapi3/marsh.go | 40 ++++++++++++++++++++++++++++++++ openapi3/marsh_test.go | 11 +++++++++ openapi3/schema.go | 18 ++++++++++++++ openapi3/schema_test.go | 23 ++++++++++++++++++ 7 files changed, 97 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 11bc6d02b..09d57e987 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 6b91d0dc9..d513eb9e9 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/loader_circular_test.go b/openapi3/loader_circular_test.go index f16b469d9..a1cb2176c 100644 --- a/openapi3/loader_circular_test.go +++ b/openapi3/loader_circular_test.go @@ -29,6 +29,7 @@ func TestLoadCircular(t *testing.T) { Value: arr, }, } + obj.PropertyKeys = []string{"children"} expected := &SchemaRef{ Ref: ref, diff --git a/openapi3/marsh.go b/openapi3/marsh.go index daa937551..72694eb83 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 4ddc4fa93..2fc34045d 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 7be6bd38e..2b30c353b 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -127,6 +127,7 @@ type Schema struct { // Object Required []string `json:"required,omitempty" yaml:"required,omitempty"` Properties Schemas `json:"properties,omitempty" yaml:"properties,omitempty"` + PropertyKeys []string `json:"-" yaml:"-"` // deterministically ordered keys MinProps uint64 `json:"minProperties,omitempty" yaml:"minProperties,omitempty"` MaxProps *uint64 `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` AdditionalProperties AdditionalProperties `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"` @@ -410,6 +411,23 @@ 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 { + // Straight up panic because UnmarshalJSON should already guarantee + // a valid input. + panic(err) + } + k, err := extractObjectKeys(rawProperties.Properties) + if err != nil { + panic(err) + } + x.PropertyKeys = k + } + _ = json.Unmarshal(data, &x.Extensions) delete(x.Extensions, "oneOf") diff --git a/openapi3/schema_test.go b/openapi3/schema_test.go index d678361bd..229e2e5e7 100644 --- a/openapi3/schema_test.go +++ b/openapi3/schema_test.go @@ -1490,3 +1490,26 @@ 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) +} From 3b324583096e3cfc0bcec78758487a750a81adf3 Mon Sep 17 00:00:00 2001 From: diamondburned Date: Mon, 12 Aug 2024 16:09:58 +0700 Subject: [PATCH 2/2] Regenerate documentation --- .github/docs/openapi3.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/docs/openapi3.txt b/.github/docs/openapi3.txt index 09f1192ab..3a24db701 100644 --- a/.github/docs/openapi3.txt +++ b/.github/docs/openapi3.txt @@ -1515,6 +1515,7 @@ type Schema struct { // Object Required []string `json:"required,omitempty" yaml:"required,omitempty"` Properties Schemas `json:"properties,omitempty" yaml:"properties,omitempty"` + PropertyKeys []string `json:"-" yaml:"-"` // deterministically ordered keys MinProps uint64 `json:"minProperties,omitempty" yaml:"minProperties,omitempty"` MaxProps *uint64 `json:"maxProperties,omitempty" yaml:"maxProperties,omitempty"` AdditionalProperties AdditionalProperties `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"`