diff --git a/.github/docs/openapi2.txt b/.github/docs/openapi2.txt index 9b8a99822..6e963a324 100644 --- a/.github/docs/openapi2.txt +++ b/.github/docs/openapi2.txt @@ -7,6 +7,13 @@ backwards-compatible with version 2, version 3 elements have been used. See https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md +FUNCTIONS + +func UnmarshalFromData(data []byte, doc *T) error + UnmarshalFromData loads a document from swagger 2.0 bytes in either JSON or + YAML. The v3 side has Loader for this; here the whole job is the decode. + + TYPES type Header struct { diff --git a/.github/docs/openapi3.txt b/.github/docs/openapi3.txt index f710e8524..ae695c0df 100644 --- a/.github/docs/openapi3.txt +++ b/.github/docs/openapi3.txt @@ -34,6 +34,9 @@ Version detection is available via helper methods: // Handle OpenAPI 3.2 specific features } +Code generated by go generate using nativeyaml.tmpl; DO NOT EDIT +native_yaml_shadow.go. + Code generated by go generate using refs.tmpl; DO NOT EDIT refs.go. CONSTANTS @@ -321,6 +324,9 @@ func (bs BoolSchema) MarshalYAML() (any, error) func (bs *BoolSchema) UnmarshalJSON(data []byte) error UnmarshalJSON sets BoolSchema to a copy of data. +func (bs *BoolSchema) UnmarshalYAML(node *yaml.Node) error + BoolSchema is `true`/`false` or a schema. + type Callback struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` @@ -365,6 +371,8 @@ func (callback *Callback) Set(key string, value *PathItem) func (callback *Callback) UnmarshalJSON(data []byte) (err error) UnmarshalJSON sets Callback to a copy of data. +func (callback *Callback) UnmarshalYAML(node *yaml.Node) error + func (callback *Callback) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Callback does not comply with the OpenAPI spec. @@ -411,6 +419,9 @@ func (x *CallbackRef) RefString() string func (x *CallbackRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets CallbackRef to a copy of data. +func (x *CallbackRef) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets CallbackRef from node. + func (x *CallbackRef) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if CallbackRef does not comply with the OpenAPI spec. @@ -504,6 +515,9 @@ func (components Components) MarshalYAML() (any, error) func (components *Components) UnmarshalJSON(data []byte) error UnmarshalJSON sets Components to a copy of data. +func (components *Components) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets Components from node. + func (components *Components) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Components does not comply with the OpenAPI spec. @@ -551,6 +565,9 @@ func (contact Contact) MarshalYAML() (any, error) func (contact *Contact) UnmarshalJSON(data []byte) error UnmarshalJSON sets Contact to a copy of data. +func (contact *Contact) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets Contact from node. + func (contact *Contact) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Contact does not comply with the OpenAPI spec. @@ -643,6 +660,9 @@ func (discriminator Discriminator) MarshalYAML() (any, error) func (discriminator *Discriminator) UnmarshalJSON(data []byte) error UnmarshalJSON sets Discriminator to a copy of data. +func (discriminator *Discriminator) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets Discriminator from node. + func (discriminator *Discriminator) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Discriminator does not comply with the OpenAPI spec. @@ -778,6 +798,9 @@ func (encoding *Encoding) SerializationMethod() *SerializationMethod func (encoding *Encoding) UnmarshalJSON(data []byte) error UnmarshalJSON sets Encoding to a copy of data. +func (encoding *Encoding) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets Encoding from node. + func (encoding *Encoding) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Encoding does not comply with the OpenAPI spec. @@ -829,6 +852,9 @@ func (example Example) MarshalYAML() (any, error) func (example *Example) UnmarshalJSON(data []byte) error UnmarshalJSON sets Example to a copy of data. +func (example *Example) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets Example from node. + func (example *Example) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Example does not comply with the OpenAPI spec. @@ -872,6 +898,9 @@ func (x *ExampleRef) RefString() string func (x *ExampleRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets ExampleRef to a copy of data. +func (x *ExampleRef) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets ExampleRef from node. + func (x *ExampleRef) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if ExampleRef does not comply with the OpenAPI spec. @@ -934,6 +963,10 @@ func (eb ExclusiveBound) MarshalYAML() (any, error) func (eb *ExclusiveBound) UnmarshalJSON(data []byte) error UnmarshalJSON sets ExclusiveBound to a copy of data. +func (eb *ExclusiveBound) UnmarshalYAML(node *yaml.Node) error + ExclusiveBound is a bool in OAS 3.0, where it modifies minimum/maximum, + or a number in 3.1, where it is the bound itself. + type ExternalDocs struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` @@ -953,6 +986,9 @@ func (e ExternalDocs) MarshalYAML() (any, error) func (e *ExternalDocs) UnmarshalJSON(data []byte) error UnmarshalJSON sets ExternalDocs to a copy of data. +func (e *ExternalDocs) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets ExternalDocs from node. + func (e *ExternalDocs) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if ExternalDocs does not comply with the OpenAPI spec. @@ -991,6 +1027,38 @@ func (e *ExtraSiblingFieldsError) Code() string func (e *ExtraSiblingFieldsError) Error() string +type FieldLocations []Location + FieldLocations holds the locations of a collection's scalar fields, in the + order they appear in the document. + + It is a slice rather than a map[string]Location because a collection carries + only a handful of fields, while a Go map allocates a whole bucket per + collection whatever it holds. On a large document that overhead dominated + the retained size of a parsed spec. Each Location already carries its Name, + so the lookup key costs nothing extra here. + +func (f FieldLocations) Get(name string) Location + Get returns the location of the named field, or the zero Location when the + field has none. Use Lookup to tell an absent field from a zero location. + +func (f FieldLocations) Lookup(name string) (Location, bool) + Lookup returns the location of the named field and whether it was found. + The scan is linear: collections have few fields, and a linear scan over a + contiguous slice beats a map lookup at these sizes. + + Deliberately a hand-written loop rather than slices.IndexFunc: the closure + does not inline, so IndexFunc pays a call per element. Measured on 3/6/12 + fields it is 5-100% slower on a hit and 2-3x slower on a miss, and misses + are the common case here (most fields carry no recorded location). + +func (f FieldLocations) MarshalJSON() ([]byte, error) + MarshalJSON keeps the serialized shape a name-keyed object, as it was when + this was a map, so the change is invisible to anything reading the output. + +func (f *FieldLocations) UnmarshalJSON(data []byte) error + UnmarshalJSON reads the name-keyed object written by MarshalJSON. Entries + are sorted by name, since a JSON object carries no order to restore. + type FieldVersionMismatchError struct { // Field is the field name flagged (e.g. "summary", "identifier", // "$defs", "prefixItems", "contains", ...). @@ -1071,6 +1139,9 @@ func (header *Header) SerializationMethod() (*SerializationMethod, error) func (header *Header) UnmarshalJSON(data []byte) error UnmarshalJSON sets Header to a copy of data. +func (header *Header) UnmarshalYAML(node *yaml.Node) error + Header embeds Parameter and carries no fields of its own. + func (header *Header) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Header does not comply with the OpenAPI spec. @@ -1150,6 +1221,9 @@ func (x *HeaderRef) RefString() string func (x *HeaderRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets HeaderRef to a copy of data. +func (x *HeaderRef) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets HeaderRef from node. + func (x *HeaderRef) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if HeaderRef does not comply with the OpenAPI spec. @@ -1198,6 +1272,9 @@ func (info *Info) MarshalYAML() (any, error) func (info *Info) UnmarshalJSON(data []byte) error UnmarshalJSON sets Info to a copy of data. +func (info *Info) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets Info from node. + func (info *Info) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Info does not comply with the OpenAPI spec. @@ -1343,6 +1420,9 @@ func (license License) MarshalYAML() (any, error) func (license *License) UnmarshalJSON(data []byte) error UnmarshalJSON sets License to a copy of data. +func (license *License) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets License from node. + func (license *License) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if License does not comply with the OpenAPI spec. @@ -1387,6 +1467,9 @@ func (link Link) MarshalYAML() (any, error) func (link *Link) UnmarshalJSON(data []byte) error UnmarshalJSON sets Link to a copy of data. +func (link *Link) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets Link from node. + func (link *Link) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Link does not comply with the OpenAPI spec. @@ -1442,6 +1525,9 @@ func (x *LinkRef) RefString() string func (x *LinkRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets LinkRef to a copy of data. +func (x *LinkRef) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets LinkRef from node. + func (x *LinkRef) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if LinkRef does not comply with the OpenAPI spec. @@ -1571,6 +1657,9 @@ func (mediaType MediaType) MarshalYAML() (any, error) func (mediaType *MediaType) UnmarshalJSON(data []byte) error UnmarshalJSON sets MediaType to a copy of data. +func (mediaType *MediaType) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets MediaType from node. + func (mediaType *MediaType) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if MediaType does not comply with the OpenAPI spec. @@ -1687,6 +1776,9 @@ func (flow OAuthFlow) MarshalYAML() (any, error) func (flow *OAuthFlow) UnmarshalJSON(data []byte) error UnmarshalJSON sets OAuthFlow to a copy of data. +func (flow *OAuthFlow) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets OAuthFlow from node. + func (flow *OAuthFlow) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if OAuthFlows does not comply with the OpenAPI spec. @@ -1768,6 +1860,9 @@ func (flows OAuthFlows) MarshalYAML() (any, error) func (flows *OAuthFlows) UnmarshalJSON(data []byte) error UnmarshalJSON sets OAuthFlows to a copy of data. +func (flows *OAuthFlows) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets OAuthFlows from node. + func (flows *OAuthFlows) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if OAuthFlows does not comply with the OpenAPI spec. @@ -1845,6 +1940,12 @@ func (operation Operation) MarshalYAML() (any, error) func (operation *Operation) UnmarshalJSON(data []byte) error UnmarshalJSON sets Operation to a copy of data. +func (operation *Operation) UnmarshalYAML(node *yaml.Node) error + Operation distinguishes an omitted responses from an explicitly null one: + the first is allowed in OAS 3.1 and later, the second never is. A null node + decodes to an empty Responses, which is indistinguishable from `{}` without + the flag. + func (operation *Operation) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Operation does not comply with the OpenAPI spec. @@ -1872,14 +1973,23 @@ func (e *OperationValidationError) Unwrap() error type Origin struct { Key *Location `json:"key,omitempty" yaml:"key,omitempty"` - Fields map[string]Location `json:"fields,omitempty" yaml:"fields,omitempty"` + Fields FieldLocations `json:"fields,omitempty" yaml:"fields,omitempty"` Sequences map[string][]Location `json:"sequences,omitempty" yaml:"sequences,omitempty"` } - Origin contains the origin of a collection. Key is the location of the - collection itself. Fields is a map of the location of each scalar field + Origin contains the origin of a collection. Key is the location of + the collection itself. Fields holds the location of each scalar field in the collection. Sequences is a map of the location of each item in sequence-valued fields. + Sequences stays a map although Fields is a slice, which is deliberate. + FieldLocations drops the map because Location.Name already carries the key, + so the map was storing information the value repeated. Here Location.Name + holds the *item's* value (an enum member, a required property) while the key + is the *field's* name ("enum", "required", "tags"), so a slice would need a + wrapper type invented to hold it. The memory argument is also much weaker: + only a collection with a sequence-valued field allocates one at all, + which measured at 5% of collections on a large spec, and a nil map is free. + type Parameter struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` @@ -1927,6 +2037,9 @@ func (parameter *Parameter) SerializationMethod() (*SerializationMethod, error) func (parameter *Parameter) UnmarshalJSON(data []byte) error UnmarshalJSON sets Parameter to a copy of data. +func (parameter *Parameter) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets Parameter from node. + func (parameter *Parameter) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Parameter does not comply with the OpenAPI spec. @@ -2027,6 +2140,9 @@ func (x *ParameterRef) RefString() string func (x *ParameterRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets ParameterRef to a copy of data. +func (x *ParameterRef) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets ParameterRef from node. + func (x *ParameterRef) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if ParameterRef does not comply with the OpenAPI spec. @@ -2089,6 +2205,9 @@ func (pathItem *PathItem) SetOperation(method string, operation *Operation) func (pathItem *PathItem) UnmarshalJSON(data []byte) error UnmarshalJSON sets PathItem to a copy of data. +func (pathItem *PathItem) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets PathItem from node. + func (pathItem *PathItem) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if PathItem does not comply with the OpenAPI spec. @@ -2223,6 +2342,8 @@ func (paths *Paths) Set(key string, value *PathItem) func (paths *Paths) UnmarshalJSON(data []byte) (err error) UnmarshalJSON sets Paths to a copy of data. +func (paths *Paths) UnmarshalYAML(node *yaml.Node) error + func (paths *Paths) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Paths does not comply with the OpenAPI spec. @@ -2338,6 +2459,9 @@ func (requestBody RequestBody) MarshalYAML() (any, error) func (requestBody *RequestBody) UnmarshalJSON(data []byte) error UnmarshalJSON sets RequestBody to a copy of data. +func (requestBody *RequestBody) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets RequestBody from node. + func (requestBody *RequestBody) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if RequestBody does not comply with the OpenAPI spec. @@ -2406,6 +2530,9 @@ func (x *RequestBodyRef) RefString() string func (x *RequestBodyRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets RequestBodyRef to a copy of data. +func (x *RequestBodyRef) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets RequestBodyRef from node. + func (x *RequestBodyRef) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if RequestBodyRef does not comply with the OpenAPI spec. @@ -2460,6 +2587,9 @@ func (response Response) MarshalYAML() (any, error) func (response *Response) UnmarshalJSON(data []byte) error UnmarshalJSON sets Response to a copy of data. +func (response *Response) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets Response from node. + func (response *Response) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Response does not comply with the OpenAPI spec. @@ -2523,6 +2653,9 @@ func (x *ResponseRef) RefString() string func (x *ResponseRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets ResponseRef to a copy of data. +func (x *ResponseRef) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets ResponseRef from node. + func (x *ResponseRef) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if ResponseRef does not comply with the OpenAPI spec. @@ -2581,6 +2714,8 @@ func (responses *Responses) Status(status int) *ResponseRef func (responses *Responses) UnmarshalJSON(data []byte) (err error) UnmarshalJSON sets Responses to a copy of data. +func (responses *Responses) UnmarshalYAML(node *yaml.Node) error + func (responses *Responses) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Responses does not comply with the OpenAPI spec. @@ -2745,6 +2880,9 @@ func (schema *Schema) PermitsNull() bool func (schema *Schema) UnmarshalJSON(data []byte) error UnmarshalJSON sets Schema to a copy of data. +func (schema *Schema) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets Schema from node. + func (schema *Schema) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Schema does not comply with the OpenAPI spec. @@ -2975,6 +3113,9 @@ func (x *SchemaRef) RefString() string func (x *SchemaRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets SchemaRef to a copy of data. +func (x *SchemaRef) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets SchemaRef from node. + func (x *SchemaRef) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if SchemaRef does not comply with the OpenAPI spec. @@ -3205,6 +3346,9 @@ func (ss SecurityScheme) MarshalYAML() (any, error) func (ss *SecurityScheme) UnmarshalJSON(data []byte) error UnmarshalJSON sets SecurityScheme to a copy of data. +func (ss *SecurityScheme) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets SecurityScheme from node. + func (ss *SecurityScheme) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if SecurityScheme does not comply with the OpenAPI spec. @@ -3302,6 +3446,9 @@ func (x *SecuritySchemeRef) RefString() string func (x *SecuritySchemeRef) UnmarshalJSON(data []byte) error UnmarshalJSON sets SecuritySchemeRef to a copy of data. +func (x *SecuritySchemeRef) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets SecuritySchemeRef from node. + func (x *SecuritySchemeRef) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if SecuritySchemeRef does not comply with the OpenAPI spec. @@ -3347,6 +3494,9 @@ func (server Server) ParameterNames() ([]string, error) func (server *Server) UnmarshalJSON(data []byte) error UnmarshalJSON sets Server to a copy of data. +func (server *Server) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets Server from node. + func (server *Server) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Server does not comply with the OpenAPI spec. @@ -3401,6 +3551,9 @@ func (serverVariable ServerVariable) MarshalYAML() (any, error) func (serverVariable *ServerVariable) UnmarshalJSON(data []byte) error UnmarshalJSON sets ServerVariable to a copy of data. +func (serverVariable *ServerVariable) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets ServerVariable from node. + func (serverVariable *ServerVariable) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if ServerVariable does not comply with the OpenAPI spec. @@ -3557,6 +3710,10 @@ func (doc *T) SetStringFormatValidators(validators map[string]StringFormatValida func (doc *T) UnmarshalJSON(data []byte) error UnmarshalJSON sets T to a copy of data. +func (doc *T) UnmarshalYAML(node *yaml.Node) error + T is the document root, so no parent stamps its Origin.Key. It takes its own + first key instead, the same rule a sequence item follows. + func (doc *T) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if T does not comply with the OpenAPI spec. Validations Options can be provided to modify the validation behavior. @@ -3620,6 +3777,9 @@ func (t Tag) MarshalYAML() (any, error) func (t *Tag) UnmarshalJSON(data []byte) error UnmarshalJSON sets Tag to a copy of data. +func (t *Tag) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets Tag from node. + func (t *Tag) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Tag does not comply with the OpenAPI spec. @@ -3772,6 +3932,9 @@ func (types *Types) Slice() []string func (types *Types) UnmarshalJSON(data []byte) error +func (types *Types) UnmarshalYAML(node *yaml.Node) error + Types is a string or a list of strings. + type UnevaluatedItemsFieldFor31Plus struct{ ValidationError } func (e *UnevaluatedItemsFieldFor31Plus) As(target any) bool @@ -4025,6 +4188,9 @@ func (xml XML) MarshalYAML() (any, error) func (xml *XML) UnmarshalJSON(data []byte) error UnmarshalJSON sets XML to a copy of data. +func (xml *XML) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets XML from node. + func (xml *XML) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if XML does not comply with the OpenAPI spec. diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 16bb82e9f..ce991ac3c 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -104,9 +104,9 @@ jobs: ! git grep -IErn '\s$' - if: runner.os == 'Linux' - name: Ensure use of unmarshal + name: Ensure yaml stays inside the decode layer run: | - [[ "$(git grep -F yaml. -- openapi3/ | grep -v _test.go | grep -v origin.go | wc -l)" = 2 ]] + [[ "$(git grep -F yaml. -- openapi3/ | grep -v _test.go | grep -vE 'openapi3/(origin|marsh|end_positions|refs|loader|native_yaml.*|nativeyaml.*)[.](go|tmpl)' | wc -l)" -eq 0 ]] - if: runner.os == 'Linux' name: Use `loader := NewLoader(); loader.Load ...` diff --git a/cmd/validate/main.go b/cmd/validate/main.go index b742a9611..c785c5f2b 100644 --- a/cmd/validate/main.go +++ b/cmd/validate/main.go @@ -6,7 +6,7 @@ import ( "os" "strings" - "github.com/oasdiff/yaml" + yaml "go.yaml.in/yaml/v3" "github.com/getkin/kin-openapi/openapi2" "github.com/getkin/kin-openapi/openapi3" @@ -53,7 +53,7 @@ func main() { OpenAPI string `json:"openapi" yaml:"openapi"` Swagger string `json:"swagger" yaml:"swagger"` } - if _, err := yaml.Unmarshal(data, &vd, yaml.DecodeOpts{DisableTimestamps: true}); err != nil { + if err := yaml.Unmarshal(data, &vd); err != nil { log.Fatal(err) } @@ -109,7 +109,7 @@ func main() { } var doc openapi2.T - if _, err := yaml.Unmarshal(data, &doc, yaml.DecodeOpts{DisableTimestamps: true}); err != nil { + if err := openapi2.UnmarshalFromData(data, &doc); err != nil { log.Fatalln("Loading error:", err) } diff --git a/go.mod b/go.mod index d2473838d..6cf3bf581 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,9 @@ go 1.25 require ( github.com/go-openapi/jsonpointer v0.22.5 github.com/gorilla/mux v1.8.0 - github.com/oasdiff/yaml v0.1.1 - github.com/oasdiff/yaml3 v0.0.14 github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/stretchr/testify v1.9.0 + go.yaml.in/yaml/v3 v3.0.5 ) require ( diff --git a/go.sum b/go.sum index 9949c5d6c..e84c17f5e 100644 --- a/go.sum +++ b/go.sum @@ -17,16 +17,14 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY= -github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU= -github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw= -github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/yamlconv/yamlconv.go b/internal/yamlconv/yamlconv.go new file mode 100644 index 000000000..5ddeadfa9 --- /dev/null +++ b/internal/yamlconv/yamlconv.go @@ -0,0 +1,70 @@ +// Package yamlconv converts between YAML and types that describe themselves +// with json tags and MarshalJSON/UnmarshalJSON methods. +// +// YAML reaches such a type through JSON, so its methods run and extensions are +// carried. Two things have to be reconciled first, both of which YAML resolves +// and JSON cannot represent. +package yamlconv + +import ( + "encoding/json" + + yaml "go.yaml.in/yaml/v3" +) + +// PrepareForJSON retags the scalars that would not survive the conversion. +// +// A date-shaped scalar resolves to a timestamp, which has no JSON form. A +// non-string mapping key -- the unquoted 200: written in most specs for a +// status code -- decodes to a map[any]any that json.Marshal rejects. Both +// become strings. An explicitly tagged value is left alone, being a deliberate +// request for that type. +func PrepareForJSON(n *yaml.Node) { + if n == nil { + return + } + if n.Kind == yaml.ScalarNode && n.Tag == "!!timestamp" && n.Style != yaml.TaggedStyle { + n.Tag = "!!str" + } + if n.Kind == yaml.MappingNode { + for i := 0; i < len(n.Content); i += 2 { + if k := n.Content[i]; k.Kind == yaml.ScalarNode && k.Tag != "!!str" { + k.Tag = "!!str" + } + } + } + for _, c := range n.Content { + PrepareForJSON(c) + } +} + +// Unmarshal decodes YAML into v via JSON, so v's UnmarshalJSON runs. +func Unmarshal(data []byte, v any) error { + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return err + } + PrepareForJSON(&root) + var generic any + if err := root.Decode(&generic); err != nil { + return err + } + j, err := json.Marshal(generic) + if err != nil { + return err + } + return json.Unmarshal(j, v) +} + +// Marshal renders v as YAML via JSON, so v's MarshalJSON runs. +func Marshal(v any) ([]byte, error) { + j, err := json.Marshal(v) + if err != nil { + return nil, err + } + var generic any + if err := json.Unmarshal(j, &generic); err != nil { + return nil, err + } + return yaml.Marshal(generic) +} diff --git a/openapi2/marsh.go b/openapi2/marsh.go index 6e3bb540a..7a5b740f3 100644 --- a/openapi2/marsh.go +++ b/openapi2/marsh.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/oasdiff/yaml" + "github.com/getkin/kin-openapi/internal/yamlconv" ) func unmarshalError(jsonUnmarshalErr error) error { @@ -24,11 +24,18 @@ func unmarshal(data []byte, v any) error { return nil } - // UnmarshalStrict(data, v) TODO: investigate how ymlv3 handles duplicate map keys - if _, yamlErr = yaml.Unmarshal(data, v, yaml.DecodeOpts{DisableTimestamps: true}); yamlErr == nil { + // YAML reaches these types through JSON, since they implement + // UnmarshalJSON and not UnmarshalYAML. + if yamlErr = yamlconv.Unmarshal(data, v); yamlErr == nil { return nil } // 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) } + +// UnmarshalFromData loads a document from swagger 2.0 bytes in either JSON or +// YAML. The v3 side has Loader for this; here the whole job is the decode. +func UnmarshalFromData(data []byte, doc *T) error { + return unmarshal(data, doc) +} diff --git a/openapi2/openapi2_test.go b/openapi2/openapi2_test.go index c385215d1..b9cefb638 100644 --- a/openapi2/openapi2_test.go +++ b/openapi2/openapi2_test.go @@ -6,8 +6,7 @@ import ( "os" "reflect" - "github.com/oasdiff/yaml" - + "github.com/getkin/kin-openapi/internal/yamlconv" "github.com/getkin/kin-openapi/openapi2" ) @@ -37,12 +36,12 @@ func Example() { fmt.Println("objects doc & docAgainFromJSON should be the same") } - outputYAML, err := yaml.Marshal(doc) + outputYAML, err := yamlconv.Marshal(doc) if err != nil { panic(err) } var docAgainFromYAML openapi2.T - if _, err = yaml.Unmarshal(outputYAML, &docAgainFromYAML, yaml.DecodeOpts{DisableTimestamps: true}); err != nil { + if err = yamlconv.Unmarshal(outputYAML, &docAgainFromYAML); err != nil { panic(err) } if !reflect.DeepEqual(doc, docAgainFromYAML) { diff --git a/openapi2/testdata/apis_guru_openapi_directory/powerdns_local_0_0_13_swagger_yaml__load b/openapi2/testdata/apis_guru_openapi_directory/powerdns_local_0_0_13_swagger_yaml__load index 551a41634..f096ecb35 100644 --- a/openapi2/testdata/apis_guru_openapi_directory/powerdns_local_0_0_13_swagger_yaml__load +++ b/openapi2/testdata/apis_guru_openapi_directory/powerdns_local_0_0_13_swagger_yaml__load @@ -1 +1 @@ -error unmarshaling JSON: while decoding JSON: json: cannot unmarshal array into field Schema.items of type openapi2.Schema +json: cannot unmarshal array into field Schema.items of type openapi2.Schema diff --git a/openapi2/v2_apis_guru_openapi_directory_test.go b/openapi2/v2_apis_guru_openapi_directory_test.go index dd4838880..ab971cd6d 100644 --- a/openapi2/v2_apis_guru_openapi_directory_test.go +++ b/openapi2/v2_apis_guru_openapi_directory_test.go @@ -13,9 +13,9 @@ import ( "sync" "testing" - "github.com/oasdiff/yaml" "github.com/stretchr/testify/require" + "github.com/getkin/kin-openapi/internal/yamlconv" "github.com/getkin/kin-openapi/openapi2" ) @@ -189,7 +189,7 @@ func TestV2ApisGuruOpenapiDirectory(t *testing.T) { require.NoError(t, err) var doc openapi2.T - _, err = yaml.Unmarshal(data, &doc, yaml.DecodeOpts{DisableTimestamps: true}) + err = yamlconv.Unmarshal(data, &doc) golden(t, err, shortName, "load") }) } diff --git a/openapi2conv/issue1062_test.go b/openapi2conv/issue1062_test.go index 1c308774b..bdceb699b 100644 --- a/openapi2conv/issue1062_test.go +++ b/openapi2conv/issue1062_test.go @@ -3,9 +3,9 @@ package openapi2conv_test import ( "testing" - "github.com/oasdiff/yaml" "github.com/stretchr/testify/require" + "github.com/getkin/kin-openapi/internal/yamlconv" "github.com/getkin/kin-openapi/openapi2conv" "github.com/getkin/kin-openapi/openapi3" ) @@ -59,7 +59,7 @@ components: ` var doc3 openapi3.T - _, err := yaml.Unmarshal([]byte(v3Spec), &doc3, yaml.DecodeOpts{DisableTimestamps: true}) + err := yamlconv.Unmarshal([]byte(v3Spec), &doc3) require.NoError(t, err, "unmarshal v3 spec") // Pre-fix: this call panicked with diff --git a/openapi2conv/issue1069_test.go b/openapi2conv/issue1069_test.go index 8a1d385bd..3c2a86316 100644 --- a/openapi2conv/issue1069_test.go +++ b/openapi2conv/issue1069_test.go @@ -3,10 +3,10 @@ package openapi2conv_test import ( "testing" - "github.com/oasdiff/yaml" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/getkin/kin-openapi/internal/yamlconv" "github.com/getkin/kin-openapi/openapi2" "github.com/getkin/kin-openapi/openapi2conv" "github.com/getkin/kin-openapi/openapi3" @@ -199,7 +199,7 @@ paths: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var doc3 openapi3.T - _, err := yaml.Unmarshal([]byte(tt.v3Spec), &doc3, yaml.DecodeOpts{DisableTimestamps: true}) + err := yamlconv.Unmarshal([]byte(tt.v3Spec), &doc3) require.NoError(t, err) v2, err := openapi2conv.FromV3(&doc3) diff --git a/openapi2conv/issue187_test.go b/openapi2conv/issue187_test.go index 3978d3693..1e22ee0c2 100644 --- a/openapi2conv/issue187_test.go +++ b/openapi2conv/issue187_test.go @@ -4,9 +4,9 @@ import ( "encoding/json" "testing" - "github.com/oasdiff/yaml" "github.com/stretchr/testify/require" + "github.com/getkin/kin-openapi/internal/yamlconv" "github.com/getkin/kin-openapi/openapi2" "github.com/getkin/kin-openapi/openapi2conv" "github.com/getkin/kin-openapi/openapi3" @@ -23,7 +23,7 @@ func v2v3JSON(spec2 []byte) (doc3 *openapi3.T, err error) { func v2v3YAML(spec2 []byte) (doc3 *openapi3.T, err error) { var doc2 openapi2.T - if _, err = yaml.Unmarshal(spec2, &doc2, yaml.DecodeOpts{DisableTimestamps: true}); err != nil { + if err = yamlconv.Unmarshal(spec2, &doc2); err != nil { return } doc3, err = openapi2conv.ToV3(&doc2) @@ -137,7 +137,7 @@ definitions: doc3, err := v2v3YAML([]byte(spec)) require.NoError(t, err) - spec3, err := yaml.Marshal(doc3) + spec3, err := yamlconv.Marshal(doc3) require.NoError(t, err) const expected = `components: schemas: @@ -185,7 +185,7 @@ securityDefinitions: doc3, err := v2v3YAML([]byte(spec)) require.NoError(t, err) require.NotNil(t, doc3.Components.SecuritySchemes["OAuth2Application"].Value.Flows.ClientCredentials) - _, err = yaml.Marshal(doc3) + _, err = yamlconv.Marshal(doc3) require.NoError(t, err) doc2, err := openapi2conv.FromV3(doc3) diff --git a/openapi2conv/issue558_test.go b/openapi2conv/issue558_test.go index 79668c22f..c29a9ecf7 100644 --- a/openapi2conv/issue558_test.go +++ b/openapi2conv/issue558_test.go @@ -3,9 +3,9 @@ package openapi2conv_test import ( "testing" - "github.com/oasdiff/yaml" "github.com/stretchr/testify/require" + "github.com/getkin/kin-openapi/internal/yamlconv" "github.com/getkin/kin-openapi/openapi2conv" ) @@ -30,7 +30,7 @@ paths: doc3, err := v2v3YAML([]byte(spec)) require.NoError(t, err) require.NotEmpty(t, doc3.Paths.Value("/test").Get.Deprecated) - _, err = yaml.Marshal(doc3) + _, err = yamlconv.Marshal(doc3) require.NoError(t, err) doc2, err := openapi2conv.FromV3(doc3) diff --git a/openapi3/additionalProperties_test.go b/openapi3/additionalProperties_test.go index ac55b1d30..85ea9beb1 100644 --- a/openapi3/additionalProperties_test.go +++ b/openapi3/additionalProperties_test.go @@ -5,8 +5,8 @@ import ( "os" "testing" - yaml "github.com/oasdiff/yaml3" "github.com/stretchr/testify/require" + yaml "go.yaml.in/yaml/v3" "github.com/getkin/kin-openapi/openapi3" ) diff --git a/openapi3/end_positions.go b/openapi3/end_positions.go new file mode 100644 index 000000000..7091b4fd7 --- /dev/null +++ b/openapi3/end_positions.go @@ -0,0 +1,107 @@ +package openapi3 + +import ( + "bytes" + + yaml "go.yaml.in/yaml/v3" +) + +// endIndex answers where the block headed by a key ends. +// +// The parser reports where every node starts and nothing about where it stops, +// so the end is derived: a block ends on the last line any part of it occupies, +// which is the largest line among the node's descendants. +// +// Reading it off the tree rather than from indentation is what makes a sequence +// item work. A parameter's key location is the item's first key, which sits at +// the same column as the keys following it, so no column comparison can tell +// the end of the item from the start of its own second field. Its subtree ends +// where the item ends either way. +// +// Trailing blank and comment lines fall outside, carrying no node. +type endIndex struct { + end map[*yaml.Node]int + // anchorKey is the key heading each anchored node, so an alias can report + // where the content it points at is defined. + anchorKey map[*yaml.Node]*yaml.Node + // lineLen is each line's length, giving the column a block's last line + // stops at. + lineLen []int +} + +// originEndsVar is the index for the decode in progress, alongside +// originFileVar. Ends are derived per file: a $ref into another file is decoded +// separately, against its own text. +var originEndsVar *endIndex + +// newEndIndex indexes data's node tree. Returns nil when origins are off, in +// which case no end is ever asked for. +func newEndIndex(root *yaml.Node, data []byte) *endIndex { + if !originEnabledVar || root == nil { + return nil + } + ei := &endIndex{end: map[*yaml.Node]int{}, anchorKey: map[*yaml.Node]*yaml.Node{}} + for line := range bytes.SplitSeq(data, []byte("\n")) { + ei.lineLen = append(ei.lineLen, len(bytes.TrimRight(line, "\r"))) + } + ei.measure(root) + return ei +} + +// measure records each node's last line, bottom up, and returns it. +func (ei *endIndex) measure(n *yaml.Node) int { + if n == nil { + return 0 + } + last := n.Line + if n.Kind == yaml.MappingNode { + for i := 0; i+1 < len(n.Content); i += 2 { + if v := n.Content[i+1]; v.Anchor != "" { + ei.anchorKey[v] = n.Content[i] + } + } + } + for _, c := range n.Content { + if l := ei.measure(c); l > last { + last = l + } + } + ei.end[n] = last + return last +} + +// endOf returns the last line and column of the block node occupies. +func (ei *endIndex) endOf(node *yaml.Node) (int, int) { + if ei == nil || node == nil { + return 0, 0 + } + line, ok := ei.end[node] + if !ok || line < 1 || line > len(ei.lineLen) { + return 0, 0 + } + // The column just past the last character, matching how a parser reports + // the position it stopped at. + return line, ei.lineLen[line-1] + 1 +} + +// withEnd returns loc carrying the extent of the block node heads. +func withEnd(loc Location, node *yaml.Node) Location { + loc.EndLine, loc.EndColumn = originEndsVar.endOf(node) + return loc +} + +// resolveAlias follows an alias to the node it points at, together with the key +// that node was defined under. An aliased schema is the anchored one, so its +// origin is where that content was written, not where it was referred to. +func resolveAlias(keyNode, valNode *yaml.Node) (*yaml.Node, *yaml.Node) { + if valNode == nil || valNode.Kind != yaml.AliasNode || valNode.Alias == nil { + return keyNode, valNode + } + if originEndsVar == nil { + return keyNode, valNode.Alias + } + if k, ok := originEndsVar.anchorKey[valNode.Alias]; ok { + return k, valNode.Alias + } + return keyNode, valNode.Alias +} diff --git a/openapi3/issue241_test.go b/openapi3/issue241_test.go index caf9a7e08..8dcc9db9a 100644 --- a/openapi3/issue241_test.go +++ b/openapi3/issue241_test.go @@ -5,8 +5,8 @@ import ( "os" "testing" - yaml "github.com/oasdiff/yaml3" "github.com/stretchr/testify/require" + yaml "go.yaml.in/yaml/v3" "github.com/getkin/kin-openapi/openapi3" ) diff --git a/openapi3/issue883_test.go b/openapi3/issue883_test.go index 1ff5dee5f..4d2c766ed 100644 --- a/openapi3/issue883_test.go +++ b/openapi3/issue883_test.go @@ -3,10 +3,10 @@ package openapi3_test import ( "testing" - yaml "github.com/oasdiff/yaml" - yamlv3 "github.com/oasdiff/yaml3" "github.com/stretchr/testify/require" + yamlv3 "go.yaml.in/yaml/v3" + "github.com/getkin/kin-openapi/internal/yamlconv" "github.com/getkin/kin-openapi/openapi3" ) @@ -39,7 +39,7 @@ paths: require.NotNil(t, doc.Paths) t.Run("Roundtrip using yaml pkg", func(t *testing.T) { - justPaths, err := yaml.Marshal(doc.Paths) + justPaths, err := yamlconv.Marshal(doc.Paths) require.NoError(t, err) require.NotNil(t, doc.Paths) require.YAMLEq(t, ` @@ -51,13 +51,13 @@ paths: description: OK `[1:], string(justPaths)) - marshalledYaml, err := yaml.Marshal(doc) + marshalledYaml, err := yamlconv.Marshal(doc) require.NoError(t, err) require.NotNil(t, doc.Paths) require.YAMLEq(t, spec, string(marshalledYaml)) var newDoc openapi3.T - _, err = yaml.Unmarshal(marshalledYaml, &newDoc, yaml.DecodeOpts{DisableTimestamps: true}) + err = yamlconv.Unmarshal(marshalledYaml, &newDoc) require.NoError(t, err) require.NotNil(t, newDoc.Paths) require.Equal(t, doc, &newDoc) @@ -93,7 +93,6 @@ paths: require.NotNil(t, doc.Paths) require.YAMLEq(t, spec, string(marshalledYaml)) - t.Skip("TODO: impl https://pkg.go.dev/github.com/oasdiff/yaml3#Unmarshaler on maplike types") var newDoc openapi3.T err = yamlv3.Unmarshal(marshalledYaml, &newDoc) require.NoError(t, err) diff --git a/openapi3/issue972_test.go b/openapi3/issue972_test.go index 6bb274893..121b44dda 100644 --- a/openapi3/issue972_test.go +++ b/openapi3/issue972_test.go @@ -3,8 +3,8 @@ package openapi3_test import ( "testing" - yaml "github.com/oasdiff/yaml3" "github.com/stretchr/testify/assert" + yaml "go.yaml.in/yaml/v3" "github.com/getkin/kin-openapi/openapi3" ) diff --git a/openapi3/loader.go b/openapi3/loader.go index ce02e46ba..419cbb1cc 100644 --- a/openapi3/loader.go +++ b/openapi3/loader.go @@ -13,6 +13,8 @@ import ( "reflect" "strconv" "strings" + + yaml "go.yaml.in/yaml/v3" ) // IncludeOrigin specifies whether to include the origin of the OpenAPI elements. @@ -147,10 +149,29 @@ func (loader *Loader) loadSingleElementFromURI(ref string, rootPath *url.URL, el return resolvedPath, nil } -// rememberOriginTree retains doc's origin tree for attachOriginToResolved. -// tree is nil when IncludeOrigin is off or the data took the json path. +// rememberOriginTree retains doc's parsed node tree for +// attachOriginToResolved. tree is nil when origins are off or the data took +// the json path. +// +// The tree is kept only for a document a $ref can reach into untyped, which is +// what attachOriginToResolved exists to re-origin. In practice that means a +// file of shared fragments, whose top level is the fragment name itself rather +// than the fields of an OpenAPI Object: +// +// User: # a $ref to "./schemas.yaml#/User" lands here, untyped +// type: object +// +// Anything OpenAPI defines a field for resolves through typed structures and +// keeps its origins on the way, so its tree could never be read. That includes +// a referenced document that is itself an OpenAPI Object: a $ref to +// "#/components/schemas/User" needs no tree. (A top-level x- extension is +// undefined by the same rule, so a document carrying one keeps its tree too, +// whether or not anything ever points at it.) +// +// Worth the condition: on a 22 MB spec the retained tree was a third of +// everything the loader held. func (loader *Loader) rememberOriginTree(doc *T, tree *originTree) { - if tree == nil { + if tree == nil || len(doc.Extensions) == 0 { return } if loader.originTrees == nil { @@ -568,15 +589,34 @@ func (loader *Loader) attachOriginToResolved(resolved any, componentDoc *T, frag if tree == nil { return } + // Stamp against the file this tree came from, not the document being + // loaded, which is what originFileVar still names here. + originMu.Lock() + defer originMu.Unlock() + prevFile, prevEnds := originFileVar, originEndsVar + originFileVar, originEndsVar = tree.file, tree.ends + defer func() { originFileVar, originEndsVar = prevFile, prevEnds }() + + node := tree.node + // Walk the retained node tree down to the fragment and decode that subtree + // into the resolved value, which runs its UnmarshalYAML and so produces + // origins. The generic-map resolution path that produced `resolved` has + // none, because an extension value decodes as plain data. + var keyNode *yaml.Node for part := range strings.SplitSeq(strings.Trim(fragment, "/"), "/") { if part == "" { continue } - if tree = tree.Fields[unescapeRefString(part)]; tree == nil { + if keyNode, node = mappingEntry(node, unescapeRefString(part)); node == nil { return } } - applyOrigins(resolved, tree) + if node.Decode(resolved) != nil { + return + } + // The key location is normally stamped by the mapping above, which does not + // run on this path: the last fragment part is that key. + setOriginKey(reflect.ValueOf(resolved), keyNode, node, tree.file) } func readableType(x any) string { diff --git a/openapi3/marsh.go b/openapi3/marsh.go index f895e6e40..920dff5af 100644 --- a/openapi3/marsh.go +++ b/openapi3/marsh.go @@ -6,7 +6,7 @@ import ( "net/url" "strings" - "github.com/oasdiff/yaml" + goyaml "go.yaml.in/yaml/v3" ) func unmarshalError(jsonUnmarshalErr error) error { @@ -23,26 +23,49 @@ func unmarshalError(jsonUnmarshalErr error) error { func unmarshal(data []byte, v any, includeOrigin bool, location *url.URL) (*originTree, error) { var jsonErr, yamlErr error - // See https://github.com/getkin/kin-openapi/issues/680 - if jsonErr = json.Unmarshal(data, v); jsonErr == nil { - return nil, nil - } - - // UnmarshalStrict(data, v) TODO: investigate how ymlv3 handles duplicate map keys var file string if location != nil { file = location.String() } - if tree, err := yaml.Unmarshal(data, v, yaml.DecodeOpts{ - Origin: yaml.OriginOpt{Enabled: includeOrigin, File: file}, - DisableTimestamps: true, - }); err == nil { - applyOrigins(v, tree) - return tree, nil + + // One parse, straight into the types via UnmarshalYAML, with origins read + // off the nodes. A JSON document gets origins too, since JSON parses as + // YAML. + originMu.Lock() + defer originMu.Unlock() + originFileVar, originEnabledVar = file, includeOrigin + var root goyaml.Node + if err := goyaml.Unmarshal(data, &root); err == nil { + stripTimestamps(&root) + // Ends are derived from the tree, the parser reporting only starts. + originEndsVar = newEndIndex(&root, data) + if err = root.Decode(v); err == nil { + if !includeOrigin { + return nil, nil + } + // Retained so a $ref to an arbitrary top-level key can be decoded + // from its own node; that path resolves through plain data, which + // carries no positions. + if root.Kind == goyaml.DocumentNode && len(root.Content) > 0 { + stampRootOrigin(v, root.Content[0]) + return &originTree{node: root.Content[0], file: file, ends: originEndsVar}, nil + } + stampRootOrigin(v, &root) + return &originTree{node: &root, file: file, ends: originEndsVar}, nil + } + yamlErr = err } else { yamlErr = err } + // Fall back to the json path for what the yaml parser will not accept -- + // most importantly duplicate keys, which json resolves last-one-wins and + // yaml rejects. Such documents load as they always did, without origins. + // See https://github.com/getkin/kin-openapi/issues/680 + if jsonErr = json.Unmarshal(data, v); jsonErr == nil { + return nil, nil + } + // If both unmarshaling attempts fail, return a new error that includes both errors return nil, fmt.Errorf("failed to unmarshal data: json error: %v, yaml error: %v", jsonErr, yamlErr) } diff --git a/openapi3/marsh_test.go b/openapi3/marsh_test.go index cf0eb4d28..134a34336 100644 --- a/openapi3/marsh_test.go +++ b/openapi3/marsh_test.go @@ -40,7 +40,9 @@ paths: sl := openapi3.NewLoader() _, err := sl.LoadFromData(spec) - require.ErrorContains(t, err, `json: cannot unmarshal object into field Schema.allOf of type openapi3.SchemaRefs`) + // The parser reports the line, having decoded the document itself + // rather than a json rendering of it. It does not name the field. + require.ErrorContains(t, err, `line 24: cannot unmarshal !!map into openapi3.SchemaRefs`) } spec := []byte(` diff --git a/openapi3/native_e2e_test.go b/openapi3/native_e2e_test.go new file mode 100644 index 000000000..c2605bf35 --- /dev/null +++ b/openapi3/native_e2e_test.go @@ -0,0 +1,79 @@ +package openapi3 + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + goyaml "go.yaml.in/yaml/v3" +) + +// A complete document decoded through UnmarshalYAML must equal the same +// document decoded as JSON. +func TestNativeE2E_WholeDocument(t *testing.T) { + // Every full document in testdata, so this is breadth rather than a + // hand-picked sample. + paths, err := filepath.Glob("testdata/*.y*ml") + require.NoError(t, err) + var ran int + for _, path := range paths { + data, err := os.ReadFile(path) + if err != nil || !bytes.HasPrefix(bytes.TrimSpace(data), []byte("openapi:")) { + continue + } + ran++ + t.Run(path, func(t *testing.T) { + // Reference: the JSON path, via a plain YAML->JSON conversion. + var asAny any + require.NoError(t, goyaml.Unmarshal(data, &asAny)) + jsonBytes, err := json.Marshal(asAny) + require.NoError(t, err) + var viaJSON T + require.NoError(t, json.Unmarshal(jsonBytes, &viaJSON)) + + // Native: straight from the node tree. + var viaYAML T + require.NoError(t, goyaml.Unmarshal(data, &viaYAML)) + + want, err := json.Marshal(&viaJSON) + require.NoError(t, err) + got, err := json.Marshal(&viaYAML) + require.NoError(t, err) + require.JSONEq(t, string(want), string(got), "native decode should match the JSON path") + }) + } + require.Positive(t, ran, "should have found documents to compare") +} + +// Path items and operations must carry an origin naming their own key. +func TestNativeE2E_OriginsReachOperations(t *testing.T) { + defer func(v bool) { originEnabledVar = v }(originEnabledVar) + originEnabledVar = true + + data, err := os.ReadFile("testdata/callbacks.yml") + require.NoError(t, err) + var doc T + require.NoError(t, goyaml.Unmarshal(data, &doc)) + require.NotNil(t, doc.Paths) + + var checked int + for path, pi := range doc.Paths.Map() { + require.NotNil(t, pi.Origin, "path item %q has no origin", path) + require.NotNil(t, pi.Origin.Key, "path item %q has no key origin", path) + require.Equal(t, path, pi.Origin.Key.Name) + require.Positive(t, pi.Origin.Key.Line) + for method, op := range pi.Operations() { + require.NotNil(t, op.Origin, "%s %s has no origin", method, path) + require.NotNil(t, op.Origin.Key, "%s %s has no key origin", method, path) + // Operations() reports the method uppercased; the origin names + // the key as it appears in the document. + require.Equal(t, strings.ToLower(method), op.Origin.Key.Name) + checked++ + } + } + require.Positive(t, checked, "should have checked some operations") +} diff --git a/openapi3/native_scalars_test.go b/openapi3/native_scalars_test.go new file mode 100644 index 000000000..8aa875361 --- /dev/null +++ b/openapi3/native_scalars_test.go @@ -0,0 +1,79 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" + goyaml "go.yaml.in/yaml/v3" +) + +// YAML resolves more scalar forms than JSON does, so a value reaching an +// any-typed field must not carry a type that depends on its notation. +func TestNativeScalars_AnyValuesAreJSONShaped(t *testing.T) { + const src = ` +type: object +x-dec: 42 +x-hex: 0x2A +x-underscore: 4_2 +x-float: 1.5 +x-str: "42" +x-bool: true +x-nested: {n: 7, list: [1, 2]} +example: 42 +default: 0x10 +` + var node goyaml.Node + require.NoError(t, goyaml.Unmarshal([]byte(src), &node)) + stripTimestamps(&node) + + var s Schema + require.NoError(t, node.Content[0].Decode(&s)) + + // Every integer notation lands as float64, as it would through JSON. + for _, k := range []string{"x-dec", "x-hex", "x-underscore", "x-float"} { + require.IsType(t, float64(0), s.Extensions[k], "%s", k) + } + require.EqualValues(t, 42, s.Extensions["x-dec"]) + require.EqualValues(t, 42, s.Extensions["x-hex"], "hex resolves to its value, not its text") + require.EqualValues(t, 42, s.Extensions["x-underscore"]) + + // Other types are untouched. + require.Equal(t, "42", s.Extensions["x-str"]) + require.Equal(t, true, s.Extensions["x-bool"]) + + // Nested maps and lists too. + n := s.Extensions["x-nested"].(map[string]any) + require.IsType(t, float64(0), n["n"]) + require.IsType(t, float64(0), n["list"].([]any)[0]) + + // The any-typed struct fields the decoder fills directly. + require.IsType(t, float64(0), s.Example) + require.IsType(t, float64(0), s.Default) + require.EqualValues(t, 16, s.Default) +} + +// A declared integer field keeps its own type and full range: normalising +// those too would cost precision beyond 2^53, which the previous decode path +// did not. +func TestNativeScalars_DeclaredIntegerFieldsKeepPrecision(t *testing.T) { + const src = "type: integer\nmaxLength: 9007199254740993\n" + var node goyaml.Node + require.NoError(t, goyaml.Unmarshal([]byte(src), &node)) + + var s Schema + require.NoError(t, node.Content[0].Decode(&s)) + require.NotNil(t, s.MaxLength) + require.Equal(t, uint64(9007199254740993), *s.MaxLength, "must not round-trip through float64") +} + +// Date-shaped scalars stay strings; an explicit tag still asks for a time. +func TestNativeScalars_Timestamps(t *testing.T) { + const src = "example: 2020-06-11T16:32:50Z\n" + var node goyaml.Node + require.NoError(t, goyaml.Unmarshal([]byte(src), &node)) + stripTimestamps(&node) + + var s Schema + require.NoError(t, node.Content[0].Decode(&s)) + require.IsType(t, "", s.Example, "a date-shaped example stays a string") +} diff --git a/openapi3/native_yaml.go b/openapi3/native_yaml.go new file mode 100644 index 000000000..3b38300da --- /dev/null +++ b/openapi3/native_yaml.go @@ -0,0 +1,174 @@ +package openapi3 + +//go:generate go run nativeyamlgenerator.go + +import ( + "reflect" + "strings" + "sync" + + yaml "go.yaml.in/yaml/v3" +) + +var knownYAMLFieldsCache sync.Map // reflect.Type -> map[string]struct{} + +// knownYAMLFields returns the yaml keys a struct type declares, skipping "-". +func knownYAMLFields(t reflect.Type) map[string]struct{} { + if v, ok := knownYAMLFieldsCache.Load(t); ok { + return v.(map[string]struct{}) + } + known := make(map[string]struct{}, t.NumField()) + for i := range t.NumField() { + f := t.Field(i) + if !f.IsExported() { + continue + } + name, _, _ := strings.Cut(f.Tag.Get("yaml"), ",") + if name == "" { + name = strings.ToLower(f.Name) + } + if name == "-" { + continue + } + known[name] = struct{}{} + } + knownYAMLFieldsCache.Store(t, known) + return known +} + +// normalizeNumbers converts integers to float64 inside a decoded any. +// +// JSON has one number type, so a value reaching an any-typed field carries a +// float64 whichever notation the source used. YAML resolves 42, 0x2A and 4_2 +// to an int, which would make a consumer's type switch depend on notation. +// +// Applied only to any-typed values. A declared integer field keeps its own +// type and its full range, which a blanket conversion would cost beyond 2^53. +func normalizeNumbers(v any) any { + switch t := v.(type) { + case int: + return float64(t) + case int64: + return float64(t) + case uint64: + return float64(t) + case map[string]any: + for k, e := range t { + t[k] = normalizeNumbers(e) + } + case []any: + for i, e := range t { + t[i] = normalizeNumbers(e) + } + } + return v +} + +// normalizeAnyFields applies normalizeNumbers to a struct's any-typed fields, +// which the decoder fills directly: Example and Default, but also Enum, whose +// element type is any. Missing the slice case left an integer example as a +// float64 and the enum it must match as an int, so a schema failed against its +// own allowed values. +func normalizeAnyFields(out any) { + v := reflect.ValueOf(out) + for v.Kind() == reflect.Pointer { + if v.IsNil() { + return + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return + } + for i := range v.NumField() { + f := v.Field(i) + if !f.CanSet() { + continue + } + switch f.Kind() { + case reflect.Interface: + if !f.IsNil() { + f.Set(reflect.ValueOf(normalizeNumbers(f.Interface()))) + } + case reflect.Slice, reflect.Map: + // []any and map[string]any: normalise the elements in place. + if f.Type().Elem().Kind() != reflect.Interface || f.IsNil() { + continue + } + if f.Kind() == reflect.Slice { + for j := range f.Len() { + e := f.Index(j) + if !e.IsNil() { + e.Set(reflect.ValueOf(normalizeNumbers(e.Interface()))) + } + } + continue + } + for _, k := range f.MapKeys() { + e := f.MapIndex(k) + if !e.IsNil() { + f.SetMapIndex(k, reflect.ValueOf(normalizeNumbers(e.Interface()))) + } + } + } + } +} + +// decodeMapping decodes node into a method-less view of the target, supplied by +// the caller as a locally-declared shadow type, and returns the keys the target +// does not declare. +func decodeMapping[S any](node *yaml.Node, shadow *S) (map[string]any, error) { + return decodeStructWithExtensions(node, shadow) +} + +// decodeStructWithExtensions decodes node into out and returns the mapping keys +// out does not declare, which are the extensions. Returns nil rather than an +// empty map when there are none. +// +// The declared set comes from out's yaml tags, so adding a field to a struct is +// enough to stop it being collected as an extension. +func decodeStructWithExtensions(node *yaml.Node, out any) (map[string]any, error) { + if err := node.Decode(out); err != nil { + return nil, err + } + if node.Kind != yaml.MappingNode { + return nil, nil + } + known := knownYAMLFields(reflect.TypeOf(out).Elem()) + + var ext map[string]any + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i].Value + if _, ok := known[key]; ok { + continue + } + var v any + if err := node.Content[i+1].Decode(&v); err != nil { + return nil, err + } + if ext == nil { + ext = make(map[string]any) + } + ext[key] = normalizeNumbers(v) + } + normalizeAnyFields(out) + return ext, nil +} + +// stripTimestamps retags implicitly-resolved date-shaped scalars as strings. +// +// YAML 1.1 resolves an untagged scalar such as 2020-06-11T16:32:50-03:00 to a +// timestamp, which would make an OpenAPI `example` of that shape decode to a +// time.Time and fail validation as an unhandled type. An explicit !!timestamp +// tag is a deliberate request for a time.Time and is left alone. +func stripTimestamps(n *yaml.Node) { + if n == nil { + return + } + if n.Kind == yaml.ScalarNode && n.Tag == "!!timestamp" && n.Style != yaml.TaggedStyle { + n.Tag = "!!str" + } + for _, c := range n.Content { + stripTimestamps(c) + } +} diff --git a/openapi3/native_yaml_refs.go b/openapi3/native_yaml_refs.go new file mode 100644 index 000000000..8b2c2399a --- /dev/null +++ b/openapi3/native_yaml_refs.go @@ -0,0 +1,45 @@ +package openapi3 + +// Shared by the generated $ref wrapper UnmarshalYAML methods in refs.go. + +import ( + "strings" + + yaml "go.yaml.in/yaml/v3" +) + +// unmarshalRefYAML fills the reference half of a wrapper and reports whether +// the node was a reference. When false the caller decodes the value. +func unmarshalRefYAML(node *yaml.Node, ref *string, summary, description **string, extensions *map[string]any) bool { + refNode := mappingValue(node, "$ref") + if refNode == nil || refNode.Value == "" { + return false + } + *ref = refNode.Value + for i := 0; i+1 < len(node.Content); i += 2 { + k, v := node.Content[i].Value, node.Content[i+1] + switch { + case k == "$ref": + case k == "summary" && summary != nil: + var s string + if v.Decode(&s) == nil { + *summary = &s + } + case k == "description" && description != nil: + var s string + if v.Decode(&s) == nil { + *description = &s + } + case strings.HasPrefix(k, "x-"): + var a any + if v.Decode(&a) != nil { + continue + } + if *extensions == nil { + *extensions = make(map[string]any) + } + (*extensions)[k] = a + } + } + return true +} diff --git a/openapi3/native_yaml_shadow.go b/openapi3/native_yaml_shadow.go new file mode 100644 index 000000000..f0d97b775 --- /dev/null +++ b/openapi3/native_yaml_shadow.go @@ -0,0 +1,277 @@ +// Code generated by go generate using nativeyaml.tmpl; DO NOT EDIT native_yaml_shadow.go. +package openapi3 + +import ( + yaml "go.yaml.in/yaml/v3" +) + +// UnmarshalYAML for the types whose YAML form is a mapping of declared fields +// plus extensions. +// +// The local shadow type is what stops the decoder recursing into this method; +// converting the receiver to it decodes in place, so only the two fields the +// decoder skips are set afterwards. Everything else is in decodeMapping. + +// UnmarshalYAML sets Components from node. +func (components *Components) UnmarshalYAML(node *yaml.Node) error { + type bis Components + ext, err := decodeMapping(node, (*bis)(components)) + if err != nil { + return err + } + components.Extensions, components.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, components, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets Contact from node. +func (contact *Contact) UnmarshalYAML(node *yaml.Node) error { + type bis Contact + ext, err := decodeMapping(node, (*bis)(contact)) + if err != nil { + return err + } + contact.Extensions, contact.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, contact, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets Discriminator from node. +func (discriminator *Discriminator) UnmarshalYAML(node *yaml.Node) error { + type bis Discriminator + ext, err := decodeMapping(node, (*bis)(discriminator)) + if err != nil { + return err + } + discriminator.Extensions, discriminator.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, discriminator, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets Encoding from node. +func (encoding *Encoding) UnmarshalYAML(node *yaml.Node) error { + type bis Encoding + ext, err := decodeMapping(node, (*bis)(encoding)) + if err != nil { + return err + } + encoding.Extensions, encoding.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, encoding, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets Example from node. +func (example *Example) UnmarshalYAML(node *yaml.Node) error { + type bis Example + ext, err := decodeMapping(node, (*bis)(example)) + if err != nil { + return err + } + example.Extensions, example.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, example, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets ExternalDocs from node. +func (e *ExternalDocs) UnmarshalYAML(node *yaml.Node) error { + type bis ExternalDocs + ext, err := decodeMapping(node, (*bis)(e)) + if err != nil { + return err + } + e.Extensions, e.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, e, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets Info from node. +func (info *Info) UnmarshalYAML(node *yaml.Node) error { + type bis Info + ext, err := decodeMapping(node, (*bis)(info)) + if err != nil { + return err + } + info.Extensions, info.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, info, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets License from node. +func (license *License) UnmarshalYAML(node *yaml.Node) error { + type bis License + ext, err := decodeMapping(node, (*bis)(license)) + if err != nil { + return err + } + license.Extensions, license.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, license, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets Link from node. +func (link *Link) UnmarshalYAML(node *yaml.Node) error { + type bis Link + ext, err := decodeMapping(node, (*bis)(link)) + if err != nil { + return err + } + link.Extensions, link.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, link, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets MediaType from node. +func (mediaType *MediaType) UnmarshalYAML(node *yaml.Node) error { + type bis MediaType + ext, err := decodeMapping(node, (*bis)(mediaType)) + if err != nil { + return err + } + mediaType.Extensions, mediaType.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, mediaType, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets OAuthFlow from node. +func (flow *OAuthFlow) UnmarshalYAML(node *yaml.Node) error { + type bis OAuthFlow + ext, err := decodeMapping(node, (*bis)(flow)) + if err != nil { + return err + } + flow.Extensions, flow.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, flow, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets OAuthFlows from node. +func (flows *OAuthFlows) UnmarshalYAML(node *yaml.Node) error { + type bis OAuthFlows + ext, err := decodeMapping(node, (*bis)(flows)) + if err != nil { + return err + } + flows.Extensions, flows.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, flows, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets Parameter from node. +func (parameter *Parameter) UnmarshalYAML(node *yaml.Node) error { + type bis Parameter + ext, err := decodeMapping(node, (*bis)(parameter)) + if err != nil { + return err + } + parameter.Extensions, parameter.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, parameter, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets PathItem from node. +func (pathItem *PathItem) UnmarshalYAML(node *yaml.Node) error { + type bis PathItem + ext, err := decodeMapping(node, (*bis)(pathItem)) + if err != nil { + return err + } + pathItem.Extensions, pathItem.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, pathItem, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets RequestBody from node. +func (requestBody *RequestBody) UnmarshalYAML(node *yaml.Node) error { + type bis RequestBody + ext, err := decodeMapping(node, (*bis)(requestBody)) + if err != nil { + return err + } + requestBody.Extensions, requestBody.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, requestBody, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets Response from node. +func (response *Response) UnmarshalYAML(node *yaml.Node) error { + type bis Response + ext, err := decodeMapping(node, (*bis)(response)) + if err != nil { + return err + } + response.Extensions, response.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, response, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets Schema from node. +func (schema *Schema) UnmarshalYAML(node *yaml.Node) error { + type bis Schema + ext, err := decodeMapping(node, (*bis)(schema)) + if err != nil { + return err + } + schema.Extensions, schema.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, schema, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets SecurityScheme from node. +func (ss *SecurityScheme) UnmarshalYAML(node *yaml.Node) error { + type bis SecurityScheme + ext, err := decodeMapping(node, (*bis)(ss)) + if err != nil { + return err + } + ss.Extensions, ss.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, ss, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets Server from node. +func (server *Server) UnmarshalYAML(node *yaml.Node) error { + type bis Server + ext, err := decodeMapping(node, (*bis)(server)) + if err != nil { + return err + } + server.Extensions, server.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, server, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets ServerVariable from node. +func (serverVariable *ServerVariable) UnmarshalYAML(node *yaml.Node) error { + type bis ServerVariable + ext, err := decodeMapping(node, (*bis)(serverVariable)) + if err != nil { + return err + } + serverVariable.Extensions, serverVariable.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, serverVariable, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets Tag from node. +func (t *Tag) UnmarshalYAML(node *yaml.Node) error { + type bis Tag + ext, err := decodeMapping(node, (*bis)(t)) + if err != nil { + return err + } + t.Extensions, t.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, t, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets XML from node. +func (xml *XML) UnmarshalYAML(node *yaml.Node) error { + type bis XML + ext, err := decodeMapping(node, (*bis)(xml)) + if err != nil { + return err + } + xml.Extensions, xml.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, xml, nativeOriginFile()) + return nil +} diff --git a/openapi3/native_yaml_special.go b/openapi3/native_yaml_special.go new file mode 100644 index 000000000..df6dcfa22 --- /dev/null +++ b/openapi3/native_yaml_special.go @@ -0,0 +1,166 @@ +package openapi3 + +// UnmarshalYAML for the maplike collections, whose entries are components +// rather than declared fields, and for the union-typed values. + +import ( + "reflect" + "strings" + + yaml "go.yaml.in/yaml/v3" +) + +// unmarshalMaplikeYAML decodes a mapping whose x- keys are extensions and whose +// remaining entries are components, stamping each entry's origin from the key +// that heads it. +func unmarshalMaplikeYAML[V any](node *yaml.Node, ext *map[string]any, out *map[string]*V) error { + if node.Kind != yaml.MappingNode { + return node.Decode(out) + } + *ext = make(map[string]any) + *out = make(map[string]*V, len(node.Content)/2) + for i := 0; i+1 < len(node.Content); i += 2 { + k, v := node.Content[i].Value, node.Content[i+1] + if strings.HasPrefix(k, "x-") { + var a any + if err := v.Decode(&a); err != nil { + return err + } + (*ext)[k] = a + continue + } + var vv V + if err := v.Decode(&vv); err != nil { + return err + } + (*out)[k] = &vv + // The key node is in hand here, so no reflection is needed to find it. + setOriginKey(reflect.ValueOf(&vv), node.Content[i], node.Content[i+1], nativeOriginFile()) + } + return nil +} + +func (responses *Responses) UnmarshalYAML(node *yaml.Node) error { + var x Responses + if err := unmarshalMaplikeYAML(node, &x.Extensions, &x.m); err != nil { + return err + } + *responses = x + return nil +} + +func (callback *Callback) UnmarshalYAML(node *yaml.Node) error { + var x Callback + if err := unmarshalMaplikeYAML(node, &x.Extensions, &x.m); err != nil { + return err + } + *callback = x + return nil +} + +func (paths *Paths) UnmarshalYAML(node *yaml.Node) error { + var x Paths + if err := unmarshalMaplikeYAML(node, &x.Extensions, &x.m); err != nil { + return err + } + *paths = x + return nil +} + +// Header embeds Parameter and carries no fields of its own. +func (header *Header) UnmarshalYAML(node *yaml.Node) error { + return header.Parameter.UnmarshalYAML(node) +} + +// Types is a string or a list of strings. +func (types *Types) UnmarshalYAML(node *yaml.Node) error { + var list []string + if err := node.Decode(&list); err != nil { + var s string + if err := node.Decode(&s); err != nil { + return err + } + list = []string{s} + } + *types = list + return nil +} + +// BoolSchema is `true`/`false` or a schema. +func (bs *BoolSchema) UnmarshalYAML(node *yaml.Node) error { + if node.Kind == yaml.ScalarNode { + if node.Tag == "!!null" { + return nil + } + var b bool + if err := node.Decode(&b); err == nil { + bs.Has = &b + return nil + } + } + var sr SchemaRef + if err := node.Decode(&sr); err != nil { + return err + } + bs.Schema = &sr + return nil +} + +// ExclusiveBound is a bool in OAS 3.0, where it modifies minimum/maximum, or a +// number in 3.1, where it is the bound itself. +func (eb *ExclusiveBound) UnmarshalYAML(node *yaml.Node) error { + if node.Kind != yaml.ScalarNode || node.Tag == "!!null" { + return nil + } + var b bool + if err := node.Decode(&b); err == nil { + eb.Bool = &b + return nil + } + var f float64 + if err := node.Decode(&f); err != nil { + return err + } + eb.Value = &f + return nil +} + +// Operation distinguishes an omitted responses from an explicitly null one: +// the first is allowed in OAS 3.1 and later, the second never is. A null node +// decodes to an empty Responses, which is indistinguishable from `{}` without +// the flag. +func (operation *Operation) UnmarshalYAML(node *yaml.Node) error { + type bis Operation + ext, err := decodeMapping(node, (*bis)(operation)) + if err != nil { + return err + } + operation.Extensions, operation.Origin = ext, originFromNode(node, nativeOriginFile()) + if v := mappingValue(node, "responses"); v != nil && v.Tag == "!!null" { + operation.Responses = &Responses{explicitlyNull: true} + } + setChildOriginKeys(node, operation, nativeOriginFile()) + return nil +} + +// T is the document root, so no parent stamps its Origin.Key. It takes its own +// first key instead, the same rule a sequence item follows. +func (doc *T) UnmarshalYAML(node *yaml.Node) error { + type bis T + ext, err := decodeMapping(node, (*bis)(doc)) + if err != nil { + return err + } + doc.Extensions, doc.Origin = ext, originFromNode(node, nativeOriginFile()) + if doc.Origin != nil && node.Kind == yaml.MappingNode && len(node.Content) > 0 { + first := node.Content[0] + doc.Origin.Key = &Location{ + File: nativeOriginFile(), + Line: first.Line, + Column: first.Column, + Name: first.Value, + } + } + setChildOriginKeys(node, doc, nativeOriginFile()) + return nil +} diff --git a/openapi3/native_yaml_test.go b/openapi3/native_yaml_test.go new file mode 100644 index 000000000..5fdfdd165 --- /dev/null +++ b/openapi3/native_yaml_test.go @@ -0,0 +1,64 @@ +package openapi3 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + goyaml "go.yaml.in/yaml/v3" +) + +const nativeSrc = `"200": + description: ok + x-tags: + - alpha + - beta + content: + application/json: + x-media: 1 +"404": + $ref: '#/components/responses/NotFound' + summary: missing +x-collection: top +` + +// A document decoded from the node must equal the same document decoded as +// JSON: the two paths are interchangeable for content. +func TestNativeStock_MatchesJSONPath(t *testing.T) { + var viaJSON Responses + jsonBytes, err := yamlToJSON(nativeSrc) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(jsonBytes, &viaJSON)) + + var viaNode Responses + require.NoError(t, goyaml.Unmarshal([]byte(nativeSrc), &viaNode)) + + want, err := json.Marshal(&viaJSON) + require.NoError(t, err) + got, err := json.Marshal(&viaNode) + require.NoError(t, err) + require.JSONEq(t, string(want), string(got)) +} + +// Origins must reach nested collections, not only the top level. +func TestNativeStock_OriginsAtDepth(t *testing.T) { + defer func(v bool) { originEnabledVar = v }(originEnabledVar) + originEnabledVar = true + + var r Responses + require.NoError(t, goyaml.Unmarshal([]byte(nativeSrc), &r)) + mt := r.Value("200").Value.Content["application/json"] + require.NotNil(t, mt) + require.NotNil(t, mt.Origin, "nested media type should carry an origin") + require.NotNil(t, mt.Origin.Key) + require.Equal(t, "application/json", mt.Origin.Key.Name) + require.Equal(t, 7, mt.Origin.Key.Line) +} + +func yamlToJSON(src string) ([]byte, error) { + var v any + if err := goyaml.Unmarshal([]byte(src), &v); err != nil { + return nil, err + } + return json.Marshal(v) +} diff --git a/openapi3/nativeyaml.tmpl b/openapi3/nativeyaml.tmpl new file mode 100644 index 000000000..14c5f0132 --- /dev/null +++ b/openapi3/nativeyaml.tmpl @@ -0,0 +1,26 @@ +// Code generated by go generate using nativeyaml.tmpl; DO NOT EDIT native_yaml_shadow.go. +package {{ .Package }} + +import ( + yaml "go.yaml.in/yaml/v3" +) + +// UnmarshalYAML for the types whose YAML form is a mapping of declared fields +// plus extensions. +// +// The local shadow type is what stops the decoder recursing into this method; +// converting the receiver to it decodes in place, so only the two fields the +// decoder skips are set afterwards. Everything else is in decodeMapping. +{{ range $type := .Types }} +// UnmarshalYAML sets {{ $type.Name }} from node. +func ({{ $type.Recv }} *{{ $type.Name }}) UnmarshalYAML(node *yaml.Node) error { + type bis {{ $type.Name }} + ext, err := decodeMapping(node, (*bis)({{ $type.Recv }})) + if err != nil { + return err + } + {{ $type.Recv }}.Extensions, {{ $type.Recv }}.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, {{ $type.Recv }}, nativeOriginFile()) + return nil +} +{{ end -}} diff --git a/openapi3/nativeyamlgenerator.go b/openapi3/nativeyamlgenerator.go new file mode 100644 index 000000000..fee26fc99 --- /dev/null +++ b/openapi3/nativeyamlgenerator.go @@ -0,0 +1,72 @@ +//go:build ignore + +// The program generates native_yaml_shadow.go, invoke `go generate ./...` to run. +package main + +import ( + "bytes" + _ "embed" + "go/format" + "os" + "text/template" +) + +//go:embed nativeyaml.tmpl +var tmplData string + +type shadowType struct { + Name string + Recv string +} + +func main() { + // The types whose YAML form is a mapping of declared fields plus + // extensions. The $ref wrappers are generated from refs.tmpl instead. + // Hand-written in native_yaml_special.go: the maplike collections, the + // union-typed values, Operation, which has to tell an omitted responses + // from an explicitly null one, and T, which has no parent to take its + // Key from. + types := []shadowType{ + {"Components", "components"}, + {"Contact", "contact"}, + {"Discriminator", "discriminator"}, + {"Encoding", "encoding"}, + {"Example", "example"}, + {"ExternalDocs", "e"}, + {"Info", "info"}, + {"License", "license"}, + {"Link", "link"}, + {"MediaType", "mediaType"}, + {"OAuthFlow", "flow"}, + {"OAuthFlows", "flows"}, + {"Parameter", "parameter"}, + {"PathItem", "pathItem"}, + {"RequestBody", "requestBody"}, + {"Response", "response"}, + {"Schema", "schema"}, + {"SecurityScheme", "ss"}, + {"Server", "server"}, + {"ServerVariable", "serverVariable"}, + {"Tag", "t"}, + {"XML", "xml"}, + } + + tmpl := template.Must(template.New("nativeyaml").Parse(tmplData)) + buf := new(bytes.Buffer) + if err := tmpl.Execute(buf, struct { + Package string + Types []shadowType + }{ + Package: os.Getenv("GOPACKAGE"), // set by the go:generate directive + Types: types, + }); err != nil { + panic(err) + } + src, err := format.Source(buf.Bytes()) + if err != nil { + panic(err) + } + if err := os.WriteFile("native_yaml_shadow.go", src, 0o644); err != nil { + panic(err) + } +} diff --git a/openapi3/openapi3_test.go b/openapi3/openapi3_test.go index 68ddfe8a9..bf72e9269 100644 --- a/openapi3/openapi3_test.go +++ b/openapi3/openapi3_test.go @@ -6,10 +6,10 @@ import ( "strings" "testing" - "github.com/oasdiff/yaml" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/getkin/kin-openapi/internal/yamlconv" "github.com/getkin/kin-openapi/openapi3" ) @@ -55,13 +55,13 @@ func TestRefsYAML(t *testing.T) { loader := openapi3.NewLoader() t.Log("Marshal *T to YAML") - data, err := yaml.Marshal(spec()) + data, err := yamlconv.Marshal(spec()) require.NoError(t, err) require.NotEmpty(t, data) t.Log("Unmarshal *T from YAML") docA := &openapi3.T{} - _, err = yaml.Unmarshal(specYAML, &docA, yaml.DecodeOpts{DisableTimestamps: true}) + err = yamlconv.Unmarshal(specYAML, &docA) require.NoError(t, err) require.NotEmpty(t, data) @@ -80,9 +80,9 @@ func TestRefsYAML(t *testing.T) { require.NoError(t, err) t.Log("Ensure representations match") - dataA, err := yaml.Marshal(docA) + dataA, err := yamlconv.Marshal(docA) require.NoError(t, err) - dataB, err := yaml.Marshal(docB) + dataB, err := yamlconv.Marshal(docB) require.NoError(t, err) require.YAMLEq(t, string(data), string(specYAML)) require.YAMLEq(t, string(data), string(dataA)) @@ -432,7 +432,7 @@ components: tt := tests[i] t.Run(tt.name, func(t *testing.T) { doc := &openapi3.T{} - _, err := yaml.Unmarshal([]byte(tt.spec), &doc, yaml.DecodeOpts{DisableTimestamps: true}) + err := yamlconv.Unmarshal([]byte(tt.spec), &doc) require.NoError(t, err) err = doc.Validate(t.Context()) diff --git a/openapi3/origin.go b/openapi3/origin.go index b571cb3d2..ad1fb5e6e 100644 --- a/openapi3/origin.go +++ b/openapi3/origin.go @@ -1,25 +1,108 @@ package openapi3 +// Origin records where each element of a document came from: the position of +// the key that heads a collection, of each of its fields, and of the scalar +// items in its sequence-valued fields. +// +// The positions are read from the nodes as the document decodes. A node knows +// where it starts, so the only piece it cannot supply is Key -- the key above +// it belongs to the parent, which stamps it. + import ( + "encoding/json" "reflect" + "slices" "sort" "strings" + "sync" - "github.com/oasdiff/yaml" + yaml "go.yaml.in/yaml/v3" ) var originPtrType = reflect.TypeFor[*Origin]() // Origin contains the origin of a collection. // Key is the location of the collection itself. -// Fields is a map of the location of each scalar field in the collection. +// Fields holds the location of each scalar field in the collection. // Sequences is a map of the location of each item in sequence-valued fields. +// +// Sequences stays a map although Fields is a slice, which is deliberate. +// FieldLocations drops the map because Location.Name already carries the key, +// so the map was storing information the value repeated. Here Location.Name +// holds the *item's* value (an enum member, a required property) while the key +// is the *field's* name ("enum", "required", "tags"), so a slice would need a +// wrapper type invented to hold it. The memory argument is also much weaker: +// only a collection with a sequence-valued field allocates one at all, which +// measured at 5% of collections on a large spec, and a nil map is free. type Origin struct { Key *Location `json:"key,omitempty" yaml:"key,omitempty"` - Fields map[string]Location `json:"fields,omitempty" yaml:"fields,omitempty"` + Fields FieldLocations `json:"fields,omitempty" yaml:"fields,omitempty"` Sequences map[string][]Location `json:"sequences,omitempty" yaml:"sequences,omitempty"` } +// FieldLocations holds the locations of a collection's scalar fields, in the +// order they appear in the document. +// +// It is a slice rather than a map[string]Location because a collection carries +// only a handful of fields, while a Go map allocates a whole bucket per +// collection whatever it holds. On a large document that overhead dominated +// the retained size of a parsed spec. Each Location already carries its Name, +// so the lookup key costs nothing extra here. +type FieldLocations []Location + +// Get returns the location of the named field, or the zero Location when the +// field has none. Use Lookup to tell an absent field from a zero location. +func (f FieldLocations) Get(name string) Location { + loc, _ := f.Lookup(name) + return loc +} + +// Lookup returns the location of the named field and whether it was found. +// The scan is linear: collections have few fields, and a linear scan over a +// contiguous slice beats a map lookup at these sizes. +// +// Deliberately a hand-written loop rather than slices.IndexFunc: the closure +// does not inline, so IndexFunc pays a call per element. Measured on 3/6/12 +// fields it is 5-100% slower on a hit and 2-3x slower on a miss, and misses +// are the common case here (most fields carry no recorded location). +func (f FieldLocations) Lookup(name string) (Location, bool) { + for i := range f { + if f[i].Name == name { + return f[i], true + } + } + return Location{}, false +} + +// MarshalJSON keeps the serialized shape a name-keyed object, as it was when +// this was a map, so the change is invisible to anything reading the output. +func (f FieldLocations) MarshalJSON() ([]byte, error) { + m := make(map[string]Location, len(f)) + for _, loc := range f { + m[loc.Name] = loc + } + return json.Marshal(m) +} + +// UnmarshalJSON reads the name-keyed object written by MarshalJSON. Entries are +// sorted by name, since a JSON object carries no order to restore. +func (f *FieldLocations) UnmarshalJSON(data []byte) error { + var m map[string]Location + if err := json.Unmarshal(data, &m); err != nil { + return err + } + out := make(FieldLocations, 0, len(m)) + for name, loc := range m { + if loc.Name == "" { + loc.Name = name + } + out = append(out, loc) + } + slices.SortFunc(out, func(a, b Location) int { return strings.Compare(a.Name, b.Name) }) + *f = out + return nil +} + // Location is a struct that contains the location of a field. type Location struct { File string `json:"file,omitempty" yaml:"file,omitempty"` @@ -35,296 +118,317 @@ type Location struct { EndColumn int `json:"endColumn,omitempty" yaml:"endColumn,omitempty"` } -// originFromSeq parses the compact []any sequence produced by yaml3's addOrigin. +// originTree is a decoded document's node tree together with what the origins +// read off it were stamped against. A $ref resolved later decodes a subtree of +// this, and must stamp the file the subtree came from rather than whichever +// document happened to be decoded last. +type originTree struct { + node *yaml.Node + file string + ends *endIndex +} + +// originMu guards the three package-level variables below for the length of a +// decode. They exist because UnmarshalYAML receives a node and nothing else, +// with no way to carry per-decode state through the call, and a lock is what +// makes them safe to hold that way. // -// Format: [file, key_name, key_line, key_col, nf, f1_name, f1_delta, f1_col, ..., ns, s1_name, s1_count, s1_l0_delta, s1_c0, ...] -func originFromSeq(s []any) *Origin { - // Need at least: file, key_name, key_line, key_col, nf, ns - if len(s) < 6 { - return nil - } - file, _ := s[0].(string) - keyName, _ := s[1].(string) - keyLine := toInt(s[2]) - keyCol := toInt(s[3]) +// The cost is that decodes serialise even when each has its own Loader, which +// TestIssue741 does. Correctness first: without this the three race, and the +// path this replaces passed the file as an argument and did not. +// +// A file recorded on the node itself would remove the need, since the callback +// already receives the node, but go-yaml's Node has no field for it. +var originMu sync.Mutex - o := &Origin{ - Key: &Location{ - File: file, - Line: keyLine, - Column: keyCol, - Name: keyName, - }, - } +// originFileVar is the file stamped into origins for the decode in progress. +// UnmarshalYAML receives a node and nothing else, so the file cannot be passed +// through the call. +var originFileVar string - idx := 4 - nf := toInt(s[idx]) - idx++ - if nf > 0 && idx+nf*3 <= len(s) { - o.Fields = make(map[string]Location, nf) - for range nf { - fname, _ := s[idx].(string) - delta := toInt(s[idx+1]) - col := toInt(s[idx+2]) - o.Fields[fname] = Location{ - File: file, - Line: keyLine + delta, - Column: col, - Name: fname, - } - idx += 3 - } - } +// originEnabledVar mirrors the includeOrigin argument unmarshal receives, which +// comes from the Loader. The package-level IncludeOrigin only seeds NewLoader, +// so a caller that set it on its Loader alone would be missed. +var originEnabledVar bool - if idx >= len(s) { - return o - } - ns := toInt(s[idx]) - idx++ - if ns > 0 { - o.Sequences = make(map[string][]Location, ns) - for range ns { - if idx >= len(s) { - break - } - sname, _ := s[idx].(string) - idx++ - if idx >= len(s) { - break - } - count := toInt(s[idx]) - idx++ - locs := make([]Location, 0, count) - for j := 0; j < count && idx+2 < len(s); j++ { - name, _ := s[idx].(string) - delta := toInt(s[idx+1]) - col := toInt(s[idx+2]) - locs = append(locs, Location{File: file, Line: keyLine + delta, Column: col, Name: name}) - idx += 3 - } - o.Sequences[sname] = locs - } - } +// Shared machinery for the UnmarshalYAML methods: extension collection, and +// origins read from the node being decoded. +// +// Origins record where an element starts, not where it ends. A consumer that +// needs the extent of a block derives it from the next key or sequence item at +// the same or shallower indentation. - // Trailing block end (yaml3 >= the end-position release): end_delta, end_col. - // Reconstruct the end of the whole block on Origin.Key so a consumer can - // extract the entire element. Older origin sequences omit these, leaving - // EndLine/EndColumn zero. end_col == 0 means no end information was recorded. - if o.Key != nil && idx+1 < len(s) { - if endCol := toInt(s[idx+1]); endCol > 0 { - o.Key.EndLine = keyLine + toInt(s[idx]) - o.Key.EndColumn = endCol - } - } - return o -} +func nativeOriginFile() string { return originFileVar } -// toInt converts numeric types to int. Handles int/uint64 from YAML decoding. -func toInt(v any) int { - switch n := v.(type) { - case int: - return n - case uint64: - return int(n) - } - return 0 +// mappingValue returns the value node for key, or nil. +func mappingValue(node *yaml.Node, key string) *yaml.Node { + _, v := mappingEntry(node, key) + return v } -// isScalarValuedMapField reports whether v is a non-empty map whose element -// type is a scalar (string, bool, or a numeric kind). Such a map decodes -// without an Origin field of its own, unlike a pointer- or struct-valued map -// whose elements each carry their own Origin. -func isScalarValuedMapField(v reflect.Value) bool { - if v.Kind() != reflect.Map || v.IsNil() || v.Len() == 0 { - return false - } - switch v.Type().Elem().Kind() { - case reflect.String, reflect.Bool, - reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, - reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, - reflect.Float32, reflect.Float64: - return true - } - return false +// mappingEntry returns both halves of a mapping entry, the key being what a +// value's origin records as its own location. +func mappingEntry(node *yaml.Node, key string) (*yaml.Node, *yaml.Node) { + if node.Kind != yaml.MappingNode { + return nil, nil + } + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i], node.Content[i+1] + } + } + return nil, nil } -// recordMapKeyLocations copies the map-key locations from a scalar-valued map's -// own subtree onto parentOrigin.Sequences[field], so each key is addressable by -// name (the same shape used for sequence items). It is a no-op when the child -// carries no origin data. Keys are sorted for deterministic output. -func recordMapKeyLocations(parentOrigin *Origin, field string, childTree *yaml.OriginTree) { - s, ok := childTree.Origin.([]any) - if !ok { - return +// originFromNode builds the origin data a mapping can see for itself: where +// each of its field keys is, and where the scalar items of its sequence-valued +// fields are. +// +// Origin.Key is not set here -- it is the location of the key heading this +// mapping in its parent, which a node does not know. See setChildOriginKeys. +func originFromNode(node *yaml.Node, file string) *Origin { + // Origins are opt-in: without this every decode pays for them. + if !originEnabledVar { + return nil } - childOrigin := originFromSeq(s) - if childOrigin == nil || len(childOrigin.Fields) == 0 { - return + if node == nil || node.Kind != yaml.MappingNode { + return nil } - locs := make([]Location, 0, len(childOrigin.Fields)) - for _, loc := range childOrigin.Fields { - locs = append(locs, loc) + o := &Origin{} + for i := 0; i+1 < len(node.Content); i += 2 { + k, v := node.Content[i], node.Content[i+1] + if o.Fields == nil { + o.Fields = make(FieldLocations, 0, len(node.Content)/2) + } + o.Fields = append(o.Fields, Location{File: file, Line: k.Line, Column: k.Column, Name: k.Value}) + + if v.Kind != yaml.SequenceNode { + continue + } + var locs []Location + for _, item := range v.Content { + if item.Kind == yaml.ScalarNode { + locs = append(locs, Location{File: file, Line: item.Line, Column: item.Column, Name: item.Value}) + } + } + if len(locs) > 0 { + if o.Sequences == nil { + o.Sequences = make(map[string][]Location) + } + o.Sequences[k.Value] = locs + } } - sort.Slice(locs, func(i, j int) bool { return locs[i].Name < locs[j].Name }) - if parentOrigin.Sequences == nil { - parentOrigin.Sequences = make(map[string][]Location) + if o.Fields == nil && o.Sequences == nil { + return nil } - parentOrigin.Sequences[field] = locs + return o } -// applyOrigins walks a Go struct tree and a parallel OriginTree, setting -// Origin fields on each struct from the extracted origin data. -func applyOrigins(v any, tree *yaml.OriginTree) { - if tree == nil { +// setChildOriginKeys sets Origin.Key on the immediate children of a mapping, +// from the key node heading each one. +// +// This is the only origin data a node cannot supply for itself: UnmarshalYAML +// receives the value node, and Key is the position of the key above it. Each +// child sets its own children's keys in turn, so one level per call covers the +// tree. +func setChildOriginKeys(node *yaml.Node, container any, file string) { + if !originEnabledVar { return } - applyOriginsToValue(reflect.ValueOf(v), tree) -} - -func applyOriginsToValue(val reflect.Value, tree *yaml.OriginTree) { - // Keep track of the last pointer so we can pass it to struct handlers - // (needed for calling methods like Map() on maplike types). - var ptr reflect.Value - for val.Kind() == reflect.Pointer || val.Kind() == reflect.Interface { - if val.IsNil() { + if node == nil || node.Kind != yaml.MappingNode { + return + } + v := reflect.ValueOf(container) + for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface { + if v.IsNil() { return } - if val.Kind() == reflect.Pointer { - ptr = val - } - val = val.Elem() - } - - switch val.Kind() { - case reflect.Struct: - applyOriginsToStruct(val, ptr, tree) - case reflect.Map: - applyOriginsToMap(val, tree) - case reflect.Slice: - applyOriginsToSlice(val, tree) + v = v.Elem() } -} - -func applyOriginsToStruct(val reflect.Value, ptr reflect.Value, tree *yaml.OriginTree) { - typ := val.Type() + for i := 0; i+1 < len(node.Content); i += 2 { + keyNode, valNode := node.Content[i], node.Content[i+1] + child := childByKey(v, keyNode.Value) + if !child.IsValid() { + continue + } + setOriginKey(child, keyNode, valNode, file) + recordScalarMapKeys(v, child, keyNode, valNode, file) - // Set Origin field for structs whose Origin field has a "-" json tag. - var structOrigin *Origin - if tree.Origin != nil { - if sf, ok := typ.FieldByName("Origin"); ok && sf.Type == originPtrType { - tag := sf.Tag.Get("json") - if tag == "-" { - if s, ok := tree.Origin.([]any); ok { - structOrigin = originFromSeq(s) - val.FieldByName("Origin").Set(reflect.ValueOf(structOrigin)) + switch c := deref(child); c.Kind() { + case reflect.Map: + // A map-valued field (Content, Headers, Links) holds children of + // its own, keyed in valNode. The generic map decoder gives them no + // hook of their own, so descend. + if c.CanInterface() { + setChildOriginKeys(valNode, c.Interface(), file) + } + case reflect.Slice: + // A sequence item has no key above it, so it takes its own first + // key as its Key. + if valNode.Kind != yaml.SequenceNode { + continue + } + for j := 0; j < len(valNode.Content) && j < c.Len(); j++ { + item := valNode.Content[j] + if item.Kind == yaml.MappingNode && len(item.Content) > 0 { + setOriginKey(c.Index(j), item.Content[0], item, file) } } } } +} - // Recurse into exported struct fields using json tags - for i := range typ.NumField() { - sf := typ.Field(i) - if !sf.IsExported() { - continue - } - tag := jsonTagName(sf) - if tag == "" || tag == "-" { - continue +func deref(v reflect.Value) reflect.Value { + for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface { + if v.IsNil() { + return v } - childTree := tree.Fields[tag] - if childTree == nil { - continue - } - // A scalar-valued map (e.g. OAuth scopes: map[string]string) decodes into - // a Go map that has no Origin field of its own, so its per-key locations — - // present in the child subtree — would otherwise be lost. Record them on - // this struct's Origin as a named sequence so a consumer can locate each - // entry by key. Object- or pointer-valued maps are excluded: their values - // carry their own Origin via the recursion below. - if structOrigin != nil && isScalarValuedMapField(val.Field(i)) { - recordMapKeyLocations(structOrigin, tag, childTree) - } - applyOriginsToValue(val.Field(i), childTree) + v = v.Elem() } + return v +} - // Handle wrapper types whose inner struct has no json tag: - // - *Ref types (e.g. SchemaRef, ResponseRef) have a "Value" field - // - BoolSchema (AdditionalProperties, UnevaluatedProperties, UnevaluatedItems) has a "Schema" field - // The origin tree data applies to the inner struct, not a sub-key. - for _, fieldName := range []string{"Value", "Schema"} { - vf := val.FieldByName(fieldName) - if !vf.IsValid() || vf.Kind() != reflect.Pointer || vf.IsNil() { - continue +// childByKey finds the struct field or map entry a mapping key decoded into. +func childByKey(v reflect.Value, key string) reflect.Value { + switch v.Kind() { + case reflect.Map: + if v.IsNil() { + return reflect.Value{} } - sf, _ := typ.FieldByName(fieldName) - if sf.Tag.Get("json") == "" { - applyOriginsToValue(vf, tree) + return v.MapIndex(reflect.ValueOf(key)) + case reflect.Struct: + t := v.Type() + for i := range t.NumField() { + f := t.Field(i) + if !f.IsExported() { + continue + } + if name, _, _ := strings.Cut(f.Tag.Get("yaml"), ","); name == key { + return v.Field(i) + } } } + return reflect.Value{} +} - // Handle "maplike" types (Paths, Responses, Callback) whose items are - // stored in an unexported map accessible via a Map() method. - // Use the original pointer (if available) since dereferenced values - // are not addressable. - receiver := val - if ptr.IsValid() { - receiver = ptr - } else if val.CanAddr() { - receiver = val.Addr() - } - if receiver.Kind() == reflect.Pointer { - if mapMethod := receiver.MethodByName("Map"); mapMethod.IsValid() { - results := mapMethod.Call(nil) - if len(results) == 1 { - applyOriginsToMap(results[0], tree) - } +// setOriginKey stamps Key on a child carrying an *Origin, from the key's own +// position. The extent of what the key heads is the consumer's to derive. +func setOriginKey(child reflect.Value, keyNode, valNode *yaml.Node, file string) { + if !originEnabledVar { + return + } + keyNode, valNode = resolveAlias(keyNode, valNode) + for child.Kind() == reflect.Pointer || child.Kind() == reflect.Interface { + if child.IsNil() { + return } + child = child.Elem() } + if child.Kind() != reflect.Struct { + return + } + f := child.FieldByName("Origin") + if !f.IsValid() || f.Type() != originPtrType || !f.CanSet() { + // No origin of its own; it may still wrap something that has one. + descendToWrapped(child, keyNode, valNode, file) + return + } + if f.IsNil() { + f.Set(reflect.ValueOf(&Origin{})) + } + key := withEnd(Location{ + File: file, + Line: keyNode.Line, + Column: keyNode.Column, + Name: keyNode.Value, + }, valNode) + f.Interface().(*Origin).Key = &key + // A wrapper and the thing it holds occupy the same node, so both carry + // that node's origin. Value is the $ref wrappers; Schema is BoolSchema, + // which holds either a bool or a schema. + descendToWrapped(child, keyNode, valNode, file) } -func applyOriginsToMap(val reflect.Value, tree *yaml.OriginTree) { - if tree.Fields == nil { +// descendToWrapped stamps the thing a wrapper holds, which occupies the same +// node. Value is the $ref wrappers; Schema is BoolSchema, which holds either a +// bool or a schema. +func descendToWrapped(child reflect.Value, keyNode, valNode *yaml.Node, file string) { + if child.Kind() != reflect.Struct { return } - for _, key := range val.MapKeys() { - childTree := tree.Fields[key.String()] - if childTree == nil { - continue - } - elem := val.MapIndex(key) - // Map values are not addressable. For pointer-typed values we can - // recurse directly. For value types we must copy, apply, and set back. - if elem.Kind() == reflect.Pointer || elem.Kind() == reflect.Interface { - applyOriginsToValue(elem, childTree) - } else if elem.Kind() == reflect.Struct { - // Copy to a settable value - cp := reflect.New(elem.Type()).Elem() - cp.Set(elem) - applyOriginsToStruct(cp, reflect.Value{}, childTree) - val.SetMapIndex(key, cp) + for _, name := range [...]string{"Value", "Schema"} { + if inner := child.FieldByName(name); inner.IsValid() { + setOriginKey(inner, keyNode, valNode, file) } } } -func applyOriginsToSlice(val reflect.Value, tree *yaml.OriginTree) { - for i := 0; i < val.Len() && i < len(tree.Items); i++ { - if tree.Items[i] != nil { - applyOriginsToValue(val.Index(i), tree.Items[i]) +// stampRootOrigin gives a document root the position of the document itself. +// +// Origin.Key is normally the key heading a mapping in its parent, stamped by +// that parent. A root has none -- an externally $ref'd file may be a bare +// schema -- so it takes the root node's own position and an empty name. +// Applied only when nothing has already set Key, so a type that supplies its +// own keeps it. +func stampRootOrigin(v any, node *yaml.Node) { + if !originEnabledVar || node == nil { + return + } + rv := reflect.ValueOf(v) + for rv.Kind() == reflect.Pointer || rv.Kind() == reflect.Interface { + if rv.IsNil() { + return } + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return + } + f := rv.FieldByName("Origin") + if !f.IsValid() || f.Type() != originPtrType || !f.CanSet() || f.IsNil() { + return + } + o := f.Interface().(*Origin) + if o.Key != nil { + return } + key := withEnd(Location{File: nativeOriginFile(), Line: node.Line, Column: node.Column}, node) + o.Key = &key } -// jsonTagName returns the JSON field name from a struct field's json tag. -func jsonTagName(f reflect.StructField) string { - tag := f.Tag.Get("json") - if tag == "" { - return "" - } - name, _, _ := strings.Cut(tag, ",") - return name +// recordScalarMapKeys records where each key of a scalar-valued map sits. +// +// A map[string]string -- scopes on an OAuth flow, say -- decodes to a plain Go +// map with nowhere to hang an Origin of its own, so its keys are recorded on +// the enclosing struct's Origin under the field name, sorted by key. +func recordScalarMapKeys(container, child reflect.Value, keyNode, valNode *yaml.Node, file string) { + if child.Kind() != reflect.Map || valNode == nil || valNode.Kind != yaml.MappingNode { + return + } + // A map of structs or pointers carries origins on its values instead. + switch child.Type().Elem().Kind() { + case reflect.Struct, reflect.Pointer, reflect.Interface, reflect.Map, reflect.Slice: + return + } + f := container.FieldByName("Origin") + if !f.IsValid() || f.Type() != originPtrType || !f.CanSet() { + return + } + if f.IsNil() { + f.Set(reflect.ValueOf(&Origin{})) + } + var locs []Location + for i := 0; i+1 < len(valNode.Content); i += 2 { + k := valNode.Content[i] + locs = append(locs, Location{File: file, Line: k.Line, Column: k.Column, Name: k.Value}) + } + if len(locs) == 0 { + return + } + sort.Slice(locs, func(i, j int) bool { return locs[i].Name < locs[j].Name }) + o := f.Interface().(*Origin) + if o.Sequences == nil { + o.Sequences = make(map[string][]Location) + } + o.Sequences[keyNode.Value] = locs } - -// originTree aliases the decoder-side origin tree, so the loader and marsh can -// carry it without referencing the yaml package directly. -type originTree = yaml.OriginTree diff --git a/openapi3/origin_external_ref_test.go b/openapi3/origin_external_ref_test.go index 83f7ea019..40a562706 100644 --- a/openapi3/origin_external_ref_test.go +++ b/openapi3/origin_external_ref_test.go @@ -35,14 +35,14 @@ func TestOrigin_ExternalRefToArbitraryTopLevelKey(t *testing.T) { Name: "User", EndLine: 7, EndColumn: 19, }, *user.Origin.Key, "the key origin spans the whole User block in arbitrary_key_schemas.yaml") - require.Equal(t, 2, user.Origin.Fields["type"].Line, "field origins are attached too") + require.Equal(t, 2, user.Origin.Fields.Get("type").Line, "field origins are attached too") // the subtree gets origins as well, with the same file id := user.Properties["id"].Value require.NotNil(t, id.Origin) require.Equal(t, user.Origin.Key.File, id.Origin.Key.File) require.Equal(t, 4, id.Origin.Key.Line, "the id property's own line") - require.Equal(t, 5, id.Origin.Fields["type"].Line) + require.Equal(t, 5, id.Origin.Fields.Get("type").Line) } // Re-attaching origins reuses the origin tree retained at load time: resolving @@ -101,6 +101,6 @@ User: require.Equal(t, "User", user.Origin.Key.Name) require.Equal(t, 14, user.Origin.Key.Line, "the User: line in the document above") require.Equal(t, 17, user.Origin.Key.EndLine, "the block's last line") - require.Equal(t, 15, user.Origin.Fields["type"].Line) + require.Equal(t, 15, user.Origin.Fields.Get("type").Line) require.Empty(t, user.Origin.Key.File, "a document loaded from data has no file") } diff --git a/openapi3/origin_load_test.go b/openapi3/origin_load_test.go index 438cc2dd9..b21101300 100644 --- a/openapi3/origin_load_test.go +++ b/openapi3/origin_load_test.go @@ -6,9 +6,7 @@ import ( "github.com/stretchr/testify/require" ) -// TestOrigin_LoadAllTestdata verifies that enabling origin tracking does not -// break loading of any spec in the testdata directory. It catches regressions -// where __origin__ leaks into fields and causes unmarshal failures or panics. +// Enabling origin tracking must not break loading any spec in testdata. func TestOrigin_LoadAllTestdata(t *testing.T) { specs := []struct { name string diff --git a/openapi3/origin_retention_test.go b/openapi3/origin_retention_test.go new file mode 100644 index 000000000..0b188026f --- /dev/null +++ b/openapi3/origin_retention_test.go @@ -0,0 +1,71 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// An ordinary spec never consults its origin tree, so the loader does not keep +// one. Origins are still attached to the document itself; what goes away is the +// second copy the loader used to hold for the lifetime of the loader, which on +// a large spec was a third of everything it retained. +func TestOriginTree_NotRetainedForAnOrdinarySpec(t *testing.T) { + loader := NewLoader() + loader.IncludeOrigin = true + loader.Context = t.Context() + + doc, err := loader.LoadFromFile("testdata/origin/simple.yaml") + require.NoError(t, err) + + require.NotNil(t, doc.Origin, "origins are still attached to the document") + require.Empty(t, loader.originTrees, "no tree is retained: nothing could read it") +} + +// The tree is kept for exactly the documents that can consult it, and this OAD +// contains one of each: the entry document has only fields OpenAPI defines, +// while the file it $refs is a shared fragment whose top level is the schema +// name "User", which is what arrives untyped and what attachOriginToResolved +// walks the tree for. +// +// The retained tree still does its job here, so this pins the saving and the +// capability together: dropping the tree for the referenced file would leave +// the resolved schema without an origin. +func TestOriginTree_RetainedOnlyWhereItCanBeRead(t *testing.T) { + loader := NewLoader() + loader.IsExternalRefsAllowed = true + loader.IncludeOrigin = true + loader.Context = t.Context() + + doc, err := loader.LoadFromFile("testdata/origin/arbitrary_key.yaml") + require.NoError(t, err) + + require.NotContains(t, loader.originTrees, doc, + "the entry document has only fields OpenAPI defines, so no $ref reaches into it untyped") + require.Len(t, loader.originTrees, 1, "only the shared-fragment file keeps its tree") + for retained := range loader.originTrees { + require.NotEmpty(t, retained.Extensions, + "a tree is kept only for a document with top-level fields OpenAPI does not define") + } + + // The capability the retained tree exists for: the resolved schema carries + // the origin of its own file, not of the $ref site. + schema := doc.Paths.Find("/users").Get.Responses.Value("200").Value. + Content["application/json"].Schema.Value + require.NotNil(t, schema.Origin, "the resolved schema keeps its origin") + require.Contains(t, schema.Origin.Key.File, "arbitrary_key_schemas.yaml") +} + +// Without IncludeOrigin there is no tree to begin with, so the new condition +// cannot change anything here. +func TestOriginTree_NotRetainedWhenOriginsAreOff(t *testing.T) { + loader := NewLoader() + loader.IsExternalRefsAllowed = true + loader.Context = t.Context() + + doc, err := loader.LoadFromFile("testdata/origin/arbitrary_key.yaml") + require.NoError(t, err) + + require.Nil(t, doc.Origin) + require.Empty(t, loader.originTrees) +} diff --git a/openapi3/origin_test.go b/openapi3/origin_test.go index 2781a655b..9c286f577 100644 --- a/openapi3/origin_test.go +++ b/openapi3/origin_test.go @@ -8,8 +8,6 @@ import ( "github.com/getkin/kin-openapi/openapi3" ) -const originKey = "__origin__" - func TestOrigin_T(t *testing.T) { loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true @@ -28,7 +26,7 @@ func TestOrigin_T(t *testing.T) { Column: 1, Name: "openapi", }, - doc.Origin.Fields["openapi"]) + doc.Origin.Fields.Get("openapi")) } func TestOrigin_Info(t *testing.T) { @@ -59,7 +57,7 @@ func TestOrigin_Info(t *testing.T) { Column: 3, Name: "title", }, - doc.Info.Origin.Fields["title"]) + doc.Info.Origin.Fields.Get("title")) require.Equal(t, openapi3.Location{ @@ -68,7 +66,7 @@ func TestOrigin_Info(t *testing.T) { Column: 3, Name: "version", }, - doc.Info.Origin.Fields["version"]) + doc.Info.Origin.Fields.Get("version")) } func TestOrigin_Paths(t *testing.T) { @@ -211,7 +209,7 @@ func TestOrigin_Responses(t *testing.T) { Column: 11, Name: "description", }, - base.Value("200").Value.Origin.Fields["description"]) + base.Value("200").Value.Origin.Fields.Get("description")) } func TestOrigin_Parameters(t *testing.T) { @@ -243,7 +241,7 @@ func TestOrigin_Parameters(t *testing.T) { Column: 11, Name: "in", }, - base.Origin.Fields["in"]) + base.Origin.Fields.Get("in")) require.Equal(t, openapi3.Location{ @@ -252,7 +250,7 @@ func TestOrigin_Parameters(t *testing.T) { Column: 11, Name: "name", }, - base.Origin.Fields["name"]) + base.Origin.Fields.Get("name")) } func TestOrigin_SchemaInAdditionalProperties(t *testing.T) { @@ -286,7 +284,7 @@ func TestOrigin_SchemaInAdditionalProperties(t *testing.T) { Column: 19, Name: "type", }, - base.Schema.Value.Origin.Fields["type"]) + base.Schema.Value.Origin.Fields.Get("type")) } func TestOrigin_ExternalDocs(t *testing.T) { @@ -319,7 +317,7 @@ func TestOrigin_ExternalDocs(t *testing.T) { Column: 3, Name: "description", }, - base.Origin.Fields["description"]) + base.Origin.Fields.Get("description")) require.Equal(t, openapi3.Location{ @@ -328,7 +326,7 @@ func TestOrigin_ExternalDocs(t *testing.T) { Column: 3, Name: "url", }, - base.Origin.Fields["url"]) + base.Origin.Fields.Get("url")) } func TestOrigin_Security(t *testing.T) { @@ -361,7 +359,7 @@ func TestOrigin_Security(t *testing.T) { Column: 7, Name: "type", }, - base.Origin.Fields["type"]) + base.Origin.Fields.Get("type")) require.Equal(t, &openapi3.Location{ @@ -392,7 +390,7 @@ func TestOrigin_Security(t *testing.T) { Column: 11, Name: "authorizationUrl", }, - base.Flows.Implicit.Origin.Fields["authorizationUrl"]) + base.Flows.Implicit.Origin.Fields.Get("authorizationUrl")) // scopes is a map[string]string, which decodes without an Origin of its own, // so its per-key locations are recorded on the flow's Origin as a named @@ -434,12 +432,8 @@ func TestOrigin_Example(t *testing.T) { Column: 17, Name: "summary", }, - base.Origin.Fields["summary"]) + base.Origin.Fields.Get("summary")) - // Example.Value is an any-typed field, so __origin__ is stripped from it during unmarshaling. - require.NotContains(t, - base.Value, - originKey) } func TestOrigin_XML(t *testing.T) { @@ -471,7 +465,7 @@ func TestOrigin_XML(t *testing.T) { Column: 21, Name: "namespace", }, - base.Origin.Fields["namespace"]) + base.Origin.Fields.Get("namespace")) require.Equal(t, openapi3.Location{ @@ -480,51 +474,7 @@ func TestOrigin_XML(t *testing.T) { Column: 21, Name: "prefix", }, - base.Origin.Fields["prefix"]) -} - -// TestOrigin_AnyFieldsStripped verifies that __origin__ is absent from all -// any-typed fields (Schema.Enum, Schema.Default, Schema.Example, -// Parameter.Example, MediaType.Example, Link.RequestBody) after loading. -// These fields have no dedicated UnmarshalJSON; extractOrigins strips -// __origin__ before JSON marshaling so it never reaches these values. -func TestOrigin_AnyFieldsStripped(t *testing.T) { - loader := openapi3.NewLoader() - loader.IncludeOrigin = true - doc, err := loader.LoadFromFile("testdata/origin/any_fields.yaml") - require.NoError(t, err) - - op := doc.Paths.Find("/items").Get - resp := op.Responses.Value("200").Value - - // Parameter.Example - paramEx := op.Parameters[0].Value.Example.(map[string]any) - require.NotContains(t, paramEx, originKey, "Parameter.Example must not contain __origin__") - - // MediaType.Example - mediaEx := resp.Content["application/json"].Example.(map[string]any) - require.NotContains(t, mediaEx, originKey, "MediaType.Example must not contain __origin__") - - schema := resp.Content["application/json"].Schema.Value - - // Schema.Default - schemaDefault := schema.Default.(map[string]any) - require.NotContains(t, schemaDefault, originKey, "Schema.Default must not contain __origin__") - - // Schema.Example - schemaEx := schema.Example.(map[string]any) - require.NotContains(t, schemaEx, originKey, "Schema.Example must not contain __origin__") - - // Schema.Enum items - for i, v := range schema.Enum { - m, ok := v.(map[string]any) - require.True(t, ok, "Schema.Enum[%d] must be a map", i) - require.NotContains(t, m, originKey, "Schema.Enum[%d] must not contain __origin__", i) - } - - // Link.RequestBody - linkRB := resp.Links["self"].Value.RequestBody.(map[string]any) - require.NotContains(t, linkRB, originKey, "Link.RequestBody must not contain __origin__") + base.Origin.Fields.Get("prefix")) } func TestOrigin_ExampleWithArrayValue(t *testing.T) { @@ -536,110 +486,9 @@ func TestOrigin_ExampleWithArrayValue(t *testing.T) { example := doc.Paths.Find("/subscribe").Post.RequestBody.Value.Content["application/json"].Examples["bar"] require.NotNil(t, example.Value) - // The example value contains a list of objects; __origin__ must be stripped from each. + // The example value is a list of objects and decodes as plain data. value := example.Value.Value.(map[string]any) - items := value["items"].([]any) - for _, item := range items { - itemMap := item.(map[string]any) - require.NotContains(t, itemMap, "__origin__") - } -} - -// TestOrigin_OriginExistsInProperties verifies that loading fails when a specification -// contains a property named "__origin__", highlighting a limitation in the current implementation. -func TestOrigin_ConstAndExamplesStripped(t *testing.T) { - var data = ` -openapi: "3.1.0" -info: - title: Test - version: "1.0" -paths: {} -components: - schemas: - Foo: - type: object - const: {x: reuven} - examples: - - {y: value} -` - loader := openapi3.NewLoader() - loader.IncludeOrigin = true - - doc, err := loader.LoadFromData([]byte(data)) - require.NoError(t, err) - - schema := doc.Components.Schemas["Foo"].Value - require.NotNil(t, schema) - - constMap, ok := schema.Const.(map[string]any) - require.True(t, ok) - require.NotContains(t, constMap, originKey) - - require.Len(t, schema.Examples, 1) - exampleMap, ok := schema.Examples[0].(map[string]any) - require.True(t, ok) - require.NotContains(t, exampleMap, originKey) -} - -func TestOrigin_OriginExistsInProperties(t *testing.T) { - var data = ` -paths: - /foo: - get: - responses: - "200": - description: OK - content: - application/json: - schema: - $ref: "#/components/schemas/Foo" -components: - schemas: - Foo: - type: object - properties: - __origin__: - type: string -` - - loader := openapi3.NewLoader() - loader.IncludeOrigin = true - - _, err := loader.LoadFromData([]byte(data)) - require.Error(t, err) - require.Equal(t, `failed to unmarshal data: json error: invalid character 'p' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: unmarshal errors: - line 0: mapping key "__origin__" already defined at line 17`, err.Error()) -} - -// TestOrigin_ExtensionValuesStripped verifies that __origin__ metadata injected -// by the YAML decoder is not present in any-typed extension values. -// Regression test: extension values that are YAML objects received __origin__ -// from the yaml3 decoder but it was never stripped, causing spurious diffs -// between specs loaded from different file paths. -func TestOrigin_ExtensionValuesStripped(t *testing.T) { - loader := openapi3.NewLoader() - loader.IncludeOrigin = true - - doc, err := loader.LoadFromFile("testdata/origin/extensions.yaml") - require.NoError(t, err) - - val, ok := doc.Extensions["x-object-extension"] - require.True(t, ok, "x-object-extension must be present") - - m, ok := val.(map[string]any) - require.True(t, ok, "x-object-extension value must be a map") - - require.NotContains(t, m, originKey, "__origin__ must be stripped from extension object values") - - // Also verify stripping works for a nested type (Info), covering the 20 - // per-type UnmarshalJSON call sites with a single representative case. - infoVal, ok := doc.Info.Extensions["x-info-extension"] - require.True(t, ok, "x-info-extension must be present") - - infoMap, ok := infoVal.(map[string]any) - require.True(t, ok, "x-info-extension value must be a map") - - require.NotContains(t, infoMap, originKey, "__origin__ must be stripped from nested extension object values") + require.Len(t, value["items"].([]any), 2) } func TestOrigin_WithExternalRef(t *testing.T) { @@ -672,7 +521,7 @@ func TestOrigin_WithExternalRef(t *testing.T) { Column: 3, Name: "namespace", }, - base.XML.Origin.Fields["namespace"]) + base.XML.Origin.Fields.Get("namespace")) require.Equal(t, openapi3.Location{ @@ -681,14 +530,11 @@ func TestOrigin_WithExternalRef(t *testing.T) { Column: 3, Name: "prefix", }, - base.XML.Origin.Fields["prefix"]) + base.XML.Origin.Fields.Get("prefix")) } -// TestOrigin_WithExternalRefRootOrigin verifies that the root-level schema of an -// externally $ref'd YAML file carries Origin metadata. Previously, only nested -// schemas (values inside a parent mapping) received __origin__ injection; the -// root mapping of a document was skipped. This test covers the fix in yaml3's -// document() decoder that injects __origin__ for the root mapping too. +// The root-level schema of an externally $ref'd file carries an Origin, not +// only the schemas nested inside a parent mapping. func TestOrigin_WithExternalRefRootOrigin(t *testing.T) { loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true @@ -721,44 +567,7 @@ func TestOrigin_WithExternalRefRootOrigin(t *testing.T) { Column: 1, Name: "type", }, - base.Origin.Fields["type"]) -} - -// TestOrigin_MaplikeNoOriginKey verifies that __origin__ does not appear as a -// map key in Responses, Paths, or Callback maplike types after loading. -// The if k == originKey blocks in their UnmarshalJSON were removed; this -// confirms extractOrigins strips __origin__ before it reaches those iterators. -func TestOrigin_MaplikeNoOriginKey(t *testing.T) { - loader := openapi3.NewLoader() - loader.IncludeOrigin = true - doc, err := loader.LoadFromFile("testdata/origin/simple.yaml") - require.NoError(t, err) - - // Paths map must not contain __origin__ as a path key - require.Nil(t, doc.Paths.Find(originKey), "Paths must not contain __origin__ as a key") - - // Responses map must not contain __origin__ as a status code key - op := doc.Paths.Find("/partner-api/test/some-method").Get - require.Nil(t, op.Responses.Value(originKey), "Responses must not contain __origin__ as a key") -} - -func TestOrigin_NoSpuriousOriginsInComponents(t *testing.T) { - loader := openapi3.NewLoader() - loader.IncludeOrigin = true - - doc, err := loader.LoadFromFile("testdata/origin/components.yaml") - - require.Nil(t, doc.Components.Schemas[originKey]) - require.Nil(t, doc.Components.Parameters[originKey]) - require.Nil(t, doc.Components.Headers[originKey]) - require.Nil(t, doc.Components.RequestBodies[originKey]) - require.Nil(t, doc.Components.Responses[originKey]) - require.Nil(t, doc.Components.SecuritySchemes[originKey]) - require.Nil(t, doc.Components.Examples[originKey]) - require.Nil(t, doc.Components.Links[originKey]) - require.Nil(t, doc.Components.Callbacks[originKey]) - - require.NoError(t, err) + base.Origin.Fields.Get("type")) } // TestOrigin_RequiredSequence verifies that Origin.Sequences records the @@ -776,7 +585,7 @@ func TestOrigin_RequiredSequence(t *testing.T) { require.NotNil(t, schema.Origin) // "required" must appear in Fields (it's a sequence-valued field) - require.Contains(t, schema.Origin.Fields, "required") + require.True(t, mustHaveField(schema.Origin.Fields, "required")) // Sequences must record per-item locations for "required" seqLocs, ok := schema.Origin.Sequences["required"] @@ -800,7 +609,7 @@ func TestOrigin_RequiredSequence(t *testing.T) { // TestOrigin_YAMLAlias verifies that a schema referenced via YAML alias loads // without error and carries origin metadata from the anchor definition. -// Multiple aliases of the same anchor must not produce duplicate __origin__ keys. +// Multiple aliases of the same anchor must each resolve to their own origin. func TestOrigin_YAMLAlias(t *testing.T) { loader := openapi3.NewLoader() loader.IncludeOrigin = true @@ -854,7 +663,7 @@ func TestOrigin_Headers(t *testing.T) { Column: 15, Name: "description", }, - headers["X-Rate-Limit"].Value.Origin.Fields["description"]) + headers["X-Rate-Limit"].Value.Origin.Fields.Get("description")) require.Equal(t, &openapi3.Location{ @@ -941,29 +750,36 @@ func TestOrigin_MappingFields(t *testing.T) { file := "testdata/origin/mapping_fields.yaml" // dependentRequired is a map[string][]string — mapping-valued - require.Contains(t, schema.Origin.Fields, "dependentRequired") + require.True(t, mustHaveField(schema.Origin.Fields, "dependentRequired")) require.Equal(t, openapi3.Location{ File: file, Line: 18, Column: 21, Name: "dependentRequired", - }, schema.Origin.Fields["dependentRequired"]) + }, schema.Origin.Fields.Get("dependentRequired")) // dependentSchemas is a Schemas map — mapping-valued - require.Contains(t, schema.Origin.Fields, "dependentSchemas") + require.True(t, mustHaveField(schema.Origin.Fields, "dependentSchemas")) require.Equal(t, openapi3.Location{ File: file, Line: 22, Column: 21, Name: "dependentSchemas", - }, schema.Origin.Fields["dependentSchemas"]) + }, schema.Origin.Fields.Get("dependentSchemas")) // patternProperties is a Schemas map — mapping-valued - require.Contains(t, schema.Origin.Fields, "patternProperties") + require.True(t, mustHaveField(schema.Origin.Fields, "patternProperties")) require.Equal(t, openapi3.Location{ File: file, Line: 25, Column: 21, Name: "patternProperties", - }, schema.Origin.Fields["patternProperties"]) + }, schema.Origin.Fields.Get("patternProperties")) +} + +// mustHaveField reports whether the named field carries a location, the +// slice-shaped equivalent of asserting a key is present in a map. +func mustHaveField(f openapi3.FieldLocations, name string) bool { + _, ok := f.Lookup(name) + return ok } diff --git a/openapi3/refs.go b/openapi3/refs.go index c04d30551..0dd322d3e 100644 --- a/openapi3/refs.go +++ b/openapi3/refs.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/go-openapi/jsonpointer" + yaml "go.yaml.in/yaml/v3" ) // CallbackRef represents either a Callback or a $ref to a Callback. @@ -73,6 +74,15 @@ func (x CallbackRef) MarshalJSON() ([]byte, error) { return json.Marshal(y) } +// UnmarshalYAML sets CallbackRef from node. +func (x *CallbackRef) UnmarshalYAML(node *yaml.Node) error { + x.Origin = originFromNode(node, nativeOriginFile()) + if !unmarshalRefYAML(node, &x.Ref, &x.Summary, &x.Description, &x.Extensions) { + return node.Decode(&x.Value) + } + return nil +} + // UnmarshalJSON sets CallbackRef to a copy of data. func (x *CallbackRef) UnmarshalJSON(data []byte) error { var refOnly Ref @@ -235,6 +245,15 @@ func (x ExampleRef) MarshalJSON() ([]byte, error) { return json.Marshal(y) } +// UnmarshalYAML sets ExampleRef from node. +func (x *ExampleRef) UnmarshalYAML(node *yaml.Node) error { + x.Origin = originFromNode(node, nativeOriginFile()) + if !unmarshalRefYAML(node, &x.Ref, &x.Summary, &x.Description, &x.Extensions) { + return node.Decode(&x.Value) + } + return nil +} + // UnmarshalJSON sets ExampleRef to a copy of data. func (x *ExampleRef) UnmarshalJSON(data []byte) error { var refOnly Ref @@ -397,6 +416,15 @@ func (x HeaderRef) MarshalJSON() ([]byte, error) { return json.Marshal(y) } +// UnmarshalYAML sets HeaderRef from node. +func (x *HeaderRef) UnmarshalYAML(node *yaml.Node) error { + x.Origin = originFromNode(node, nativeOriginFile()) + if !unmarshalRefYAML(node, &x.Ref, &x.Summary, &x.Description, &x.Extensions) { + return node.Decode(&x.Value) + } + return nil +} + // UnmarshalJSON sets HeaderRef to a copy of data. func (x *HeaderRef) UnmarshalJSON(data []byte) error { var refOnly Ref @@ -559,6 +587,15 @@ func (x LinkRef) MarshalJSON() ([]byte, error) { return json.Marshal(y) } +// UnmarshalYAML sets LinkRef from node. +func (x *LinkRef) UnmarshalYAML(node *yaml.Node) error { + x.Origin = originFromNode(node, nativeOriginFile()) + if !unmarshalRefYAML(node, &x.Ref, &x.Summary, &x.Description, &x.Extensions) { + return node.Decode(&x.Value) + } + return nil +} + // UnmarshalJSON sets LinkRef to a copy of data. func (x *LinkRef) UnmarshalJSON(data []byte) error { var refOnly Ref @@ -721,6 +758,15 @@ func (x ParameterRef) MarshalJSON() ([]byte, error) { return json.Marshal(y) } +// UnmarshalYAML sets ParameterRef from node. +func (x *ParameterRef) UnmarshalYAML(node *yaml.Node) error { + x.Origin = originFromNode(node, nativeOriginFile()) + if !unmarshalRefYAML(node, &x.Ref, &x.Summary, &x.Description, &x.Extensions) { + return node.Decode(&x.Value) + } + return nil +} + // UnmarshalJSON sets ParameterRef to a copy of data. func (x *ParameterRef) UnmarshalJSON(data []byte) error { var refOnly Ref @@ -883,6 +929,15 @@ func (x RequestBodyRef) MarshalJSON() ([]byte, error) { return json.Marshal(y) } +// UnmarshalYAML sets RequestBodyRef from node. +func (x *RequestBodyRef) UnmarshalYAML(node *yaml.Node) error { + x.Origin = originFromNode(node, nativeOriginFile()) + if !unmarshalRefYAML(node, &x.Ref, &x.Summary, &x.Description, &x.Extensions) { + return node.Decode(&x.Value) + } + return nil +} + // UnmarshalJSON sets RequestBodyRef to a copy of data. func (x *RequestBodyRef) UnmarshalJSON(data []byte) error { var refOnly Ref @@ -1045,6 +1100,15 @@ func (x ResponseRef) MarshalJSON() ([]byte, error) { return json.Marshal(y) } +// UnmarshalYAML sets ResponseRef from node. +func (x *ResponseRef) UnmarshalYAML(node *yaml.Node) error { + x.Origin = originFromNode(node, nativeOriginFile()) + if !unmarshalRefYAML(node, &x.Ref, &x.Summary, &x.Description, &x.Extensions) { + return node.Decode(&x.Value) + } + return nil +} + // UnmarshalJSON sets ResponseRef to a copy of data. func (x *ResponseRef) UnmarshalJSON(data []byte) error { var refOnly Ref @@ -1205,6 +1269,33 @@ func (x SchemaRef) MarshalJSON() ([]byte, error) { return json.Marshal(y) } +// UnmarshalYAML sets SchemaRef from node. +func (x *SchemaRef) UnmarshalYAML(node *yaml.Node) error { + x.Origin = originFromNode(node, nativeOriginFile()) + if !unmarshalRefYAML(node, &x.Ref, nil, nil, &x.Extensions) { + return node.Decode(&x.Value) + } + // OAS 3.1 / JSON Schema 2020-12: schema keywords alongside a $ref are valid + // and are merged with the resolved reference, so they are held on sibling + // until resolveSchemaRef can apply them. + var hasSiblings bool + for i := 0; i+1 < len(node.Content); i += 2 { + if k := node.Content[i].Value; k != "$ref" { + x.extra = append(x.extra, k) + if !strings.HasPrefix(k, "x-") { + hasSiblings = true + } + } + } + if hasSiblings { + var sibling Schema + if err := node.Decode(&sibling); err == nil { + x.sibling = &sibling + } + } + return nil +} + // UnmarshalJSON sets SchemaRef to a copy of data. func (x *SchemaRef) UnmarshalJSON(data []byte) error { var refOnly Ref @@ -1373,6 +1464,15 @@ func (x SecuritySchemeRef) MarshalJSON() ([]byte, error) { return json.Marshal(y) } +// UnmarshalYAML sets SecuritySchemeRef from node. +func (x *SecuritySchemeRef) UnmarshalYAML(node *yaml.Node) error { + x.Origin = originFromNode(node, nativeOriginFile()) + if !unmarshalRefYAML(node, &x.Ref, &x.Summary, &x.Description, &x.Extensions) { + return node.Decode(&x.Value) + } + return nil +} + // UnmarshalJSON sets SecuritySchemeRef to a copy of data. func (x *SecuritySchemeRef) UnmarshalJSON(data []byte) error { var refOnly Ref diff --git a/openapi3/refs.tmpl b/openapi3/refs.tmpl index 1b20b4481..6ea0f0de8 100644 --- a/openapi3/refs.tmpl +++ b/openapi3/refs.tmpl @@ -8,6 +8,7 @@ import ( "strings" "github.com/go-openapi/jsonpointer" + yaml "go.yaml.in/yaml/v3" ) {{ range $type := .Types }} // {{ $type.Name }}Ref represents either a {{ $type.Name }} or a $ref to a {{ $type.Name }}. @@ -83,6 +84,35 @@ func (x {{ $type.Name }}Ref) MarshalJSON() ([]byte, error) { return json.Marshal(y) } +// UnmarshalYAML sets {{ $type.Name }}Ref from node. +func (x *{{ $type.Name }}Ref) UnmarshalYAML(node *yaml.Node) error { + x.Origin = originFromNode(node, nativeOriginFile()) + if !unmarshalRefYAML(node, &x.Ref{{ if ne $type.Name "Schema" }}, &x.Summary, &x.Description{{ else }}, nil, nil{{ end }}, &x.Extensions) { + return node.Decode(&x.Value) + } +{{- if eq $type.Name "Schema" }} + // OAS 3.1 / JSON Schema 2020-12: schema keywords alongside a $ref are valid + // and are merged with the resolved reference, so they are held on sibling + // until resolveSchemaRef can apply them. + var hasSiblings bool + for i := 0; i+1 < len(node.Content); i += 2 { + if k := node.Content[i].Value; k != "$ref" { + x.extra = append(x.extra, k) + if !strings.HasPrefix(k, "x-") { + hasSiblings = true + } + } + } + if hasSiblings { + var sibling Schema + if err := node.Decode(&sibling); err == nil { + x.sibling = &sibling + } + } +{{- end }} + return nil +} + // UnmarshalJSON sets {{ $type.Name }}Ref to a copy of data. func (x *{{ $type.Name }}Ref) UnmarshalJSON(data []byte) error { var refOnly Ref diff --git a/openapi3/schema.go b/openapi3/schema.go index 4388a3981..e4e7f3487 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -2063,6 +2063,10 @@ func (schema *Schema) visitEnumOperation(settings *schemaValidationSettings, val if v == f { return } + case int: + if v == float64(c) { + return + } case int64: if v == float64(c) { return diff --git a/openapi3/schema_test.go b/openapi3/schema_test.go index cc28eb128..490a3e15e 100644 --- a/openapi3/schema_test.go +++ b/openapi3/schema_test.go @@ -9,8 +9,8 @@ import ( "strings" "testing" - yaml "github.com/oasdiff/yaml3" "github.com/stretchr/testify/require" + yaml "go.yaml.in/yaml/v3" ) type schemaExample struct { diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_37_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_37_openapi_yaml__load index 902879ef1..600b4ea6a 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_37_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_37_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 4971: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 4971: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_40_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_40_openapi_yaml__load index 01edf3ae1..62c48deaa 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_40_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_40_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 5279: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 5279: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_41_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_41_openapi_yaml__load index 6c4628c14..f0e607f51 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_41_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_41_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 5364: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 5364: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_46_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_46_openapi_yaml__load index 253010369..cd68be2ac 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_46_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_46_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 5365: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 5365: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_49_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_49_openapi_yaml__load index 279a03b98..8128eb958 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_49_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_49_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 5375: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 5375: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_50_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_50_openapi_yaml__load index 426e3b15b..906a6e29d 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_50_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_50_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 5433: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 5433: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_51_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_51_openapi_yaml__load index 097ec6932..e969d9153 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_51_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_51_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 5435: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 5435: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_52_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_52_openapi_yaml__load index 6c1420bd4..a3a07f381 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_52_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_52_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 5441: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 5441: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_53_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_53_openapi_yaml__load index 6c1420bd4..a3a07f381 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_53_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_53_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 5441: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 5441: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_64_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_64_openapi_yaml__load index 6c1420bd4..a3a07f381 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_64_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_64_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 5441: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 5441: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_65_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_65_openapi_yaml__load index 4a31bfe54..b6763b9db 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_65_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_65_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 5456: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 5456: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_66_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_66_openapi_yaml__load index 4a31bfe54..b6763b9db 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_66_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_66_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 5456: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 5456: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_67_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_67_openapi_yaml__load index e6fe6ddef..8e2627520 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_67_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_67_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 5410: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 5410: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_68_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_68_openapi_yaml__load index c0210c8b9..7297a1030 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_68_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_68_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 4685: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 4685: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_69_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_69_openapi_yaml__load index c8c9e7e90..4d22dd9e4 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_69_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_69_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 4730: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 4730: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_70_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_70_openapi_yaml__load index a0b2b1c49..5a419976b 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_70_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_70_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 4776: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 4776: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_v71_71_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_v71_71_openapi_yaml__load index ba2850b0d..827845a98 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_v71_71_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_v71_71_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 4772: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 4772: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_25_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_25_openapi_yaml__load index 67d2323f8..d0ddc2d76 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_25_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_25_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 964: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 964: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_30_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_30_openapi_yaml__load index 14aaec114..a54c3ace2 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_30_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_30_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 1158: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 1158: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_40_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_40_openapi_yaml__load index 238ecf375..41d00cc98 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_40_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_40_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 1562: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 1562: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_46_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_46_openapi_yaml__load index 238ecf375..41d00cc98 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_46_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_46_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 1562: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 1562: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_49_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_49_openapi_yaml__load index 238ecf375..41d00cc98 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_49_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_49_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 1562: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 1562: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_50_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_50_openapi_yaml__load index 9ff4f6619..5a583a987 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_50_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_50_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 1575: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 1575: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_51_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_51_openapi_yaml__load index 9cf7a1624..e4447e976 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_51_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_51_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 1647: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 1647: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_52_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_52_openapi_yaml__load index 9cf7a1624..e4447e976 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_52_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_52_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 1647: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 1647: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_64_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_64_openapi_yaml__load index 9cf7a1624..e4447e976 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_64_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_64_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 1647: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 1647: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_67_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_67_openapi_yaml__load index 9cf7a1624..e4447e976 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_67_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_67_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 1647: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 1647: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_68_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_68_openapi_yaml__load index a113cdfeb..508bf8a06 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_68_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PaymentService_68_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 1808: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 1808: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PayoutService_46_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PayoutService_46_openapi_yaml__load index 36ba354c8..325474a9d 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PayoutService_46_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PayoutService_46_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 541: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 541: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PayoutService_49_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PayoutService_49_openapi_yaml__load index 36ba354c8..325474a9d 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PayoutService_49_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PayoutService_49_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 541: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 541: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/amadeus_com_amadeus_trip_parser_3_0_1_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/amadeus_com_amadeus_trip_parser_3_0_1_openapi_yaml__load index 10b861a73..9600eeaf7 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/amadeus_com_amadeus_trip_parser_3_0_1_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/amadeus_com_amadeus_trip_parser_3_0_1_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 275: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 275: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/bunq_com_1_0_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/bunq_com_1_0_openapi_yaml__load index d065d5a33..44519b11a 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/bunq_com_1_0_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/bunq_com_1_0_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 1142: did not find expected key +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 1142: did not find expected key diff --git a/openapi3/testdata/apis_guru_openapi_directory/codat_io_accounting_2_1_0_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/codat_io_accounting_2_1_0_openapi_yaml__load index 711e7ca6c..429dfa06b 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/codat_io_accounting_2_1_0_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/codat_io_accounting_2_1_0_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: line 43981: found a tab character where an indentation space is expected +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: line 43981: found a tab character where an indentation space is expected diff --git a/openapi3/testdata/apis_guru_openapi_directory/codat_io_assess_1_0_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/codat_io_assess_1_0_openapi_yaml__load index 7f6544a0b..11379f886 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/codat_io_assess_1_0_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/codat_io_assess_1_0_openapi_yaml__load @@ -1 +1,2 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal object into field Schema.examples of type []interface {} +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: unmarshal errors: + line 4692: cannot unmarshal !!map into []interface {} diff --git a/openapi3/testdata/apis_guru_openapi_directory/codat_io_sync_for_commerce_1_1_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/codat_io_sync_for_commerce_1_1_openapi_yaml__load index 2a6522f53..f3a32347d 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/codat_io_sync_for_commerce_1_1_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/codat_io_sync_for_commerce_1_1_openapi_yaml__load @@ -1 +1,2 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal bool into field Schema.properties of type openapi3.Schema +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: unmarshal errors: + line 751: cannot unmarshal !!bool `false` into openapi3.bis diff --git a/openapi3/testdata/apis_guru_openapi_directory/codat_io_sync_for_expenses_prealpha_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/codat_io_sync_for_expenses_prealpha_openapi_yaml__load index 2a6522f53..510fd3030 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/codat_io_sync_for_expenses_prealpha_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/codat_io_sync_for_expenses_prealpha_openapi_yaml__load @@ -1 +1,2 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal bool into field Schema.properties of type openapi3.Schema +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: unmarshal errors: + line 766: cannot unmarshal !!bool `false` into openapi3.bis diff --git a/openapi3/testdata/apis_guru_openapi_directory/docusign_net_v2_1_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/docusign_net_v2_1_openapi_yaml__load index e5fb18fb8..f6be00efb 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/docusign_net_v2_1_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/docusign_net_v2_1_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: control characters are not allowed +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: control characters are not allowed diff --git a/openapi3/testdata/apis_guru_openapi_directory/sendgrid_com_1_0_0_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/sendgrid_com_1_0_0_openapi_yaml__load index e5fb18fb8..f6be00efb 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/sendgrid_com_1_0_0_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/sendgrid_com_1_0_0_openapi_yaml__load @@ -1 +1 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: control characters are not allowed +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: control characters are not allowed diff --git a/openapi3/testdata/apis_guru_openapi_directory/statsocial_com_1_0_0_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/statsocial_com_1_0_0_openapi_yaml__load deleted file mode 100644 index 099ffe7fb..000000000 --- a/openapi3/testdata/apis_guru_openapi_directory/statsocial_com_1_0_0_openapi_yaml__load +++ /dev/null @@ -1 +0,0 @@ -map key "18_24" not found diff --git a/openapi3/testdata/apis_guru_openapi_directory/vercel_com_0_0_1_openapi_yaml__load b/openapi3/testdata/apis_guru_openapi_directory/vercel_com_0_0_1_openapi_yaml__load index 2a6522f53..6d0ad8ac2 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/vercel_com_0_0_1_openapi_yaml__load +++ b/openapi3/testdata/apis_guru_openapi_directory/vercel_com_0_0_1_openapi_yaml__load @@ -1 +1,2 @@ -failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal bool into field Schema.properties of type openapi3.Schema +failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: yaml: unmarshal errors: + line 860: cannot unmarshal !!bool `false` into openapi3.bis diff --git a/openapi3/validation_error.go b/openapi3/validation_error.go index f2954afb3..f5e78e2fa 100644 --- a/openapi3/validation_error.go +++ b/openapi3/validation_error.go @@ -1424,7 +1424,7 @@ func exampleValueOrigin(ex *Example, fallback *Origin) *Origin { if ex == nil || ex.Origin == nil { return fallback } - if loc, ok := ex.Origin.Fields["value"]; ok { + if loc, ok := ex.Origin.Fields.Lookup("value"); ok { return &Origin{Key: &loc} } return ex.Origin diff --git a/openapi3/validation_error_test.go b/openapi3/validation_error_test.go index ebae2a290..23bf0adc0 100644 --- a/openapi3/validation_error_test.go +++ b/openapi3/validation_error_test.go @@ -382,8 +382,8 @@ paths: {} require.Equal(t, "openapi", rfe.Field) require.NotNil(t, rfe.Origin, "doc-root fields now carry the document's Origin") require.Same(t, doc.Origin, rfe.Origin, "the error carries T.Origin") - require.Greater(t, rfe.Origin.Fields["openapi"].Line, 0, - `Origin.Fields["openapi"] locates the openapi: line`) + require.Greater(t, rfe.Origin.Fields.Get("openapi").Line, 0, + `Origin.Fields.Get("openapi") locates the openapi: line`) } // SchemaValueError clusters "'s example/default value @@ -2105,7 +2105,7 @@ func TestValidationError_SchemaCombinatorElementValidationError_NoStutter(t *tes // A run of same-combinator wrappers renders the prefix once, not per level. var nested error = leaf - for i := 0; i < 5; i++ { + for range 5 { nested = &openapi3.SchemaCombinatorElementValidationError{Combinator: "allOf", Cause: nested} } require.Equal(t, "invalid allOf element: boom", nested.Error()) diff --git a/openapi3filter/ghsa_74vm_87hj_r66f_test.go b/openapi3filter/ghsa_74vm_87hj_r66f_test.go new file mode 100644 index 000000000..ffb888dd3 --- /dev/null +++ b/openapi3filter/ghsa_74vm_87hj_r66f_test.go @@ -0,0 +1,74 @@ +package openapi3filter_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" +) + +const specBody = ` +openapi: 3.0.3 +info: {title: poc, version: "1.0.0"} +paths: + /x: + get: + responses: + "200": + description: ok + content: + application/json: {} +` + +const specHeader = ` +openapi: 3.0.3 +info: {title: poc, version: "1.0.0"} +paths: + /x: + get: + responses: + "200": + description: ok + headers: + X-Thing: + content: + application/json: {} +` + +func validatedInput(t *testing.T, spec string, hdr http.Header) *openapi3filter.ResponseValidationInput { + t.Helper() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + err = doc.Validate(t.Context()) + require.NoError(t, err) + op := doc.Paths.Find("/x").Get + return &openapi3filter.ResponseValidationInput{ + RequestValidationInput: &openapi3filter.RequestValidationInput{ + Request: httptest.NewRequest(http.MethodGet, "/x", nil), + Route: &routers.Route{Spec: doc, Operation: op, Method: http.MethodGet, Path: "/x"}, + }, + Status: 200, + Header: hdr, + Body: io.NopCloser(strings.NewReader(`{}`)), + } +} + +func TestControl_ResponseBodyNilSchema(t *testing.T) { + in := validatedInput(t, specBody, http.Header{"Content-Type": {"application/json"}}) + err := openapi3filter.ValidateResponse(t.Context(), in) + require.NoError(t, err) +} + +func TestResponseHeaderNilSchema(t *testing.T) { + in := validatedInput(t, specHeader, http.Header{"Content-Type": {"application/json"}}) + err := openapi3filter.ValidateResponse(t.Context(), in) + require.NoError(t, err) +} diff --git a/openapi3filter/req_resp_decoder.go b/openapi3filter/req_resp_decoder.go index 702adbc84..d1bde2787 100644 --- a/openapi3filter/req_resp_decoder.go +++ b/openapi3filter/req_resp_decoder.go @@ -19,7 +19,7 @@ import ( "strconv" "strings" - yaml "github.com/oasdiff/yaml3" + yaml "go.yaml.in/yaml/v3" "github.com/getkin/kin-openapi/openapi3" ) @@ -270,6 +270,10 @@ func decodeStyledParameter(param *openapi3.Parameter, input *RequestValidationIn func decodeValue(dec valueDecoder, param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef, required bool) (any, bool, error) { var found bool + if schema == nil { + return nil, false, nil + } + if len(schema.Value.AllOf) > 0 { var value any var err error @@ -684,10 +688,15 @@ func (d *urlValuesDecoder) DecodeObject(param string, sm *openapi3.Serialization return propsFromString(values[0], ",", ",") } case "deepObject": + // Compile the parameter-name prefix matcher once: it depends only on + // param (constant for the whole loop), not on the loop variable. Doing + // this inside the loop recompiles it once per query key, turning an + // attacker-controlled key count into proportional CPU. + paramPrefixRE := regexp.MustCompile(fmt.Sprintf(`^%s\[`, regexp.QuoteMeta(param))) propsFn = func(params url.Values) (map[string]string, error) { props := make(map[string]string) for key, values := range params { - if !regexp.MustCompile(fmt.Sprintf(`^%s\[`, regexp.QuoteMeta(param))).MatchString(key) { + if !paramPrefixRE.MatchString(key) { continue } matches := deepObjectBracketRE.FindAllStringSubmatch(key, -1)