diff --git a/.github/docs/openapi2.txt b/.github/docs/openapi2.txt index 8b5d379b2..beb32cc9b 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 66f9acf65..757bb2d3a 100644 --- a/.github/docs/openapi3.txt +++ b/.github/docs/openapi3.txt @@ -36,6 +36,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 @@ -393,6 +396,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 BooleanSchemaFor31Plus struct{ ValidationError } func (e *BooleanSchemaFor31Plus) As(target any) bool @@ -448,6 +454,8 @@ func (callback *Callback) UnmarshalJSONFrom(dec *jsontext.Decoder) error Decoding each member from dec (rather than from a copy of its bytes) keeps the member's key in the path of any error. +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. @@ -494,6 +502,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. @@ -587,6 +598,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. @@ -634,6 +648,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. @@ -739,6 +756,19 @@ func (e *DependentSchemasFieldFor31Plus) As(target any) bool func (e *DependentSchemasFieldFor31Plus) Code() string +type DisallowedExternalRefError struct { + // Ref is the $ref value as it appears in the document, e.g. + // "other.yaml#/components/schemas/Thing". + Ref string +} + DisallowedExternalRefError is returned for a $ref to another document + encountered while Loader.IsExternalRefsAllowed is false. A caller that tells + a refused $ref apart from a document that failed to load for another reason, + to report the setting as the remedy or to map it to its own error, matches + this type with errors.As rather than the message text. + +func (e *DisallowedExternalRefError) Error() string + type Discriminator struct { Extensions map[string]any `json:"-" yaml:"-"` Origin *Origin `json:"-" yaml:"-"` @@ -758,6 +788,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. @@ -903,6 +936,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. @@ -954,6 +990,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. @@ -997,6 +1036,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. @@ -1059,6 +1101,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 ExclusiveBoundBooleanBefore31 struct{ ValidationError } func (e *ExclusiveBoundBooleanBefore31) As(target any) bool @@ -1090,6 +1136,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. @@ -1243,6 +1292,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. @@ -1322,6 +1374,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. @@ -1370,6 +1425,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. @@ -1530,6 +1588,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. @@ -1574,6 +1635,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. @@ -1629,6 +1693,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. @@ -1756,6 +1823,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. @@ -1884,6 +1954,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. @@ -1965,6 +2038,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. @@ -2042,6 +2118,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. @@ -2133,6 +2215,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. @@ -2233,6 +2318,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. @@ -2282,6 +2370,8 @@ type PathItem struct { // Keys are case-sensitive HTTP method tokens and must not duplicate // the methods covered by the fixed fields above. AdditionalOperations map[string]*Operation `json:"additionalOperations,omitempty,case:strict" yaml:"additionalOperations,omitempty"` // OpenAPI >=3.2 + + // Has unexported fields. } PathItem is specified by OpenAPI/Swagger standard version 3. See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#path-item-object @@ -2305,6 +2395,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. @@ -2444,6 +2537,8 @@ func (paths *Paths) UnmarshalJSONFrom(dec *jsontext.Decoder) error Decoding each member from dec (rather than from a copy of its bytes) keeps the member's key in the path of any error. +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. @@ -2565,6 +2660,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. @@ -2633,6 +2731,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. @@ -2687,6 +2788,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. @@ -2750,6 +2854,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. @@ -2813,6 +2920,8 @@ func (responses *Responses) UnmarshalJSONFrom(dec *jsontext.Decoder) error Decoding each member from dec (rather than from a copy of its bytes) keeps the member's key in the path of any error. +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. @@ -2993,6 +3102,13 @@ func (schema *Schema) PermitsNull(opts ...SchemaValidationOption) bool func (schema *Schema) UnmarshalJSON(data []byte) error UnmarshalJSON sets Schema to a copy of data. +func (schema *Schema) UnmarshalYAML(node *yaml.Node) error + Schema is `true`/`false` or a mapping. JSON Schema 2020-12, which OpenAPI + 3.1 adopts, allows a boolean wherever a schema is expected. + + The mapping branch is what the generator emits for the other declared-field + types; only the scalar case is particular to Schema. + func (schema *Schema) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if Schema does not comply with the OpenAPI spec. @@ -3164,6 +3280,22 @@ func (e *SchemaItemsRequired) As(target any) bool func (e *SchemaItemsRequired) Code() string +type SchemaMultipleOfNotPositiveError struct { + // MultipleOf is the rejected `multipleOf` value. + MultipleOf float64 + // Origin is the source location of the offending schema when the + // document was loaded with Loader.IncludeOrigin = true. + Origin *Origin +} + SchemaMultipleOfNotPositiveError clusters "multipleOf must be greater + than zero" failures. The value of a schema's `multipleOf` MUST be strictly + greater than 0 (JSON Schema 2020-12 §6.2.1 for OpenAPI 3.1, draft-04 for + OpenAPI 3.0). + +func (e *SchemaMultipleOfNotPositiveError) Code() string + +func (e *SchemaMultipleOfNotPositiveError) Error() string + type SchemaPatternRegexError struct { // Pattern is the schema's `pattern:` value that failed to compile. Pattern string @@ -3236,6 +3368,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. @@ -3437,6 +3572,17 @@ func NewSecurityRequirement() SecurityRequirement func (security SecurityRequirement) Authenticate(provider string, scopes ...string) SecurityRequirement +func (security SecurityRequirement) MarshalYAML() (any, error) + MarshalYAML returns the YAML encoding of SecurityRequirement. A nil + SecurityRequirement or list of scopes is written null, as JSON writes it, + rather than the "{}" or "[]" YAML would make of it. + +func (sr *SecurityRequirement) UnmarshalYAML(node *yaml.Node) error + UnmarshalYAML sets SecurityRequirement from node. + + A scheme's scopes are a list of strings, and decoding one directly drops a + null from it rather than keeping its place, which is what stringList is for. + func (security *SecurityRequirement) Validate(ctx context.Context, opts ...ValidationOption) error Validate returns an error if SecurityRequirement does not comply with the OpenAPI spec. @@ -3503,6 +3649,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. @@ -3600,6 +3749,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. @@ -3645,6 +3797,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. @@ -3704,6 +3859,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. @@ -3860,6 +4018,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. @@ -3924,6 +4086,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. @@ -4092,12 +4257,10 @@ func (types *Types) Slice() []string func (types *Types) UnmarshalJSON(data []byte) error -func (types *Types) UnmarshalYAML(unmarshal func(any) error) error - UnmarshalYAML accepts a single type as well as a sequence of types, - mirroring UnmarshalJSON. Swagger 2 and OpenAPI 3.0 documents always - write `type` as a scalar, so decoding such a document into openapi2.T or - openapi3.T straight from YAML would otherwise fail with "cannot unmarshal - !!str into Types". +func (types *Types) UnmarshalYAML(node *yaml.Node) error + Types is a string or a list of strings. Swagger 2 and OpenAPI 3.0 documents + always write `type` as a scalar, so the single-string case is what decoding + such a document into openapi2.T or openapi3.T relies on. func (types *Types) With(typs ...string) With adds the given types to the receiver, skipping those already present. @@ -4207,7 +4370,10 @@ func AllowExtensionsWithRef() ValidationOption func AllowExtraSiblingFields(fields ...string) ValidationOption AllowExtraSiblingFields called as AllowExtraSiblingFields("description") - makes Validate not return an error when said field appears next to a $ref. + makes Validate not return an error when said field appears where it is + not expected: as an unknown field of an object, as an OpenAPI 3.1 schema + keyword in an OpenAPI 3.0 document, or as an extension next to a $ref under + ProhibitExtensionsWithRef. Other fields next to a $ref are never an error. func DisableExamplesValidation() ValidationOption DisableExamplesValidation disables all example schema validation. @@ -4269,8 +4435,8 @@ func IsOpenAPI32OrLater() ValidationOption func ProhibitExtensionsWithRef() ValidationOption ProhibitExtensionsWithRef causes the validation to return an error if extensions (fields starting with 'x-') are found as siblings for $ref - fields, even in OpenAPI 3.0. Use AllowExtraSiblingFields to allow specific - extensions. + fields, in every OpenAPI version. Use AllowExtraSiblingFields to allow + specific extensions. func SetRegexCompiler(c RegexCompilerFunc) ValidationOption SetRegexCompiler allows to override the regex implementation used to @@ -4369,6 +4535,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/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 000000000..d26deeb21 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,130 @@ +name: fuzz +on: + pull_request: + push: + branches: + - master + schedule: + # Nightly, so a long run has a chance to reach code a 90s PR run never does. + - cron: '17 3 * * *' + workflow_dispatch: + inputs: + fuzztime: + description: 'How long to fuzz each target (go test -fuzztime syntax)' + required: false + default: 30m + +permissions: + contents: read + +jobs: + fuzz: + name: ${{ matrix.target }} + runs-on: ubuntu-latest + # A budget of 15m plus corpus minimization has to fit, and a hung target + # must not hold a runner forever. + timeout-minutes: 45 + env: + # Keep in sync with the go workflow, whose cache this restores. + APISGURU_COMMIT: f7207cf0a5c56081d275ebae4cf615249323385d + GO111MODULE: 'on' + CGO_ENABLED: '0' + strategy: + # Every target reports independently: one crasher must not hide the rest. + fail-fast: false + matrix: + include: + - {package: ./openapi3, target: FuzzLoadFromData} + - {package: ./openapi3, target: FuzzLoadWithOrigins} + - {package: ./openapi3, target: FuzzYAMLMatchesJSON} + - {package: ./openapi3, target: FuzzSchemaVisitJSON} + - {package: ./openapi3, target: FuzzInternalizeRefs} + - {package: ./openapi3, target: FuzzInternalizeRefsKeepsMeaning} + - {package: ./openapi3, target: FuzzValidateOptions} + - {package: ./openapi3, target: FuzzInternalizeRefsCustomResolver} + - {package: ./openapi3, target: FuzzMarshalYAML} + - {package: ./openapi2, target: FuzzUnmarshalFromData} + - {package: ./internal/nodeindex, target: FuzzNew} + - {package: ./openapi2conv, target: FuzzToV3} + - {package: ./openapi3filter, target: FuzzValidateRequest} + - {package: ./openapi3filter, target: FuzzValidateResponse} + - {package: ./openapi3filter, target: FuzzBodyDecoder} + - {package: ./routers, target: FuzzRoutersAgree} + steps: + + # Checked out first: setup-go keys its module and build cache on go.sum, + # and without one it caches nothing. + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version: stable + + # The APIs-guru corpus seeds the fuzzers alongside testdata. The go + # workflow caches it, so fetch it only when that cache is missing. + - name: APIs-guru corpus cache + id: apisguru + uses: actions/cache/restore@v4 + with: + path: | + openapi2/testdata/APIs-guru-openapi-directory-* + openapi3/testdata/APIs-guru-openapi-directory-* + key: suite-${{ env.APISGURU_COMMIT }} + + - name: Fetch APIs-guru corpus + if: steps.apisguru.outputs.cache-hit != 'true' + run: | + dir=openapi3/testdata/APIs-guru-openapi-directory-${APISGURU_COMMIT:0:7} + mkdir -p "$dir" + curl -fsSL "https://github.com/APIs-guru/openapi-directory/tarball/$APISGURU_COMMIT" | tar -xz -C "$dir" + + # The generated corpus lives under $GOCACHE/fuzz and is what makes fuzzing + # cumulative: without it every run restarts from the seed corpus. The key + # carries the run id so each run writes a fresh entry, and restore-keys + # falls back to the most recent one for this target. + - name: Fuzzing corpus cache + uses: actions/cache@v4 + with: + path: ~/.cache/go-build/fuzz + key: fuzz-${{ matrix.target }}-${{ github.run_id }} + restore-keys: fuzz-${{ matrix.target }}- + + - name: Pick a budget + id: budget + run: | + if [[ -n '${{ inputs.fuzztime }}' ]]; then + fuzztime='${{ inputs.fuzztime }}' + elif [[ '${{ github.event_name }}' = schedule ]]; then + fuzztime=15m + else + fuzztime=90s + fi + echo "fuzztime=$fuzztime" >>"$GITHUB_OUTPUT" + + # -run='^$' skips the ordinary tests: the seed corpus is already replayed by + # `go test ./...` in the go workflow, so this job only spends time fuzzing. + # + # The minimize budget is small on purpose. A minimize that cannot finish + # inside it writes the candidate it had and the job fails with "fuzzing + # process hung or terminated unexpectedly while minimizing", whose + # candidate then passes on replay: a red build and nothing to act on. The + # budget only decides how small the saved input gets, so spending less of + # it costs a larger seed and buys a signal that means what it says. + - name: Fuzz ${{ matrix.target }} + run: | + go test \ + -run='^$' \ + -fuzz='^${{ matrix.target }}$' \ + -fuzztime='${{ steps.budget.outputs.fuzztime }}' \ + -fuzzminimizetime=10s \ + ${{ matrix.package }} + + # A failing input is written next to the package it broke. Upload it so the + # crasher can be committed to the seed corpus, where it becomes a permanent + # regression test replayed by `go test ./...`. + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: crashers-${{ matrix.target }} + path: '**/testdata/fuzz/**' + if-no-files-found: ignore diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 9395acb85..2c039182e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -25,6 +25,10 @@ jobs: name: ${{ matrix.go }} on ${{ matrix.os }} steps: + # Checked out first: setup-go keys its module and build cache on go.sum, + # and without one it caches nothing. + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} @@ -32,10 +36,8 @@ jobs: - if: runner.os == 'Linux' run: sudo apt install silversearcher-ag - - uses: actions/checkout@v6 - - name: Test suite fixtures cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | openapi2/testdata/APIs-guru-openapi-directory-* @@ -90,6 +92,9 @@ jobs: - run: go test -v -count=10 -run TestIssue741 -race ./... env: CGO_ENABLED: '1' + - run: go test -v -count=10 -run TestConcurrentDecodeDoesNotLeakOrigins -race ./... + env: + CGO_ENABLED: '1' - run: git --no-pager diff --exit-code - if: runner.os == 'Linux' @@ -117,9 +122,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|refs|loader|native_yaml.*|nativeyaml.*)[.](go|tmpl)' | wc -l)" -eq 0 ]] - if: runner.os == 'Linux' name: Use `loader := NewLoader(); loader.Load ...` @@ -201,9 +206,9 @@ jobs: - if: runner.os == 'Linux' name: Ensure readableType() covers all possible values of resolved var run: | - [[ "$(git grep -F 'var resolved ' -- openapi3/loader.go | awk '{print $4}' | sort | tr '\n' ' ')" = "$RESOLVEDS" ]] + [[ "$(git grep -F 'var resolved ' -- openapi3/loader.go | awk '{print $4}' | sort | uniq | tr '\n' ' ')" = "$RESOLVEDS" ]] env: - RESOLVEDS: 'Callback CallbackRef ExampleRef HeaderRef LinkRef ParameterRef PathItem RequestBodyRef ResponseRef SchemaRef SecuritySchemeRef ' + RESOLVEDS: 'CallbackRef ExampleRef HeaderRef LinkRef ParameterRef PathItem RequestBodyRef ResponseRef SchemaRef SecuritySchemeRef ' check-goimports: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index 57bab901a..2e6dd0569 100644 --- a/Makefile +++ b/Makefile @@ -1,28 +1,41 @@ -.DEFAULT_GOAL := prepare +.DEFAULT_GOAL := all +.PHONY: help generate format test test-fast fuzz all -.PHONY: help generate format test prepare +# How long to spend on each fuzz target. Override: make fuzz FUZZTIME=10m +FUZZTIME ?= 60s -help: - @printf '%s\n' \ - 'Usage: make ' \ - '' \ - ' generate Regenerate Go code, map helpers, and API documentation.' \ - ' format Apply the repository formatters and import cleanup.' \ - ' test Run the full Go package test suite.' \ - ' prepare Run generation, formatting, and tests in sequence.' +## all: Run generation, formatting, and tests in sequence +all: generate format test +## generate: Regenerate Go code, map helpers, and API documentation generate: go generate ./... ./maps.sh ./docs.sh +## format: Apply the repository formatters and import cleanup format: go fmt ./... +## test: Run the full Go package test suite test: go test ./... -prepare: - $(MAKE) generate - $(MAKE) format - $(MAKE) test +## test-fast: Run tests skipping the slow ones +test-fast: + go test -short ./... + +## fuzz: Fuzz each target for FUZZTIME (default 60s) +fuzz: + go test -run='^$$' -fuzz='^FuzzLoadFromData$$' -fuzztime=$(FUZZTIME) ./openapi3 + go test -run='^$$' -fuzz='^FuzzLoadWithOrigins$$' -fuzztime=$(FUZZTIME) ./openapi3 + go test -run='^$$' -fuzz='^FuzzYAMLMatchesJSON$$' -fuzztime=$(FUZZTIME) ./openapi3 + go test -run='^$$' -fuzz='^FuzzSchemaVisitJSON$$' -fuzztime=$(FUZZTIME) ./openapi3 + go test -run='^$$' -fuzz='^FuzzUnmarshalFromData$$' -fuzztime=$(FUZZTIME) ./openapi2 + go test -run='^$$' -fuzz='^FuzzNew$$' -fuzztime=$(FUZZTIME) ./internal/nodeindex + go test -run='^$$' -fuzz='^FuzzToV3$$' -fuzztime=$(FUZZTIME) ./openapi2conv + go test -run='^$$' -fuzz='^FuzzValidateRequest$$' -fuzztime=$(FUZZTIME) ./openapi3filter + +## help: list targets +help: + @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / make /' diff --git a/README.md b/README.md index 0ba04f93d..610de68e5 100644 --- a/README.md +++ b/README.md @@ -329,14 +329,33 @@ for _, path := range doc.Paths.InMatchingOrder() { ## CHANGELOG: Sub-v1 breaking API changes -### v0.151.0 -* Removed the deprecated package-global `openapi3.IncludeOrigin` variable. Set the `IncludeOrigin` field of `openapi3.Loader` instead. -* `openapi3filter.UrlencodedBodyDecoder` (hence `application/x-www-form-urlencoded` request body validation) now returns a `*openapi3filter.ParseError` for a value that cannot be parsed into its property's schema type, such as `n=abc` for an `integer` property, instead of silently dropping it from the decoded body. -* `openapi3filter` now writes a query parameter's array `default` into the request URL following the parameter's `style` and `explode` (including their defaults), so an array without `explode` set is written as `?ids=1&ids=2` rather than `?ids=1,2`, and `pipeDelimited`/`spaceDelimited` arrays use their own delimiter. +### v0.153.0 +* `openapi3filter` now parses `number` values of parameters and `application/x-www-form-urlencoded`/`multipart/form-data` bodies as decimal numbers only, so Go float literals such as `0x1p4`, `1_000`, `Inf`, `-infinity` or `NaN` are now rejected as invalid numbers. +* `(*openapi3.Schema).Validate(..)` (hence `(*openapi3.T).Validate(..)`) now rejects a `multipleOf` of zero or less (`openapi3.SchemaMultipleOfNotPositiveError`), as JSON Schema requires it to be strictly greater than 0. Such a schema used to pass validation and then reject every value with `multipleOf must be greater than zero`. +* `(*openapi3.Schema).Validate(..)` now also compiles the `pattern` of a schema without `type`, as it does for `type: string`, and rejects one that does not compile with `openapi3.SchemaPatternRegexError`. Such a schema used to pass validation and then fail on every string value it was given. +* `(*openapi3.Schema).WithMaxLengthDecodedBase64(..)` now sets `maxLength`. It used to set `minLength` instead, overwriting any minimum and leaving the length unbounded. + +### v0.152.0 +* JSON keys of OpenAPI documents only fill the field of the same case (`json:",case:strict"`) when `encoding/json` is implemented with `encoding/json/v2` (by default from Go 1.27, and on Go 1.25 and 1.26 with `GOEXPERIMENT=jsonv2`): a key such as `"Components"` (not `"components"`) is kept as an extension instead of filling `openapi3.T.Components`. Other builds still match keys case-insensitively, as `encoding/json` v1 does. +* On Go 1.27+, unless built with `GOEXPERIMENT=nojsonv2`, `openapi2` and `openapi3` decode documents with `encoding/json/v2`: objects with duplicate member names and strings with invalid UTF-8 are rejected, and decoding errors are worded differently and locate the faulty value from the root of the document, keys of paths, responses and callbacks included (e.g. `.paths.~1pets.get.responses.200.description`). On these builds `openapi2.Responses`, `openapi3.Callback`, `openapi3.Paths` and `openapi3.Responses` also implement `encoding/json/v2.UnmarshalerFrom`. * A schema with an empty `type: []` no longer marshals as `"type": null` which did not survive being read back. * The error for a header with neither or both of `content` and `schema` reads `header must contain exactly one of content and schema`. It used to say `parameter ...` followed by a `%v` dump of the `Header` struct, which printed heap addresses: the message differed between two runs over the same document, and leaked addresses into anything that logged it. * `openapi3filter` now parses `integer` values of parameters and `application/x-www-form-urlencoded`/`multipart/form-data` bodies as base 10 only, so `010` decodes to `10` rather than `8`, and Go integer literals such as `0x10`, `0b10`, `0o10` or `1_000` are now rejected as invalid integers. * `openapi3filter.ValidateResponse` now decodes and validates response headers described with `content` rather than `schema`, with the body decoder registered for the header's media type (see `RegisterBodyDecoder`). Such a header is no longer reported missing when it is required and present, and a value that does not match its schema is now rejected. As with a response body, a media type with no registered decoder fails with `unsupported content type`. +* `Validate` no longer reports `extra sibling fields` for fields written next to a `$ref`: every version's Reference Object says they "SHALL be ignored". From OpenAPI 3.1 on, `summary` and `description` still override the referenced component's. `ExtraSiblingFieldsError` is still returned for unknown fields of inline objects, and for `x-` extensions next to a `$ref` under `ProhibitExtensionsWithRef`, which now applies to OpenAPI 3.1+ schemas too. It is no longer returned after `InternalizeRefs` for the fields that were next to a component's `$ref` it replaced, and which it leaves out. +* In OpenAPI 3.1+ documents, the keywords next to a schema's `$ref` now apply alongside the referenced schema, as JSON Schema 2020-12 has it, instead of replacing its keywords of the same name: `{"$ref": ..., "maxLength": 10}` next to a referenced `maxLength: 3` still rejects `"abcd"`, and sibling `required` or `properties` add to the referenced ones. The resolved `SchemaRef.Value` keeps the referenced schema's shape: annotations such as `description`, `readOnly` or `default` replace the referenced ones there, and the other sibling keywords are appended to its `AllOf`. +* Marshaling an `openapi3.*Ref` with a `$ref` now writes back the fields that were written next to it, instead of emitting a lone `{"$ref": ...}`. An OpenAPI 3.1 schema's sibling keywords, which constrain the referenced schema, were lost that way, and so was every other field next to a `$ref`. +* The loader now resolves a Path Item Object's `$ref` even when fields are written next to it, and merges the two: it used to skip the `$ref` then, silently dropping every operation of the referenced path item and never noticing a `$ref` to nothing. The specification leaves the result undefined only for a field set on both sides, which is then taken from next to the `$ref`. Marshaling writes back the `$ref` with the fields that were next to it, which it used to drop. +* The loader no longer panics with `interface conversion` when the same `$ref` is written where two kinds of objects are expected, one nested in the other, such as a path item and one of its responses. +* `InternalizeRefs` now makes a component of the schema an external `$ref` points to, rather than of that schema with the OpenAPI 3.1 keywords next to the `$ref` applied to it, which leaked them into every other use of the component. The external `$ref`s within those keywords are now rewritten in the marshaled document too. +* The keywords next to a schema's `$ref` in a file of bare schemas, which has no `openapi` field, now follow the version of the document that refers to the file: they were ignored, as in OpenAPI 3.0, even from an OpenAPI 3.1 document. +* In OpenAPI 3.1+ documents, loading now fails when the keywords next to a schema's `$ref` do not form a schema, such as `allOf: {type: integer}`, instead of silently ignoring them all. +* The errors raised by the keywords next to an OpenAPI 3.1 schema's `$ref` now carry their `Origin`. + +### v0.151.0 +* Removed the deprecated package-global `openapi3.IncludeOrigin` variable. Set the `IncludeOrigin` field of `openapi3.Loader` instead. +* `openapi3filter.UrlencodedBodyDecoder` (hence `application/x-www-form-urlencoded` request body validation) now returns a `*openapi3filter.ParseError` for a value that cannot be parsed into its property's schema type, such as `n=abc` for an `integer` property, instead of silently dropping it from the decoded body. +* `openapi3filter` now writes a query parameter's array `default` into the request URL following the parameter's `style` and `explode` (including their defaults), so an array without `explode` set is written as `?ids=1&ids=2` rather than `?ids=1,2`, and `pipeDelimited`/`spaceDelimited` arrays use their own delimiter. ### v0.150.0 * `openapi2.Operation.Responses` field type changed from `map[string]*Response` to `*Responses`, which keeps the Responses Object's `x-` extensions in `Responses.Extensions` instead of failing to decode them as responses. @@ -353,8 +372,6 @@ for _, path := range doc.Paths.InMatchingOrder() { * `(*openapi3.Parameter).SerializationMethod()` (for query parameters) and `(*openapi3.Encoding).SerializationMethod()` now default `explode` to `false` for `style: spaceDelimited` and `style: pipeDelimited`, as the spec says, so `openapi3filter` splits `?ids=1|2|3` and `application/x-www-form-urlencoded` body properties such as `ids=1|2|3` instead of expecting `ids=1&ids=2&ids=3`. Set `explode: true` to keep the previous behavior. * Removed the package-global `openapi3.SchemaErrorDetailsDisabled` variable. Pass the new `openapi3.DisableSchemaErrorDetails()` option to `(*openapi3.Schema).VisitJSON(..)` instead, or add it to `openapi3filter.Options.SchemaValidationOptions`, which `openapi3filter` now also applies when validating parameters. Combined with `openapi3.EnableJSONSchema2020()` (hence with `openapi3filter` on OpenAPI 3.1+ documents), validation fails with `openapi3.ErrSchemaErrorDetailsWithJSONSchema2020`, as that validator's error messages include values. * The message customizer set with `openapi3.SetSchemaErrorMessageCustomizer(..)` (hence `(*openapi3filter.Options).WithCustomSchemaErrorFunc(..)`) now also applies to discriminator errors, to errors of patterns that fail to compile and to errors of the JSON Schema 2020-12 validator (`openapi3.EnableJSONSchema2020()`). -* JSON keys of OpenAPI documents only fill the field of the same case (`json:",case:strict"`) when `encoding/json` is implemented with `encoding/json/v2` (by default from Go 1.27, and on Go 1.25 and 1.26 with `GOEXPERIMENT=jsonv2`): a key such as `"Components"` (not `"components"`) is kept as an extension instead of filling `openapi3.T.Components`. Other builds still match keys case-insensitively, as `encoding/json` v1 does. -* On Go 1.27+, unless built with `GOEXPERIMENT=nojsonv2`, `openapi2` and `openapi3` decode documents with `encoding/json/v2`: objects with duplicate member names and strings with invalid UTF-8 are rejected, and decoding errors are worded differently and locate the faulty value from the root of the document, keys of paths, responses and callbacks included (e.g. `.paths.~1pets.get.responses.200.description`). On these builds `openapi2.Responses`, `openapi3.Callback`, `openapi3.Paths` and `openapi3.Responses` also implement `encoding/json/v2.UnmarshalerFrom`. ### v0.147.0 * `(*openapi3.PathItem).SetOperation(string, *Operation)` no longer panics on unhandled HTTP methods: these are now stored in the new `openapi3.PathItem.AdditionalOperations` field (passing a nil operation deletes the entry). 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 b1b1528d1..c4d5d9829 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.3 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 afadd341f..c51c4ec31 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.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/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/fuzzing/fuzzing.go b/internal/fuzzing/fuzzing.go new file mode 100644 index 000000000..ad8bf19d9 --- /dev/null +++ b/internal/fuzzing/fuzzing.go @@ -0,0 +1,119 @@ +// Package fuzzing holds what the fuzz targets share: finding the documents +// they seed their corpus with, and telling which documents survive YAML. +package fuzzing + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// MaxSize keeps the seed corpus small enough that `go test ./...` still +// replays it on every run without noticeably slowing the suite down. +const MaxSize = 16 << 10 + +// Testdata returns every spec under the given testdata roots, however deep, +// so documents kept beside the tests that load them seed the fuzzers too. +// Fuzz corpora are skipped, and so is the downloaded APIs-guru directory, +// which APIsGuru reads. +func Testdata(tb testing.TB, roots ...string) [][]byte { + tb.Helper() + var seeds [][]byte + for _, root := range roots { + seeds = append(seeds, walk(tb, root, func(name string) bool { + return name == "fuzz" || strings.HasPrefix(name, apisGuruPrefix) + }, nil)...) + } + return seeds +} + +const apisGuruPrefix = "APIs-guru-openapi-directory-" + +// APIsGuru returns the documents of the APIs-guru corpus under testdata whose +// top-level keys include marker, e.g. "openapi:" or "swagger:". The corpus is +// downloaded by the TestV{2,3}ApisGuruOpenapiDirectory tests, and when it is +// absent this returns nothing. Under -short, which skips those tests, it +// returns nothing either, keeping the short suite short. +func APIsGuru(tb testing.TB, testdata, marker string) [][]byte { + tb.Helper() + if testing.Short() { + return nil + } + dirs, err := filepath.Glob(filepath.Join(testdata, apisGuruPrefix+"*")) + if err != nil { + tb.Fatal(err) + } + var seeds [][]byte + for _, dir := range dirs { + // The tarball's own top-level directory carries the same name. + root := filepath.Join(dir, filepath.Base(dir), "APIs") + seeds = append(seeds, walk(tb, root, nil, func(data []byte) bool { + return bytes.HasPrefix(data, []byte(marker)) || bytes.Contains(data, []byte("\n"+marker)) + })...) + } + return seeds +} + +func walk(tb testing.TB, root string, skipDir func(string) bool, keep func([]byte) bool) [][]byte { + tb.Helper() + var seeds [][]byte + if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if path != root && skipDir != nil && skipDir(d.Name()) { + return filepath.SkipDir + } + return nil + } + switch filepath.Ext(path) { + case ".yml", ".yaml", ".json": + default: + return nil + } + if info, err := d.Info(); err != nil || info.Size() > MaxSize { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + if keep == nil || keep(data) { + seeds = append(seeds, data) + } + return nil + }); err != nil { + tb.Fatal(err) + } + return seeds +} + +// LosesInYAML reports whether v, decoded from JSON, holds something the YAML +// emitter does not write back faithfully, so a round trip through YAML text +// cannot be expected to preserve it: +// +// - a "<<" key, which is written unquoted and so reads back as a merge; +// - a string starting with a line break, which is written as a block +// scalar that drops it. +func LosesInYAML(v any) bool { + switch v := v.(type) { + case string: + return strings.HasPrefix(v, "\n") || strings.HasPrefix(v, "\r") + case map[string]any: + for k, e := range v { + if k == "<<" || LosesInYAML(k) || LosesInYAML(e) { + return true + } + } + case []any: + for _, e := range v { + if LosesInYAML(e) { + return true + } + } + } + return false +} diff --git a/internal/nodeindex/fuzz_test.go b/internal/nodeindex/fuzz_test.go new file mode 100644 index 000000000..2fc789951 --- /dev/null +++ b/internal/nodeindex/fuzz_test.go @@ -0,0 +1,91 @@ +package nodeindex_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + yaml "go.yaml.in/yaml/v3" + + "github.com/getkin/kin-openapi/internal/fuzzing" + "github.com/getkin/kin-openapi/internal/nodeindex" +) + +// A block ends no earlier than it starts, no later than the input does, no +// earlier than any block it contains, and, in a block collection, before the +// next entry starts. +func FuzzNew(f *testing.F) { + f.Add([]byte("a:\n b: 1\n c: |\n x\n\n y\n\nd: [1,\n 2]\n")) + f.Add([]byte("- name: id\n in: path\n- &a {x: 1}\n- *a\n- <<: *a\n y: 2\n")) + f.Add([]byte("a: >-\n folded\n # not a comment\nb:\n# comment\n c: 1\n")) + f.Add([]byte("a:\r\n b: 1\r\n c:\r\n - 1\r\n")) + f.Add([]byte("--- &r\nk: \"multi\n line\"\n? complex\n: key\n")) + f.Add([]byte("a:\n\tb\n")) + for _, seed := range fuzzing.Testdata(f, "../../openapi3/testdata") { + f.Add(seed) + } + for _, seed := range fuzzing.APIsGuru(f, "../../openapi3/testdata", "") { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 32<<10 { + t.Skip() + } + var root yaml.Node + if yaml.Unmarshal(data, &root) != nil { + return + } + ix := nodeindex.New(&root, data) + lines := countLines(data) + + var walk func(n *yaml.Node) int + walk = func(n *yaml.Node) int { + require.True(t, ix.Has(n)) + end, col := ix.EndOf(n) + if end != 0 { + require.LessOrEqual(t, end, lines) + require.GreaterOrEqual(t, col, 1) + if n.Line <= lines { + require.GreaterOrEqual(t, end, n.Line, "node at %d:%d", n.Line, n.Column) + } + } + for _, c := range n.Content { + if childEnd := walk(c); end != 0 && childEnd != 0 { + require.GreaterOrEqual(t, end, childEnd, "node at %d:%d ends before its child at %d:%d", n.Line, n.Column, c.Line, c.Column) + } + } + if n.Style&yaml.FlowStyle == 0 && (n.Kind == yaml.MappingNode || n.Kind == yaml.SequenceNode) { + for i := 0; i+1 < len(n.Content); i++ { + if n.Kind == yaml.MappingNode && i%2 == 0 { + continue // a key and its own value + } + cur, next := n.Content[i], n.Content[i+1] + if curEnd, _ := ix.EndOf(cur); curEnd != 0 && next.Line > cur.Line { + require.Less(t, curEnd, next.Line, "node at %d:%d runs into its sibling at %d:%d", cur.Line, cur.Column, next.Line, next.Column) + } + } + } + return end + } + walk(&root) + }) +} + +// countLines counts lines the way the parser does: a line feed, a carriage +// return, or the two together each end one. Counting line feeds alone reads a +// lone carriage return as ordinary text and comes up short. +func countLines(data []byte) int { + lines := 1 + for i := 0; i < len(data); i++ { + switch data[i] { + case '\n': + lines++ + case '\r': + if i+1 < len(data) && data[i+1] == '\n' { + continue + } + lines++ + } + } + return lines +} diff --git a/internal/nodeindex/nodeindex.go b/internal/nodeindex/nodeindex.go new file mode 100644 index 000000000..2125a1f59 --- /dev/null +++ b/internal/nodeindex/nodeindex.go @@ -0,0 +1,265 @@ +// Package nodeindex derives, from a YAML node tree and the text it was parsed +// from, the two things the parser does not report: where the block headed by a +// node ends, and which key an anchor was defined under. +// +// The first half is interim. https://github.com/yaml/go-yaml/pull/391 records +// end positions on the node, which is where they belong and which they are +// exact at; this derives them instead, and agrees on 99.98% of blocks. +// +// The import is the yaml fork this module already depends on rather than the +// stock package, whose Node is the same type: nothing here needs the fork, and +// the switch to stock yaml belongs to the change that removes the fork. +package nodeindex + +import ( + "bytes" + + yaml "go.yaml.in/yaml/v3" +) + +// Index 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, in two passes over the tree. +// +// measure takes the largest line among a node's descendants. Reading that 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, 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. +// +// extend then grows that over lines belonging to the block that carry no node, +// which is the body of a block scalar. Together they agree with a parser's own +// end positions on 99.98% of blocks across a 4,240-file corpus; measure alone +// agrees on 99.60%, the difference being blocks that end in a `description: |`. +// +// Trailing blank and comment lines fall outside. +type Index 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. indent is its leading-space count, or -1 when the line holds + // nothing, which is what extend walks. + lineLen []int + indent []int +} + +// New indexes data's node tree. Ends are derived per file: a $ref into another +// document is decoded separately, against its own text. +func New(root *yaml.Node, data []byte) *Index { + if root == nil { + return nil + } + ix := &Index{end: map[*yaml.Node]int{}, anchorKey: map[*yaml.Node]*yaml.Node{}} + for _, line := range splitLines(data) { + ix.lineLen = append(ix.lineLen, len(line)) + if t := bytes.TrimRight(line, " \t"); len(t) == 0 { + ix.indent = append(ix.indent, -1) + } else { + ix.indent = append(ix.indent, len(t)-len(bytes.TrimLeft(t, " "))) + } + } + ix.measure(root) + _ = ix.extend(root) + ix.separate(root) + return ix +} + +// separate holds each entry of a block collection to its own lines: an entry +// ends before the next one starts. +// +// measure and extend work from a node and the lines under it, which is enough +// for the common document and not enough in general: a flow collection written +// across lines, an entry whose value is indented back to the key's column, a +// comment between entries. Rather than keep refining what the two passes guess, +// the bound they must respect is applied here, because an entry that runs into +// its sibling is wrong whatever produced it, and a shorter end is the safe +// direction. A flow collection is exempt, its entries sharing lines by design. +func (ix *Index) separate(n *yaml.Node) { + for _, c := range n.Content { + ix.separate(c) + } + if n.Style&yaml.FlowStyle != 0 { + return + } + if n.Kind != yaml.MappingNode && n.Kind != yaml.SequenceNode { + return + } + step := 1 + if n.Kind == yaml.MappingNode { + step = 2 // a key and its value belong to the same entry + } + for i := 0; i+step < len(n.Content); i += step { + cur, next := n.Content[i], n.Content[i+step] + if next.Line <= cur.Line { + continue + } + for _, part := range n.Content[i : i+step] { + ix.clamp(part, next.Line-1) + } + } +} + +// clamp holds n and everything inside it to line at the latest. A bound on an +// entry binds its contents too, or the entry would end before what it contains. +func (ix *Index) clamp(n *yaml.Node, line int) { + if end, ok := ix.end[n]; ok && end > line { + ix.end[n] = line + } + for _, c := range n.Content { + ix.clamp(c, line) + } +} + +// splitLines cuts data where the parser does. YAML breaks a line on a line +// feed, a carriage return, or the two together, so splitting on line feeds +// alone counts a lone carriage return as ordinary text and every line after it +// is then numbered one too low. A break is not part of the line it ends. +func splitLines(data []byte) [][]byte { + var lines [][]byte + start := 0 + for i := 0; i < len(data); i++ { + switch data[i] { + case '\n': + case '\r': + if i+1 < len(data) && data[i+1] == '\n' { + continue // the pair is one break, taken on the line feed + } + default: + continue + } + end := i + if data[i] == '\n' && end > start && data[end-1] == '\r' { + end-- // the carriage return of a CRLF pair ends the line, it is not on it + } + lines = append(lines, data[start:end]) + start = i + 1 + } + return append(lines, data[start:]) +} + +// extend grows each node's end over lines that belong to it but hang off no +// node of their own. +// +// measure can only see lines a descendant starts on, and the body of a block +// scalar has no nodes at all: `description: |` produces one scalar whose Line +// is where it starts, so a block ending in one is cut short. Since those lines +// are indented deeper than the block itself, indentation finds them. +// +// A blank line is included only when the block resumes below it, which is what +// separates a gap inside a block scalar from the gap after the block. +func (ix *Index) extend(n *yaml.Node) int { + if isImplicitNull(n) { + // No text, so no extent: growing from nothing would hand it the lines + // above the position the parser gave it. + return 0 + } + e := ix.end[n] + // A child's extended end carries up: measure took the largest line among + // descendants, but extend can push a child past it, and a node cannot end + // before what it contains. The node's own column decides nothing here, + // which matters for a tagged node, whose column is the tag's and can sit + // deeper than the block it heads. + for _, c := range n.Content { + if ce := ix.extend(c); ce > e { + e = ce + } + } + for { + if ind := ix.indentOf(e); ind > n.Column-1 { // line e+1 is index e + e++ + continue + } else if ind == -1 { + j := e + for j < len(ix.indent) && ix.indentOf(j) == -1 { + j++ + } + if ix.indentOf(j) > n.Column-1 { + e = j + 1 + continue + } + } + break + } + ix.end[n] = e + return e +} + +// indentOf returns the indentation of the given 0-based line, or -1 when the +// line holds nothing or is past the end of the file. +func (ix *Index) indentOf(i int) int { + if i < 0 || i >= len(ix.indent) { + return -1 + } + return ix.indent[i] +} + +// measure records each node's last line, bottom up, and returns it. +func (ix *Index) measure(n *yaml.Node) int { + if n == nil { + return 0 + } + last := n.Line + if isImplicitNull(n) { + // The parser gives a value that was never written the position of the + // next token, which can be past everything the block contains, and in + // a block collection that is where the next entry starts. A node with + // no text of its own extends nothing. + last = 0 + } + if n.Kind == yaml.MappingNode { + for i := 0; i+1 < len(n.Content); i += 2 { + if v := n.Content[i+1]; v.Anchor != "" { + ix.anchorKey[v] = n.Content[i] + } + } + } + for _, c := range n.Content { + if l := ix.measure(c); l > last { + last = l + } + } + ix.end[n] = last + return last +} + +// isImplicitNull reports whether n is a value the document never wrote: the +// parser represents it as an empty null scalar. One written as null or ~ has +// text, and a position of its own. +func isImplicitNull(n *yaml.Node) bool { + return n.Kind == yaml.ScalarNode && n.Tag == "!!null" && n.Value == "" +} + +// EndOf returns the last line and column of the block node occupies. +func (ix *Index) EndOf(node *yaml.Node) (int, int) { + if ix == nil || node == nil { + return 0, 0 + } + line, ok := ix.end[node] + if !ok || line < 1 || line > len(ix.lineLen) { + return 0, 0 + } + // The column just past the last character, matching how a parser reports + // the position it stopped at. + return line, ix.lineLen[line-1] + 1 +} + +// AnchorKey returns the key the anchored node was defined under. +func (ix *Index) AnchorKey(anchored *yaml.Node) (*yaml.Node, bool) { + if ix == nil { + return nil, false + } + k, ok := ix.anchorKey[anchored] + return k, ok +} + +// Has reports whether node is part of the tree this index was built from. +func (ix *Index) Has(node *yaml.Node) bool { + if ix == nil || node == nil { + return false + } + _, ok := ix.end[node] + return ok +} diff --git a/internal/nodeindex/nodeindex_test.go b/internal/nodeindex/nodeindex_test.go new file mode 100644 index 000000000..2f02ba5b2 --- /dev/null +++ b/internal/nodeindex/nodeindex_test.go @@ -0,0 +1,118 @@ +package nodeindex_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + yaml "go.yaml.in/yaml/v3" + + "github.com/getkin/kin-openapi/internal/nodeindex" +) + +func parse(t *testing.T, src string) *yaml.Node { + t.Helper() + + var root yaml.Node + require.NoError(t, yaml.Unmarshal([]byte(src), &root)) + return &root +} + +// The end of a block is the last line of its deepest descendant, grown over +// the lines of a block scalar, which carry no node of their own. +func TestEndOf(t *testing.T) { + t.Parallel() + + const src = "a:\n b: 1\n c: |\n text\n more\nd: 2\n" + root := parse(t, src) + ix := nodeindex.New(root, []byte(src)) + + mapping := root.Content[0] + aValue := mapping.Content[1] + line, column := ix.EndOf(aValue) + require.Equal(t, 5, line, lastLineOfTheBlockScalar) + require.Equal(t, 9, column, pastTheLastCharacter) + + line, _ = ix.EndOf(mapping.Content[3]) + require.Equal(t, 6, line) +} + +const ( + lastLineOfTheBlockScalar = "the block ends at the last line of its scalar" + pastTheLastCharacter = "the column is one past the last character" + aTreeThisIndexNeverSaw = "a node from another tree is not in this index" +) + +// A node the index never saw gets nothing, which is how a decode tells its own +// tree from another one. +func TestHas(t *testing.T) { + t.Parallel() + + const src = "a: 1\n" + ix := nodeindex.New(parse(t, src), []byte(src)) + + foreign := parse(t, src) + require.False(t, ix.Has(foreign.Content[0]), aTreeThisIndexNeverSaw) + require.False(t, ix.Has(nil)) + + root := parse(t, src) + own := nodeindex.New(root, []byte(src)) + require.True(t, own.Has(root.Content[0])) +} + +// An alias reports the key its anchor was defined under, not where it is used. +func TestAnchorKey(t *testing.T) { + t.Parallel() + + const src = "base: &anchor\n x: 1\nuse: *anchor\n" + root := parse(t, src) + ix := nodeindex.New(root, []byte(src)) + + mapping := root.Content[0] + anchored := mapping.Content[1] + key, ok := ix.AnchorKey(anchored) + require.True(t, ok) + require.Equal(t, 1, key.Line) + + _, ok = ix.AnchorKey(mapping.Content[0]) + require.False(t, ok) +} + +// Every method is safe on a nil index, which is what a decode without origins +// carries. +func TestNilIndex(t *testing.T) { + t.Parallel() + + var ix *nodeindex.Index + line, column := ix.EndOf(parse(t, "a: 1\n")) + require.Zero(t, line) + require.Zero(t, column) + require.False(t, ix.Has(parse(t, "a: 1\n"))) + _, ok := ix.AnchorKey(parse(t, "a: 1\n")) + require.False(t, ok) + require.Nil(t, nodeindex.New(nil, nil)) +} + +// A line break is not a character on the line it ends, so the same document +// ends its nodes in the same place with either ending. Git checks files out +// with CRLF endings on Windows. +func TestEndOfWithEitherLineEnding(t *testing.T) { + t.Parallel() + + const src = "a:\n c: |\n text\n\n more\nb: 2\n" + for ending, text := range map[string]string{ + "lf": src, + "crlf": strings.ReplaceAll(src, "\n", "\r\n"), + } { + t.Run(ending, func(t *testing.T) { + t.Parallel() + + root := parse(t, text) + ix := nodeindex.New(root, []byte(text)) + + line, column := ix.EndOf(root.Content[0].Content[1]) + require.Equal(t, 5, line, lastLineOfTheBlockScalar) + require.Equal(t, 9, column, pastTheLastCharacter) + }) + } +} diff --git a/internal/nodeindex/testdata/fuzz/FuzzNew/3a92fc51140972a8 b/internal/nodeindex/testdata/fuzz/FuzzNew/3a92fc51140972a8 new file mode 100644 index 000000000..d620df785 --- /dev/null +++ b/internal/nodeindex/testdata/fuzz/FuzzNew/3a92fc51140972a8 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte(" &a\n- #0") diff --git a/internal/nodeindex/testdata/fuzz/FuzzNew/75c986f1e6252964 b/internal/nodeindex/testdata/fuzz/FuzzNew/75c986f1e6252964 new file mode 100644 index 000000000..8fda598bb --- /dev/null +++ b/internal/nodeindex/testdata/fuzz/FuzzNew/75c986f1e6252964 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte(" !0\n0: 0\n 0") diff --git a/internal/nodeindex/testdata/fuzz/FuzzNew/ef854579488f3d3f b/internal/nodeindex/testdata/fuzz/FuzzNew/ef854579488f3d3f new file mode 100644 index 000000000..45103ad1d --- /dev/null +++ b/internal/nodeindex/testdata/fuzz/FuzzNew/ef854579488f3d3f @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte(" 0: [\n0]\n 0:") diff --git a/internal/nodeindex/testdata/fuzz/FuzzNew/explicit-key-null b/internal/nodeindex/testdata/fuzz/FuzzNew/explicit-key-null new file mode 100644 index 000000000..bb3c1c27a --- /dev/null +++ b/internal/nodeindex/testdata/fuzz/FuzzNew/explicit-key-null @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("a:\n ? k\nb: 1\n") diff --git a/internal/nodeindex/testdata/fuzz/FuzzNew/lone-cr b/internal/nodeindex/testdata/fuzz/FuzzNew/lone-cr new file mode 100644 index 000000000..990e8fe21 --- /dev/null +++ b/internal/nodeindex/testdata/fuzz/FuzzNew/lone-cr @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte(" \r0:\n0: 00\n 00") 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/fuzz_test.go b/openapi2/fuzz_test.go new file mode 100644 index 000000000..4e7efa478 --- /dev/null +++ b/openapi2/fuzz_test.go @@ -0,0 +1,80 @@ +package openapi2 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + yaml "go.yaml.in/yaml/v3" + + "github.com/getkin/kin-openapi/internal/fuzzing" +) + +// maxFuzzInput bounds fuzzer inputs so the fuzzer spends its budget on +// decoding rather than on allocating huge documents. +const maxFuzzInput = 32 << 10 + +// A swagger document, JSON or YAML, loads back to itself from its own JSON and +// YAML forms. +func FuzzUnmarshalFromData(f *testing.F) { + for _, seed := range fuzzing.Testdata(f, "testdata") { + f.Add(seed) + } + // Read from openapi3, the one copy the fuzz workflow fetches. + for _, seed := range fuzzing.APIsGuru(f, "../openapi3/testdata", "swagger:") { + f.Add(seed) + } + f.Add([]byte(`swagger: "2.0" +info: {title: t, version: "1.0"} +x-date: 2020-06-11 +paths: + /a/{id}: + get: + parameters: + - &id {name: id, in: path, required: true, type: string} + responses: + 200: &ok {description: ok, schema: {type: integer, enum: [0x2A, 4_2]}} + 201: *ok + /b: + <<: {get: {responses: {default: {description: d}}}} +definitions: + A: + type: object + x-keys: {18_24: a, 1.5: b} + properties: {b: {$ref: '#/definitions/A'}} +`)) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > maxFuzzInput { + t.Skip() + } + + var doc T + if unmarshal(data, &doc) != nil { + return + } + once, err := json.Marshal(&doc) + require.NoError(t, err) + + var fromJSON T + require.NoError(t, unmarshal(once, &fromJSON), "json:\n%s", once) + twice, err := json.Marshal(&fromJSON) + require.NoError(t, err) + require.JSONEq(t, string(once), string(twice)) + + var generic any + require.NoError(t, json.Unmarshal(once, &generic)) + if fuzzing.LosesInYAML(generic) { + return + } + // Emitted from the generic form, the bytes the JSON assertions above + // ran on, so a difference here is the YAML round trip and nothing else. + y, err := yaml.Marshal(generic) + require.NoError(t, err) + var fromYAML T + require.NoError(t, unmarshal(y, &fromYAML), "yaml:\n%s", y) + thrice, err := json.Marshal(&fromYAML) + require.NoError(t, err) + require.JSONEq(t, string(once), string(thrice), "yaml:\n%s", y) + }) +} diff --git a/openapi2/issue1135_test.go b/openapi2/issue1135_test.go index e73def7a2..599f46633 100644 --- a/openapi2/issue1135_test.go +++ b/openapi2/issue1135_test.go @@ -3,8 +3,8 @@ package openapi2_test import ( "testing" - yaml "github.com/oasdiff/yaml3" "github.com/stretchr/testify/require" + yaml "go.yaml.in/yaml/v3" "github.com/getkin/kin-openapi/openapi2" "github.com/getkin/kin-openapi/openapi3" diff --git a/openapi2/marsh.go b/openapi2/marsh.go index 30c458810..2138c887f 100644 --- a/openapi2/marsh.go +++ b/openapi2/marsh.go @@ -4,7 +4,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/marsh_test.go b/openapi2/marsh_test.go index 68826932e..777c7609c 100644 --- a/openapi2/marsh_test.go +++ b/openapi2/marsh_test.go @@ -34,7 +34,7 @@ paths: var doc T err := unmarshal(v2, &doc) if UsingJSONv2 { - require.ErrorContains(t, err, `json: cannot unmarshal object into Go struct field .paths.~1ping.post.parameters of type openapi2.Parameters`) + require.ErrorContains(t, err, `json: cannot unmarshal object into Go struct field T.paths.~1ping.post.parameters of type openapi2.Parameters`) } else { require.ErrorContains(t, err, `json: cannot unmarshal object into field Operation.parameters of type openapi2.Parameters`) } diff --git a/openapi2/openapi2.go b/openapi2/openapi2.go index f46c9332e..e46245ddf 100644 --- a/openapi2/openapi2.go +++ b/openapi2/openapi2.go @@ -83,7 +83,9 @@ func (doc *T) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "swagger") delete(x.Extensions, "info") delete(x.Extensions, "externalDocs") 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/operation.go b/openapi2/operation.go index 55740a04b..b8420140f 100644 --- a/openapi2/operation.go +++ b/openapi2/operation.go @@ -49,7 +49,13 @@ func (operation Operation) MarshalJSON() ([]byte, error) { if x := operation.Parameters; len(x) != 0 { m["parameters"] = x } - m["responses"] = operation.Responses + // Required, so written even when missing: as null, not as the "{}" YAML + // would make of a nil map. + if x := operation.Responses; x != nil { + m["responses"] = x + } else { + m["responses"] = nil + } if x := operation.Consumes; len(x) != 0 { m["consumes"] = x } @@ -72,7 +78,9 @@ func (operation *Operation) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "summary") delete(x.Extensions, "description") delete(x.Extensions, "deprecated") diff --git a/openapi2/parameter.go b/openapi2/parameter.go index eaeed8601..3b0e1e85d 100644 --- a/openapi2/parameter.go +++ b/openapi2/parameter.go @@ -128,7 +128,9 @@ func (parameter *Parameter) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "$ref") delete(x.Extensions, "in") diff --git a/openapi2/path_item.go b/openapi2/path_item.go index 79596b455..f141403de 100644 --- a/openapi2/path_item.go +++ b/openapi2/path_item.go @@ -66,7 +66,9 @@ func (pathItem *PathItem) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "$ref") delete(x.Extensions, "delete") delete(x.Extensions, "get") diff --git a/openapi2/refs.go b/openapi2/refs.go index c7402faaf..b9f8a7189 100644 --- a/openapi2/refs.go +++ b/openapi2/refs.go @@ -56,7 +56,9 @@ func (x *SchemaRef) UnmarshalJSON(data []byte) error { var refOnly Ref if err := unmarshalJSON(data, &refOnly); err == nil && refOnly.Ref != "" { extra := map[string]any{} - _ = unmarshalJSON(data, &extra) + if err := unmarshalJSON(data, &extra); err != nil { + return unmarshalError(err) + } delete(extra, "$ref") x.Ref = refOnly.Ref if len(extra) != 0 { diff --git a/openapi2/response.go b/openapi2/response.go index 40276de0f..e28d03fbb 100644 --- a/openapi2/response.go +++ b/openapi2/response.go @@ -50,7 +50,9 @@ func (response *Response) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "$ref") delete(x.Extensions, "description") delete(x.Extensions, "schema") diff --git a/openapi2/schema.go b/openapi2/schema.go index e24435016..88ac9c570 100644 --- a/openapi2/schema.go +++ b/openapi2/schema.go @@ -198,7 +198,9 @@ func (schema *Schema) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "oneOf") delete(x.Extensions, "anyOf") diff --git a/openapi2/security_scheme.go b/openapi2/security_scheme.go index 6a0e9ae8b..6eb7235c0 100644 --- a/openapi2/security_scheme.go +++ b/openapi2/security_scheme.go @@ -70,7 +70,9 @@ func (securityScheme *SecurityScheme) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "$ref") delete(x.Extensions, "description") delete(x.Extensions, "type") 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 3fd4ef7b1..ad27007f9 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 Go struct field .paths.~1servers~1{server_id}~1statistics.get.responses.200.schema.items of type openapi2.Schema: json: cannot unmarshal JSON array into openapi2.Schema +json: cannot unmarshal array into Go struct field T.paths.~1servers~1{server_id}~1statistics.get.responses.200.schema.items of type openapi2.Schema: json: cannot unmarshal JSON array into openapi2.Schema diff --git a/openapi2/testdata/fuzz/FuzzUnmarshalFromData/ed99ca47ae7577b2 b/openapi2/testdata/fuzz/FuzzUnmarshalFromData/ed99ca47ae7577b2 new file mode 100644 index 000000000..de3288486 --- /dev/null +++ b/openapi2/testdata/fuzz/FuzzUnmarshalFromData/ed99ca47ae7577b2 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("{aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"Ό\":\"0}") diff --git a/openapi2/v2_apis_guru_openapi_directory_test.go b/openapi2/v2_apis_guru_openapi_directory_test.go index 03c46e02d..22f77083f 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" ) @@ -195,7 +195,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/binary_parameter_test.go b/openapi2conv/binary_parameter_test.go new file mode 100644 index 000000000..b66a29e89 --- /dev/null +++ b/openapi2conv/binary_parameter_test.go @@ -0,0 +1,92 @@ +package openapi2conv_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi2" + "github.com/getkin/kin-openapi/openapi2conv" + "github.com/getkin/kin-openapi/openapi3" +) + +// A binary schema outside formData used to come back from FromV3 as an +// unnamed formData file, so a header became a form field and a round trip +// lost the parameter altogether. +func TestBinaryParameterOutsideFormData(t *testing.T) { + t.Parallel() + + t.Run("v3 header and response header", func(t *testing.T) { + t.Parallel() + + spec := []byte(`{ + "openapi": "3.0.3", + "info": {"title": "t", "version": "1"}, + "paths": {"/f": {"get": { + "parameters": [{"in": "header", "name": "X-Blob", "required": true, "schema": {"type": "string", "format": "binary"}}], + "responses": {"200": { + "description": "ok", + "headers": {"X-Blob": {"schema": {"type": "string", "format": "binary"}}} + }} + }}} +}`) + loader := openapi3.NewLoader() + doc3, err := loader.LoadFromData(spec) + require.NoError(t, err) + + doc2, err := openapi2conv.FromV3(doc3) + require.NoError(t, err) + op := doc2.Paths["/f"].Get + require.Len(t, op.Parameters, 1) + require.Equal(t, &openapi2.Parameter{ + In: "header", + Name: "X-Blob", + Required: true, + Type: &openapi3.Types{"string"}, + Format: "binary", + }, op.Parameters[0]) + header := op.Responses.Value("200").Headers["X-Blob"] + require.NotNil(t, header) + require.Equal(t, &openapi3.Types{"string"}, header.Type) + require.Equal(t, "binary", header.Format) + + back3, err := openapi2conv.ToV3(doc2) + require.NoError(t, err) + param := back3.Paths.Value("/f").Get.Parameters[0].Value + require.Equal(t, "header", param.In) + require.Equal(t, "X-Blob", param.Name) + require.True(t, param.Required) + require.Equal(t, "binary", param.Schema.Value.Format) + require.Nil(t, back3.Paths.Value("/f").Get.RequestBody) + }) + + t.Run("v2 file outside formData round-trips", func(t *testing.T) { + t.Parallel() + + spec := []byte(`{"swagger":"2.0","paths":{"/f":{"post":{ + "consumes":["multipart/form-data"], + "parameters":[{"in":"firmData","name":"file","type":"file"}], + "responses":{"200":{"description":"ok"}} +}}}}`) + var doc2 openapi2.T + require.NoError(t, json.Unmarshal(spec, &doc2)) + + roundTrip := func(doc *openapi3.T) []byte { + back2, err := openapi2conv.FromV3(doc) + require.NoError(t, err) + next3, err := openapi2conv.ToV3(back2) + require.NoError(t, err) + data, err := json.Marshal(next3) + require.NoError(t, err) + return data + } + + doc3, err := openapi2conv.ToV3(&doc2) + require.NoError(t, err) + first, err := json.Marshal(doc3) + require.NoError(t, err) + once := roundTrip(doc3) + require.JSONEq(t, string(first), string(once)) + }) +} diff --git a/openapi2conv/form_data_test.go b/openapi2conv/form_data_test.go new file mode 100644 index 000000000..9dad4434b --- /dev/null +++ b/openapi2conv/form_data_test.go @@ -0,0 +1,122 @@ +package openapi2conv_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi2" + "github.com/getkin/kin-openapi/openapi2conv" + "github.com/getkin/kin-openapi/openapi3" +) + +const formDataSpecV2 = `{ + "swagger": "2.0", + "info": {"title": "Example", "version": "1"}, + "paths": { + "/upload": { + "post": { + "consumes": ["multipart/form-data"], + "parameters": [ + {"name": "file", "in": "formData", "type": "file", "required": true}, + {"name": "taken", "in": "formData", "type": "string", "format": "date"}, + {"name": "note", "in": "formData", "type": "string"} + ], + "responses": {"200": {"description": "OK"}} + } + } + } +}` + +func formDataParams(t *testing.T, doc2 *openapi2.T) map[string]*openapi2.Parameter { + t.Helper() + + byName := map[string]*openapi2.Parameter{} + for _, parameter := range doc2.Paths["/upload"].Post.Parameters { + require.Equal(t, "formData", parameter.In) + byName[parameter.Name] = parameter + } + return byName +} + +// TestFormDataRoundTrip covers the fields of a form field that the conversion +// back from OpenAPI 3 used to drop: whether the field is required, which is +// recorded on the object holding the properties, and its format. +func TestFormDataRoundTrip(t *testing.T) { + t.Parallel() + + var doc2 openapi2.T + require.NoError(t, json.Unmarshal([]byte(formDataSpecV2), &doc2)) + + doc3, err := openapi2conv.ToV3(&doc2) + require.NoError(t, err) + + // The required field is named on the object, as OpenAPI 3 requires. + body := doc3.Paths.Value("/upload").Post.RequestBody.Value + require.Equal(t, []string{"file"}, body.Content["multipart/form-data"].Schema.Value.Required) + + back, err := openapi2conv.FromV3(doc3) + require.NoError(t, err) + params := formDataParams(t, back) + require.Len(t, params, 3) + + require.True(t, params["file"].Required, "a required upload stays required") + require.Equal(t, &openapi3.Types{"file"}, params["file"].Type) + require.Empty(t, params["file"].Format, "type file spends the format naming the type") + + require.Equal(t, "date", params["taken"].Format, "a format on a form field survives") + require.Equal(t, &openapi3.Types{"string"}, params["taken"].Type) + require.False(t, params["taken"].Required) + + require.Empty(t, params["note"].Format) + require.False(t, params["note"].Required) +} + +// TestFormDataRequiredOnTheObject pins that required is read from the object +// holding the properties, which is where a document not written by this +// package records it. +func TestFormDataRequiredOnTheObject(t *testing.T) { + t.Parallel() + + mediaType := &openapi3.MediaType{Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"file"}, + Properties: openapi3.Schemas{ + "file": {Value: &openapi3.Schema{Type: &openapi3.Types{"string"}, Format: "binary"}}, + "note": {Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + }, + }}} + + byName := map[string]*openapi2.Parameter{} + for _, parameter := range openapi2conv.FromV3RequestBodyFormData(mediaType) { + byName[parameter.Name] = parameter + } + require.True(t, byName["file"].Required) + require.False(t, byName["note"].Required) +} + +// TestFormDataNameMarkerStaysInternal pins that x-formData-name, which carries +// the field name into OpenAPI 3 where it has nowhere else to live, does not +// come back out in the converted document, and that converting leaves the +// schema it was read from as it was. +func TestFormDataNameMarkerStaysInternal(t *testing.T) { + t.Parallel() + + extensions := map[string]any{"x-formData-name": "file", "x-mimetype": "text/plain", "unknown": true} + schema := &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "binary", + Extensions: extensions, + } + mediaType := &openapi3.MediaType{Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{"file": {Value: schema}}, + }}} + + params := openapi2conv.FromV3RequestBodyFormData(mediaType) + require.Len(t, params, 1) + require.Equal(t, map[string]any{"x-mimetype": "text/plain"}, params[0].Extensions) + + require.Equal(t, extensions, schema.Extensions, "the schema keeps its own extensions") +} diff --git a/openapi2conv/from_v3_parameter_test.go b/openapi2conv/from_v3_parameter_test.go index 794618580..21e6954cb 100644 --- a/openapi2conv/from_v3_parameter_test.go +++ b/openapi2conv/from_v3_parameter_test.go @@ -26,7 +26,37 @@ func TestFromV3ParameterSchemas(t *testing.T) { }} got, err := openapi2conv.FromV3Parameter(param, components) require.NoError(t, err) - require.Equal(t, &openapi2.Parameter{Ref: "#/parameters/File"}, got) + require.Equal(t, &openapi2.Parameter{ + Name: "file", + In: "query", + Type: &openapi3.Types{"string"}, + Format: "binary", + }, got) + }) + + t.Run("binary header keeps its location and name", func(t *testing.T) { + t.Parallel() + + param := &openapi3.ParameterRef{Value: &openapi3.Parameter{ + Name: "X-Blob", + In: "header", + Required: true, + Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "binary", + MaxLength: openapi3.Ptr(uint64(64)), + }}, + }} + got, err := openapi2conv.FromV3Parameter(param, nil) + require.NoError(t, err) + require.Equal(t, &openapi2.Parameter{ + Name: "X-Blob", + In: "header", + Required: true, + Type: &openapi3.Types{"string"}, + Format: "binary", + MaxLength: openapi3.Ptr(uint64(64)), + }, got) }) t.Run("schema without a value", func(t *testing.T) { diff --git a/openapi2conv/fuzz_test.go b/openapi2conv/fuzz_test.go new file mode 100644 index 000000000..1a7b21aab --- /dev/null +++ b/openapi2conv/fuzz_test.go @@ -0,0 +1,97 @@ +package openapi2conv_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/internal/fuzzing" + "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" +) + +// maxFuzzInput bounds fuzzer inputs so the fuzzer spends its budget on +// conversion logic rather than on allocating huge documents. +const maxFuzzInput = 32 << 10 + +// FuzzToV3 converts an arbitrary OpenAPI 2 document to OpenAPI 3 and back. +func FuzzToV3(f *testing.F) { + paths, err := filepath.Glob("../openapi2/testdata/*.json") + require.NoError(f, err) + for _, path := range paths { + data, err := os.ReadFile(path) + require.NoError(f, err) + if len(data) <= maxFuzzInput { + f.Add(data) + } + } + // The corpus is YAML, which this target does not decode, so it goes in as + // JSON. It is read from openapi3, the one copy the fuzz workflow fetches. + for _, seed := range fuzzing.APIsGuru(f, "../openapi3/testdata", "swagger:") { + var doc any + if yamlconv.Unmarshal(seed, &doc) != nil { + continue + } + if data, err := json.Marshal(doc); err == nil && len(data) <= maxFuzzInput { + f.Add(data) + } + } + f.Add([]byte(`{"swagger":"2.0","info":{"title":"t","version":"1"},"paths":{}}`)) + f.Add([]byte(`{"swagger":"2.0","paths":{"/x/{id}":{"get":{"parameters":[{"name":"id","in":"path","type":"string","required":true}],"responses":{"200":{"description":"ok"}}}}}}`)) + f.Add([]byte(`{"swagger":"2.0","definitions":{"A":{"type":"object","properties":{"b":{"$ref":"#/definitions/A"}}}},"paths":{}}`)) + f.Add([]byte(`{"swagger":"2.0","consumes":["multipart/form-data"],"paths":{"/f":{"post":{"parameters":[{"name":"file","in":"formData","type":"file"}],"responses":{"200":{"description":"ok"}}}}}}`)) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > maxFuzzInput { + t.Skip() + } + + var doc2 openapi2.T + if err := json.Unmarshal(data, &doc2); err != nil { + t.Skip() + } + + doc3, err := openapi2conv.ToV3(&doc2) + if err != nil { + return + } + require.NotNil(t, doc3) + + roundTrip := func(doc *openapi3.T) *openapi3.T { + back2, err := openapi2conv.FromV3(doc) + if err != nil { + return nil + } + next3, err := openapi2conv.ToV3(back2) + if err != nil { + return nil + } + return next3 + } + + once := roundTrip(doc3) + if once == nil { + return + } + twice := roundTrip(once) + if twice == nil { + return + } + + if err := once.Validate(t.Context()); err != nil { + return + } + + onceJSON, err := json.Marshal(once) + require.NoError(t, err) + twiceJSON, err := json.Marshal(twice) + require.NoError(t, err) + + require.JSONEq(t, string(onceJSON), string(twiceJSON)) + }) +} diff --git a/openapi2conv/issue1062_test.go b/openapi2conv/issue1062_test.go index dddf39b11..71769a1a9 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" ) @@ -61,7 +61,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 1f13dab9b..c07bff95f 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" @@ -203,7 +203,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 ee2d6658f..399c9d05d 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) @@ -141,7 +141,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: @@ -191,7 +191,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 c1b689ba3..9de9458d6 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" ) @@ -32,7 +32,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/openapi2conv/nil_collections_test.go b/openapi2conv/nil_collections_test.go new file mode 100644 index 000000000..0793695d4 --- /dev/null +++ b/openapi2conv/nil_collections_test.go @@ -0,0 +1,64 @@ +package openapi2conv_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi2" + "github.com/getkin/kin-openapi/openapi2conv" +) + +// A Swagger document can write null wherever an object belongs, and every +// collection that accepts one reached a dereference. Converting such a +// document reports it rather than panicking: the member stays nil and the +// resulting document says so when validated. +func TestToV3NullMembers(t *testing.T) { + t.Parallel() + + for _, doc2 := range []string{ + `{"paths":{"0":null}}`, + `{"paths":{"0":{"get":{"responses":{"":null}}}}}`, + `{"paths":{"0":{"get":{"responses":{"200":{"description":"d","headers":{"h":null}}}}}}}`, + `{"paths":{"0":{"get":{"parameters":[null]}}}}`, + `{"paths":{"0":{"parameters":[null],"get":{"responses":{"200":{"description":"d"}}}}}}`, + `{"responses":{"x":null}}`, + `{"parameters":{"x":null}}`, + `{"definitions":{"x":null}}`, + `{"securityDefinitions":{"x":null}}`, + } { + t.Run(doc2, func(t *testing.T) { + t.Parallel() + + var doc openapi2.T + require.NoError(t, json.Unmarshal([]byte(doc2), &doc)) + + // Whatever comes back, nothing dereferences a null on the way. + doc3, err := openapi2conv.ToV3(&doc) + if err != nil { + return + } + _, err = doc3.MarshalJSON() + require.NoError(t, err) + }) + } +} + +// Converting a document leaves it as it was: a v3 header drops the in and name +// a v2 header carries, and the copy being converted is where that happens. +func TestToV3HeadersLeavesInputAlone(t *testing.T) { + t.Parallel() + + const spec = `{"paths":{"/a":{"get":{"responses":{"200":{"description":"d","headers":{"h":{"type":"string"}}}}}}}}` + + var doc openapi2.T + require.NoError(t, json.Unmarshal([]byte(spec), &doc)) + header := doc.Paths["/a"].Get.Responses.Value("200").Headers["h"] + header.Name, header.In = "h", "header" + + _, err := openapi2conv.ToV3(&doc) + require.NoError(t, err) + require.Equal(t, "h", header.Name) + require.Equal(t, "header", header.In) +} diff --git a/openapi2conv/openapi2_conv.go b/openapi2conv/openapi2_conv.go index c77072af0..681cadf43 100644 --- a/openapi2conv/openapi2_conv.go +++ b/openapi2conv/openapi2_conv.go @@ -57,7 +57,13 @@ func ToV3WithLoader(doc2 *openapi2.T, loader *openapi3.Loader, location *url.URL // allows a relative server URL. Emit the base path on its own rather // than dropping it: with no servers the default server URL of "/" is // assumed, so `/v1` used to disappear (issue #1284). - doc3.AddServer(&openapi3.Server{URL: basePath}) + // + // The path goes through url.URL, as it does when there is a host, so + // that it is escaped the same way in both cases. Written out as it + // came, a base path holding a percent-escape or a control character + // made a server URL that reads back as a different path, or as no + // path at all because it no longer parses. + doc3.AddServer(&openapi3.Server{URL: (&url.URL{Path: basePath}).String()}) } doc3.Components.Schemas = make(map[string]*openapi3.SchemaRef) @@ -127,6 +133,9 @@ func ToV3WithLoader(doc2 *openapi2.T, loader *openapi3.Loader, location *url.URL } func ToV3PathItem(doc2 *openapi2.T, components *openapi3.Components, pathItem *openapi2.PathItem, consumes []string) (*openapi3.PathItem, error) { + if pathItem == nil { + return nil, nil + } doc3 := &openapi3.PathItem{ Extensions: stripNonExtensions(pathItem.Extensions), } @@ -245,7 +254,11 @@ func inheritedRequestBodyParams(components *openapi3.Components, pathItem *opena // parameterIdentity returns the key a parameter is known by inside a path item: // a reference keeps its reference, an inline parameter its location and name. +// A parameter written as null has no identity and overrides nothing. func parameterIdentity(parameter *openapi2.Parameter) string { + if parameter == nil { + return "" + } if parameter.Ref != "" { return parameter.Ref } @@ -260,6 +273,9 @@ func getParameterNameFromOldRef(ref string) string { } func ToV3Parameter(components *openapi3.Components, parameter *openapi2.Parameter, consumes []string) (*openapi3.ParameterRef, *openapi3.RequestBodyRef, map[string]*openapi3.SchemaRef, error) { + if parameter == nil { + return nil, nil, nil, nil + } if ref := parameter.Ref; ref != "" { if strings.HasPrefix(ref, "#/parameters/") { name := getParameterNameFromOldRef(ref) @@ -487,6 +503,9 @@ func onlyOneReqBodyParam(bodies []*openapi3.RequestBodyRef, formDataSchemas map[ } func ToV3Response(response *openapi2.Response, produces []string) (*openapi3.ResponseRef, error) { + if response == nil { + return nil, nil + } if ref := response.Ref; ref != "" { return &openapi3.ResponseRef{Ref: ToV3Ref(ref)}, nil } @@ -527,16 +546,27 @@ func ToV3Response(response *openapi2.Response, produces []string) (*openapi3.Res func ToV3Headers(defs map[string]*openapi2.Header) openapi3.Headers { headers := make(openapi3.Headers, len(defs)) for name, header := range defs { - header.In = "" - header.Name = "" - if ref := header.Ref; ref != "" { + if header == nil { + headers[name] = nil + continue + } + // On a copy: a header's in and name are dropped on the way to a + // v3 header, and the document being converted keeps its own. + h := *header + h.In = "" + h.Name = "" + if ref := h.Ref; ref != "" { headers[name] = &openapi3.HeaderRef{Ref: ToV3Ref(ref)} - } else { - parameter, _, _, _ := ToV3Parameter(nil, &header.Parameter, nil) - headers[name] = &openapi3.HeaderRef{Value: &openapi3.Header{ - Parameter: *parameter.Value, - }} + continue + } + parameter, _, _, _ := ToV3Parameter(nil, &h.Parameter, nil) + if parameter == nil || parameter.Value == nil { + headers[name] = nil + continue } + headers[name] = &openapi3.HeaderRef{Value: &openapi3.Header{ + Parameter: *parameter.Value, + }} } return headers } @@ -565,7 +595,7 @@ func ToV3SchemaRef(schema *openapi2.SchemaRef) *openapi3.SchemaRef { } v3Schema := &openapi3.Schema{ - Extensions: schema.Value.Extensions, + Extensions: maps.Clone(schema.Value.Extensions), Type: schema.Value.Type, Title: schema.Value.Title, Format: schema.Value.Format, @@ -746,11 +776,16 @@ func ToV3SecurityScheme(securityScheme *openapi2.SecurityScheme) (*openapi3.Secu // FromV3 converts an OpenAPIv3 spec to an OpenAPIv2 spec func FromV3(doc3 *openapi3.T) (*openapi2.T, error) { - doc2Responses, err := FromV3Responses(doc3.Components.Responses, doc3.Components) + // A document need not declare components. + components := doc3.Components + if components == nil { + components = &openapi3.Components{} + } + doc2Responses, err := FromV3Responses(components.Responses, components) if err != nil { return nil, err } - schemas, parameters := FromV3Schemas(doc3.Components.Schemas, doc3.Components) + schemas, parameters := FromV3Schemas(components.Schemas, components) doc2 := &openapi2.T{ Swagger: "2.0", Info: *doc3.Info, @@ -766,18 +801,27 @@ func FromV3(doc3 *openapi3.T) (*openapi2.T, error) { isHTTP := false servers := doc3.Servers for i, server := range servers { - if parsedURL, err := url.Parse(server.URL); err == nil { - switch parsedURL.Scheme { - case "https": - isHTTPS = true - case "http": - isHTTP = true - } - // The first server is assumed to provide the base path + parsedURL, err := url.Parse(server.URL) + if err != nil { + // A URL that does not parse is still the only thing said about + // where the API lives, and basePath is the one field of the two + // that can hold it as it was written. Dropping it lost the server + // altogether, and converting back then produced no server at all. if i == 0 { - doc2.Host = parsedURL.Host - doc2.BasePath = parsedURL.Path + doc2.BasePath = server.URL } + continue + } + switch parsedURL.Scheme { + case "https": + isHTTPS = true + case "http": + isHTTP = true + } + // The first server is assumed to provide the base path + if i == 0 { + doc2.Host = parsedURL.Host + doc2.BasePath = parsedURL.Path } } @@ -809,7 +853,7 @@ func FromV3(doc3 *openapi3.T) (*openapi2.T, error) { } params := openapi2.Parameters{} for _, param := range pathItem.Parameters { - p, err := FromV3Parameter(param, doc3.Components) + p, err := FromV3Parameter(param, components) if err != nil { return nil, err } @@ -819,14 +863,14 @@ func FromV3(doc3 *openapi3.T) (*openapi2.T, error) { doc2.Paths[path].Parameters = params } - for name, param := range doc3.Components.Parameters { - if doc2.Parameters[name], err = FromV3Parameter(param, doc3.Components); err != nil { + for name, param := range components.Parameters { + if doc2.Parameters[name], err = FromV3Parameter(param, components); err != nil { return nil, err } } - for name, requestBodyRef := range doc3.Components.RequestBodies { - bodyOrRefParameters, formDataParameters, consumes, err := fromV3RequestBodies(name, requestBodyRef, doc3.Components) + for name, requestBodyRef := range components.RequestBodies { + bodyOrRefParameters, formDataParameters, consumes, err := fromV3RequestBodies(name, requestBodyRef, components) if err != nil { return nil, err } @@ -845,7 +889,7 @@ func FromV3(doc3 *openapi3.T) (*openapi2.T, error) { } } - if m := doc3.Components.SecuritySchemes; m != nil { + if m := components.SecuritySchemes; m != nil { doc2SecuritySchemes := make(map[string]*openapi2.SecurityScheme) for id, securityScheme := range m { v, err := FromV3SecurityScheme(securityScheme) @@ -927,6 +971,10 @@ func FromV3Schemas(schemas map[string]*openapi3.SchemaRef, components *openapi3. } func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components) (*openapi2.SchemaRef, *openapi2.Parameter) { + // A media type need not carry a schema. + if schema == nil { + return nil, nil + } if ref := schema.Ref; ref != "" { // FromV3RequestBodyFormData (and other recursive call sites in // this file) pass components=nil when recursing into array @@ -938,7 +986,7 @@ func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components // schema is not known locally': emit a plain $ref and move on. name := getParameterNameFromNewRef(ref) if components != nil { - if val, ok := components.Schemas[name]; ok { + if val, ok := components.Schemas[name]; ok && val != nil && val.Value != nil { if val.Value.Format == "binary" { v2Ref := strings.Replace(ref, "#/components/schemas/", "#/parameters/", 1) return nil, &openapi2.Parameter{Ref: v2Ref} @@ -987,7 +1035,7 @@ func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components } v2Schema := &openapi2.Schema{ - Extensions: schema.Value.Extensions, + Extensions: maps.Clone(schema.Value.Extensions), Type: schema.Value.Type, Title: schema.Value.Title, Format: schema.Value.Format, @@ -1040,8 +1088,7 @@ func FromV3SchemaRef(schema *openapi3.SchemaRef, components *openapi3.Components v2Schema.AllOf[i], _ = FromV3SchemaRef(v, components) } if schema.Value.PermitsNull() { - schema.Value.Nullable = false - if schema.Value.Extensions == nil { + if v2Schema.Extensions == nil { v2Schema.Extensions = make(map[string]any) } v2Schema.Extensions["x-nullable"] = true @@ -1127,18 +1174,27 @@ nameSearch: func FromV3RequestBodyFormData(mediaType *openapi3.MediaType) openapi2.Parameters { parameters := openapi2.Parameters{} - for propName, schemaRef := range mediaType.Schema.Value.Properties { + schema := mediaType.Schema.Value + for propName, schemaRef := range schema.Properties { if ref := schemaRef.Ref; ref != "" { v2Ref := strings.Replace(ref, "#/components/schemas/", "#/parameters/", 1) parameters = append(parameters, &openapi2.Parameter{Ref: v2Ref}) continue } val := schemaRef.Value - typ := val.Type + typ, format := val.Type, val.Format if val.Format == "binary" { - typ = &openapi3.Types{"file"} - } - required := slices.Contains(val.Required, propName) + // Swagger 2.0 writes a file upload as type file, which has no + // format of its own, so the format is spent on naming the type. + typ, format = &openapi3.Types{"file"}, "" + } + // required lists the names of the required properties and belongs to + // the object holding them, which is where ToV3 puts it. Reading only + // the property's own list therefore lost the flag on the way back. A + // document that does carry it on the property is still honoured, as + // that is how a form field kept in components records it. + required := slices.Contains(schema.Required, propName) || + slices.Contains(val.Required, propName) var v2Items *openapi2.SchemaRef if val.Items != nil { @@ -1149,7 +1205,7 @@ func FromV3RequestBodyFormData(mediaType *openapi3.MediaType) openapi2.Parameter Description: val.Description, Type: typ, In: "formData", - Extensions: stripNonExtensions(val.Extensions), + Extensions: formFieldExtensions(val.Extensions), Enum: val.Enum, ExclusiveMin: exclusiveBoundToBool(val.ExclusiveMin), ExclusiveMax: exclusiveBoundToBool(val.ExclusiveMax), @@ -1162,8 +1218,8 @@ func FromV3RequestBodyFormData(mediaType *openapi3.MediaType) openapi2.Parameter Maximum: effectiveMax(val.Max, val.ExclusiveMax), Minimum: effectiveMin(val.Min, val.ExclusiveMin), Pattern: val.Pattern, + Format: format, // CollectionFormat: val.CollectionFormat, - // Format: val.Format, AllowEmptyValue: val.AllowEmptyValue, Required: required, UniqueItems: val.UniqueItems, @@ -1274,7 +1330,13 @@ func FromV3Parameter(ref *openapi3.ParameterRef, components *openapi3.Components if schemaRef := parameter.Schema; schemaRef != nil { schemaRefV2, parameterV2 := FromV3SchemaRef(schemaRef, components) if schemaRefV2 == nil { - return parameterV2, nil + if parameter.In == "formData" { + return parameterV2, nil + } + // A binary schema converts to a formData file, which only a + // request body property may become. Anywhere else the parameter + // keeps its location and name and stays a binary string. + return fromV3BinaryParameter(result, schemaRef, components), nil } if ref := schemaRefV2.Ref; ref != "" { result.Schema = &openapi2.SchemaRef{Ref: FromV3Ref(ref)} @@ -1308,6 +1370,34 @@ func FromV3Parameter(ref *openapi3.ParameterRef, components *openapi3.Components return result, nil } +// fromV3BinaryParameter fills result, a parameter outside formData, from its +// binary schema. Swagger 2.0 allows type file only in formData, so the schema +// is kept as a string of format binary. +func fromV3BinaryParameter(result *openapi2.Parameter, schemaRef *openapi3.SchemaRef, components *openapi3.Components) *openapi2.Parameter { + schema := schemaRef.Value + if schema == nil && components != nil { + if val, ok := components.Schemas[getParameterNameFromNewRef(schemaRef.Ref)]; ok && val != nil { + schema = val.Value + } + } + result.Type = &openapi3.Types{"string"} + result.Format = "binary" + if schema == nil { + return result + } + result.Enum = schema.Enum + result.Minimum = effectiveMin(schema.Min, schema.ExclusiveMin) + result.Maximum = effectiveMax(schema.Max, schema.ExclusiveMax) + result.ExclusiveMin = exclusiveBoundToBool(schema.ExclusiveMin) + result.ExclusiveMax = exclusiveBoundToBool(schema.ExclusiveMax) + result.MinLength = schema.MinLength + result.MaxLength = schema.MaxLength + result.Pattern = schema.Pattern + result.Default = schema.Default + result.AllowEmptyValue = schema.AllowEmptyValue + return result +} + // fromV3QueryStyle maps a query parameter's explicit style and explode to the equivalent collectionFormat. func fromV3QueryStyle(parameter *openapi3.Parameter) string { if parameter.Style == "" && parameter.Explode == nil { @@ -1463,6 +1553,25 @@ var attemptedBodyParameterNames = []string{ } // stripNonExtensions removes invalid extensions: those not prefixed by "x-" and returns them +// formFieldExtensions returns the extensions of a form field without +// x-formData-name, which names the field for the conversion to OpenAPI 3, +// where the name has nowhere else to live, and is not part of the document +// being converted. A field kept in components keeps the marker, as the +// conversion back reads the name from it. +// +// The map is built fresh rather than filtered in place, so the schema the +// extensions came from keeps its own. +func formFieldExtensions(extensions map[string]any) map[string]any { + result := make(map[string]any, len(extensions)) + for name, value := range extensions { + if !strings.HasPrefix(name, "x-") || name == "x-formData-name" { + continue + } + result[name] = value + } + return result +} + func stripNonExtensions(extensions map[string]any) map[string]any { for extName := range extensions { if !strings.HasPrefix(extName, "x-") { diff --git a/openapi2conv/openapi2_conv_test.go b/openapi2conv/openapi2_conv_test.go index eec57db96..d9a5e594d 100644 --- a/openapi2conv/openapi2_conv_test.go +++ b/openapi2conv/openapi2_conv_test.go @@ -525,7 +525,6 @@ const exampleV2 = ` "in": "formData", "name": "fileUpload", "type": "file", - "x-formData-name": "fileUpload", "x-mimetype": "text/plain" }, { @@ -538,8 +537,7 @@ const exampleV2 = ` "description": "Description of file contents", "in": "formData", "name": "note", - "type": "integer", - "x-formData-name": "note" + "type": "integer" } ], "responses": { diff --git a/openapi2conv/schema_conv_test.go b/openapi2conv/schema_conv_test.go new file mode 100644 index 000000000..b215f7fba --- /dev/null +++ b/openapi2conv/schema_conv_test.go @@ -0,0 +1,56 @@ +package openapi2conv_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi2" + "github.com/getkin/kin-openapi/openapi2conv" + "github.com/getkin/kin-openapi/openapi3" +) + +// A media type need not carry a schema. +func TestFromV3_MediaTypeWithoutSchema(t *testing.T) { + var doc3 openapi3.T + require.NoError(t, json.Unmarshal([]byte(`{ + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"responses": {"200": { + "description": "ok", + "content": {"application/json": {}} + }}}}} + }`), &doc3)) + + doc2, err := openapi2conv.FromV3(&doc3) + require.NoError(t, err) + require.Nil(t, doc2.Paths["/a"].Get.Responses.Value("200").Schema) +} + +// Converting a nullable schema either way leaves the source document as it +// was: converting it again gives the same result. +func TestSchemaConversion_LeavesSourceUnchanged(t *testing.T) { + var doc2 openapi2.T + require.NoError(t, json.Unmarshal([]byte(`{ + "swagger": "2.0", + "info": {"title": "t", "version": "1"}, + "paths": {}, + "definitions": {"A": {"type": "string", "x-nullable": true, "x-other": 1}} + }`), &doc2)) + + doc3, err := openapi2conv.ToV3(&doc2) + require.NoError(t, err) + require.Equal(t, true, doc2.Definitions["A"].Value.Extensions["x-nullable"]) + require.True(t, doc3.Components.Schemas["A"].Value.Nullable) + + back, err := openapi2conv.FromV3(doc3) + require.NoError(t, err) + require.Equal(t, true, back.Definitions["A"].Value.Extensions["x-nullable"]) + require.True(t, doc3.Components.Schemas["A"].Value.Nullable) + require.NotContains(t, doc3.Components.Schemas["A"].Value.Extensions, "x-nullable") + + again, err := openapi2conv.FromV3(doc3) + require.NoError(t, err) + require.Equal(t, back.Definitions, again.Definitions) +} diff --git a/openapi2conv/server_base_path_test.go b/openapi2conv/server_base_path_test.go new file mode 100644 index 000000000..816e57a07 --- /dev/null +++ b/openapi2conv/server_base_path_test.go @@ -0,0 +1,78 @@ +package openapi2conv_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi2" + "github.com/getkin/kin-openapi/openapi2conv" + "github.com/getkin/kin-openapi/openapi3" +) + +// TestToV3BasePathEscaping covers the base path of a document with no host, +// which becomes a server URL of its own. The URL is read back as a URL, so a +// base path that is not already one has to be escaped on the way out, or the +// path that comes back differs from the path that went in. +func TestToV3BasePathEscaping(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + basePath string + url string + }{ + {name: "plain", basePath: "/v1", url: "/v1"}, + {name: "percent escape", basePath: "0%00", url: "0%2500"}, + {name: "control character", basePath: "0\x00", url: "0%00"}, + {name: "space", basePath: "/a b", url: "/a%20b"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + doc2 := &openapi2.T{ + Swagger: "2.0", + Info: openapi3.Info{Title: "t", Version: "1"}, + BasePath: tc.basePath, + } + + doc3, err := openapi2conv.ToV3(doc2) + require.NoError(t, err) + require.Len(t, doc3.Servers, 1) + require.Equal(t, tc.url, doc3.Servers[0].URL) + + // The base path has to come back as it was written, and the + // conversion has to settle: the server URL of the second pass is + // the server URL of the first. + back, err := openapi2conv.FromV3(doc3) + require.NoError(t, err) + require.Equal(t, tc.basePath, back.BasePath) + require.Empty(t, back.Host) + + again, err := openapi2conv.ToV3(back) + require.NoError(t, err) + require.Len(t, again.Servers, 1) + require.Equal(t, tc.url, again.Servers[0].URL) + }) + } +} + +// TestFromV3UnparseableServer pins that a server URL which is not a URL is +// kept rather than dropped. basePath is the one field that can hold it as it +// was written, and dropping it left the document with no server at all. +func TestFromV3UnparseableServer(t *testing.T) { + t.Parallel() + + doc3 := &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{Title: "t", Version: "1"}, + Servers: openapi3.Servers{{URL: "ht tp://example.com/v1"}}, + Paths: openapi3.NewPaths(), + } + + back, err := openapi2conv.FromV3(doc3) + require.NoError(t, err) + require.Equal(t, "ht tp://example.com/v1", back.BasePath) + require.Empty(t, back.Host) + require.Empty(t, back.Schemes) +} diff --git a/openapi2conv/testdata/fuzz/FuzzToV3/2915761dc93996a1 b/openapi2conv/testdata/fuzz/FuzzToV3/2915761dc93996a1 new file mode 100644 index 000000000..c3ae52221 --- /dev/null +++ b/openapi2conv/testdata/fuzz/FuzzToV3/2915761dc93996a1 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("{\"paths\":{\"0\":{\"get\":{\"responses\":{\"\":null}}}}}") diff --git a/openapi2conv/testdata/fuzz/FuzzToV3/binary-param-outside-formdata b/openapi2conv/testdata/fuzz/FuzzToV3/binary-param-outside-formdata new file mode 100644 index 000000000..03d4a10dd --- /dev/null +++ b/openapi2conv/testdata/fuzz/FuzzToV3/binary-param-outside-formdata @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("{\"definitions\":{\"CollectionLinks\":{\"example\":{\"\":\"\"},\"properties\":{\"s\":{\"$ref\":\"#/definitions/CollectionLinks\"}},\"title\":\"\"},\"Error\":{\"properties\":{\"\":{\"description\":\"\",\"type\":\"integer\"},\"l\":{\"description\":\"’\",\"type\":\"string\"},\"n\":{\"description\":\"\",\"format\":\"\",\"type\":\"string\"},\"e\":{\"$ref\":\"#/definitions/ErrorSource\"},\"s\":{\"description\":\"\",\"type\":\"integer\"},\"title\":{\"description\":\"\",\"type\":\"string\"}},\"required\":[\"status\",\"code\",\"title\"]},\"Error400\":{\"properties\":{\"\":{\"items\":{\"$ref\":\"#/definitions/Error\"},\"type\":\"array\"}},\"required\":[\"\"]},\"Error401\":{\"example\":{\"errors\":[{\"code\":0,\"l\":\"\",\"status\":4,\"title\":\"\"}]},\"properties\":{\"errors\":{\"items\":{\"$ref\":\"#/definitions/Error\"},\"type\":\"array\"}},\"required\":[\"errors\"]},\"Error500\":{\"\":{\"\":[{\"\":1,\"s\":5,\"e\":\"\"}]},\"s\":{\"\":{\"\":{\"\":\"\"},\"e\":\"\"}},\"d\":[\"\"]},\"ErrorSource\":{\"properties\":{\"\":{\"description\":\"\",\"type\":\"string\"},\"r\":{\"description\":\"\",\"type\":\"string\"},\"e\":{\"description\":\"\",\"type\":\"string\"}}},\"HotelSentiment\":{\"\":{\"\":{\"\":\"\",\"e\":\"\",\"p\":\"\"},\"s\":{\"\":0,\"e\":\"\"},\"w\":{\"\":2,\"e\":\"\"},\"g\":{\"\":\"\"},\"t\":{\"\":\"\",\"s\":{\"\":{\"\":\"\"},\"s\":{\"\":\"\"},\"t\":{\"\":\"\"},\"n\":{\"\":\"\"},\"st\":{\"\":\"\"},\"ts\":{\"\":\"\"},\"e\":{\"\":\"\"},\"y\":{\"\":\"\"},\"f\":{\"\":\"\"},\"l\":{\"\":\"\"},\"ey\":{\"\":\"\"}},\"e\":\"\"},\"e\":{\"\":\"\",\"e\":\"\"}},\"d\":[\"\",\"\",\"\",\"\",\"\"],\"e\":\"\"},\"e\":{\"\":\"\",\"e\":2,\"p\":\"\"},\"Warning\":{\"\":{\"\":{\"\":\"\",\"e\":\"\"},\"l\":{\"\":\"’\",\"e\":\"\"},\"n\":{\"\":\"\",\"t\":\"\",\"e\":\"\"},\"e\":{\"\":\"\"},\"le\":{\"\":\"\",\"e\":\"\"}},\"d\":[\"\",\"\"]},\"WarningNotFound\":{\"\":[{\"\":\"\"}],\"e\":{\"\":3,\"l\":\"\",\"e\":{\"\":\"\",\"r\":\"\"},\"le\":\"\"}}},\"t\":\"\",\"o\":{\"\":\"\",\"e\":\"\",\"n\":\"\",\"o\":{\"\":\"\"},\"i\":[{\"\":\"\",\"l\":\"\",\"n\":\"\"}],\"m\":\"\",\"me\":\"\"},\"s\":{\"\":{\"\":{\"\":\"\",\"d\":\"\",\"s\":[{\"\":\"\",\"n\":\"\",\"s\":{\"\":\"\"},\"m\":1,\"ms\":1,\"e\":\"\",\"d\":true,\"p\":\"\",\"l\":[\"\"]}],\"e\":{\"\":{\"\":\"\"},\"0\":{\"\":\"\"},\"1\":{\"\":\"\"},\"t\":{\"\":\"\"}},\"y\":\"\",\"g\":[\"\"]}}},\"e\":[\"\"],\"es\":{\"\":{\"\":\"\",\"a\":{\"\":\"\"}},\"1\":{\"\":\"\",\"a\":{\"\":\"\"}},\"0\":{\"\":\"\",\"a\":{\"\":\"\"}},\"s\":{\"\":\"\",\"a\":{\"\":{\"\":[{\"\":\"\",\"s\":8,\"w\":8,\"g\":3,\"t\":{\"\":9,\"s\":0,\"n\":8,\"t\":1,\"ts\":2,\"e\":8,\"y\":7,\"f\":1,\"ey\":7},\"e\":\"\"},{\"\":\"\",\"s\":6,\"w\":7,\"g\":1,\"t\":{\"\":1,\"s\":5,\"t\":2,\"n\":9,\"st\":1,\"ts\":7,\"e\":0,\"y\":8,\"f\":9,\"ey\":5},\"e\":\"\"}],\"a\":{\"\":1,\"s\":{\"\":\"\"}},\"s\":[{\"\":3,\"l\":\"\",\"e\":{\"\":\"\",\"r\":\"\"},\"le\":\"\"}]},\"s\":{\"\":{\"\":{\"\":\"\"},\"e\":\"\"},\"a\":{\"\":\"\"},\"s\":{\"\":{\"\":\"\"},\"e\":\"\"}},\"d\":[\"\"],\"e\":\"\"}}},\"m\":[\"\"],\"r\":\"\",\"g\":[{\"\":\"\",\"e\":\"\"}]{") diff --git a/openapi2conv/testdata/fuzz/FuzzToV3/relative-server-base-path b/openapi2conv/testdata/fuzz/FuzzToV3/relative-server-base-path new file mode 100644 index 000000000..08a44941e --- /dev/null +++ b/openapi2conv/testdata/fuzz/FuzzToV3/relative-server-base-path @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("{\"basePath\":\"0%00\",\"info\":{\"title\":\"0\",\"version\":\"0\"},\"paths\":{\"/\":{}}}") diff --git a/openapi3/additionalProperties_test.go b/openapi3/additionalProperties_test.go index a84939236..977072c10 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/case_strict_jsonv1_test.go b/openapi3/case_strict_jsonv1_test.go index 4c4a4c3b5..34a16bd8d 100644 --- a/openapi3/case_strict_jsonv1_test.go +++ b/openapi3/case_strict_jsonv1_test.go @@ -11,38 +11,6 @@ import ( "github.com/getkin/kin-openapi/openapi3" ) -// TestKeysOnlyMatchFieldsOfTheSameCase records how encoding/json v1, which -// ignores case:strict, matches keys to fields regardless of case: such keys -// fill the field and are also kept as extensions, so the document marshals -// both keys and does not load again. -func TestKeysOnlyMatchFieldsOfTheSameCase(t *testing.T) { - t.Parallel() - - const spec = "Components:\n schemas:\n SChemAs:\n 0:" - - loader := openapi3.NewLoader() - doc, err := loader.LoadFromData([]byte(spec)) - require.NoError(t, err) - - require.NotNil(t, doc.Components) - require.Empty(t, doc.Components.Schemas) - require.Equal(t, map[string]any{"SChemAs": map[string]any{"0": nil}}, doc.Components.Extensions) - require.Equal(t, map[string]any{ - "Components": map[string]any{ - "schemas": nil, - "SChemAs": map[string]any{"0": nil}, - }, - }, doc.Extensions) - - once, err := doc.MarshalJSON() - require.NoError(t, err) - require.JSONEq(t, `{"openapi": "", "info": null, "paths": null, "Components": {"schemas": null, "SChemAs": {"0": null}}, "components": {"SChemAs": {"0": null}}}`, string(once)) - - loader = openapi3.NewLoader() - _, err = loader.LoadFromData(once) - require.EqualError(t, err, "invalid schema: value MUST be an object") -} - func TestFieldsIgnoreKeysOfAnotherCase(t *testing.T) { t.Parallel() diff --git a/openapi3/case_strict_jsonv2_test.go b/openapi3/case_strict_jsonv2_test.go index 3d683c9cc..aa5e1a7e7 100644 --- a/openapi3/case_strict_jsonv2_test.go +++ b/openapi3/case_strict_jsonv2_test.go @@ -11,38 +11,6 @@ import ( "github.com/getkin/kin-openapi/openapi3" ) -// TestKeysOnlyMatchFieldsOfTheSameCase covers keys that differ from a known -// field only in case: the json:",case:strict" tags make encoding/json keep -// them as extensions rather than fill the field. -func TestKeysOnlyMatchFieldsOfTheSameCase(t *testing.T) { - t.Parallel() - - const spec = "Components:\n schemas:\n SChemAs:\n 0:" - - loader := openapi3.NewLoader() - doc, err := loader.LoadFromData([]byte(spec)) - require.NoError(t, err) - - require.Nil(t, doc.Components) - require.Equal(t, map[string]any{ - "Components": map[string]any{ - "schemas": nil, - "SChemAs": map[string]any{"0": nil}, - }, - }, doc.Extensions) - - once, err := doc.MarshalJSON() - require.NoError(t, err) - require.JSONEq(t, `{"openapi": "", "info": null, "paths": null, "Components": {"schemas": null, "SChemAs": {"0": null}}}`, string(once)) - - loader = openapi3.NewLoader() - reloaded, err := loader.LoadFromData(once) - require.NoError(t, err) - twice, err := reloaded.MarshalJSON() - require.NoError(t, err) - require.JSONEq(t, string(once), string(twice)) -} - func TestFieldsIgnoreKeysOfAnotherCase(t *testing.T) { t.Parallel() diff --git a/openapi3/case_strict_test.go b/openapi3/case_strict_test.go new file mode 100644 index 000000000..eecd6705e --- /dev/null +++ b/openapi3/case_strict_test.go @@ -0,0 +1,52 @@ +package openapi3_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +// TestKeysOnlyMatchFieldsOfTheSameCase covers keys that differ from a known +// field only in case. A document decodes from its nodes, where a key matches a +// field by name and nothing else, so such a key is an extension rather than a +// value for the field it resembles. That holds whichever encoding/json the +// build has, since the json package decodes no document here. +func TestKeysOnlyMatchFieldsOfTheSameCase(t *testing.T) { + t.Parallel() + + const spec = "Components:\n schemas:\n SChemAs:\n 0:" + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + require.Nil(t, doc.Components) + require.Equal(t, map[string]any{ + "Components": map[string]any{ + "schemas": nil, + "SChemAs": map[string]any{"0": nil}, + }, + }, doc.Extensions) + + once, err := doc.MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, `{"openapi": "", "info": null, "paths": null, "Components": {"schemas": null, "SChemAs": {"0": null}}}`, string(once)) + + loader = openapi3.NewLoader() + reloaded, err := loader.LoadFromData(once) + require.NoError(t, err) + + require.Nil(t, reloaded.Components) + require.Equal(t, map[string]any{ + "Components": map[string]any{ + "schemas": nil, + "SChemAs": map[string]any{"0": nil}, + }, + }, reloaded.Extensions) + + twice, err := reloaded.MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, string(once), string(twice)) +} diff --git a/openapi3/components.go b/openapi3/components.go index 30bcef45c..2d9b1735f 100644 --- a/openapi3/components.go +++ b/openapi3/components.go @@ -90,7 +90,9 @@ func (components *Components) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "schemas") delete(x.Extensions, "parameters") delete(x.Extensions, "headers") diff --git a/openapi3/contact.go b/openapi3/contact.go index 07f55eccc..7af350632 100644 --- a/openapi3/contact.go +++ b/openapi3/contact.go @@ -49,7 +49,9 @@ func (contact *Contact) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "name") delete(x.Extensions, "url") diff --git a/openapi3/discriminator.go b/openapi3/discriminator.go index c1853480f..4b425bef8 100644 --- a/openapi3/discriminator.go +++ b/openapi3/discriminator.go @@ -57,7 +57,9 @@ func (discriminator *Discriminator) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "propertyName") delete(x.Extensions, "mapping") diff --git a/openapi3/encoding.go b/openapi3/encoding.go index 954aab9fd..afb2bba8f 100644 --- a/openapi3/encoding.go +++ b/openapi3/encoding.go @@ -82,7 +82,9 @@ func (encoding *Encoding) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "contentType") delete(x.Extensions, "headers") diff --git a/openapi3/end_positions_test.go b/openapi3/end_positions_test.go new file mode 100644 index 000000000..af8bb0487 --- /dev/null +++ b/openapi3/end_positions_test.go @@ -0,0 +1,58 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// A block ending in a multi-line scalar reaches the end of that scalar. The +// scalar is one node whose Line is where it starts, so the lines below it hang +// off nothing and a subtree walk alone stops short. +func TestEndPositions_BlockScalarAtEndOfBlock(t *testing.T) { + spec := ` +openapi: 3.0.0 +info: + title: t + version: "1" +paths: + /a: + get: + responses: + "200": + description: | + first line + second line + + after a blank line + /b: + get: + responses: + "200": + description: ok +`[1:] + + loader := NewLoader() + loader.IncludeOrigin = true + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + // /a spans to the last line of the description, line 14, not to line 10 + // where the scalar starts. + a := doc.Paths.Value("/a").Origin + require.NotNil(t, a) + require.NotNil(t, a.Key) + require.Equal(t, 6, a.Key.Line) + require.Equal(t, 14, a.Key.EndLine, "block must reach the end of the trailing block scalar") + + // The blank line inside the scalar is part of it; the one after is not. + get := doc.Paths.Value("/a").Get.Origin + require.NotNil(t, get) + require.Equal(t, 14, get.Key.EndLine) + + // The following sibling is unaffected. + b := doc.Paths.Value("/b").Origin + require.NotNil(t, b) + require.Equal(t, 15, b.Key.Line) + require.Equal(t, 19, b.Key.EndLine) +} diff --git a/openapi3/example.go b/openapi3/example.go index f568e8841..42b10c621 100644 --- a/openapi3/example.go +++ b/openapi3/example.go @@ -57,7 +57,9 @@ func (example *Example) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "summary") delete(x.Extensions, "description") delete(x.Extensions, "value") diff --git a/openapi3/external_docs.go b/openapi3/external_docs.go index 862cd7e4b..d6893e44c 100644 --- a/openapi3/external_docs.go +++ b/openapi3/external_docs.go @@ -46,7 +46,9 @@ func (e *ExternalDocs) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "description") delete(x.Extensions, "url") if len(x.Extensions) == 0 { diff --git a/openapi3/fuzz_test.go b/openapi3/fuzz_test.go new file mode 100644 index 000000000..447ef3ce4 --- /dev/null +++ b/openapi3/fuzz_test.go @@ -0,0 +1,248 @@ +package openapi3_test + +import ( + "encoding/json" + "net/url" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/require" + yaml "go.yaml.in/yaml/v3" + + "github.com/getkin/kin-openapi/internal/fuzzing" + "github.com/getkin/kin-openapi/openapi3" +) + +// maxFuzzInput bounds fuzzer inputs so the fuzzer spends its budget on +// conversion logic rather than on allocating huge documents. +const maxFuzzInput = 32 << 10 + +func newFuzzLoader() *openapi3.Loader { + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(*openapi3.Loader, *url.URL) ([]byte, error) { return []byte(`{}`), nil } // deterministic + return loader +} + +// seedFromTestdata adds the YAML-specific seeds and the specs under testdata, +// then those of the APIs-guru corpus when it has been downloaded. +func seedFromTestdata(f *testing.F) { + f.Helper() + for _, seed := range openapi3.YAMLFuzzSeeds { + f.Add([]byte(seed)) + } + for _, seed := range fuzzing.Testdata(f, "testdata") { + f.Add(seed) + } + for _, seed := range fuzzing.APIsGuru(f, "testdata", "openapi:") { + f.Add(seed) + } +} + +// enc(dec) == enc(dec(enc(dec))) +func FuzzLoadFromData(f *testing.F) { + seedFromTestdata(f) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > maxFuzzInput { + t.Skip() + } + + loader := newFuzzLoader() + doc, err := loader.LoadFromData(data) + if err != nil { + return + } + require.NotNil(t, doc) + + // Validation runs over attacker-shaped documents: it may reject them but + // must never panic nor fail to terminate. + valid := doc.Validate(t.Context()) + + once, err := doc.MarshalJSON() + require.NoError(t, err) + + loader = newFuzzLoader() + // The loader must accept its own output + reloaded, err := loader.LoadFromData(once) + require.NoError(t, err, "\nonce: %s\nvalid: %+v\ndata:\n%s", string(once), valid, string(data)) + + twice, err := reloaded.MarshalJSON() + require.NoError(t, err) + + require.JSONEq(t, string(once), string(twice)) + + // Validation should be deterministic + require.Equal(t, valid, reloaded.Validate(t.Context())) + }) +} + +// Asking for origins changes nothing about the document, and every recorded +// position lies inside the input. +func FuzzLoadWithOrigins(f *testing.F) { + seedFromTestdata(f) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > maxFuzzInput { + t.Skip() + } + + plain, plainErr := newFuzzLoader().LoadFromData(data) + + loader := newFuzzLoader() + loader.IncludeOrigin = true + withOrigins, err := loader.LoadFromData(data) + require.Equal(t, plainErr == nil, err == nil, "without origins: %v\nwith origins: %v", plainErr, err) + if err != nil { + return + } + + a, err := plain.MarshalJSON() + require.NoError(t, err) + b, err := withOrigins.MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, string(a), string(b)) + + lines := yamlLines(data) + checkLocation := func(what string, loc openapi3.Location) { + if loc.File != "" || loc.Line == 0 { + return // another document, or not recorded + } + require.GreaterOrEqual(t, loc.Line, 1, what) + require.LessOrEqual(t, loc.Line, lines, what) + require.GreaterOrEqual(t, loc.Column, 1, what) + if loc.EndLine != 0 { + require.GreaterOrEqual(t, loc.EndLine, loc.Line, "%s: %+v", what, loc) + require.LessOrEqual(t, loc.EndLine, lines, "%s: %+v", what, loc) + require.GreaterOrEqual(t, loc.EndColumn, 1, "%s: %+v", what, loc) + } + } + walkOrigins(withOrigins, func(o *openapi3.Origin) { + if o.Key != nil { + checkLocation("key", *o.Key) + } + for _, loc := range o.Fields { + checkLocation("field "+loc.Name, loc) + } + for name, locs := range o.Sequences { + for _, loc := range locs { + checkLocation("sequence "+name, loc) + } + } + }) + }) +} + +// yamlLines counts lines the way the YAML parser numbers them, which breaks +// on CR, LF, CRLF, NEL, LS and PS alike. +func yamlLines(data []byte) int { + s := strings.ReplaceAll(string(data), "\r\n", "\n") + return 1 + strings.Count(s, "\n") + strings.Count(s, "\r") + + strings.Count(s, "\u0085") + strings.Count(s, "\u2028") + strings.Count(s, "\u2029") +} + +var originType = reflect.TypeFor[openapi3.Origin]() + +// walkOrigins calls fn on every *Origin reachable from v, unexported fields +// included since that is where Paths and Responses keep their entries. A +// resolved document is a graph, so each pointer is visited once. +func walkOrigins(v any, fn func(*openapi3.Origin)) { + seen := make(map[uintptr]struct{}) + var walk func(reflect.Value) + walk = func(v reflect.Value) { + switch v.Kind() { + case reflect.Pointer: + if v.IsNil() { + return + } + if _, ok := seen[v.Pointer()]; ok { + return + } + seen[v.Pointer()] = struct{}{} + if v.Type().Elem() == originType { + fn((*openapi3.Origin)(v.UnsafePointer())) + return + } + walk(v.Elem()) + case reflect.Interface: + if !v.IsNil() { + walk(v.Elem()) + } + case reflect.Struct: + for i := range v.NumField() { + walk(v.Field(i)) + } + case reflect.Slice, reflect.Array: + for i := range v.Len() { + walk(v.Index(i)) + } + case reflect.Map: + for it := v.MapRange(); it.Next(); { + walk(it.Value()) + } + } + } + walk(reflect.ValueOf(v)) +} + +// No crash when validating values against schemas +func FuzzSchemaVisitJSON(f *testing.F) { + f.Add([]byte(`{"type":"string","minLength":1,"pattern":"^a+$"}`), []byte(`"aaa"`)) + f.Add([]byte(`{"type":"integer","multipleOf":0.01,"maximum":10}`), []byte(`3`)) + f.Add([]byte(`{"type":"array","items":{"type":"number"},"uniqueItems":true}`), []byte(`[1,2,1]`)) + f.Add([]byte(`{"type":"object","required":["a"],"additionalProperties":false}`), []byte(`{"a":null,"b":1}`)) + f.Add([]byte(`{"allOf":[{"type":"string"},{"maxLength":2}]}`), []byte(`"abc"`)) + f.Add([]byte(`{"oneOf":[{"type":"string"},{"type":"number"}],"nullable":true}`), []byte(`null`)) + f.Add([]byte(`{"type":"string","format":"date-time"}`), []byte(`"not a date"`)) + + f.Fuzz(func(t *testing.T, schemaJSON, valueJSON []byte) { + if len(schemaJSON) > maxFuzzInput || len(valueJSON) > maxFuzzInput { + t.Skip() + } + + var schema openapi3.Schema + if err := schema.UnmarshalJSON(schemaJSON); err != nil { + t.Skip() + } + + var value any + if err := json.Unmarshal(valueJSON, &value); err != nil { + t.Skip() + } + + _ = schema.VisitJSON(value) + _ = schema.VisitJSON(value, openapi3.EnableFormatValidation()) + _ = schema.VisitJSON(value, openapi3.EnableJSONSchema2020()) + }) +} + +// A document written out as YAML by its own MarshalYAML loads back to itself. +func FuzzMarshalYAML(f *testing.F) { + seedFromTestdata(f) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > maxFuzzInput { + t.Skip() + } + doc, err := newFuzzLoader().LoadFromData(data) + if err != nil { + return + } + j, err := doc.MarshalJSON() + require.NoError(t, err) + var generic any + require.NoError(t, json.Unmarshal(j, &generic)) + if fuzzing.LosesInYAML(generic) { + return + } + + y, err := yaml.Marshal(doc) + require.NoError(t, err) + back, err := newFuzzLoader().LoadFromData(y) + require.NoError(t, err, "yaml:\n%s", y) + j2, err := back.MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, string(j), string(j2), "yaml:\n%s", y) + }) +} diff --git a/openapi3/info.go b/openapi3/info.go index 9230d348d..5c79a8d7b 100644 --- a/openapi3/info.go +++ b/openapi3/info.go @@ -65,7 +65,9 @@ func (info *Info) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "title") delete(x.Extensions, "summary") delete(x.Extensions, "description") diff --git a/openapi3/internalize_refs.go b/openapi3/internalize_refs.go index 3dd99aca7..82548f1b9 100644 --- a/openapi3/internalize_refs.go +++ b/openapi3/internalize_refs.go @@ -69,7 +69,7 @@ func DefaultRefNameResolver(doc *T, ref ComponentRef) string { } // Trim the common prefix with the root doc path. - if doc.url != nil { + if doc.url != nil && filePath != "" { for commonDir := path.Dir(doc.url.Path); /*no common prefix*/ commonDir != "."; commonDir = path.Dir(commonDir) { if p, found := cutDirectories(filePath, commonDir); found { filePath = p @@ -97,6 +97,11 @@ func DefaultRefNameResolver(doc *T, ref ComponentRef) string { // Replace invalid characters in component fixed field names. internalizedName = InvalidIdentifierCharRegExp.ReplaceAllString(internalizedName, "_") + // Nothing is left of a ref to the directory itself, such as "/" or "./". + if internalizedName == "" { + internalizedName = ref.CollectionName() + } + return internalizedName } @@ -125,12 +130,16 @@ func cutDirectories(p, dirs string) (string, bool) { return p, false } +// isExternalRef reports whether ref has to be moved into the root document. +// The add*ToSpec functions also leave alone any ref without a RefPath: the +// loader did not follow it (its target was empty), so there is nothing to +// move and the resolver could not name it. func isExternalRef(ref string, parentIsExternal bool) bool { return ref != "" && (!strings.HasPrefix(ref, "#/components/") || parentIsExternal) } func (doc *T) addSchemaToSpec(s *SchemaRef, refNameResolver RefNameResolver, parentIsExternal bool) bool { - if s == nil || !isExternalRef(s.Ref, parentIsExternal) { + if s == nil || !isExternalRef(s.Ref, parentIsExternal) || s.RefPath() == nil { return false } @@ -148,13 +157,19 @@ func (doc *T) addSchemaToSpec(s *SchemaRef, refNameResolver RefNameResolver, par if doc.Components.Schemas == nil { doc.Components.Schemas = make(Schemas) } - doc.Components.Schemas[name] = s.Value.NewRef() + // A $ref with siblings has a copy of its target as Value, with the siblings + // applied: the component is the target itself. + value := s.Value + if s.target != nil { + value = s.target + } + doc.Components.Schemas[name] = value.NewRef() s.Ref = "#/components/schemas/" + name return true } func (doc *T) addParameterToSpec(p *ParameterRef, refNameResolver RefNameResolver, parentIsExternal bool) bool { - if p == nil || !isExternalRef(p.Ref, parentIsExternal) { + if p == nil || !isExternalRef(p.Ref, parentIsExternal) || p.RefPath() == nil { return false } name := refNameResolver(doc, p) @@ -177,7 +192,7 @@ func (doc *T) addParameterToSpec(p *ParameterRef, refNameResolver RefNameResolve } func (doc *T) addHeaderToSpec(h *HeaderRef, refNameResolver RefNameResolver, parentIsExternal bool) bool { - if h == nil || !isExternalRef(h.Ref, parentIsExternal) { + if h == nil || !isExternalRef(h.Ref, parentIsExternal) || h.RefPath() == nil { return false } name := refNameResolver(doc, h) @@ -200,7 +215,7 @@ func (doc *T) addHeaderToSpec(h *HeaderRef, refNameResolver RefNameResolver, par } func (doc *T) addRequestBodyToSpec(r *RequestBodyRef, refNameResolver RefNameResolver, parentIsExternal bool) bool { - if r == nil || !isExternalRef(r.Ref, parentIsExternal) { + if r == nil || !isExternalRef(r.Ref, parentIsExternal) || r.RefPath() == nil { return false } name := refNameResolver(doc, r) @@ -223,7 +238,7 @@ func (doc *T) addRequestBodyToSpec(r *RequestBodyRef, refNameResolver RefNameRes } func (doc *T) addResponseToSpec(r *ResponseRef, refNameResolver RefNameResolver, parentIsExternal bool) bool { - if r == nil || !isExternalRef(r.Ref, parentIsExternal) { + if r == nil || !isExternalRef(r.Ref, parentIsExternal) || r.RefPath() == nil { return false } name := refNameResolver(doc, r) @@ -246,7 +261,7 @@ func (doc *T) addResponseToSpec(r *ResponseRef, refNameResolver RefNameResolver, } func (doc *T) addSecuritySchemeToSpec(ss *SecuritySchemeRef, refNameResolver RefNameResolver, parentIsExternal bool) { - if ss == nil || !isExternalRef(ss.Ref, parentIsExternal) { + if ss == nil || !isExternalRef(ss.Ref, parentIsExternal) || ss.RefPath() == nil { return } name := refNameResolver(doc, ss) @@ -269,7 +284,7 @@ func (doc *T) addSecuritySchemeToSpec(ss *SecuritySchemeRef, refNameResolver Ref } func (doc *T) addExampleToSpec(e *ExampleRef, refNameResolver RefNameResolver, parentIsExternal bool) { - if e == nil || !isExternalRef(e.Ref, parentIsExternal) { + if e == nil || !isExternalRef(e.Ref, parentIsExternal) || e.RefPath() == nil { return } name := refNameResolver(doc, e) @@ -292,7 +307,7 @@ func (doc *T) addExampleToSpec(e *ExampleRef, refNameResolver RefNameResolver, p } func (doc *T) addLinkToSpec(l *LinkRef, refNameResolver RefNameResolver, parentIsExternal bool) { - if l == nil || !isExternalRef(l.Ref, parentIsExternal) { + if l == nil || !isExternalRef(l.Ref, parentIsExternal) || l.RefPath() == nil { return } name := refNameResolver(doc, l) @@ -315,7 +330,7 @@ func (doc *T) addLinkToSpec(l *LinkRef, refNameResolver RefNameResolver, parentI } func (doc *T) addCallbackToSpec(c *CallbackRef, refNameResolver RefNameResolver, parentIsExternal bool) bool { - if c == nil || !isExternalRef(c.Ref, parentIsExternal) { + if c == nil || !isExternalRef(c.Ref, parentIsExternal) || c.RefPath() == nil { return false } name := refNameResolver(doc, c) @@ -383,7 +398,7 @@ func (doc *T) derefHeaders(hs Headers, refNameResolver RefNameResolver, parentIs for _, name := range componentNames(hs) { h := hs[name] isExternal := doc.addHeaderToSpec(h, refNameResolver, parentIsExternal) - if doc.isVisitedHeader(h.Value) { + if h == nil || h.Value == nil || doc.isVisitedHeader(h.Value) { continue } doc.derefParameter(h.Value.Parameter, refNameResolver, parentIsExternal || isExternal) @@ -400,6 +415,9 @@ func (doc *T) derefExamples(es Examples, refNameResolver RefNameResolver, parent func (doc *T) derefContent(c Content, refNameResolver RefNameResolver, parentIsExternal bool) { for _, name := range componentNames(c) { mediatype := c[name] + if mediatype == nil { + continue + } isExternal := doc.addSchemaToSpec(mediatype.Schema, refNameResolver, parentIsExternal) if mediatype.Schema != nil { doc.derefSchema(mediatype.Schema.Value, refNameResolver, isExternal || parentIsExternal) @@ -407,6 +425,9 @@ func (doc *T) derefContent(c Content, refNameResolver RefNameResolver, parentIsE doc.derefExamples(mediatype.Examples, refNameResolver, parentIsExternal) for _, name := range componentNames(mediatype.Encoding) { e := mediatype.Encoding[name] + if e == nil { + continue + } doc.derefHeaders(e.Headers, refNameResolver, parentIsExternal) } } @@ -420,6 +441,9 @@ func (doc *T) derefLinks(ls Links, refNameResolver RefNameResolver, parentIsExte } func (doc *T) derefResponse(r *ResponseRef, refNameResolver RefNameResolver, parentIsExternal bool) { + if r == nil { + return + } isExternal := doc.addResponseToSpec(r, refNameResolver, parentIsExternal) if v := r.Value; v != nil { doc.derefHeaders(v.Headers, refNameResolver, isExternal || parentIsExternal) @@ -454,6 +478,9 @@ func (doc *T) derefRequestBody(r RequestBody, refNameResolver RefNameResolver, p func (doc *T) derefPaths(paths map[string]*PathItem, refNameResolver RefNameResolver, parentIsExternal bool) { for _, name := range componentNames(paths) { ops := paths[name] + if ops == nil { + continue + } pathIsExternal := isExternalRef(ops.Ref, parentIsExternal) // inline full operations ops.Ref = "" @@ -513,7 +540,7 @@ func (doc *T) InternalizeRefs(ctx context.Context, refNameResolver func(*T, Comp for _, name := range componentNames(components.Schemas) { schema := components.Schemas[name] isExternal := doc.addSchemaToSpec(schema, refNameResolver, false) - if schema != nil { + if schema != nil && schema.Value != nil { schema.Ref = "" // always dereference the top level doc.derefSchema(schema.Value, refNameResolver, isExternal) } @@ -527,6 +554,11 @@ func (doc *T) InternalizeRefs(ctx context.Context, refNameResolver func(*T, Comp } } doc.derefHeaders(components.Headers, refNameResolver, false) + for _, name := range componentNames(components.Headers) { + if h := components.Headers[name]; h != nil && h.Value != nil { + h.Ref = "" // always dereference the top level + } + } for _, name := range componentNames(components.RequestBodies) { req := components.RequestBodies[name] isExternal := doc.addRequestBodyToSpec(req, refNameResolver, false) @@ -536,12 +568,30 @@ func (doc *T) InternalizeRefs(ctx context.Context, refNameResolver func(*T, Comp } } doc.derefResponseBodies(components.Responses, refNameResolver, false) + for _, name := range componentNames(components.Responses) { + if r := components.Responses[name]; r != nil && r.Value != nil { + r.Ref = "" // always dereference the top level + } + } for _, name := range componentNames(components.SecuritySchemes) { ss := components.SecuritySchemes[name] doc.addSecuritySchemeToSpec(ss, refNameResolver, false) + if ss != nil && ss.Value != nil { + ss.Ref = "" // always dereference the top level + } } doc.derefExamples(components.Examples, refNameResolver, false) + for _, name := range componentNames(components.Examples) { + if e := components.Examples[name]; e != nil && e.Value != nil { + e.Ref = "" // always dereference the top level + } + } doc.derefLinks(components.Links, refNameResolver, false) + for _, name := range componentNames(components.Links) { + if l := components.Links[name]; l != nil && l.Value != nil { + l.Ref = "" // always dereference the top level + } + } for _, name := range componentNames(components.Callbacks) { cb := components.Callbacks[name] @@ -555,4 +605,5 @@ func (doc *T) InternalizeRefs(ctx context.Context, refNameResolver func(*T, Comp } doc.derefPaths(doc.Paths.Map(), refNameResolver, false) + doc.derefPaths(doc.Webhooks, refNameResolver, false) } diff --git a/openapi3/internalize_refs_fuzz_test.go b/openapi3/internalize_refs_fuzz_test.go new file mode 100644 index 000000000..a73626327 --- /dev/null +++ b/openapi3/internalize_refs_fuzz_test.go @@ -0,0 +1,262 @@ +package openapi3_test + +import ( + "encoding/json" + "net/url" + "os" + "path" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/internal/fuzzing" + "github.com/getkin/kin-openapi/openapi3" +) + +// fuzzRootURL is where the root document of FuzzInternalizeRefs lives. A ref +// naming its file reads the root again, any other file reads the second input. +var fuzzRootURL = &url.URL{Path: "/spec/root.yaml"} + +func newTwoDocLoader(root, other []byte) *openapi3.Loader { + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(_ *openapi3.Loader, location *url.URL) ([]byte, error) { + if path.Base(location.Path) == path.Base(fuzzRootURL.Path) { + return root, nil + } + return other, nil + } + return loader +} + +// internalizeSeeds pairs each root of TestInternalizeRefs with a document its +// external refs point to. +var internalizeSeeds = [][2]string{ + {"testdata/testref.openapi.yml", "testdata/components.openapi.yml"}, + {"testdata/recursiveRef/openapi.yml", "testdata/recursiveRef/components/Foo.yml"}, + {"testdata/interalizationNameCollision/api.yml", "testdata/interalizationNameCollision/schemas/book/record.yml"}, + {"testdata/issue1205/openapi.yml", "testdata/issue1205/ext.yml"}, + {"testdata/issue1205/mutual.yml", "testdata/issue1205/mutual-a.yml"}, + {"testdata/issue1205/mutual.yml", "testdata/issue1205/mutual-b.yml"}, +} + +// Once internalized, a document refers to nothing outside itself, loads +// without external refs allowed, and internalizing it again changes nothing. +// A valid document stays valid. +func FuzzInternalizeRefs(f *testing.F) { + for _, pair := range internalizeSeeds { + root, err := os.ReadFile(pair[0]) + require.NoError(f, err) + other, err := os.ReadFile(pair[1]) + require.NoError(f, err) + f.Add(root, other) + } + // Any document whose refs, whatever file they name, read itself back. + for _, seed := range fuzzing.Testdata(f, "testdata") { + f.Add(seed, seed) + } + f.Add( + []byte(`{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/a":{"$ref":"other.yaml#/paths/~1b"}}}`), + []byte(`{"paths":{"/b":{"get":{"responses":{"200":{"$ref":"#/components/responses/R"}}}}},"components":{"responses":{"R":{"description":"d","content":{"application/json":{"schema":{"$ref":"#/components/schemas/S"}}}}},"schemas":{"S":{"type":"string"}}}}`), + ) + f.Add( + []byte(`{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{},"components":{"schemas":{"A":{"$ref":"x.json#/components/schemas/A"},"x_A":{"type":"integer"}}}}`), + []byte(`{"components":{"schemas":{"A":{"type":"object","properties":{"self":{"$ref":"#/components/schemas/A"},"root":{"$ref":"root.yaml#/components/schemas/x_A"}}}}}}`), + ) + + f.Fuzz(func(t *testing.T, root, other []byte) { + if len(root)+len(other) > maxFuzzInput { + t.Skip() + } + + doc, err := newTwoDocLoader(root, other).LoadFromDataWithPath(root, fuzzRootURL) + if err != nil { + return + } + valid := doc.Validate(t.Context()) + + doc.InternalizeRefs(t.Context(), nil) + + unfollowed := false + forEachRef(doc, func(site refSite) { + if site.Mapping && !strings.Contains(site.Ref, "/") { + return // a plain schema name, not a reference + } + if site.Ref == "" || strings.HasPrefix(site.Ref, "#") { + return + } + // The loader did not follow it, so there is nothing to internalize. + require.True(t, site.Unfollowed, "external ref left: %q", site.Ref) + unfollowed = true + }) + + once, err := doc.MarshalJSON() + require.NoError(t, err) + + strict := openapi3.NewLoader() + reloaded, err := strict.LoadFromData(once) + if unfollowed { + return // still refers elsewhere, which strict refuses + } + require.NoError(t, err, "\nonce: %s\nroot:\n%s\nother:\n%s", once, root, other) + + if valid == nil { + require.NoError(t, reloaded.Validate(t.Context()), "\nonce: %s", once) + } + + reloaded.InternalizeRefs(t.Context(), nil) + twice, err := reloaded.MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, string(once), string(twice)) + }) +} + +var mappingRefType = reflect.TypeFor[openapi3.MappingRef]() + +// refSite is the Ref field of a reference object, path item or discriminator +// mapping. +type refSite struct { + Ref string + // Mapping tells a discriminator mapping value, which may be a plain name. + Mapping bool + // Unfollowed tells a ref the loader left without a target. + Unfollowed bool +} + +// forEachRef calls fn on every refSite reachable from v. A resolved document +// is a graph, so each pointer is visited once. Values typed any, such as +// examples and extensions, are not entered: a "$ref" key there is data. +func forEachRef(v any, fn func(refSite)) { + seen := make(map[uintptr]struct{}) + var walk func(reflect.Value) + walk = func(v reflect.Value) { + switch v.Kind() { + case reflect.Pointer: + if v.IsNil() { + return + } + if _, ok := seen[v.Pointer()]; ok { + return + } + seen[v.Pointer()] = struct{}{} + walk(v.Elem()) + case reflect.Struct: + if f, ok := v.Type().FieldByName("Ref"); ok && f.Type.Kind() == reflect.String && len(f.Index) == 1 { + site := refSite{Ref: v.FieldByIndex(f.Index).String(), Mapping: v.Type() == mappingRefType} + if p := v.FieldByName("refPath"); p.IsValid() { + site.Unfollowed = site.Ref != "" && p.IsNil() + } + fn(site) + } + for i := range v.NumField() { + walk(v.Field(i)) + } + case reflect.Slice, reflect.Array: + for i := range v.Len() { + walk(v.Index(i)) + } + case reflect.Map: + for it := v.MapRange(); it.Next(); { + walk(it.Value()) + } + } + } + walk(reflect.ValueOf(v)) +} + +// Internalizing changes where schemas live, not what they mean: each schema +// of the root's components accepts the same values before and after. +func FuzzInternalizeRefsKeepsMeaning(f *testing.F) { + for _, pair := range internalizeSeeds { + root, err := os.ReadFile(pair[0]) + require.NoError(f, err) + other, err := os.ReadFile(pair[1]) + require.NoError(f, err) + for _, value := range []string{`{}`, `null`, `"s"`, `1`, `[{"cat":{}}]`, `{"foo":{"bar":"x"},"cat":{"cat":1}}`} { + f.Add(root, other, []byte(value)) + } + } + f.Add( + []byte(`{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{},"components":{"schemas":{"A":{"type":"object","properties":{"b":{"$ref":"other.yaml#/components/schemas/B"}}}}}}`), + []byte(`{"components":{"schemas":{"B":{"type":"array","items":{"$ref":"#/components/schemas/C"}},"C":{"type":"integer","maximum":3}}}}`), + []byte(`{"b":[1,2,4]}`), + ) + + f.Fuzz(func(t *testing.T, root, other, value []byte) { + if len(root)+len(other)+len(value) > maxFuzzInput { + t.Skip() + } + var v any + if json.Unmarshal(value, &v) != nil { + t.Skip() + } + + doc, err := newTwoDocLoader(root, other).LoadFromDataWithPath(root, fuzzRootURL) + if err != nil || doc.Components == nil { + return + } + if doc.Validate(t.Context()) != nil { + return + } + before := make(map[string]bool, len(doc.Components.Schemas)) + for name, schema := range doc.Components.Schemas { + if schema != nil && schema.Value != nil { + before[name] = schema.Value.VisitJSON(v) == nil + } + } + + doc.InternalizeRefs(t.Context(), nil) + once, err := doc.MarshalJSON() + require.NoError(t, err) + loader := openapi3.NewLoader() + reloaded, err := loader.LoadFromData(once) + if err != nil { + return // FuzzInternalizeRefs covers this + } + for name, accepted := range before { + schema := reloaded.Components.Schemas[name] + require.NotNil(t, schema, name) + require.NotNil(t, schema.Value, name) + require.Equal(t, accepted, schema.Value.VisitJSON(v) == nil, "schema %q, value %s\nonce: %s", name, value, once) + } + }) +} + +// Whatever names a resolver hands out, colliding, empty or invalid ones +// included, internalizing does not panic and leaves only local refs behind. +func FuzzInternalizeRefsCustomResolver(f *testing.F) { + for _, pair := range internalizeSeeds { + root, err := os.ReadFile(pair[0]) + require.NoError(f, err) + other, err := os.ReadFile(pair[1]) + require.NoError(f, err) + f.Add(root, other, "a,a,b,,#/x,~1") + } + + f.Fuzz(func(t *testing.T, root, other []byte, names string) { + if len(root)+len(other)+len(names) > maxFuzzInput { + t.Skip() + } + doc, err := newTwoDocLoader(root, other).LoadFromDataWithPath(root, fuzzRootURL) + if err != nil { + return + } + given := strings.Split(names, ",") + calls := 0 + doc.InternalizeRefs(t.Context(), func(*openapi3.T, openapi3.ComponentRef) string { + name := given[calls%len(given)] + calls++ + return name + }) + forEachRef(doc, func(site refSite) { + if site.Mapping && !strings.Contains(site.Ref, "/") || site.Unfollowed { + return + } + require.True(t, site.Ref == "" || strings.HasPrefix(site.Ref, "#"), "external ref left: %q", site.Ref) + }) + _, err = doc.MarshalJSON() + require.NoError(t, err) + }) +} diff --git a/openapi3/issue1271_test.go b/openapi3/issue1271_test.go new file mode 100644 index 000000000..d5b700975 --- /dev/null +++ b/openapi3/issue1271_test.go @@ -0,0 +1,744 @@ +package openapi3_test + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +// These cases come from https://github.com/getkin/kin-openapi/pull/1271. +// There, the keywords next to a 3.1 schema $ref become the Value and the +// referenced schema an allOf entry of it. Here Value keeps the shape of the +// referenced schema, so the cases check what a schema accepts and how it is +// written back out rather than where each keyword ends up. +// +// The skipped cases are $refs that only lead to one another, which leaves +// their Value nil, with or without siblings. JSON Schema 2020-12 leaves such +// infinite recursion undefined (§9.4.1); #1271 resolves them to the +// conjunction of their siblings. + +func TestSchemaRefSiblingIsSerializedAndAppliedAsConjunct(t *testing.T) { + t.Parallel() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(` +openapi: 3.1.0 +info: + title: ref sibling + version: "1" +paths: {} +components: + schemas: + Base: + type: string + maxLength: 5 + Use: + $ref: '#/components/schemas/Base' + minLength: 3 + x-use-site: true +`)) + require.NoError(t, err) + + useRef := doc.Components.Schemas["Use"] + require.Equal(t, "string", useRef.Value.Type.Slice()[0]) + require.Equal(t, uint64(5), *useRef.Value.MaxLength) + require.NotContains(t, doc.Components.Schemas["Base"].Value.Extensions, "x-use-site") + require.Error(t, useRef.Value.VisitJSON("xx", openapi3.EnableJSONSchema2020())) + require.Error(t, useRef.Value.VisitJSON("xxxxxx", openapi3.EnableJSONSchema2020())) + require.NoError(t, useRef.Value.VisitJSON("xxxx", openapi3.EnableJSONSchema2020())) + + data, err := json.Marshal(useRef) + require.NoError(t, err) + require.JSONEq(t, `{ + "$ref":"#/components/schemas/Base", + "minLength":3, + "x-use-site":true +}`, string(data)) + documentData, err := json.Marshal(doc) + require.NoError(t, err) + roundTripLoader := openapi3.NewLoader() + roundTripped, err := roundTripLoader.LoadFromData(documentData) + require.NoError(t, err) + roundTrippedUse := roundTripped.Components.Schemas["Use"].Value + require.Error(t, roundTrippedUse.VisitJSON("xx", openapi3.EnableJSONSchema2020())) + require.Error(t, roundTrippedUse.VisitJSON("xxxxxx", openapi3.EnableJSONSchema2020())) + require.NoError(t, roundTrippedUse.VisitJSON("xxxx", openapi3.EnableJSONSchema2020())) + + useRef.Extensions["x-use-site"] = "changed" + useRef.Extensions["x-added"] = true + data, err = json.Marshal(useRef) + require.NoError(t, err) + require.JSONEq(t, `{ + "$ref":"#/components/schemas/Base", + "minLength":3, + "x-use-site":"changed", + "x-added":true +}`, string(data)) + delete(useRef.Extensions, "x-use-site") + data, err = json.Marshal(useRef) + require.NoError(t, err) + require.NotContains(t, string(data), "x-use-site") + + value := useRef.Value + allOf := len(value.AllOf) + for range 3 { + require.NoError(t, loader.ResolveRefsIn(doc, nil)) + require.Same(t, value, useRef.Value) + require.Len(t, useRef.Value.AllOf, allOf) + } +} + +func TestSchemaRefSiblingRoundTripsAsWritten(t *testing.T) { + t.Parallel() + data := []byte(`{ + "$ref":"#/components/schemas/Base", + "pattern":"", + "minLength":0, + "uniqueItems":false, + "required":[], + "properties":{}, + "default":null, + "maximum":0, + "x-use-site":true +}`) + var ref openapi3.SchemaRef + require.NoError(t, json.Unmarshal(data, &ref)) + + // Unlike #1271, which writes them out as an ordinary Schema would, dropping + // the zero values, the fields next to the $ref come back as written. + out, err := json.Marshal(ref) + require.NoError(t, err) + require.JSONEq(t, string(data), string(out)) +} + +func TestSchemaRefSiblingPreservesBothDynamicRefs(t *testing.T) { + t.Parallel() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(` +openapi: 3.1.0 +info: + title: dynamic ref conjunction + version: "1" +paths: {} +components: + schemas: + Base: + $dynamicRef: '#base' + Use: + $ref: '#/components/schemas/Base' + $dynamicRef: '#use' +`)) + require.NoError(t, err) + + use := doc.Components.Schemas["Use"].Value + require.Equal(t, "#base", use.DynamicRef) + require.Len(t, use.AllOf, 1) + require.Equal(t, "#use", use.AllOf[0].Value.DynamicRef) + require.Equal(t, "#base", doc.Components.Schemas["Base"].Value.DynamicRef) +} + +func TestSchemaRefSiblingOnSelfReference(t *testing.T) { + t.Parallel() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(` +openapi: 3.1.0 +info: + title: recursive ref sibling + version: "1" +paths: {} +components: + schemas: + Node: + type: object + required: [name] + properties: + name: + type: string + parent: + $ref: '#/components/schemas/Node' + minProperties: 2 +`)) + require.NoError(t, err) + + node := doc.Components.Schemas["Node"].Value + parent := node.Properties["parent"] + require.Error(t, parent.Value.VisitJSON(map[string]any{"name": "child"}, openapi3.EnableJSONSchema2020())) + require.NoError(t, parent.Value.VisitJSON(map[string]any{"name": "child", "extra": true}, openapi3.EnableJSONSchema2020())) + + invalidRecursiveValue := map[string]any{ + "name": "child", + "parent": map[string]any{ + "first": true, + "second": true, + }, + } + validRecursiveValue := map[string]any{ + "name": "child", + "parent": map[string]any{ + "name": "parent", + "extra": true, + }, + } + for _, opts := range [][]openapi3.SchemaValidationOption{ + nil, + {openapi3.EnableJSONSchema2020()}, + } { + require.Error(t, node.VisitJSON(invalidRecursiveValue, opts...), "the recursive target must still require name") + require.NoError(t, node.VisitJSON(validRecursiveValue, opts...)) + } + + data, err := json.Marshal(parent) + require.NoError(t, err) + require.JSONEq(t, `{ + "$ref":"#/components/schemas/Node", + "minProperties":2 +}`, string(data)) +} + +func TestSchemaRefSiblingOnSelfReferenceOutsideComponents(t *testing.T) { + t.Skip("TODO: give a value to the schemas of a cycle of $refs that never reaches a schema") + t.Parallel() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(` +openapi: 3.1.0 +info: + title: path-local recursive ref sibling + version: "1" +paths: + /x: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/paths/~1x/post/requestBody/content/application~1json/schema' + minProperties: 1 + responses: + "200": + description: ok +`)) + require.NoError(t, err) + + schemaRef := doc.Paths.Value("/x").Post.RequestBody.Value.Content["application/json"].Schema + require.NotNil(t, schemaRef.Value) + require.Error(t, schemaRef.Value.VisitJSON(map[string]any{})) + require.NoError(t, schemaRef.Value.VisitJSON(map[string]any{"value": true})) + + data, err := json.Marshal(schemaRef) + require.NoError(t, err) + require.JSONEq(t, `{ + "$ref":"#/paths/~1x/post/requestBody/content/application~1json/schema", + "minProperties":1 +}`, string(data)) +} + +func TestSchemaRefSiblingsOnLongerCycle(t *testing.T) { + t.Skip("TODO: give a value to the schemas of a cycle of $refs that never reaches a schema") + t.Parallel() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(` +openapi: 3.1.0 +info: + title: ref-only cycle with siblings + version: "1" +paths: {} +components: + schemas: + A: + $ref: '#/components/schemas/B' + minLength: 2 + B: + $ref: '#/components/schemas/A' + maxLength: 4 +`)) + require.NoError(t, err) + + a := doc.Components.Schemas["A"].Value + b := doc.Components.Schemas["B"].Value + require.NotNil(t, a) + require.NotNil(t, b) + validationErr := a.VisitJSON("x", openapi3.EnableJSONSchema2020()) + require.Error(t, validationErr) + getErr := func() { _ = validationErr.Error() } + require.NotPanics(t, getErr, "cyclic effective schemas must remain serializable in errors") + require.Error(t, a.VisitJSON("12345", openapi3.EnableJSONSchema2020())) + require.Error(t, b.VisitJSON("x", openapi3.EnableJSONSchema2020())) + require.Error(t, b.VisitJSON("12345", openapi3.EnableJSONSchema2020())) + require.NoError(t, a.VisitJSON("1234", openapi3.EnableJSONSchema2020())) + require.NoError(t, b.VisitJSON("1234", openapi3.EnableJSONSchema2020())) + + aValue, bValue := a, b + for range 3 { + require.NoError(t, loader.ResolveRefsIn(doc, nil)) + require.Same(t, aValue, doc.Components.Schemas["A"].Value) + require.Same(t, bValue, doc.Components.Schemas["B"].Value) + } + for name, expected := range map[string]string{ + "A": `{"$ref":"#/components/schemas/B","minLength":2}`, + "B": `{"$ref":"#/components/schemas/A","maxLength":4}`, + } { + data, err := json.Marshal(doc.Components.Schemas[name]) + require.NoError(t, err) + require.JSONEq(t, expected, string(data)) + } +} + +func TestSchemaRefSiblingAnchorsPartiallyAnnotatedAliasCycle(t *testing.T) { + t.Skip("TODO: give a value to the schemas of a cycle of $refs that never reaches a schema") + t.Parallel() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(` +openapi: 3.1.0 +info: + title: partially annotated alias cycle + version: "1" +paths: {} +components: + schemas: + A: + $ref: '#/components/schemas/B' + minLength: 2 + B: + $ref: '#/components/schemas/A' +`)) + require.NoError(t, err) + + for _, name := range []string{"A", "B"} { + value := doc.Components.Schemas[name].Value + require.NotNil(t, value) + require.Error(t, value.VisitJSON("x", openapi3.EnableJSONSchema2020())) + require.NoError(t, value.VisitJSON("xx", openapi3.EnableJSONSchema2020())) + } +} + +func TestSchemaRefSiblingsAreIgnoredOnOpenAPI30AliasCycle(t *testing.T) { + t.Skip("TODO: give a value to the schemas of a cycle of $refs that never reaches a schema") + t.Parallel() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(` +openapi: 3.0.3 +info: + title: ref-only cycle with ignored siblings + version: "1" +paths: {} +components: + schemas: + A: + $ref: '#/components/schemas/B' + minLength: 2 + B: + $ref: '#/components/schemas/A' + maxLength: 4 +`)) + require.NoError(t, err) + + for _, name := range []string{"A", "B"} { + ref := doc.Components.Schemas[name] + require.NotNil(t, ref.Value) + require.Zero(t, ref.Value.MinLength) + require.Nil(t, ref.Value.MaxLength) + require.NoError(t, ref.Value.VisitJSON("x")) + require.NoError(t, ref.Value.VisitJSON("12345")) + } + + a, err := json.Marshal(doc.Components.Schemas["A"]) + require.NoError(t, err) + require.JSONEq(t, `{"$ref":"#/components/schemas/B","minLength":2}`, string(a)) + b, err := json.Marshal(doc.Components.Schemas["B"]) + require.NoError(t, err) + require.JSONEq(t, `{"$ref":"#/components/schemas/A","maxLength":4}`, string(b)) +} + +func TestSchemaRefSiblingErrorRetainsUseSiteOrigin(t *testing.T) { + t.Parallel() + loader := openapi3.NewLoader() + loader.IncludeOrigin = true + doc, err := loader.LoadFromData([]byte(` +openapi: 3.1.0 +info: + title: sibling origin + version: "1" +paths: {} +components: + schemas: + Base: + type: string + Use: + $ref: '#/components/schemas/Base' + pattern: '(?invalid' +`)) + require.NoError(t, err) + + use := doc.Components.Schemas["Use"] + validationErr := use.Value.VisitJSON("value", openapi3.EnableJSONSchema2020()) + var patternErr *openapi3.SchemaPatternRegexError + require.True(t, errors.As(validationErr, &patternErr)) + require.NotNil(t, patternErr.Origin) + require.NotNil(t, patternErr.Origin.Key) + require.Equal(t, 11, patternErr.Origin.Key.Line) + require.Equal(t, 7, patternErr.Origin.Fields.Get("pattern").Column) +} + +func TestSchemaRefSiblingStructuralCycleKeepsAliasTarget(t *testing.T) { + t.Parallel() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(` +openapi: 3.1.0 +info: + title: structural sibling cycle + version: "1" +paths: {} +components: + schemas: + A: + $ref: '#/components/schemas/B' + properties: + child: + $ref: '#/components/schemas/A' + B: + type: object + required: [name] + properties: + name: + type: string +`)) + require.NoError(t, err) + + a := doc.Components.Schemas["A"].Value + require.Error(t, a.VisitJSON(map[string]any{ + "name": "root", + "child": map[string]any{"other": true}, + })) + require.NoError(t, a.VisitJSON(map[string]any{ + "name": "root", + "child": map[string]any{"name": "nested"}, + })) +} + +func TestSchemaRefSiblingInRawExternalSchemaUsesRootDialect(t *testing.T) { + t.Parallel() + dir := t.TempDir() + rootPath := filepath.Join(dir, "openapi.yaml") + externalPath := filepath.Join(dir, "schemas.yaml") + + require.NoError(t, os.WriteFile(rootPath, []byte(` +openapi: 3.1.0 +info: + title: raw external schema sibling + version: "1" +paths: {} +components: + schemas: + Use: + $ref: 'schemas.yaml#/$defs/Alias' +`), 0o600)) + require.NoError(t, os.WriteFile(externalPath, []byte(` +$defs: + Base: + type: string + Alias: + $ref: '#/$defs/Base' + minLength: 3 +`), 0o600)) + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile(rootPath) + require.NoError(t, err) + + use := doc.Components.Schemas["Use"].Value + require.Error(t, use.VisitJSON("xx", openapi3.EnableJSONSchema2020())) + require.NoError(t, use.VisitJSON("xxx", openapi3.EnableJSONSchema2020())) +} + +func TestSchemaRefSiblingDoesNotMutateSharedTarget(t *testing.T) { + t.Parallel() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(` +openapi: 3.1.0 +info: + title: independent ref siblings + version: "1" +paths: {} +components: + schemas: + Base: + type: string + maxLength: 5 + Short: + $ref: '#/components/schemas/Base' + maxLength: 3 + Long: + $ref: '#/components/schemas/Base' + minLength: 4 +`)) + require.NoError(t, err) + + base := doc.Components.Schemas["Base"].Value + short := doc.Components.Schemas["Short"].Value + long := doc.Components.Schemas["Long"].Value + require.Equal(t, uint64(5), *base.MaxLength) + require.Zero(t, base.MinLength) + require.Empty(t, base.AllOf) + require.NotSame(t, base, short) + require.NotSame(t, base, long) + require.NoError(t, base.VisitJSON("xxxxx", openapi3.EnableJSONSchema2020())) + require.Error(t, short.VisitJSON("xxxx", openapi3.EnableJSONSchema2020())) + require.Error(t, long.VisitJSON("xxx", openapi3.EnableJSONSchema2020())) + require.NoError(t, long.VisitJSON("xxxx", openapi3.EnableJSONSchema2020())) +} + +func TestSchemaRefSiblingSerializationIsIndependentOfOASVersion(t *testing.T) { + t.Parallel() + const spec = ` +openapi: %s +info: + title: versioned sibling semantics + version: "1" +paths: {} +components: + schemas: + Base: + type: string + Use: + $ref: '#/components/schemas/Base' + minLength: 3 +` + + for _, version := range []string{"3.0.3", "3.1.0"} { + t.Run(version, func(t *testing.T) { + t.Parallel() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData(fmt.Appendf(nil, spec, version)) + require.NoError(t, err) + use := doc.Components.Schemas["Use"] + data, err := json.Marshal(use) + require.NoError(t, err) + require.JSONEq(t, `{ + "$ref":"#/components/schemas/Base", + "minLength":3 +}`, string(data)) + if version == "3.0.3" { + require.NoError(t, use.Value.VisitJSON("xx")) + } else { + require.Error(t, use.Value.VisitJSON("xx")) + } + }) + } +} + +func TestMalformedSchemaRefSiblingUsesRootDialect(t *testing.T) { + t.Parallel() + const spec = ` +openapi: %s +info: + title: malformed sibling + version: "1" +paths: {} +components: + schemas: + Base: + type: string + Use: + $ref: '#/components/schemas/Base' + allOf: + type: integer +` + + loader30 := openapi3.NewLoader() + doc, err := loader30.LoadFromData(fmt.Appendf(nil, spec, "3.0.3")) + require.NoError(t, err, "OpenAPI 3.0 ignores malformed fields next to $ref") + require.Equal(t, "string", doc.Components.Schemas["Use"].Value.Type.Slice()[0]) + + loader31 := openapi3.NewLoader() + _, err = loader31.LoadFromData(fmt.Appendf(nil, spec, "3.1.0")) + require.Error(t, err, "OpenAPI 3.1 treats the adjacent fields as a Schema Object") +} + +func TestInternalizeRefsUpdatesSchemaRefSiblingFields(t *testing.T) { + t.Parallel() + dir := t.TempDir() + rootPath := filepath.Join(dir, "root.yaml") + externalPath := filepath.Join(dir, "external.yaml") + + require.NoError(t, os.WriteFile(rootPath, []byte(` +openapi: 3.1.0 +info: + title: Internalized sibling refs + version: "1.0" +paths: {} +components: + schemas: + Root: + type: object + properties: + use: + $ref: external.yaml#/Base + items: + $ref: external.yaml#/Item +`), 0o600)) + require.NoError(t, os.WriteFile(externalPath, []byte(` +Base: + type: array +Item: + type: string +`), 0o600)) + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile(rootPath) + require.NoError(t, err) + + doc.InternalizeRefs(t.Context(), nil) + data, err := doc.MarshalJSON() + require.NoError(t, err) + require.NotContains(t, string(data), "external.yaml") + + use := doc.Components.Schemas["Root"].Value.Properties["use"] + require.True(t, strings.HasPrefix(use.Ref, "#/components/schemas/")) + targetName := strings.TrimPrefix(use.Ref, "#/components/schemas/") + target := doc.Components.Schemas[targetName].Value + require.Nil(t, target.Items, + "internalization must componentize the referenced target, not the schema with the siblings applied") + require.Empty(t, target.AllOf, + "internalization must componentize the referenced target, not the schema with the siblings applied") + var marshaledUse map[string]any + require.NoError(t, json.Unmarshal(mustMarshalJSON(t, use), &marshaledUse)) + items := marshaledUse["items"].(map[string]any) + require.True(t, strings.HasPrefix(items["$ref"].(string), "#/components/schemas/")) + + loader = openapi3.NewLoader() + roundTripped, err := loader.LoadFromData(data) + require.NoError(t, err, "the internalized document must not require external access") + roundTrippedUse := roundTripped.Components.Schemas["Root"].Value.Properties["use"].Value + require.NoError(t, roundTrippedUse.VisitJSON([]any{"value"}, openapi3.EnableJSONSchema2020())) + require.Error(t, roundTrippedUse.VisitJSON([]any{1}, openapi3.EnableJSONSchema2020())) +} + +// In OpenAPI 3.0 the fields next to a $ref are ignored, so the loader never +// follows the $refs within them and internalization leaves them as written. +func TestInternalizeRefsLeavesIgnoredSchemaRefSiblingFieldsInOpenAPI30(t *testing.T) { + t.Parallel() + dir := t.TempDir() + rootPath := filepath.Join(dir, "root.yaml") + externalPath := filepath.Join(dir, "external.yaml") + + require.NoError(t, os.WriteFile(rootPath, []byte(` +openapi: 3.0.3 +info: + title: Internalized OpenAPI 3.0 sibling refs + version: "1.0" +paths: {} +components: + schemas: + Root: + type: object + properties: + use: + $ref: external.yaml#/Base + items: + $ref: external.yaml#/Item +`), 0o600)) + require.NoError(t, os.WriteFile(externalPath, []byte(` +Base: + type: array +Item: + type: string +`), 0o600)) + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile(rootPath) + require.NoError(t, err) + + use := doc.Components.Schemas["Root"].Value.Properties["use"] + require.Nil(t, use.Value.Items, "an OpenAPI 3.0 sibling must not affect the resolved target") + before, err := json.Marshal(use) + require.NoError(t, err) + require.Contains(t, string(before), `external.yaml#/Item`) + + doc.InternalizeRefs(t.Context(), nil) + data, err := doc.MarshalJSON() + require.NoError(t, err) + require.Nil(t, use.Value.Items, "internalization must not change OpenAPI 3.0 sibling semantics") + + var marshaledUse map[string]any + require.NoError(t, json.Unmarshal(mustMarshalJSON(t, use), &marshaledUse)) + require.True(t, strings.HasPrefix(marshaledUse["$ref"].(string), "#/components/schemas/")) + items := marshaledUse["items"].(map[string]any) + require.Equal(t, "external.yaml#/Item", items["$ref"]) + + roundTripLoader := openapi3.NewLoader() + roundTripped, err := roundTripLoader.LoadFromData(data) + require.NoError(t, err, "the internalized document must not require external access") + roundTrippedUse := roundTripped.Components.Schemas["Root"].Value.Properties["use"] + require.Nil(t, roundTrippedUse.Value.Items) +} + +func TestInternalizeRefsUpdatesSiblingFieldsWithFalseTarget(t *testing.T) { + t.Parallel() + dir := t.TempDir() + rootPath := filepath.Join(dir, "root.yaml") + externalPath := filepath.Join(dir, "external.yaml") + + require.NoError(t, os.WriteFile(rootPath, []byte(` +openapi: 3.1.0 +info: + title: Internalized false-target sibling refs + version: "1.0" +paths: {} +components: + schemas: + RejectEverything: false + Root: + type: object + properties: + use: + $ref: '#/components/schemas/RejectEverything' + items: + $ref: external.yaml#/Item +`), 0o600)) + require.NoError(t, os.WriteFile(externalPath, []byte(` +Item: + type: string +`), 0o600)) + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile(rootPath) + require.NoError(t, err) + + use := doc.Components.Schemas["Root"].Value.Properties["use"] + require.Error(t, use.Value.VisitJSON([]any{"value"}, openapi3.EnableJSONSchema2020())) + + doc.InternalizeRefs(t.Context(), nil) + data, err := doc.MarshalJSON() + require.NoError(t, err) + require.NotContains(t, string(data), "external.yaml") + require.Error(t, use.Value.VisitJSON([]any{"value"}, openapi3.EnableJSONSchema2020())) +} + +func TestRecursiveSchemaDistinguishesOverlappingSlices(t *testing.T) { + t.Parallel() + schema := openapi3.NewArraySchema().WithMinItems(1) + schema.Items = &openapi3.SchemaRef{Value: schema} + + value := make([]any /*,*/, 1) + value[0] = value[:0] + require.Error(t, schema.VisitJSON(value), + "a shorter subslice with the same backing address is a distinct JSON instance") +} + +func mustMarshalJSON(t *testing.T, value any) []byte { + t.Helper() + data, err := json.Marshal(value) + require.NoError(t, err) + return data +} diff --git a/openapi3/issue1392_test.go b/openapi3/issue1392_test.go new file mode 100644 index 000000000..61d3192e0 --- /dev/null +++ b/openapi3/issue1392_test.go @@ -0,0 +1,169 @@ +package openapi3_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestInternalizeRefsWebhooks(t *testing.T) { + t.Parallel() + + const operation = ` +post: + requestBody: + content: + application/json: + schema: + $ref: ./Item.yaml + responses: + '200': + description: OK +` + for _, test := range []struct { + name string + document string + schemaName string + }{ + { + name: "external path control", + document: `paths: + /ping: + $ref: ./ping.yaml +`, + schemaName: "Item", + }, + { + name: "external webhook without paths", + document: `webhooks: + ping: + $ref: ./ping.yaml +`, + schemaName: "Item", + }, + { + name: "inline webhook", + document: `paths: {} +webhooks: + ping: + post: + requestBody: + content: + application/json: + schema: + $ref: ./Item.yaml + responses: + '200': + description: OK +`, + schemaName: "Item", + }, + { + name: "external webhook with local component reference", + document: `webhooks: + ping: + $ref: ./external.yaml#/paths/~1ping +`, + schemaName: "external_Item", + }, + { + name: "path and webhook sharing references", + document: `paths: + /ping: + $ref: ./ping.yaml +webhooks: + ping: + $ref: ./ping.yaml +`, + schemaName: "Item", + }, + } { + for _, prefix := range []string{"", "custom_"} { + t.Run(test.name+"/"+prefix, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + for name, content := range map[string]string{ + "root.yaml": "openapi: 3.1.0\ninfo: {title: t, version: '1'}\n" + test.document, + "ping.yaml": operation, + "Item.yaml": "type: object\nproperties:\n child:\n $ref: ./Child.yaml\n", + "Child.yaml": "type: integer\n", + "external.yaml": `openapi: 3.1.0 +info: {title: external, version: '1'} +paths: + /ping: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + responses: + '200': + description: OK +components: + schemas: + Item: + type: object + properties: + child: + $ref: ./Child.yaml +`, + } { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)) + } + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile(filepath.Join(dir, "root.yaml")) + require.NoError(t, err) + require.NoError(t, doc.Validate(t.Context())) + + var resolver openapi3.RefNameResolver + if prefix != "" { + resolver = func(doc *openapi3.T, ref openapi3.ComponentRef) string { + return prefix + openapi3.DefaultRefNameResolver(doc, ref) + } + } + doc.InternalizeRefs(t.Context(), resolver) + require.NoError(t, doc.Validate(t.Context())) + + for _, item := range []*openapi3.PathItem{doc.Webhooks["ping"], doc.Paths.Value("/ping")} { + if item == nil { + continue + } + require.Empty(t, item.Ref) + schema := item.Post.RequestBody.Value.Content["application/json"].Schema + require.Equal(t, "#/components/schemas/"+prefix+test.schemaName, schema.Ref) + require.Equal(t, schema.Value, doc.Components.Schemas[prefix+test.schemaName].Value) + require.Equal(t, "#/components/schemas/"+prefix+"Child", schema.Value.Properties["child"].Ref) + } + require.Len(t, doc.Components.Schemas, 2) + + data, err := doc.MarshalJSON() + require.NoError(t, err) + loader = openapi3.NewLoader() + reloaded, err := loader.LoadFromData(data) + require.NoError(t, err, "internalized webhooks must not require external access") + require.NoError(t, reloaded.Validate(t.Context())) + + doc.InternalizeRefs(t.Context(), resolver) + again, err := doc.MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, string(data), string(again)) + }) + } + } +} + +func TestInternalizeRefsEmptyWebhooks(t *testing.T) { + t.Parallel() + for _, webhooks := range []map[string]*openapi3.PathItem{nil, {}} { + doc := &openapi3.T{Webhooks: webhooks} + require.NotPanics(t, func() { doc.InternalizeRefs(t.Context(), nil) }) + require.Nil(t, doc.Components) + } +} diff --git a/openapi3/issue1393_test.go b/openapi3/issue1393_test.go new file mode 100644 index 000000000..8b30960d0 --- /dev/null +++ b/openapi3/issue1393_test.go @@ -0,0 +1,88 @@ +package openapi3_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestInternalizeRefsDereferencesComponentRefsToFiles(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + for name, content := range map[string]string{ + "root.yaml": `openapi: 3.0.3 +info: {title: t, version: '1'} +security: [{bearerAuth: []}] +paths: + /a: + get: + operationId: getA + responses: + '200': + description: OK + headers: + X-Header: {$ref: '#/components/headers/X-Header'} + content: + application/json: + schema: {type: integer} + examples: + example: {$ref: '#/components/examples/Example'} + links: + link: {$ref: '#/components/links/Link'} + '404': {$ref: '#/components/responses/NotFound'} +components: + headers: + X-Header: {$ref: ./header.yaml} + responses: + NotFound: {$ref: ./response.yaml} + securitySchemes: + bearerAuth: {$ref: ./securityScheme.yaml} + examples: + Example: {$ref: ./example.yaml} + links: + Link: {$ref: ./link.yaml} +`, + "header.yaml": "schema: {type: string}\n", + "response.yaml": "description: Not found\n", + "securityScheme.yaml": "type: http\nscheme: bearer\n", + "example.yaml": "value: 42\n", + "link.yaml": "operationId: getA\n", + } { + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600)) + } + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + doc, err := loader.LoadFromFile(filepath.Join(dir, "root.yaml")) + require.NoError(t, err) + require.NoError(t, doc.Validate(t.Context())) + + doc.InternalizeRefs(t.Context(), nil) + require.NoError(t, doc.Validate(t.Context())) + + components := doc.Components + require.Empty(t, components.Headers["X-Header"].Ref) + require.Empty(t, components.Responses["NotFound"].Ref) + require.Empty(t, components.SecuritySchemes["bearerAuth"].Ref) + require.Empty(t, components.Examples["Example"].Ref) + require.Empty(t, components.Links["Link"].Ref) + require.Equal(t, "Not found", *components.Responses["NotFound"].Value.Description) + + operation := doc.Paths.Value("/a").Get + require.Equal(t, "#/components/headers/X-Header", operation.Responses.Value("200").Value.Headers["X-Header"].Ref) + require.Equal(t, "#/components/examples/Example", operation.Responses.Value("200").Value.Content["application/json"].Examples["example"].Ref) + require.Equal(t, "#/components/links/Link", operation.Responses.Value("200").Value.Links["link"].Ref) + require.Equal(t, "#/components/responses/NotFound", operation.Responses.Value("404").Ref) + + data, err := doc.MarshalJSON() + require.NoError(t, err) + loader = openapi3.NewLoader() + reloaded, err := loader.LoadFromData(data) + require.NoError(t, err) + require.NoError(t, reloaded.Validate(t.Context())) +} diff --git a/openapi3/issue241_test.go b/openapi3/issue241_test.go index 5b5268b03..73633742d 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/issue741_test.go b/openapi3/issue741_test.go index f6cd672e0..21863bfe3 100644 --- a/openapi3/issue741_test.go +++ b/openapi3/issue741_test.go @@ -29,11 +29,14 @@ func TestIssue741(t *testing.T) { ts.URL, ) + readFromURI := openapi3.URIMapCache(openapi3.ReadFromHTTP(ts.Client())) + var wg sync.WaitGroup for range 10 { wg.Go(func() { loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = readFromURI doc, err := loader.LoadFromData(rootSpec) require.NoError(t, err) require.NotNil(t, doc) diff --git a/openapi3/issue883_test.go b/openapi3/issue883_test.go index fa5f063b7..2a500e73f 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" ) @@ -41,7 +41,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, ` @@ -53,13 +53,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) @@ -95,7 +95,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 23922236d..11b62e3e6 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/license.go b/openapi3/license.go index 014bbb880..e54ccd4a6 100644 --- a/openapi3/license.go +++ b/openapi3/license.go @@ -51,7 +51,9 @@ func (license *License) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "name") delete(x.Extensions, "url") delete(x.Extensions, "identifier") diff --git a/openapi3/link.go b/openapi3/link.go index af9e792c1..4356e038e 100644 --- a/openapi3/link.go +++ b/openapi3/link.go @@ -63,7 +63,9 @@ func (link *Link) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "operationRef") delete(x.Extensions, "operationId") diff --git a/openapi3/loader.go b/openapi3/loader.go index fdcf6ed84..6abc4b173 100644 --- a/openapi3/loader.go +++ b/openapi3/loader.go @@ -1,6 +1,7 @@ package openapi3 import ( + "cmp" "context" "encoding/json" "errors" @@ -13,6 +14,8 @@ import ( "reflect" "strconv" "strings" + + yaml "go.yaml.in/yaml/v3" ) func failedToResolveRefFragmentPart(value, what string) error { @@ -57,16 +60,23 @@ type Loader struct { visitedDocuments map[string]*T - // originTrees retains each loaded document's origin tree, keyed by the - // document itself so insert and lookup cannot disagree, populated when - // IncludeOrigin is set. resolveComponent uses it to re-attach origins to - // components that lose them in the generic-map path, without re-reading - // or re-parsing the file. + // originTrees retains a loaded document's parsed node tree, keyed by the + // document itself so insert and lookup cannot disagree. Populated only for + // the documents rememberOriginTree keeps, which is the condition described + // there. resolveComponent uses it to re-attach origins to components that + // lose them in the generic-map path, without re-reading or re-parsing the + // file. originTrees map[*T]*originTree visitedRefs map[string]struct{} visitedPath []string backtrack map[string][]func(value any) + + // rootDoc is the document whose refs are being resolved, as opposed to + // the documents it refers to, and resolvingDepth how deep in these the + // loader currently is. + rootDoc *T + resolvingDepth int } // NewLoader returns an empty Loader @@ -103,11 +113,26 @@ func (loader *Loader) loadFromURIInternal(location *url.URL) (*T, error) { return loader.loadFromDataWithPathInternal(data, location) } -func (loader *Loader) allowsExternalRefs(ref string) (err error) { +// DisallowedExternalRefError is returned for a $ref to another document +// encountered while Loader.IsExternalRefsAllowed is false. A caller that tells +// a refused $ref apart from a document that failed to load for another reason, +// to report the setting as the remedy or to map it to its own error, matches +// this type with errors.As rather than the message text. +type DisallowedExternalRefError struct { + // Ref is the $ref value as it appears in the document, e.g. + // "other.yaml#/components/schemas/Thing". + Ref string +} + +func (e *DisallowedExternalRefError) Error() string { + return fmt.Sprintf("encountered disallowed external reference: %q", e.Ref) +} + +func (loader *Loader) allowsExternalRefs(ref string) error { if !loader.IsExternalRefsAllowed { - err = fmt.Errorf("encountered disallowed external reference: %q", ref) + return &DisallowedExternalRefError{Ref: ref} } - return + return nil } func (loader *Loader) loadSingleElementFromURI(ref string, rootPath *url.URL, element any) (*url.URL, error) { @@ -139,8 +164,9 @@ 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 @@ -157,8 +183,8 @@ func (loader *Loader) loadSingleElementFromURI(ref string, rootPath *url.URL, el // 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. +// Worth the condition: the node tree is most of what a loaded document +// costs, so keeping one per document would dominate the loader's memory. func (loader *Loader) rememberOriginTree(doc *T, tree *originTree) { if tree == nil || len(doc.Extensions) == 0 { return @@ -250,6 +276,12 @@ func (loader *Loader) ResolveRefsIn(doc *T, location *url.URL) (err error) { loader.Context = context.Background() } + if loader.resolvingDepth == 0 { + loader.rootDoc = doc + } + loader.resolvingDepth++ + defer func() { loader.resolvingDepth-- }() + if loader.visitedPathItemRefs == nil { loader.resetVisitedPathItemRefs() } @@ -402,29 +434,39 @@ func isSingleRefElement(ref string) bool { return !strings.Contains(ref, "#") } -func (loader *Loader) visitRef(ref string) { +// visitedRefKey identifies a $ref being resolved as an object of the given +// kind. The same $ref can be written where different kinds of objects are +// expected, as in a path item and in one of its responses: resolving one must +// not hand its value over to the other. +func visitedRefKey(kind, ref string) string { return kind + " " + ref } + +func (loader *Loader) visitRef(kind, ref string) { if loader.visitedRefs == nil { loader.visitedRefs = make(map[string]struct{}) loader.backtrack = make(map[string][]func(value any)) } - loader.visitedPath = append(loader.visitedPath, ref) - loader.visitedRefs[ref] = struct{}{} + key := visitedRefKey(kind, ref) + loader.visitedPath = append(loader.visitedPath, key) + loader.visitedRefs[key] = struct{}{} } -func (loader *Loader) unvisitRef(ref string, value any) { - if value != nil { - for _, fn := range loader.backtrack[ref] { +func (loader *Loader) unvisitRef(kind, ref string, value any) { + key := visitedRefKey(kind, ref) + // value is a typed pointer, nil when the cycle resolved to nothing. + if v := reflect.ValueOf(value); v.IsValid() && !(v.Kind() == reflect.Pointer && v.IsNil()) { + for _, fn := range loader.backtrack[key] { fn(value) } } - delete(loader.visitedRefs, ref) - delete(loader.backtrack, ref) + delete(loader.visitedRefs, key) + delete(loader.backtrack, key) loader.visitedPath = loader.visitedPath[:len(loader.visitedPath)-1] } -func (loader *Loader) shouldVisitRef(ref string, fn func(value any)) bool { - if _, ok := loader.visitedRefs[ref]; ok { - loader.backtrack[ref] = append(loader.backtrack[ref], fn) +func (loader *Loader) shouldVisitRef(kind, ref string, fn func(value any)) bool { + key := visitedRefKey(kind, ref) + if _, ok := loader.visitedRefs[key]; ok { + loader.backtrack[key] = append(loader.backtrack[key], fn) return false } return true @@ -452,6 +494,7 @@ func (loader *Loader) resolveComponent(doc *T, ref string, path *url.URL, resolv } drill := func(cursor any) (any, error) { + var err error // not the named result, which a failed drill must not clear for pathPart := range strings.SplitSeq(fragment[1:], "/") { pathPart = unescapeRefString(pathPart) attempted := false @@ -571,10 +614,11 @@ func (loader *Loader) resolveComponent(doc *T, ref string, path *url.URL, resolv // attachOriginToResolved re-attaches source origins to a component resolved // through the generic-map path: a $ref to a schema under an arbitrary top-level // key lands in T.Extensions, and the json round-trip in resolveComponent strips -// its origin. It walks the document's retained origin tree (see originTrees, -// populated when the document was first unmarshaled: no re-read, no re-parse) -// down to the ref fragment and applies that subtree, so the object carries the -// same origins a typed resolution would, with the original file's line numbers. +// its origin. It walks the document's retained node tree (see originTrees, +// kept when the document was first unmarshaled: no re-read, no re-parse) down +// to the ref fragment and decodes that subtree, which runs the type's +// UnmarshalYAML and so produces origins, giving the object what a typed +// resolution would have, with the original file's line numbers. // Best-effort: a missing tree or fragment leaves the object without origins. func (loader *Loader) attachOriginToResolved(resolved any, componentDoc *T, fragment string) { if !loader.IncludeOrigin { @@ -584,15 +628,41 @@ 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. The lock is held across the decode below, so nothing that decode + // reaches may load a document: that would wait here for a lock this call + // holds, and hang rather than fail. + originMu.Lock() + defer originMu.Unlock() + // Set rather than inherited: resolution runs after the load that produced + // the tree, so no decode is in progress and there is nothing to inherit. + originState.Store(&originDecode{file: tree.file, ends: tree.ends}) + defer originState.Store(nil) + + 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) + // Defensive: the json round trip in the caller already decoded this data + // into this type, so a failure here means the two decode paths disagree, + // which nothing is known to trigger. The object is resolved either way, so + // a failure costs positions rather than the load. + if err := node.Decode(resolved); err != 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 { @@ -745,7 +815,7 @@ func (loader *Loader) resolveHeaderRef(doc *T, component *HeaderRef, documentPat if component.Value != nil { return nil } - if !loader.shouldVisitRef(ref, func(value any) { + if !loader.shouldVisitRef("Header", ref, func(value any) { component.Value = value.(*Header) applyHeaderRefMetadata(component.Value, component, isOpenAPI31OrLater) refPath, _ := loader.resolveRefPath(ref, documentPath) @@ -753,14 +823,22 @@ func (loader *Loader) resolveHeaderRef(doc *T, component *HeaderRef, documentPat }) { return nil } - loader.visitRef(ref) + loader.visitRef("Header", ref) if isSingleRefElement(ref) { - var header Header - if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &header); err != nil { + var resolved HeaderRef + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &resolved); err != nil { return err } - component.Value = &header - component.setRefPath(documentPath) + // The file holds the Header itself or a $ref to it, to follow from there. + if err := loader.resolveHeaderRef(doc, &resolved, documentPath); err != nil { + if err == errMUSTHeader { + return nil + } + return err + } + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(cmp.Or(resolved.RefPath(), documentPath)) + } } else { var resolved HeaderRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -773,10 +851,11 @@ func (loader *Loader) resolveHeaderRef(doc *T, component *HeaderRef, documentPat } return err } - component.Value = resolved.Value - component.setRefPath(resolved.RefPath()) + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(resolved.RefPath()) + } } - defer loader.unvisitRef(ref, component.Value) + defer loader.unvisitRef("Header", ref, component.Value) applyHeaderRefMetadata(component.Value, component, isOpenAPI31OrLater) } value := component.Value @@ -816,7 +895,7 @@ func (loader *Loader) resolveParameterRef(doc *T, component *ParameterRef, docum if component.Value != nil { return nil } - if !loader.shouldVisitRef(ref, func(value any) { + if !loader.shouldVisitRef("Parameter", ref, func(value any) { component.Value = value.(*Parameter) applyParameterRefMetadata(component.Value, component, isOpenAPI31OrLater) refPath, _ := loader.resolveRefPath(ref, documentPath) @@ -824,14 +903,22 @@ func (loader *Loader) resolveParameterRef(doc *T, component *ParameterRef, docum }) { return nil } - loader.visitRef(ref) + loader.visitRef("Parameter", ref) if isSingleRefElement(ref) { - var param Parameter - if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, ¶m); err != nil { + var resolved ParameterRef + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &resolved); err != nil { return err } - component.Value = ¶m - component.setRefPath(documentPath) + // The file holds the Parameter itself or a $ref to it, to follow from there. + if err := loader.resolveParameterRef(doc, &resolved, documentPath); err != nil { + if err == errMUSTParameter { + return nil + } + return err + } + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(cmp.Or(resolved.RefPath(), documentPath)) + } } else { var resolved ParameterRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -844,10 +931,11 @@ func (loader *Loader) resolveParameterRef(doc *T, component *ParameterRef, docum } return err } - component.Value = resolved.Value - component.setRefPath(resolved.RefPath()) + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(resolved.RefPath()) + } } - defer loader.unvisitRef(ref, component.Value) + defer loader.unvisitRef("Parameter", ref, component.Value) applyParameterRefMetadata(component.Value, component, isOpenAPI31OrLater) } value := component.Value @@ -895,7 +983,7 @@ func (loader *Loader) resolveRequestBodyRef(doc *T, component *RequestBodyRef, d if component.Value != nil { return nil } - if !loader.shouldVisitRef(ref, func(value any) { + if !loader.shouldVisitRef("RequestBody", ref, func(value any) { component.Value = value.(*RequestBody) applyRequestBodyRefMetadata(component.Value, component, isOpenAPI31OrLater) refPath, _ := loader.resolveRefPath(ref, documentPath) @@ -903,30 +991,39 @@ func (loader *Loader) resolveRequestBodyRef(doc *T, component *RequestBodyRef, d }) { return nil } - loader.visitRef(ref) + loader.visitRef("RequestBody", ref) if isSingleRefElement(ref) { - var requestBody RequestBody - if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &requestBody); err != nil { + var resolved RequestBodyRef + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &resolved); err != nil { + return err + } + // The file holds the RequestBody itself or a $ref to it, to follow from there. + if err := loader.resolveRequestBodyRef(doc, &resolved, documentPath); err != nil { + if err == errMUSTRequestBody { + return nil + } return err } - component.Value = &requestBody - component.setRefPath(documentPath) + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(cmp.Or(resolved.RefPath(), documentPath)) + } } else { var resolved RequestBodyRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) if err != nil { return err } - if err = loader.resolveRequestBodyRef(doc, &resolved, componentPath); err != nil { + if err := loader.resolveRequestBodyRef(doc, &resolved, componentPath); err != nil { if err == errMUSTRequestBody { return nil } return err } - component.Value = resolved.Value - component.setRefPath(resolved.RefPath()) + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(resolved.RefPath()) + } } - defer loader.unvisitRef(ref, component.Value) + defer loader.unvisitRef("RequestBody", ref, component.Value) applyRequestBodyRefMetadata(component.Value, component, isOpenAPI31OrLater) } value := component.Value @@ -961,7 +1058,7 @@ func (loader *Loader) resolveResponseRef(doc *T, component *ResponseRef, documen if component.Value != nil { return nil } - if !loader.shouldVisitRef(ref, func(value any) { + if !loader.shouldVisitRef("Response", ref, func(value any) { component.Value = value.(*Response) applyResponseRefMetadata(component.Value, component, isOpenAPI31OrLater) refPath, _ := loader.resolveRefPath(ref, documentPath) @@ -969,14 +1066,22 @@ func (loader *Loader) resolveResponseRef(doc *T, component *ResponseRef, documen }) { return nil } - loader.visitRef(ref) + loader.visitRef("Response", ref) if isSingleRefElement(ref) { - var resp Response - if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &resp); err != nil { + var resolved ResponseRef + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &resolved); err != nil { return err } - component.Value = &resp - component.setRefPath(documentPath) + // The file holds the Response itself or a $ref to it, to follow from there. + if err := loader.resolveResponseRef(doc, &resolved, documentPath); err != nil { + if err == errMUSTResponse { + return nil + } + return err + } + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(cmp.Or(resolved.RefPath(), documentPath)) + } } else { var resolved ResponseRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -989,10 +1094,11 @@ func (loader *Loader) resolveResponseRef(doc *T, component *ResponseRef, documen } return err } - component.Value = resolved.Value - component.setRefPath(resolved.RefPath()) + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(resolved.RefPath()) + } } - defer loader.unvisitRef(ref, component.Value) + defer loader.unvisitRef("Response", ref, component.Value) applyResponseRefMetadata(component.Value, component, isOpenAPI31OrLater) } value := component.Value @@ -1053,25 +1159,50 @@ func (loader *Loader) resolveSchemaRef(doc *T, component *SchemaRef, documentPat if component.Value != nil { return nil } - if !loader.shouldVisitRef(ref, func(value any) { + if loader.isOpenAPI31OrLater(doc) && component.siblingErr != nil { + return fmt.Errorf("the fields next to $ref %q are not a schema: %w", ref, component.siblingErr) + } + if loader.isOpenAPI31OrLater(doc) && component.sibling != nil { + // Resolve the $refs within the siblings themselves, such as those of + // the properties they add, before applyRefSiblings shares them with + // Value: it may run from the callback below, which resolves nothing. + if err := loader.resolveSchemaRef(doc, &SchemaRef{Value: component.sibling}, documentPath, visited); err != nil { + return err + } + } + if !loader.shouldVisitRef("Schema", ref, func(value any) { component.Value = value.(*Schema) refPath, _ := loader.resolveRefPath(ref, documentPath) component.setRefPath(refPath) // A $ref reached again while its target is still being resolved is // filled in here when the cycle unwinds, which can overwrite a value // that already had its siblings applied, so apply them here as well. - component.applyRefSiblings(doc) + component.applyRefSiblings(loader.isOpenAPI31OrLater(doc)) }) { return nil } - loader.visitRef(ref) + loader.visitRef("Schema", ref) if isSingleRefElement(ref) { - var schema Schema - if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &schema); err != nil { + var resolved SchemaRef + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &resolved); err != nil { return err } - component.Value = &schema - component.setRefPath(documentPath) + if strings.HasPrefix(resolved.Ref, "#") { + // Local fragments belong to the referenced file, not the referring document. + if doc, err = loader.loadFromURIInternal(documentPath); err != nil { + return err + } + } + // The file holds the Schema itself or a $ref to it, to follow from there. + if err := loader.resolveSchemaRef(doc, &resolved, documentPath, visited); err != nil { + if err == errMUSTSchema { + return nil + } + return err + } + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(cmp.Or(resolved.RefPath(), documentPath)) + } } else { var resolved SchemaRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -1084,17 +1215,18 @@ func (loader *Loader) resolveSchemaRef(doc *T, component *SchemaRef, documentPat } return err } - component.Value = resolved.Value - component.setRefPath(resolved.RefPath()) + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(resolved.RefPath()) + } } - defer loader.unvisitRef(ref, component.Value) + defer loader.unvisitRef("Schema", ref, component.Value) // OAS 3.1 / JSON Schema 2020-12: apply sibling keywords from the original schema // object on top of the resolved $ref value. In 3.1, siblings are not ignored — // they augment the referenced schema (e.g. deprecated:true alongside $ref). // Only apply for OAS 3.1+ — in 3.0 $ref replaces its entire object and siblings // are (validly) ignored. - component.applyRefSiblings(doc) + component.applyRefSiblings(loader.isOpenAPI31OrLater(doc)) } value := component.Value if value == nil { @@ -1249,7 +1381,7 @@ func (loader *Loader) resolveSecuritySchemeRef(doc *T, component *SecurityScheme if component.Value != nil { return nil } - if !loader.shouldVisitRef(ref, func(value any) { + if !loader.shouldVisitRef("SecurityScheme", ref, func(value any) { component.Value = value.(*SecurityScheme) applySecuritySchemeRefMetadata(component.Value, component, isOpenAPI31OrLater) refPath, _ := loader.resolveRefPath(ref, documentPath) @@ -1257,14 +1389,22 @@ func (loader *Loader) resolveSecuritySchemeRef(doc *T, component *SecurityScheme }) { return nil } - loader.visitRef(ref) + loader.visitRef("SecurityScheme", ref) if isSingleRefElement(ref) { - var scheme SecurityScheme - if _, err = loader.loadSingleElementFromURI(ref, documentPath, &scheme); err != nil { + var resolved SecuritySchemeRef + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &resolved); err != nil { return err } - component.Value = &scheme - component.setRefPath(documentPath) + // The file holds the SecurityScheme itself or a $ref to it, to follow from there. + if err := loader.resolveSecuritySchemeRef(doc, &resolved, documentPath); err != nil { + if err == errMUSTSecurityScheme { + return nil + } + return err + } + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(cmp.Or(resolved.RefPath(), documentPath)) + } } else { var resolved SecuritySchemeRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -1277,10 +1417,11 @@ func (loader *Loader) resolveSecuritySchemeRef(doc *T, component *SecurityScheme } return err } - component.Value = resolved.Value - component.setRefPath(resolved.RefPath()) + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(resolved.RefPath()) + } } - defer loader.unvisitRef(ref, component.Value) + defer loader.unvisitRef("SecurityScheme", ref, component.Value) applySecuritySchemeRefMetadata(component.Value, component, isOpenAPI31OrLater) } return nil @@ -1310,7 +1451,7 @@ func (loader *Loader) resolveExampleRef(doc *T, component *ExampleRef, documentP if component.Value != nil { return nil } - if !loader.shouldVisitRef(ref, func(value any) { + if !loader.shouldVisitRef("Example", ref, func(value any) { component.Value = value.(*Example) applyExampleRefMetadata(component.Value, component, isOpenAPI31OrLater) refPath, _ := loader.resolveRefPath(ref, documentPath) @@ -1318,14 +1459,22 @@ func (loader *Loader) resolveExampleRef(doc *T, component *ExampleRef, documentP }) { return nil } - loader.visitRef(ref) + loader.visitRef("Example", ref) if isSingleRefElement(ref) { - var example Example - if _, err = loader.loadSingleElementFromURI(ref, documentPath, &example); err != nil { + var resolved ExampleRef + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &resolved); err != nil { return err } - component.Value = &example - component.setRefPath(documentPath) + // The file holds the Example itself or a $ref to it, to follow from there. + if err := loader.resolveExampleRef(doc, &resolved, documentPath); err != nil { + if err == errMUSTExample { + return nil + } + return err + } + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(cmp.Or(resolved.RefPath(), documentPath)) + } } else { var resolved ExampleRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -1338,10 +1487,11 @@ func (loader *Loader) resolveExampleRef(doc *T, component *ExampleRef, documentP } return err } - component.Value = resolved.Value - component.setRefPath(resolved.RefPath()) + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(resolved.RefPath()) + } } - defer loader.unvisitRef(ref, component.Value) + defer loader.unvisitRef("Example", ref, component.Value) applyExampleRefMetadata(component.Value, component, isOpenAPI31OrLater) } return nil @@ -1356,37 +1506,46 @@ func (loader *Loader) resolveCallbackRef(doc *T, component *CallbackRef, documen if component.Value != nil { return nil } - if !loader.shouldVisitRef(ref, func(value any) { + if !loader.shouldVisitRef("Callback", ref, func(value any) { component.Value = value.(*Callback) refPath, _ := loader.resolveRefPath(ref, documentPath) component.setRefPath(refPath) }) { return nil } - loader.visitRef(ref) + loader.visitRef("Callback", ref) if isSingleRefElement(ref) { - var resolved Callback + var resolved CallbackRef if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &resolved); err != nil { return err } - component.Value = &resolved - component.setRefPath(documentPath) + // The file holds the Callback itself or a $ref to it, to follow from there. + if err := loader.resolveCallbackRef(doc, &resolved, documentPath); err != nil { + if err == errMUSTCallback { + return nil + } + return err + } + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(cmp.Or(resolved.RefPath(), documentPath)) + } } else { var resolved CallbackRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) if err != nil { return err } - if err = loader.resolveCallbackRef(doc, &resolved, componentPath); err != nil { + if err := loader.resolveCallbackRef(doc, &resolved, componentPath); err != nil { if err == errMUSTCallback { return nil } return err } - component.Value = resolved.Value - component.setRefPath(resolved.RefPath()) + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(resolved.RefPath()) + } } - defer loader.unvisitRef(ref, component.Value) + defer loader.unvisitRef("Callback", ref, component.Value) } value := component.Value if value == nil { @@ -1422,7 +1581,7 @@ func (loader *Loader) resolveLinkRef(doc *T, component *LinkRef, documentPath *u if component.Value != nil { return nil } - if !loader.shouldVisitRef(ref, func(value any) { + if !loader.shouldVisitRef("Link", ref, func(value any) { component.Value = value.(*Link) applyLinkRefMetadata(component.Value, component, isOpenAPI31OrLater) refPath, _ := loader.resolveRefPath(ref, documentPath) @@ -1430,14 +1589,22 @@ func (loader *Loader) resolveLinkRef(doc *T, component *LinkRef, documentPath *u }) { return nil } - loader.visitRef(ref) + loader.visitRef("Link", ref) if isSingleRefElement(ref) { - var link Link - if _, err = loader.loadSingleElementFromURI(ref, documentPath, &link); err != nil { + var resolved LinkRef + if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &resolved); err != nil { return err } - component.Value = &link - component.setRefPath(documentPath) + // The file holds the Link itself or a $ref to it, to follow from there. + if err := loader.resolveLinkRef(doc, &resolved, documentPath); err != nil { + if err == errMUSTLink { + return nil + } + return err + } + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(cmp.Or(resolved.RefPath(), documentPath)) + } } else { var resolved LinkRef doc, componentPath, err := loader.resolveComponent(doc, ref, documentPath, &resolved) @@ -1450,10 +1617,11 @@ func (loader *Loader) resolveLinkRef(doc *T, component *LinkRef, documentPath *u } return err } - component.Value = resolved.Value - component.setRefPath(resolved.RefPath()) + if component.Value = resolved.Value; component.Value != nil { + component.setRefPath(resolved.RefPath()) + } } - defer loader.unvisitRef(ref, component.Value) + defer loader.unvisitRef("Link", ref, component.Value) applyLinkRefMetadata(component.Value, component, isOpenAPI31OrLater) } return nil @@ -1466,21 +1634,38 @@ func (loader *Loader) resolvePathItemRef(doc *T, pathItem *PathItem, documentPat } if ref := pathItem.Ref; ref != "" { - if !pathItem.isEmpty() { + if pathItem.refResolved { return } - if !loader.shouldVisitRef(ref, func(value any) { - *pathItem = *value.(*PathItem) + // The fields written next to $ref used to stop it from being resolved. + // They are merged with the item it points to instead; see mergeRefSiblings. + var siblings *PathItem + if !pathItem.isEmpty() || len(pathItem.Extensions) != 0 { + local := *pathItem + local.Ref = "" + siblings = &local + } + resolvedTo := func(resolved PathItem) { + *pathItem = resolved + pathItem.Ref = ref + pathItem.refResolved = true + if siblings != nil { + pathItem.mergeRefSiblings(siblings) + pathItem.refSiblings = siblings + } + } + if !loader.shouldVisitRef("PathItem", ref, func(value any) { + resolvedTo(*value.(*PathItem)) }) { return nil } - loader.visitRef(ref) + loader.visitRef("PathItem", ref) if isSingleRefElement(ref) { var p PathItem if documentPath, err = loader.loadSingleElementFromURI(ref, documentPath, &p); err != nil { return } - *pathItem = p + resolvedTo(p) } else { var resolved PathItem if doc, documentPath, err = loader.resolveComponent(doc, ref, documentPath, &resolved); err != nil { @@ -1489,10 +1674,9 @@ func (loader *Loader) resolvePathItemRef(doc *T, pathItem *PathItem, documentPat } return } - *pathItem = resolved + resolvedTo(resolved) } - pathItem.Ref = ref - defer loader.unvisitRef(ref, pathItem) + defer loader.unvisitRef("PathItem", ref, pathItem) } for _, parameter := range pathItem.Parameters { @@ -1530,15 +1714,75 @@ func (loader *Loader) resolvePathItemRef(doc *T, pathItem *PathItem, documentPat return } -// applyRefSiblings overlays the sibling keywords of an OAS 3.1 $ref onto its -// resolved value. It works on a copy so a schema shared by other references is -// not mutated, and does nothing for OAS 3.0, where $ref replaces its object. -func (component *SchemaRef) applyRefSiblings(doc *T) { - if doc.IsOpenAPI31OrLater() && component.sibling != nil && component.Value != nil { - schemaCopy := *component.Value - applySiblingSchemaFields(&schemaCopy, component.sibling, component.extra) - component.Value = &schemaCopy +// refSiblingAnnotations are the keywords that, written next to a $ref, replace +// the referenced schema's own. They describe the instance rather than constrain +// it, so the one closest to where it is used wins, as summary and description +// do on a Reference Object. +var refSiblingAnnotations = map[string]struct{}{ + "$comment": {}, + "allowEmptyValue": {}, + "default": {}, + "deprecated": {}, + "description": {}, + "discriminator": {}, + "example": {}, + "examples": {}, + "externalDocs": {}, + "nullable": {}, + "readOnly": {}, + "title": {}, + "writeOnly": {}, + "xml": {}, +} + +// applyRefSiblings applies the sibling keywords of an OAS 3.1 $ref to its +// resolved value, and does nothing for OAS 3.0, where $ref replaces its object. +// +// In JSON Schema 2020-12 the keywords next to a $ref apply alongside it: an +// instance must satisfy both the referenced schema and its siblings. So the +// annotations above override the referenced schema's, while every other +// keyword is added as one more allOf entry rather than replacing the +// referenced schema's own: a sibling maxLength: 10 next to a $ref to a schema +// with maxLength: 3 still rejects "abcd". Value thus keeps the shape of the +// referenced schema, with its properties, type and so on. +// +// It works on a copy so a schema shared by other references is not mutated. +func (component *SchemaRef) applyRefSiblings(isOpenAPI31OrLater bool) { + if !isOpenAPI31OrLater || component.sibling == nil || component.Value == nil { + return + } + component.target = component.Value + var annotations, assertions []string + for _, field := range component.extra { + if _, ok := refSiblingAnnotations[field]; ok { + annotations = append(annotations, field) + } else { + assertions = append(assertions, field) + } + } + schemaCopy := *component.Value + applySiblingSchemaFields(&schemaCopy, component.sibling, annotations) + if len(assertions) != 0 { + // The siblings sit in the same object as the $ref, so they share its origin. + conjunct := Schema{Origin: component.Origin} + applySiblingSchemaFields(&conjunct, component.sibling, assertions) + if !conjunct.IsEmpty() { + allOf := make(SchemaRefs, 0, len(schemaCopy.AllOf)+1) + allOf = append(allOf, schemaCopy.AllOf...) + schemaCopy.AllOf = append(allOf, &SchemaRef{Value: &conjunct}) + } + } + component.Value = &schemaCopy +} + +// isOpenAPI31OrLater tells whether doc follows OAS 3.1 or later. A document +// that does not say, such as a file of bare schemas a $ref points into, follows +// the version of the document that refers to it. +func (loader *Loader) isOpenAPI31OrLater(doc *T) bool { + if doc.OpenAPI == "" && loader.rootDoc != nil { + doc = loader.rootDoc } + return doc.IsOpenAPI31OrLater() } func unescapeRefString(ref string) string { diff --git a/openapi3/loader_disallowed_external_ref_test.go b/openapi3/loader_disallowed_external_ref_test.go new file mode 100644 index 000000000..73d1d698e --- /dev/null +++ b/openapi3/loader_disallowed_external_ref_test.go @@ -0,0 +1,42 @@ +package openapi3_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +// A $ref naming a whole document and a $ref with a fragment take different +// paths through the loader, and each applies IsExternalRefsAllowed on its own. +func TestDisallowedExternalRefError(t *testing.T) { + t.Parallel() + + for _, ref := range []string{"other.yaml", "other.yaml#/components/schemas/Thing"} { + t.Run(ref, func(t *testing.T) { + t.Parallel() + + spec := fmt.Appendf(nil, ` +openapi: 3.0.0 +info: + title: Test + version: '1.0' +paths: {} +components: + schemas: + Thing: + $ref: %q +`, ref) + + loader := openapi3.NewLoader() + _, err := loader.LoadFromData(spec) + require.Error(t, err) + + var refused *openapi3.DisallowedExternalRefError + require.ErrorAs(t, err, &refused) + require.Equal(t, ref, refused.Ref) + }) + } +} diff --git a/openapi3/loader_http_error_test.go b/openapi3/loader_http_error_test.go index a1e51b7a2..0f886cdc6 100644 --- a/openapi3/loader_http_error_test.go +++ b/openapi3/loader_http_error_test.go @@ -48,6 +48,7 @@ func TestLoadReferenceFromRemoteURLFailsWithHttpError(t *testing.T) { loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = openapi3.ReadFromHTTP(ts.Client()) _, err := loader.LoadFromDataWithPath(spec, &url.URL{Path: "testdata/testfilename.openapi.json"}) require.EqualError(t, err, fmt.Sprintf(`error resolving reference "%s/components.openapi.json#/components/headers/CustomTestHeader": error loading "%s/components.openapi.json": request returned status code 400`, ts.URL, ts.URL)) @@ -91,6 +92,7 @@ func TestLoadFromRemoteURLFailsWithHttpError(t *testing.T) { loader := openapi3.NewLoader() loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = openapi3.ReadFromHTTP(ts.Client()) doc, err := loader.LoadFromDataWithPath(spec, &url.URL{Path: "testdata/testfilename.openapi.json"}) require.Nil(t, doc) diff --git a/openapi3/loader_ref_kinds_test.go b/openapi3/loader_ref_kinds_test.go new file mode 100644 index 000000000..8c48ce728 --- /dev/null +++ b/openapi3/loader_ref_kinds_test.go @@ -0,0 +1,26 @@ +package openapi3_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +// The same $ref written where two kinds of objects are expected, one within +// the other, must not hand the value resolved for one to the other. +func TestSameRefForDifferentKindsOfObjects(t *testing.T) { + t.Parallel() + loader := openapi3.NewLoader() + require.NotPanics(t, func() { + _, _ = loader.LoadFromData([]byte(`{ + "openapi": "3.0.3", + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": {"$ref": "#/paths/~1y"}, + "/y": {"get": {"responses": {"200": {"$ref": "#/paths/~1y"}}}} + } +}`)) + }) +} diff --git a/openapi3/loader_schema_ref_local_fragment_test.go b/openapi3/loader_schema_ref_local_fragment_test.go new file mode 100644 index 000000000..5ed9df1ab --- /dev/null +++ b/openapi3/loader_schema_ref_local_fragment_test.go @@ -0,0 +1,80 @@ +package openapi3 + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestLoaderWholeFileSchemaRefLocalFragment(t *testing.T) { + t.Parallel() + + for _, version := range []string{"3.0.3", "3.1.0"} { + for _, format := range []string{"yaml", "json"} { + for _, fragment := range []string{"", "#"} { + for _, missing := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/alias%s/missing=%t", version, format, fragment, missing), func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "schemas"), 0o700)) + root := fmt.Sprintf(`openapi: %s +info: {title: Local schema reference, version: '1'} +paths: {} +components: + schemas: + Alias: {$ref: './schemas/alias.%s%s'} + Target: {type: integer} +`, version, format, fragment) + name := "Target" + if missing { + name = "Other" + } + alias := fmt.Sprintf(`$ref: '#/components/schemas/Target' +components: + schemas: + %s: {type: string, minLength: 3} +`, name) + if format == "json" { + alias = fmt.Sprintf(`{"$ref":"#/components/schemas/Target","components":{"schemas":{"%s":{"type":"string","minLength":3}}}}`, name) + } + require.NoError(t, os.WriteFile(filepath.Join(dir, "openapi.yaml"), []byte(root), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "schemas", "alias."+format), []byte(alias), 0o600)) + + loader := NewLoader() + loader.IsExternalRefsAllowed = true + loader.IncludeOrigin = true + doc, err := loader.LoadFromFile(filepath.Join(dir, "openapi.yaml")) + if missing { + require.ErrorContains(t, err, "Target") + return + } + require.NoError(t, err) + require.NoError(t, doc.Validate(loader.Context)) + schema := doc.Components.Schemas["Alias"].Value + require.NotNil(t, schema) + require.NoError(t, schema.VisitJSON("abc")) + require.Error(t, schema.VisitJSON("ab")) + require.Error(t, schema.VisitJSON(42)) + + doc.InternalizeRefs(loader.Context, nil) + data, err := json.Marshal(doc) + require.NoError(t, err) + bundledLoader := NewLoader() + bundled, err := bundledLoader.LoadFromData(data) + require.NoError(t, err) + require.NoError(t, bundled.Validate(bundledLoader.Context)) + schema = bundled.Components.Schemas["Alias"].Value + require.NotNil(t, schema) + require.NoError(t, schema.VisitJSON("abc")) + require.Error(t, schema.VisitJSON("ab")) + require.Error(t, schema.VisitJSON(42)) + }) + } + } + } + } +} diff --git a/openapi3/marsh.go b/openapi3/marsh.go index d50f25b72..e2b66bb4c 100644 --- a/openapi3/marsh.go +++ b/openapi3/marsh.go @@ -5,7 +5,9 @@ import ( "net/url" "strings" - "github.com/oasdiff/yaml" + goyaml "go.yaml.in/yaml/v3" + + "github.com/getkin/kin-openapi/internal/nodeindex" ) func unmarshalError(jsonUnmarshalErr error) error { @@ -17,32 +19,87 @@ func unmarshalError(jsonUnmarshalErr error) error { return jsonUnmarshalErr } -// unmarshal decodes data into v. It returns the document origin tree when -// includeOrigin is set and the data took the yaml path (json input carries no -// origins), so the caller can retain it (see Loader.originTrees). +// unmarshal decodes data into v, trying yaml first and json for what the yaml +// parser will not accept. It returns the document's parsed node tree when +// origins were asked for and the data took the yaml path (json input carries +// none), so the caller can retain it (see Loader.originTrees). 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 = unmarshalJSON(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) + + tree, yamlErr := unmarshalYAML(data, v, includeOrigin, file) + if yamlErr == nil { return tree, nil - } else { - yamlErr = err + } + + // 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 + jsonErr := unmarshalJSON(data, v) + if 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) } + +// unmarshalYAML decodes data into v in 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. +func unmarshalYAML(data []byte, v any, includeOrigin bool, file string) (*originTree, error) { + var root goyaml.Node + if err := goyaml.Unmarshal(data, &root); err != nil { + return nil, err + } + if err := prepareScalars(&root); err != nil { + return nil, err + } + + var ends *nodeindex.Index + if includeOrigin { + var release func() + ends, release = installOriginState(&root, data, file) + defer release() + } + + if err := root.Decode(v); err != nil { + return nil, err + } + 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. + node := &root + if root.Kind == goyaml.DocumentNode && len(root.Content) > 0 { + node = root.Content[0] + } + stampRootOrigin(v, node) + return &originTree{node: node, file: file, ends: ends}, nil +} + +// installOriginState gives the decode about to run the file and index its +// UnmarshalYAML calls read, and returns the index along with the release to +// call when the decode ends. +// +// The state is the only thing a decode shares with another, so it is held under +// the lock, and a decode that produces no origins neither installs nor takes +// it. Releasing matters beyond the lock: the state holds an index over the +// whole node tree, which leaving it set would pin until the next decode +// replaced it, whether or not anything kept the tree. A caller that does need +// it gets it on the returned originTree. +func installOriginState(root *goyaml.Node, data []byte, file string) (*nodeindex.Index, func()) { + originMu.Lock() + // Ends are derived from the tree, the parser reporting only starts. + ends := nodeindex.New(root, data) + originState.Store(&originDecode{file: file, ends: ends}) + return ends, func() { + originState.Store(nil) + originMu.Unlock() + } +} diff --git a/openapi3/marsh_test.go b/openapi3/marsh_test.go index 583c1e5f1..383b3fc6b 100644 --- a/openapi3/marsh_test.go +++ b/openapi3/marsh_test.go @@ -42,11 +42,9 @@ paths: sl := openapi3.NewLoader() _, err := sl.LoadFromData(spec) - if openapi3.UsingJSONv2 { - require.ErrorContains(t, err, `json: cannot unmarshal object into Go struct field .paths.~1categories.get.responses.200.content.application~1json.schema.allOf of type openapi3.SchemaRefs`) - } else { - 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/media_type.go b/openapi3/media_type.go index a8f62df71..55de39dea 100644 --- a/openapi3/media_type.go +++ b/openapi3/media_type.go @@ -101,7 +101,9 @@ func (mediaType *MediaType) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "schema") delete(x.Extensions, "itemSchema") delete(x.Extensions, "example") diff --git a/openapi3/native_e2e_test.go b/openapi3/native_e2e_test.go new file mode 100644 index 000000000..9d879affa --- /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) { + data, err := os.ReadFile("testdata/callbacks.yml") + require.NoError(t, err) + var doc T + // Through unmarshal rather than the yaml package: origins are stamped only + // for the tree a decode indexed. + _, err = unmarshal(data, &doc, true, nil) + require.NoError(t, err) + 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..c6f9a00f4 --- /dev/null +++ b/openapi3/native_scalars_test.go @@ -0,0 +1,200 @@ +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)) + require.NoError(t, prepareScalars(&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, tagged or not: JSON has no timestamp, and +// a time.Time would marshal to text that reads back as a string. +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)) + require.NoError(t, prepareScalars(&node)) + + var s Schema + require.NoError(t, node.Content[0].Decode(&s)) + require.IsType(t, "", s.Example, "a date-shaped example stays a string") + + const tagged = "example: !!timestamp 2020-06-11T16:32:50Z\n" + var taggedNode goyaml.Node + require.NoError(t, goyaml.Unmarshal([]byte(tagged), &taggedNode)) + require.NoError(t, prepareScalars(&taggedNode)) + + var ts Schema + require.NoError(t, taggedNode.Content[0].Decode(&ts)) + require.IsType(t, "", ts.Example, "a tagged one too, the json path doing the same") +} + +// decodeNative decodes src the way the loader's native path does. +func decodeNative(t *testing.T, src string, v any) error { + t.Helper() + + var node goyaml.Node + require.NoError(t, goyaml.Unmarshal([]byte(src), &node)) + if err := prepareScalars(&node); err != nil { + return err + } + return node.Content[0].Decode(v) +} + +// A string cannot hold nil, so the decoder drops a null from a list of them +// and the list comes back shorter than the document. The json path keeps the +// place of the null as an empty string, and so does this. +func TestNativeScalars_NullKeepsItsPlaceInAListOfStrings(t *testing.T) { + var s Schema + require.NoError(t, decodeNative(t, "required: [a, null, b]\ntype: [string, null]\n", &s)) + require.Equal(t, []string{"a", "", "b"}, s.Required) + require.Equal(t, Types{"string", ""}, *s.Type) + + var op Operation + require.NoError(t, decodeNative(t, "tags: [a, null]\n", &op)) + require.Equal(t, []string{"a", ""}, op.Tags) + + var v ServerVariable + require.NoError(t, decodeNative(t, "default: d\nenum: [a, null]\n", &v)) + require.Equal(t, []string{"a", ""}, v.Enum) + + var sr SecurityRequirement + require.NoError(t, decodeNative(t, "scheme: [read, null]\n", &sr)) + require.Equal(t, []string{"read", ""}, sr["scheme"]) +} + +// A scalar's text is not a string where the scalar resolved to something else: +// `title: 1` is a number, which the json path refuses rather than reading as +// "1". +func TestNativeScalars_StringFieldsTakeStringsOnly(t *testing.T) { + for _, test := range []struct { + name, src, want string + }{ + {"an integer", "title: 1\nversion: \"1\"\n", `cannot unmarshal !!int into string field "title"`}, + {"a float", "title: t\nversion: 1.0\n", `cannot unmarshal !!float into string field "version"`}, + {"a boolean", "title: true\nversion: \"1\"\n", `cannot unmarshal !!bool into string field "title"`}, + } { + t.Run(test.name, func(t *testing.T) { + var info Info + require.ErrorContains(t, decodeNative(t, test.src, &info), test.want) + }) + } + + var s Schema + require.ErrorContains(t, decodeNative(t, "required: [1]\n", &s), + `cannot unmarshal !!int into string field "required"`) + require.ErrorContains(t, decodeNative(t, "discriminator: {propertyName: p, mapping: {a: 1}}\n", &s), + `cannot unmarshal !!int into string field "a"`) + + // A null is the empty string, as it is on the json path, and a date-shaped + // scalar is a string in this document's data model. + var info Info + require.NoError(t, decodeNative(t, "title: null\nversion: 2020-01-01\n", &info)) + require.Equal(t, "", info.Title) + require.Equal(t, "2020-01-01", info.Version) +} + +// YAML allows any scalar as a key and a document's names are strings, so a +// scalar key is spelled out. A null key spells nothing, and the json path has +// no key to decode it into, so the document is refused. +func TestNativeScalars_NullKeyNamesNothing(t *testing.T) { + var extension, example Schema + require.ErrorContains(t, decodeNative(t, "x-k: {null: a}\n", &extension), "mapping key is null") + require.ErrorContains(t, decodeNative(t, "example: {null: a}\n", &example), "mapping key is null") +} + +// A key names the text the document wrote it as, wherever it is read: the +// components a $ref addresses by name, and a mapping decoded into an any. +func TestNativeScalars_KeysAreTheirText(t *testing.T) { + var named Schema + require.NoError(t, decodeNative(t, "x-k: {1: a, true: b, 1.5: c, 0x2A: d}\n", &named)) + require.Equal(t, map[string]any{"1": "a", "true": "b", "1.5": "c", "0x2A": "d"}, named.Extensions["x-k"]) + + // Read as the value it resolves to instead, a key would name whatever + // another notation of the same number names, and the two would be one + // member with no telling which value it holds. + var same Schema + require.NoError(t, decodeNative(t, "x-k: {.0: a, 0: b, 18_24: c, 1824: d}\n", &same)) + require.Equal(t, map[string]any{".0": "a", "0": "b", "18_24": "c", "1824": "d"}, same.Extensions["x-k"]) + + // A key written as an alias says what the anchored scalar says, while the + // anchor's own use keeps the value it resolves to. + var aliased Schema + require.NoError(t, decodeNative(t, "x-a: &a 0x2A\nx-k:\n *a: v\n", &aliased)) + require.Equal(t, map[string]any{"0x2A": "v"}, aliased.Extensions["x-k"]) + require.Equal(t, float64(42), aliased.Extensions["x-a"]) + + // Two keys written the same text are the duplicate they are, through an + // alias as much as written out. + var dup, dupAlias Schema + require.ErrorContains(t, decodeNative(t, "x-k: {0: a, '0': b}\n", &dup), `mapping key "0" already defined`) + require.ErrorContains(t, decodeNative(t, "x-a: &a 18_24\nx-k:\n *a: v\n 18_24: w\n", &dupAlias), + `mapping key "18_24" already defined`) +} + +// A requirement written as an alias is the mapping it points at, so a null in +// its scopes keeps its place there too. +func TestNativeScalars_NullKeepsItsPlaceThroughAnAlias(t *testing.T) { + var doc T + require.NoError(t, decodeNative(t, "x-s: &s {scheme: [read, null]}\nsecurity: [*s]\n", &doc)) + require.Equal(t, []string{"read", ""}, doc.Security[0]["scheme"]) +} diff --git a/openapi3/native_yaml.go b/openapi3/native_yaml.go new file mode 100644 index 000000000..b23e637bb --- /dev/null +++ b/openapi3/native_yaml.go @@ -0,0 +1,666 @@ +package openapi3 + +//go:generate go run nativeyamlgenerator.go + +import ( + "errors" + "fmt" + "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 + } + if name := yamlKey(f); name != "-" { + known[name] = struct{}{} + } + } + knownYAMLFieldsCache.Store(t, known) + return known +} + +// yamlKey is the document key a struct field is written under. +func yamlKey(f reflect.StructField) string { + name, _, _ := strings.Cut(f.Tag.Get("yaml"), ",") + if name == "" { + name = strings.ToLower(f.Name) + } + return name +} + +// normalizeDecoded gives a decoded any the shape the JSON round trip used to +// guarantee, so a consumer's type switch does not depend on YAML notation: +// +// - integers widen to float64. JSON has one number type, so a value reaching +// an any-typed field carried a float64 whichever notation the source used. +// YAML resolves 42, 0x2A and 4_2 to an int. +// - non-string map keys become strings. YAML allows any scalar as a key, so +// an unquoted 1: becomes map[any]any, which json.Marshal rejects outright. +// +// 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 normalizeDecoded(v any) (any, error) { + switch t := v.(type) { + case int: + return float64(t), nil + case int64: + return float64(t), nil + case uint64: + return float64(t), nil + case map[string]any: + for k, e := range t { + n, err := normalizeDecoded(e) + if err != nil { + return nil, err + } + t[k] = n + } + case map[any]any: + m := make(map[string]any, len(t)) + for k, e := range t { + name, err := keyName(k) + if err != nil { + return nil, err + } + n, err := normalizeDecoded(e) + if err != nil { + return nil, err + } + m[name] = n + } + return m, nil + case []any: + for i, e := range t { + n, err := normalizeDecoded(e) + if err != nil { + return nil, err + } + t[i] = n + } + } + return v, nil +} + +// keyName is the name a key that is not a string is written under. A scalar +// key reaches the decoder already retagged by nameKeys, so what is left here +// is a key that is not a scalar at all -- a sequence or a mapping, which YAML +// allows and a document's names, being strings, do not describe -- and the +// null that nameKeys leaves to be refused. +// +// A null key spells nothing. It names no member of the document, and the json +// path has no key to decode it into, so a document that uses one is refused +// rather than given a made-up name. +func keyName(k any) (string, error) { + if k == nil { + return "", errors.New("mapping key is null, so it names nothing") + } + return fmt.Sprintf("%v", k), nil +} + +// mappingPairs returns node's effective key/value node pairs, applying YAML +// merge keys. +// +// Only Decode applies a merge, and these helpers read node.Content directly, so +// a << key arrives here unexpanded. Ignoring it drops whatever the merge brought +// in; treating it as an ordinary key records "<<" as an extension on a struct +// and as a member on a collection. Both corrupt the document, so the merge is +// resolved here instead, giving every raw-key pass the same view Decode has. +// +// Merge semantics: an explicit key wins over a merged one, and among merged +// sources an earlier one wins, per the YAML merge key spec. +// +// Interim: https://github.com/yaml/go-yaml/issues/451 asks the parser for a +// mapping's effective pairs, which is this function and the cycle guard below +// it. Reimplementing the rules here is also what the self-merge crasher came +// from. +func mappingPairs(node *yaml.Node) [][2]*yaml.Node { + return mappingPairsOf(node, nil) +} + +// mappingPairsOf is mappingPairs carrying the mappings whose merges are being +// expanded, so a mapping that merges itself through an alias contributes +// nothing instead of recursing until the stack ends. The set is allocated only +// for a mapping that has a merge key at all. +func mappingPairsOf(node *yaml.Node, expanding map[*yaml.Node]struct{}) [][2]*yaml.Node { + pairs := make([][2]*yaml.Node, 0, len(node.Content)/2) + var merged [][2]*yaml.Node + seen := make(map[string]struct{}, len(node.Content)/2) + + for i := 0; i+1 < len(node.Content); i += 2 { + k, v := node.Content[i], node.Content[i+1] + if isMergeKey(k) { + if expanding == nil { + expanding = make(map[*yaml.Node]struct{}, 1) + } + expanding[node] = struct{}{} + merged = append(merged, mergeSourcePairs(v, expanding)...) + continue + } + pairs = append(pairs, [2]*yaml.Node{k, v}) + seen[k.Value] = struct{}{} + } + + for _, kv := range merged { + if _, ok := seen[kv[0].Value]; ok { + continue + } + seen[kv[0].Value] = struct{}{} + pairs = append(pairs, kv) + } + return pairs +} + +// isMergeKey reports whether k is a merge key rather than an ordinary one. +// Only a plain << merges; quoted, it is a member actually named "<<", which +// the json path keeps and so must this. +func isMergeKey(k *yaml.Node) bool { + if k.Tag == mergeKeyTag { + return true + } + return k.Value == mergeKey && k.Style == 0 && (k.Tag == "" || k.Tag == strTag) +} + +// mergeSourcePairs returns the pairs a merge key's value contributes: one +// mapping, or a sequence of them in precedence order. Anything else is not a +// valid merge source and contributes nothing. +func mergeSourcePairs(v *yaml.Node, expanding map[*yaml.Node]struct{}) [][2]*yaml.Node { + if v.Kind == yaml.AliasNode { + v = v.Alias + } + if v == nil { + return nil + } + switch v.Kind { + case yaml.MappingNode: + if _, ok := expanding[v]; ok { + return nil + } + return mappingPairsOf(v, expanding) + case yaml.SequenceNode: + var out [][2]*yaml.Node + for _, e := range v.Content { + out = append(out, mergeSourcePairs(e, expanding)...) + } + return out + } + return nil +} + +const ( + mergeKey = "<<" + mergeKeyTag = "!!merge" + strTag = "!!str" + nullTag = "!!null" + timestampTag = "!!timestamp" +) + +// decodeAny decodes a value node into an any and normalizes it. Every path that +// puts a decoded value behind an any must come through here, so none of them can +// omit a normalization by forgetting to call it. +func decodeAny(node *yaml.Node) (any, error) { + var v any + if err := node.Decode(&v); err != nil { + return nil, err + } + return normalizeDecoded(v) +} + +// collectExtensions returns the mapping keys of node that known does not +// declare, decoded and normalized, along with their names in document order. +// The names are every undeclared sibling, including x- ones; callers that keep +// only extensions filter the map, and callers that validate siblings read the +// names. +func collectExtensions(node *yaml.Node, known map[string]struct{}) (map[string]any, []string, error) { + if node.Kind != yaml.MappingNode { + return nil, nil, nil + } + var ext map[string]any + var names []string + for _, kv := range mappingPairs(node) { + key := kv[0].Value + if _, ok := known[key]; ok { + continue + } + v, err := decodeAny(kv[1]) + if err != nil { + return nil, nil, err + } + if ext == nil { + ext = make(map[string]any) + } + ext[key] = v + names = append(names, key) + } + return ext, names, nil +} + +// 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) error { + v := reflect.ValueOf(out) + for v.Kind() == reflect.Pointer { + if v.IsNil() { + return nil + } + v = v.Elem() + } + if v.Kind() != reflect.Struct { + return nil + } + for i := range v.NumField() { + f := v.Field(i) + if !f.CanSet() { + continue + } + switch f.Kind() { + case reflect.Interface: + if f.IsNil() { + continue + } + n, err := normalizeDecoded(f.Interface()) + if err != nil { + return err + } + f.Set(reflect.ValueOf(n)) + 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() { + continue + } + n, err := normalizeDecoded(e.Interface()) + if err != nil { + return err + } + e.Set(reflect.ValueOf(n)) + } + continue + } + for _, k := range f.MapKeys() { + e := f.MapIndex(k) + if e.IsNil() { + continue + } + n, err := normalizeDecoded(e.Interface()) + if err != nil { + return err + } + f.SetMapIndex(k, reflect.ValueOf(n)) + } + } + } + return nil +} + +// 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. T is the target itself, named so that an error reports the +// type the document was read into. +func decodeMapping[T, S any](node *yaml.Node, shadow *S) (map[string]any, error) { + ext, err := decodeStructWithExtensions(node, shadow) + if err != nil { + return nil, targetTypeError[T, S](err) + } + return ext, nil +} + +// targetTypeError names T where a decoder error names the shadow type S, which +// exists only to stop the decode recursing and means nothing to a reader. +func targetTypeError[T, S any](err error) error { + var typeErr *yaml.TypeError + if !errors.As(err, &typeErr) { + return err + } + shadow, target := reflect.TypeFor[S]().String(), reflect.TypeFor[T]().String() + for i, message := range typeErr.Errors { + typeErr.Errors[i] = strings.ReplaceAll(message, shadow, target) + } + return typeErr +} + +// 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 + } + if err := checkDeclaredFields(node, out); err != nil { + return nil, err + } + ext, _, err := collectExtensions(node, knownYAMLFields(reflect.TypeOf(out).Elem())) + if err != nil { + return nil, err + } + if err := normalizeAnyFields(out); err != nil { + return nil, err + } + return ext, nil +} + +// checkDeclaredFields holds out's declared fields to the values a JSON +// document would carry, which the decoder is laxer than on two counts. +// +// A scalar's text goes into a string field whatever the scalar resolved to, so +// `title: 1` reads as "1" where the json path refuses the document. And a +// sequence element the target cannot hold is dropped, so `required: [a, null, +// b]` reads as two entries where the json path keeps the null's place as an +// empty string. A document that changes meaning with the decoder is worse than +// one that is refused, so a scalar that resolved to something other than a +// string is refused here, and a null in a list of strings keeps its place. +// +// Only out's own fields. A field whose type has an UnmarshalYAML of its own is +// held to this when the decoder reaches that type. +func checkDeclaredFields(node *yaml.Node, out any) error { + if node.Kind != yaml.MappingNode { + return nil + } + v := reflect.ValueOf(out).Elem() + t := v.Type() + for i := range t.NumField() { + f := t.Field(i) + if !f.IsExported() { + continue + } + key := yamlKey(f) + if key == "-" { + continue + } + value := mappingValue(node, key) + if value == nil { + continue + } + if err := checkFieldValue(v.Field(i), value, key); err != nil { + return err + } + } + return nil +} + +// checkFieldValue applies checkDeclaredFields to one field: the string slots +// are a string field, a list of strings, and a map whose values are written as +// text. Anything else is the decoder's business. +func checkFieldValue(field reflect.Value, node *yaml.Node, key string) error { + for field.Kind() == reflect.Pointer { + if field.IsNil() { + return nil + } + field = field.Elem() + } + switch { + case field.Kind() == reflect.String: + return requireStringScalar(node, key) + case field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.String: + return setStringList(field, node, key) + case field.Kind() == reflect.Map && writtenAsText(field.Type().Elem()): + for _, kv := range mappingPairs(node) { + if err := requireStringScalar(kv[1], kv[0].Value); err != nil { + return err + } + } + } + return nil +} + +// writtenAsText reports whether a value of type t is written as a scalar +// string, either because it is one or because it decodes from text. +func writtenAsText(t reflect.Type) bool { + if t.Kind() == reflect.String { + return true + } + return reflect.PointerTo(t).Implements(reflect.TypeFor[interface{ UnmarshalText(text []byte) error }]()) +} + +// setStringList sets a list of strings from node. The field may be any type +// whose underlying type is a list of strings, such as Types. +func setStringList(field reflect.Value, node *yaml.Node, key string) error { + list, ok, err := stringList(node, key) + if err != nil || !ok { + return err + } + field.Set(reflect.ValueOf(list).Convert(field.Type())) + return nil +} + +// stringList is the list of strings node holds, keeping the place of a null as +// the empty string. The decoder drops such an element, since a string cannot +// hold nil, which shortens the list without saying so. +// +// ok is false for a node that is not a sequence: one value where a list is +// allowed, as in `type: string`, and anything the decode reports itself. +func stringList(node *yaml.Node, key string) ([]string, bool, error) { + if node.Kind == yaml.AliasNode { + node = node.Alias + } + if node.Kind == yaml.ScalarNode { + return nil, false, requireStringScalar(node, key) + } + if node.Kind != yaml.SequenceNode { + return nil, false, nil + } + + list := make([]string /*,*/, len(node.Content)) + for i, e := range node.Content { + if e.Kind == yaml.AliasNode { + e = e.Alias + } + if err := requireStringScalar(e, key); err != nil { + return nil, false, err + } + if e.Kind != yaml.ScalarNode { + return nil, false, nil + } + if e.Tag != nullTag { + list[i] = e.Value + } + } + return list, true, nil +} + +// requireStringScalar refuses a scalar that resolved to something other than a +// string where a string belongs. +// +// A null is allowed: a string cannot hold nil, so the json path reads it as the +// empty string, and so does setStringList. A date-shaped scalar is allowed +// because it is a string in this document's data model, which is what +// stripTimestamp says. +func requireStringScalar(node *yaml.Node, key string) error { + if node.Kind != yaml.ScalarNode { + return nil + } + switch node.Tag { + case "", strTag, nullTag, timestampTag: + return nil + } + return fmt.Errorf("yaml: line %d: cannot unmarshal %s into string field %q", node.Line, node.Tag, key) +} + +// prepareScalars walks the tree once, which is the one place every scalar is +// seen before the decode reads it. +func prepareScalars(n *yaml.Node) error { + var hasAlias bool + if err := prepareNode(n, &hasAlias); err != nil { + return err + } + if hasAlias { + return checkAliasCycles(n) + } + return nil +} + +func prepareNode(n *yaml.Node, hasAlias *bool) error { + if n == nil { + return nil + } + if n.Kind == yaml.AliasNode { + *hasAlias = true + } + if n.Kind == yaml.ScalarNode { + stripLocalTag(n) + stripTimestamp(n) + if err := checkFinite(n); err != nil { + return err + } + } + if n.Kind == yaml.MappingNode { + nameKeys(n) + } + for _, c := range n.Content { + if err := prepareNode(c, hasAlias); err != nil { + return err + } + } + return nil +} + +// checkAliasCycles refuses a document in which an alias leads back to a node +// it sits inside, such as e: &a {properties: {p: *a}}. +// +// go-yaml refuses one too, but only within a single decode: it records the +// aliases it is expanding on the decoder. The native path decodes each typed +// value with a Node.Decode of its own, so every step starts with nothing +// recorded and the expansion recurses until the stack is exhausted, which is +// fatal rather than a panic a caller could recover. +// +// A node is walked once whatever number of aliases reach it, so the check is +// linear in the size of the tree even where expanding it would not be. +func checkAliasCycles(root *yaml.Node) error { + const ( + onPath = iota + 1 + done + ) + state := make(map[*yaml.Node]uint8) + var walk func(n *yaml.Node) error + walk = func(n *yaml.Node) error { + switch state[n] { + case onPath: + return fmt.Errorf("anchor %q value contains itself", n.Anchor) + case done: + return nil + } + state[n] = onPath + if n.Kind == yaml.AliasNode && n.Alias != nil { + if err := walk(n.Alias); err != nil { + return err + } + } + for _, c := range n.Content { + if err := walk(c); err != nil { + return err + } + } + state[n] = done + return nil + } + return walk(root) +} + +// nameKeys retags a scalar mapping key as a string, so the name it is read +// under is the text the document wrote. +// +// A document's names are strings, but YAML resolves a key like any other +// scalar, so .0 and 0 both resolve to the number zero and name the same +// member, as do 18_24 and 1824. Which of two such keys survives is then +// whichever the decoder writes last. The typed decode already reads a key's +// text; this gives a mapping decoded into an any the same reading, and leaves +// two keys written the same text to the duplicate-key error they are. +// +// A merge key is left alone, being an instruction rather than a name, and so +// is a null key, which names nothing and is reported as that. +func nameKeys(n *yaml.Node) { + for i := 0; i+1 < len(n.Content); i += 2 { + k := n.Content[i] + if isMergeKey(k) { + continue + } + // A key written as an alias names what the anchored scalar says, read + // from a copy so the anchor's own use keeps the type it resolves to, + // and left at the line the alias is written on, where the member is + // declared. + if k.Kind == yaml.AliasNode && k.Alias != nil && k.Alias.Kind == yaml.ScalarNode { + named := *k.Alias + named.Anchor = "" + named.Line, named.Column = k.Line, k.Column + n.Content[i], k = &named, &named + } + if k.Kind != yaml.ScalarNode || k.Tag == strTag || k.Tag == nullTag { + continue + } + k.Tag = strTag + } +} + +// stripLocalTag retags a scalar carrying an application's own tag, such as +// !md in description: !md hello, as a string. +// +// go-yaml decodes such a scalar only into an any, so a document whose author +// tagged a field for a tool of their own was refused. The json path read it +// as the text it wrote, whatever that text looks like: description: !md hello +// is "hello" and x-n: !n 42 is "42". A standard tag (!!int, !!str, ...) +// starts with two marks and is left to the decoder. +func stripLocalTag(n *yaml.Node) { + if strings.HasPrefix(n.Tag, "!") && !strings.HasPrefix(n.Tag, "!!") { + n.Tag = strTag + } +} + +// stripTimestamp retags a date-shaped scalar as a string. +// +// 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: not a type the document's own data model has, and one that +// marshals to text that reads back as a string, so the document would not +// survive a round trip. A tag written out asks for the same type and cannot +// have it either, so it is retagged too, and the scalar keeps the text the +// document gave it rather than the time it names. +// +// Interim: https://github.com/yaml/go-yaml/issues/452 asks for an option to +// leave such a scalar a string, which removes this. +func stripTimestamp(n *yaml.Node) { + if n.Tag == timestampTag { + n.Tag = strTag + } +} + +// checkFinite rejects infinity and NaN, which YAML has and JSON does not. +// Such a document decodes, and a float field or an extension then holds a +// value that cannot be written back: marshaling fails wherever the value sits, +// naming neither the document nor the line. Refusing it here names both. +func checkFinite(n *yaml.Node) error { + if n.Tag != "!!float" { + return nil + } + switch strings.ToLower(strings.TrimLeft(n.Value, "+-")) { + case ".inf", ".nan": + return fmt.Errorf("line %d: %s has no JSON representation", n.Line, n.Value) + } + return nil +} diff --git a/openapi3/native_yaml_alias_cycle_test.go b/openapi3/native_yaml_alias_cycle_test.go new file mode 100644 index 000000000..6a8884c8d --- /dev/null +++ b/openapi3/native_yaml_alias_cycle_test.go @@ -0,0 +1,35 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// An alias that leads back to a node it sits inside is refused, rather than +// expanded until the stack runs out, which no recover can catch. +func TestNativeYAMLAliasCycle(t *testing.T) { + const head = "openapi: 3.1.0\ninfo: {title: t, version: '1'}\npaths: {}\n" + + for name, doc := range map[string]string{ + "property": head + "components:\n schemas:\n e: &a\n properties:\n p: *a\n", + "items": head + "components:\n schemas:\n e: &a\n items: *a\n", + "nested": head + "components:\n schemas:\n e: &a\n allOf:\n - properties:\n p:\n items: *a\n", + "mutual": head + "components:\n schemas:\n a: &a\n items: &b\n not: *a\n b: *b\n", + "untyped": "components:\n schemas:\n e: &base\n properties:\n 2: *base\n", + } { + t.Run(name, func(t *testing.T) { + loader := NewLoader() + _, err := loader.LoadFromData([]byte(doc)) + require.ErrorContains(t, err, "value contains itself") + }) + } + + t.Run("shared", func(t *testing.T) { + doc := head + "components:\n schemas:\n s: &s {type: string}\n o:\n properties:\n a: *s\n b: *s\n items: *s\n" + loader := NewLoader() + got, err := loader.LoadFromData([]byte(doc)) + require.NoError(t, err) + require.Equal(t, "string", got.Components.Schemas["o"].Value.Properties["b"].Value.Type.Slice()[0]) + }) +} diff --git a/openapi3/native_yaml_direct_test.go b/openapi3/native_yaml_direct_test.go new file mode 100644 index 000000000..b2c37495e --- /dev/null +++ b/openapi3/native_yaml_direct_test.go @@ -0,0 +1,32 @@ +package openapi3_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + yaml "go.yaml.in/yaml/v3" + + "github.com/getkin/kin-openapi/openapi3" +) + +// Decoding these types with the yaml package keeps what the document said. +// Without UnmarshalYAML the decoder fills declared fields by struct tag, so an +// extension is dropped, since Extensions carries `yaml:"-"`, and a key that +// differs from a field only in case is dropped with it. +func TestYAMLDecodeKeepsExtensionsAndKeyCase(t *testing.T) { + t.Parallel() + + const src = "title: t\nversion: \"1\"\nx-custom: 7\nTitle: other\n" + + var info openapi3.Info + require.NoError(t, yaml.Unmarshal([]byte(src), &info)) + require.Equal(t, "t", info.Title) + require.Equal(t, "1", info.Version) + require.Equal(t, map[string]any{"x-custom": float64(7), "Title": "other"}, info.Extensions) + + // And it marshals back to what a JSON decode of the same document gives. + out, err := json.Marshal(&info) + require.NoError(t, err) + require.JSONEq(t, `{"title":"t","version":"1","x-custom":7,"Title":"other"}`, string(out)) +} diff --git a/openapi3/native_yaml_local_tag_test.go b/openapi3/native_yaml_local_tag_test.go new file mode 100644 index 000000000..513bf01db --- /dev/null +++ b/openapi3/native_yaml_local_tag_test.go @@ -0,0 +1,34 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// A scalar carrying an application's own tag reads as the text it wrote, as it +// does on the json path, whatever field it sits in. +func TestNativeYAMLLocalTag(t *testing.T) { + doc := []byte(`openapi: 3.0.0 +info: + title: t + version: !v '1' + description: !md hello + x-n: !n 42 + x-m: !m {a: !b true} + x-s: !s [1, x] +paths: {} +components: + schemas: + S: + type: !0 string +`) + loader := NewLoader() + got, err := loader.LoadFromData(doc) + require.NoError(t, err) + + info, err := got.Info.MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, `{"title":"t","version":"1","description":"hello","x-n":"42","x-m":{"a":"true"},"x-s":[1,"x"]}`, string(info)) + require.True(t, got.Components.Schemas["S"].Value.Type.Is("string")) +} diff --git a/openapi3/native_yaml_normalization_test.go b/openapi3/native_yaml_normalization_test.go new file mode 100644 index 000000000..2a4ad0901 --- /dev/null +++ b/openapi3/native_yaml_normalization_test.go @@ -0,0 +1,301 @@ +package openapi3 + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "testing" +) + +// Three functions decode a mapping, and each has to apply the same four +// normalizations to what it produces: +// +// struct - decodeStructWithExtensions, for objects with declared fields +// maplike - unmarshalMaplikeYAML, for keyed collections (Responses, Callback) +// ref - unmarshalRefYAML, for the $ref wrappers +// +// The normalizations: +// +// siblings - an undeclared non-x- key is recorded, so Validate can report it +// wherever the object it sits on rejects extra fields +// numbers - an integer behind an any is a float64, whatever the notation +// keys - a non-string mapping key becomes a string, so the document marshals +// merge - a << key is applied, neither ignored nor treated as content +// +// This table covers each function against each normalization. A function that +// leaves one out fails here. +var nativeDecodeTests = []struct { + path, property string + spec string + assert func(t *testing.T, doc *T) +}{ + { + path: "struct", property: "siblings", + spec: ` +openapi: 3.0.0 +info: {title: t, version: "1", bogus: true} +paths: {} +`, + assert: func(t *testing.T, doc *T) { requireValidateFails(t, doc, "bogus") }, + }, + { + path: "struct", property: "numbers", + spec: ` +openapi: 3.0.0 +info: {title: t, version: "1", x-num: 7} +paths: {} +`, + assert: func(t *testing.T, doc *T) { requireFloat(t, doc.Info.Extensions["x-num"]) }, + }, + { + path: "struct", property: "keys", + spec: ` +openapi: 3.0.0 +info: + title: t + version: "1" + x-thing: {nested: {1: one}} +paths: {} +`, + assert: requireMarshalable, + }, + { + path: "struct", property: "merge", + spec: ` +openapi: 3.0.0 +x-defaults: &d + title: t + x-num: 7 +info: + <<: *d + version: "1" +paths: {} +`, + assert: func(t *testing.T, doc *T) { + if _, ok := doc.Info.Extensions[mergeKey]; ok { + t.Errorf("merge key kept as an extension: %v", doc.Info.Extensions) + } + if doc.Info.Title != "t" { + t.Errorf("merged field lost: title = %q", doc.Info.Title) + } + if doc.Info.Extensions["x-num"] == nil { + t.Error("merged extension lost") + } + requireValidates(t, doc) + }, + }, + { + path: "maplike", property: "numbers", + spec: ` +openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /a: + get: + responses: + x-num: 7 + "200": {description: ok} +`, + assert: func(t *testing.T, doc *T) { requireFloat(t, responsesOf(doc).Extensions["x-num"]) }, + }, + { + path: "maplike", property: "keys", + spec: ` +openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /a: + get: + responses: + x-thing: {nested: {1: one}} + "200": {description: ok} +`, + assert: requireMarshalable, + }, + { + path: "maplike", property: "merge", + spec: ` +openapi: 3.0.0 +info: {title: t, version: "1"} +x-tmpl: &d + "200": {description: ok} +paths: + /a: + get: + responses: + <<: *d +`, + assert: func(t *testing.T, doc *T) { + if responsesOf(doc).Value("200") == nil { + t.Error("merged response lost") + } + requireValidates(t, doc) + }, + }, + { + path: "ref", property: "siblings", + spec: ` +openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /a: + get: + responses: + "200": {$ref: '#/components/responses/R', bogus: true} +components: + responses: + R: {description: ok} +`, + // A Reference Object ignores the siblings it does not define, so this + // document validates; the name still has to be recorded, which is what + // lets Validate report one where the object does reject it. + assert: func(t *testing.T, doc *T) { + requireRecorded(t, responsesOf(doc).Value("200").extra, "bogus") + requireValidates(t, doc) + }, + }, + { + path: "ref", property: "numbers", + spec: ` +openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /a: + get: + responses: + "200": {$ref: '#/components/responses/R', x-num: 7} +components: + responses: + R: {description: ok} +`, + assert: func(t *testing.T, doc *T) { + requireFloat(t, responsesOf(doc).Map()["200"].Extensions["x-num"]) + }, + }, + { + path: "ref", property: "keys", + spec: ` +openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /a: + get: + responses: + "200": + $ref: '#/components/responses/R' + x-meta: {1: one} +components: + responses: + R: {description: ok} +`, + assert: requireMarshalable, + }, + { + path: "ref", property: "merge", + spec: ` +openapi: 3.0.0 +info: {title: t, version: "1"} +x-tmpl: &d + $ref: '#/components/responses/R' +paths: + /a: + get: + responses: + "200": + <<: *d +components: + responses: + R: {description: ok} +`, + assert: func(t *testing.T, doc *T) { + if got := responsesOf(doc).Map()["200"].Ref; got != "#/components/responses/R" { + t.Errorf("merged $ref lost: %q", got) + } + requireValidates(t, doc) + }, + }, +} + +func TestNativeDecodePathsApplyTheSameNormalizations(t *testing.T) { + for _, c := range nativeDecodeTests { + t.Run(c.path+"/"+c.property, func(t *testing.T) { + loader := NewLoader() + doc, err := loader.LoadFromData([]byte(c.spec)) + if err != nil { + t.Fatalf("load: %v", err) + } + c.assert(t, doc) + }) + } +} + +// notApplicable records the combinations that cannot exist, with the reason. +// A combination that is neither tested nor listed here fails, so adding a decode +// function or a normalization has to come with its tests. +var notApplicable = map[string]string{ + "maplike/siblings": "every non-x- key of a keyed collection is a member of it, so there is no undeclared sibling to record", +} + +func TestNativeDecodeCoversEveryNormalization(t *testing.T) { + tested := map[string]bool{} + for _, c := range nativeDecodeTests { + tested[c.path+"/"+c.property] = true + } + for _, path := range []string{"struct", "maplike", "ref"} { + for _, property := range []string{"siblings", "numbers", "keys", "merge"} { + name := path + "/" + property + _, exempt := notApplicable[name] + if !tested[name] && !exempt { + t.Errorf("%s is untested\n add it to nativeDecodeTests, or list it in notApplicable with a reason", name) + } + if tested[name] && exempt { + t.Errorf("%s is tested but still listed in notApplicable\n remove it from notApplicable", name) + } + } + } +} + +func responsesOf(doc *T) *Responses { + return doc.Paths.Value("/a").Get.Responses +} + +func requireValidates(t *testing.T, doc *T) { + t.Helper() + if err := doc.Validate(context.Background()); err != nil { + t.Errorf("validate: %v", err) + } +} + +func requireRecorded(t *testing.T, extra []string, name string) { + t.Helper() + if !slices.Contains(extra, name) { + t.Errorf("sibling %q was not recorded, got %v", name, extra) + } +} + +func requireValidateFails(t *testing.T, doc *T, name string) { + t.Helper() + if err := doc.Validate(context.Background()); err != nil { + if want := fmt.Sprintf("[%s]", name); !strings.Contains(err.Error(), want) { + t.Errorf("validate reported %v, want it to name %s", err, want) + } + return + } + t.Errorf("undeclared sibling %q was accepted", name) +} + +func requireFloat(t *testing.T, v any) { + t.Helper() + if _, ok := v.(float64); !ok { + t.Errorf("integer behind an any is %T(%v), want float64", v, v) + } +} + +func requireMarshalable(t *testing.T, doc *T) { + t.Helper() + if _, err := json.Marshal(doc); err != nil { + t.Errorf("document loaded but does not marshal: %v", err) + } +} diff --git a/openapi3/native_yaml_refs.go b/openapi3/native_yaml_refs.go new file mode 100644 index 000000000..1923f7aba --- /dev/null +++ b/openapi3/native_yaml_refs.go @@ -0,0 +1,77 @@ +package openapi3 + +// Shared by the generated $ref wrapper UnmarshalYAML methods in refs.go. + +import ( + "strings" + + yaml "go.yaml.in/yaml/v3" +) + +// refYAMLFields are the keys the wrapper declares itself, so they are not +// siblings. summary and description count only for wrappers that carry them: +// SchemaRef does not, and there they are siblings like any other key, which is +// what the JSON path records. + +// 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, extra *[]string, siblingExtras *map[string]any) bool { + refNode := mappingValue(node, "$ref") + if refNode != nil && refNode.Kind == yaml.AliasNode && refNode.Alias != nil { + refNode = refNode.Alias + } + // A $ref is a string. A null, a number or a collection is not a reference, + // so the object decodes as an ordinary value, which is what the json path + // does with a $ref that will not decode into a string. + if refNode == nil || refNode.Kind != yaml.ScalarNode || refNode.Tag != strTag || refNode.Value == "" { + return false + } + *ref = refNode.Value + + known := map[string]struct{}{"$ref": {}} + if summary != nil { + known["summary"] = struct{}{} + } + if description != nil { + known["description"] = struct{}{} + } + + for _, kv := range mappingPairs(node) { + switch k, v := kv[0].Value, kv[1]; { + case k == "summary" && summary != nil: + var str string + if v.Decode(&str) == nil { + *summary = &str + } + case k == "description" && description != nil: + var str string + if v.Decode(&str) == nil { + *description = &str + } + } + } + + // Every undeclared sibling is recorded, not only the x- ones: Validate reads + // these names, and a name it never sees is a name it cannot report. The x- + // ones are extensions, the rest are siblings of the $ref, which 3.1 lets a + // schema apply alongside the resolved reference. + siblings, names, err := collectExtensions(node, known) + if err != nil { + return true + } + *extra = names + for k, v := range siblings { + if !strings.HasPrefix(k, "x-") { + if *siblingExtras == nil { + *siblingExtras = make(map[string]any, len(siblings)) + } + (*siblingExtras)[k] = v + continue + } + if *extensions == nil { + *extensions = make(map[string]any) + } + (*extensions)[k] = v + } + return true +} diff --git a/openapi3/native_yaml_shadow.go b/openapi3/native_yaml_shadow.go new file mode 100644 index 000000000..809dc0ecb --- /dev/null +++ b/openapi3/native_yaml_shadow.go @@ -0,0 +1,265 @@ +// 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[Components](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[Contact](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[Discriminator](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[Encoding](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[Example](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[ExternalDocs](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[Info](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[License](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[Link](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[MediaType](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[OAuthFlow](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[OAuthFlows](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[Parameter](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[PathItem](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[RequestBody](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[Response](node, (*bis)(response)) + if err != nil { + return err + } + response.Extensions, response.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, response, nativeOriginFile()) + return nil +} + +// UnmarshalYAML sets SecurityScheme from node. +func (ss *SecurityScheme) UnmarshalYAML(node *yaml.Node) error { + type bis SecurityScheme + ext, err := decodeMapping[SecurityScheme](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[Server](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[ServerVariable](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[Tag](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[XML](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..0b8ac94a0 --- /dev/null +++ b/openapi3/native_yaml_special.go @@ -0,0 +1,217 @@ +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 _, kv := range mappingPairs(node) { + k, v := kv[0].Value, kv[1] + if strings.HasPrefix(k, "x-") { + a, err := decodeAny(v) + if 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), kv[0], kv[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. Swagger 2 and OpenAPI 3.0 documents +// always write `type` as a scalar, so the single-string case is what decoding +// such a document into openapi2.T or openapi3.T relies on. +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 == nullTag { + 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 +} + +// Schema is `true`/`false` or a mapping. JSON Schema 2020-12, which OpenAPI 3.1 +// adopts, allows a boolean wherever a schema is expected. +// +// The mapping branch is what the generator emits for the other declared-field +// types; only the scalar case is particular to Schema. +func (schema *Schema) UnmarshalYAML(node *yaml.Node) error { + if node.Kind == yaml.ScalarNode && node.Tag == "!!bool" { + var b bool + if err := node.Decode(&b); err != nil { + return err + } + schema.Always, schema.Origin = &b, originFromNode(node, nativeOriginFile()) + return nil + } + + type bis Schema + ext, err := decodeMapping[Schema](node, (*bis)(schema)) + if err != nil { + return err + } + schema.Extensions, schema.Origin = ext, originFromNode(node, nativeOriginFile()) + setChildOriginKeys(node, schema, nativeOriginFile()) + 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 == nullTag { + 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[Operation](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 == nullTag { + 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[T](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 +} + +// UnmarshalYAML sets SecurityRequirement from node. +// +// A scheme's scopes are a list of strings, and decoding one directly drops a +// null from it rather than keeping its place, which is what stringList is for. +func (sr *SecurityRequirement) UnmarshalYAML(node *yaml.Node) error { + type bis SecurityRequirement + if err := node.Decode((*bis)(sr)); err != nil { + return err + } + if node.Kind == yaml.AliasNode { + node = node.Alias + } + for _, kv := range mappingPairs(node) { + scopes, ok, err := stringList(kv[1], kv[0].Value) + if err != nil { + return err + } + if ok { + (*sr)[kv[0].Value] = scopes + } + } + return nil +} diff --git a/openapi3/native_yaml_test.go b/openapi3/native_yaml_test.go new file mode 100644 index 000000000..1aef1f9bd --- /dev/null +++ b/openapi3/native_yaml_test.go @@ -0,0 +1,99 @@ +package openapi3 + +import ( + "encoding/json" + "fmt" + "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) { + var r Responses + // Through unmarshal rather than the yaml package: origins are stamped only + // for the tree a decode indexed. + _, err := unmarshal([]byte(nativeSrc), &r, true, nil) + require.NoError(t, err) + 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) +} + +// A type error names the OpenAPI type the document was read into, not the +// shadow type that only exists to stop the decode recursing. +func TestNativeStock_TypeErrorNamesTheTarget(t *testing.T) { + for _, test := range []struct { + name string + target any + expect string + }{ + {"document", &T{}, "openapi3.T"}, + {"info", &Info{}, "openapi3.Info"}, + } { + t.Run(test.name, func(t *testing.T) { + err := goyaml.Unmarshal([]byte("GET /anything\n"), test.target) + require.ErrorContains(t, err, test.expect) + require.NotContains(t, fmt.Sprint(err), "bis", "the shadow type is an implementation detail") + }) + } +} + +// A key YAML 1.1 reads as an int, such as an age bucket written 18_24, stays +// the string it was written as, so a $ref naming it still points at it. +func TestNativeStock_UnderscoredKeyStaysAString(t *testing.T) { + const src = `schemas: + 18_24: + type: object + 25_34: + type: object +` + var components Components + require.NoError(t, goyaml.Unmarshal([]byte(src), &components)) + require.Contains(t, components.Schemas, "18_24") + require.Contains(t, components.Schemas, "25_34") +} diff --git a/openapi3/nativeyaml.tmpl b/openapi3/nativeyaml.tmpl new file mode 100644 index 000000000..fcf28a1cd --- /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[{{ $type.Name }}](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..b40ec5fe3 --- /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, Schema, which may be written as a boolean, 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"}, + {"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/non_finite_test.go b/openapi3/non_finite_test.go new file mode 100644 index 000000000..2bdf733b8 --- /dev/null +++ b/openapi3/non_finite_test.go @@ -0,0 +1,58 @@ +package openapi3_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +// YAML has infinity and NaN and JSON does not, so a document carrying one +// cannot be written back. It is refused where it is read, naming the line, +// rather than at the marshal that would otherwise fail later. +func TestNonFiniteNumbersAreRefused(t *testing.T) { + t.Parallel() + + const header = "openapi: 3.1.0\ninfo: {title: t, version: \"1\"}\npaths: {}\n" + + for _, tt := range []struct { + name, doc, want string + }{ + {name: "schema bound", doc: "components:\n schemas:\n S: {type: number, maximum: .inf}\n", want: "line 6: .inf"}, + {name: "negative bound", doc: "components:\n schemas:\n S: {type: number, minimum: -.inf}\n", want: "line 6: -.inf"}, + {name: "extension", doc: "x-ratio: .NaN\n", want: "line 4: .NaN"}, + {name: "inside a sequence", doc: "x-list: [1, +.inf]\n", want: "line 4: +.inf"}, + {name: "explicitly tagged", doc: "x-ratio: !!float .inf\n", want: "line 4: .inf"}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + loader := openapi3.NewLoader() + _, err := loader.LoadFromData([]byte(header + tt.doc)) + require.Error(t, err) + require.ErrorContains(t, err, tt.want) + require.ErrorContains(t, err, noJSONRepresentation) + }) + } +} + +const noJSONRepresentation = "has no JSON representation" + +// A float JSON can represent, and a quoted one that is a string, both load. +func TestFiniteNumbersAndQuotedInfinityLoad(t *testing.T) { + t.Parallel() + + const spec = "openapi: 3.1.0\ninfo: {title: t, version: \"1\"}\npaths: {}\nx-big: 1e308\nx-text: '.inf'\n" + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + require.InDelta(t, 1e308, doc.Extensions["x-big"], 0) + require.Equal(t, ".inf", doc.Extensions["x-text"]) + + out, err := doc.MarshalJSON() + require.NoError(t, err) + require.Contains(t, string(out), fmt.Sprintf("%v", 1e308)) +} diff --git a/openapi3/null_ref_test.go b/openapi3/null_ref_test.go new file mode 100644 index 000000000..2383164bf --- /dev/null +++ b/openapi3/null_ref_test.go @@ -0,0 +1,37 @@ +package openapi3_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +// A null where a component belongs decodes to a ref with neither $ref nor a +// value. The loader rejects it, but a document unmarshaled directly holds it, +// and it must marshal back to null rather than dereference the missing value. +func TestRefs_NullValueMarshalsAsNull(t *testing.T) { + spec := []byte(`{ + "openapi": "3.0.0", + "info": {"title": "t", "version": "1"}, + "paths": {"/a": {"get": {"parameters": [null], "responses": {"200": null}}}}, + "components": { + "schemas": {"A": null}, + "responses": {"R": null}, + "parameters": {"P": null} + } + }`) + var doc openapi3.T + require.NoError(t, json.Unmarshal(spec, &doc)) + + once, err := json.Marshal(&doc) + require.NoError(t, err) + + var reloaded openapi3.T + require.NoError(t, json.Unmarshal(once, &reloaded)) + twice, err := json.Marshal(&reloaded) + require.NoError(t, err) + require.JSONEq(t, string(once), string(twice)) +} diff --git a/openapi3/openapi3.go b/openapi3/openapi3.go index 4463f7d13..ee2ed4fe8 100644 --- a/openapi3/openapi3.go +++ b/openapi3/openapi3.go @@ -163,7 +163,9 @@ func (doc *T) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "openapi") delete(x.Extensions, "components") delete(x.Extensions, "info") diff --git a/openapi3/openapi3_test.go b/openapi3/openapi3_test.go index 6109ea892..a7601ef9b 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" ) @@ -59,13 +59,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) @@ -84,9 +84,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)) @@ -438,7 +438,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/operation.go b/openapi3/operation.go index c734e6962..06ce48408 100644 --- a/openapi3/operation.go +++ b/openapi3/operation.go @@ -116,7 +116,11 @@ func (operation *Operation) UnmarshalJSON(data []byte) error { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + + return unmarshalError(err) + + } if value, present := x.Extensions["responses"]; present && value == nil { x.Responses = &Responses{explicitlyNull: true} } diff --git a/openapi3/origin.go b/openapi3/origin.go index c41166f43..595501d83 100644 --- a/openapi3/origin.go +++ b/openapi3/origin.go @@ -1,12 +1,25 @@ 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" + "sync/atomic" - "github.com/oasdiff/yaml" + yaml "go.yaml.in/yaml/v3" + + "github.com/getkin/kin-openapi/internal/nodeindex" ) var originPtrType = reflect.TypeFor[*Origin]() @@ -108,296 +121,385 @@ type Location struct { EndColumn int `json:"endColumn,omitempty,case:strict" 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 *nodeindex.Index +} + +// originDecode is what a decode has to reach from UnmarshalYAML, which +// receives a node and nothing else: the file to stamp into origins, and the +// index of the tree being decoded. A nil state means no decode is in progress +// and nothing gets an origin. // -// 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]) - - o := &Origin{ - Key: &Location{ - File: file, - Line: keyLine, - Column: keyCol, - Name: keyName, - }, - } - - idx := 4 - nf := toInt(s[idx]) - idx++ - if nf > 0 && idx+nf*3 <= len(s) { - o.Fields = make(FieldLocations, 0, nf) - for range nf { - fname, _ := s[idx].(string) - delta := toInt(s[idx+1]) - col := toInt(s[idx+2]) - o.Fields = append(o.Fields, Location{ - File: file, - Line: keyLine + delta, - Column: col, - Name: fname, - }) - idx += 3 - } - } +// A file recorded on the node itself would remove the need for state beside +// the call, but go-yaml's Node has no field for it. +// +// Interim: https://github.com/yaml/go-yaml/pull/392 passes a context through +// the decode, which carries both of these to the call that needs them and +// retires this type, the state below and the lock with it. +type originDecode struct { + file string + ends *nodeindex.Index +} - if idx >= len(s) { - return o +// originState points at the decode in progress. It is read atomically because +// a caller that hands openapi3 types to the yaml package decodes in its own +// goroutine, outside the lock a decode holds, and so reads this while another +// goroutine is writing it. +// +// One immutable value, published and replaced, is the whole of it: no counter, +// no compare-and-swap, nothing the atomic package's own warning about +// hand-rolled synchronisation is about. Guarding it with an RWMutex instead +// measures the same, single and in parallel, and costs two things: a second +// primitive beside the mutex that already serialises decodes, and a rule that +// the write lock must never be held across a decode, since the decode reads +// this itself and would deadlock. A reader here cannot block or deadlock, and +// the only rule is not to mutate what was published. +var originState atomic.Pointer[originDecode] + +// originMu serialises decodes, since there is one state above for all of them. +// The cost is that decodes wait on each other even when each has its own +// Loader, which TestIssue741 does. +var originMu sync.Mutex + +// 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. + +func nativeOriginFile() string { + if st := originState.Load(); st != nil { + return st.file } - 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 - } + return "" +} + +// originsApply reports whether node belongs to the decode in progress. A +// caller handing openapi3 types to the yaml package decodes a tree no decode +// here indexed, and gets no origins rather than another document's. +func originsApply(node *yaml.Node) bool { + st := originState.Load() + if st == nil { + return false } + return st.ends.Has(node) +} + +// mappingValue returns the value node for key, or nil. +func mappingValue(node *yaml.Node, key string) *yaml.Node { + _, v := mappingEntry(node, key) + return v +} - // 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 +// mappingEntry returns both halves of a mapping entry, the key being what a +// value's origin records as its own location. Merge keys are applied, so a +// field a merge brought in is found here the same as one written in place. +func mappingEntry(node *yaml.Node, key string) (*yaml.Node, *yaml.Node) { + if node.Kind != yaml.MappingNode { + return nil, nil + } + for _, kv := range mappingPairs(node) { + if kv[0].Value == key { + return kv[0], kv[1] } } - return o + return nil, nil } -// 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) +// 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 !originsApply(node) { + return nil } - return 0 -} + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + 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}) -// 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 + 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 + } } - 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 + if o.Fields == nil && o.Sequences == nil { + return nil } - return false + return o } -// recordMapKeyLocations moves 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. +// setChildOriginKeys sets Origin.Key on the immediate children of a mapping, +// from the key node heading each one. // -// childOrigin is discarded here, and nothing else holds its Fields, so the -// slice is sorted and handed over in place rather than copied. -func recordMapKeyLocations(parentOrigin *Origin, field string, childTree *yaml.OriginTree) { - s, ok := childTree.Origin.([]any) - if !ok { +// 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 !originsApply(node) { return } - childOrigin := originFromSeq(s) - if childOrigin == nil || len(childOrigin.Fields) == 0 { + if node == nil || node.Kind != yaml.MappingNode { return } - locs := childOrigin.Fields - slices.SortFunc(locs, func(a, b Location) int { return strings.Compare(a.Name, b.Name) }) - if parentOrigin.Sequences == nil { - parentOrigin.Sequences = make(map[string][]Location) + v := reflect.ValueOf(container) + for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface { + if v.IsNil() { + return + } + v = v.Elem() } - parentOrigin.Sequences[field] = locs -} + 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) -// 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 { - return + 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) + } + } + } } - 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() { - return - } - if val.Kind() == reflect.Pointer { - ptr = val +func deref(v reflect.Value) reflect.Value { + for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface { + if v.IsNil() { + return v } - val = val.Elem() + v = v.Elem() } + return v +} - switch val.Kind() { - case reflect.Struct: - applyOriginsToStruct(val, ptr, tree) +// 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: - applyOriginsToMap(val, tree) - case reflect.Slice: - applyOriginsToSlice(val, tree) + if v.IsNil() { + return reflect.Value{} + } + 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{} } -func applyOriginsToStruct(val reflect.Value, ptr reflect.Value, tree *yaml.OriginTree) { - typ := val.Type() - - // 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)) - } - } +// 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 !originsApply(keyNode) { + 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) +} - // 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 - } - 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) +// 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 _, name := range [...]string{"Value", "Schema"} { + if inner := child.FieldByName(name); inner.IsValid() { + setOriginKey(inner, keyNode, valNode, file) } - applyOriginsToValue(val.Field(i), childTree) } +} - // 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 - } - sf, _ := typ.FieldByName(fieldName) - if sf.Tag.Get("json") == "" { - applyOriginsToValue(vf, tree) +// 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 !originsApply(node) { + 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 + } + stampOriginKey(rv, node) - // 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) - } - } + // A file holding a bare component decodes to a ref wrapper, so the object + // written at the root is the one the wrapper holds. A wrapper whose file + // holds a $ref has no value yet, and the value it later resolves to was + // written elsewhere. + if value := rv.FieldByName("Value"); value.IsValid() && value.Kind() == reflect.Pointer && !value.IsNil() { + stampRootOrigin(value.Interface(), node) } } -func applyOriginsToMap(val reflect.Value, tree *yaml.OriginTree) { - if tree.Fields == nil { +// stampOriginKey sets Origin.Key on rv from node, leaving one already set. +func stampOriginKey(rv reflect.Value, node *yaml.Node) { + f := rv.FieldByName("Origin") + if !f.IsValid() || f.Type() != originPtrType || !f.CanSet() || f.IsNil() { 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) - } + o := f.Interface().(*Origin) + if o.Key != nil { + return } + key := withEnd(Location{File: nativeOriginFile(), Line: node.Line, Column: node.Column}, node) + o.Key = &key } -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]) - } +// 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 } -// 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 "" +// withEnd returns loc carrying the extent of the block node heads. +func withEnd(loc Location, node *yaml.Node) Location { + if st := originState.Load(); st != nil { + loc.EndLine, loc.EndColumn = st.ends.EndOf(node) } - name, _, _ := strings.Cut(tag, ",") - return name + return loc } -// 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 +// 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 + } + st := originState.Load() + if st == nil { + return keyNode, valNode.Alias + } + if k, ok := st.ends.AnchorKey(valNode.Alias); ok { + return k, valNode.Alias + } + return keyNode, valNode.Alias +} diff --git a/openapi3/origin_concurrency_test.go b/openapi3/origin_concurrency_test.go new file mode 100644 index 000000000..3f05a73ca --- /dev/null +++ b/openapi3/origin_concurrency_test.go @@ -0,0 +1,82 @@ +package openapi3 + +import ( + "sync" + "testing" + + goyaml "go.yaml.in/yaml/v3" +) + +const concurrentSpec = ` +openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /a: + get: + responses: + "200": {description: ok} +` + +// A decode reaches UnmarshalYAML through package state, so a caller handing +// these types to the yaml package itself decodes while another goroutine holds +// that state. Such a decode indexed no tree, so it gets no origins, and reads +// the state without racing the decode that set it. +// +// Run with -race, which the workflow does by name. Skipped in -short mode: the +// concurrency is the point of the test, and the Windows runners already retry +// the short suite around flaky memory corruption in Go 1.26 and 1.27. +func TestConcurrentDecodeDoesNotLeakOrigins(t *testing.T) { + if testing.Short() { + t.Skip("decodes concurrently; runs in the dedicated -race step") + } + + var wg sync.WaitGroup + wg.Add(3) + + go func() { + defer wg.Done() + for range 50 { + loader := NewLoader() + loader.IncludeOrigin = true + if _, err := loader.LoadFromData([]byte(concurrentSpec)); err != nil { + t.Error(err) + return + } + } + }() + + go func() { + defer wg.Done() + for range 50 { + var doc T + if err := goyaml.Unmarshal([]byte(concurrentSpec), &doc); err != nil { + t.Error(err) + return + } + if doc.Info != nil && doc.Info.Origin != nil { + t.Error("a decode through the yaml package took the origins of a decode running beside it") + return + } + } + }() + + // A Loader that asked for no origins holds no lock, so it runs beside the + // one above rather than waiting for it, and must still come back bare. + go func() { + defer wg.Done() + for range 50 { + loader := NewLoader() + doc, err := loader.LoadFromData([]byte(concurrentSpec)) + if err != nil { + t.Error(err) + return + } + if doc.Info.Origin != nil { + t.Error("a load that asked for no origins took the origins of a decode running beside it") + return + } + } + }() + + wg.Wait() +} diff --git a/openapi3/origin_load_test.go b/openapi3/origin_load_test.go index 9de9d7a57..5a7e54020 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) { t.Parallel() diff --git a/openapi3/origin_release_test.go b/openapi3/origin_release_test.go new file mode 100644 index 000000000..f5bfadf4c --- /dev/null +++ b/openapi3/origin_release_test.go @@ -0,0 +1,20 @@ +package openapi3 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// A decode releases its per-decode state when it ends. That state indexes the +// whole node tree, so leaving it set pins that tree until the next decode +// replaces it, and does so for every caller whether or not rememberOriginTree +// kept the tree. On a 25 MB spec that tree is most of what a load holds. +func TestUnmarshalReleasesPerDecodeState(t *testing.T) { + loader := NewLoader() + loader.IncludeOrigin = true + _, err := loader.LoadFromData([]byte("openapi: 3.0.0\ninfo: {title: t, version: \"1\"}\npaths: {}\n")) + require.NoError(t, err) + + require.Nil(t, originState.Load(), "the decode state outlived its decode, pinning the node tree it indexes") +} diff --git a/openapi3/origin_test.go b/openapi3/origin_test.go index 500ab3d54..487d57822 100644 --- a/openapi3/origin_test.go +++ b/openapi3/origin_test.go @@ -635,14 +635,14 @@ components: loader := openapi3.NewLoader() loader.IncludeOrigin = true - _, err := loader.LoadFromData([]byte(data)) - require.Error(t, err) - jsonErr := `invalid character 'p' looking for beginning of value` - if openapi3.UsingJSONv2 { - jsonErr = `jsontext: invalid character 'p' at start of value after offset 1` - } - require.Equal(t, `failed to unmarshal data: json error: `+jsonErr+`, yaml error: error converting YAML to JSON: yaml: unmarshal errors: - line 0: mapping key "__origin__" already defined at line 17`, err.Error()) + doc, err := loader.LoadFromData([]byte(data)) + require.NoError(t, err) + + // "__origin__" is an ordinary property name: positions travel beside the + // document rather than inside it, so no key is reserved. + properties := doc.Components.Schemas["Foo"].Value.Properties + require.Contains(t, properties, "__origin__") + require.True(t, properties["__origin__"].Value.Type.Is("string")) } // TestOrigin_ExtensionValuesStripped verifies that __origin__ metadata injected diff --git a/openapi3/parameter.go b/openapi3/parameter.go index abfa90154..15f026b56 100644 --- a/openapi3/parameter.go +++ b/openapi3/parameter.go @@ -212,7 +212,9 @@ func (parameter *Parameter) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "name") delete(x.Extensions, "in") diff --git a/openapi3/path_item.go b/openapi3/path_item.go index 2ce2f5233..9a7129cd3 100644 --- a/openapi3/path_item.go +++ b/openapi3/path_item.go @@ -35,6 +35,14 @@ type PathItem struct { // Keys are case-sensitive HTTP method tokens and must not duplicate // the methods covered by the fixed fields above. AdditionalOperations map[string]*Operation `json:"additionalOperations,omitempty,case:strict" yaml:"additionalOperations,omitempty"` // OpenAPI >=3.2 + + // refSiblings holds the fields written next to Ref, as the document had + // them: once the loader has merged the referenced path item in, they can no + // longer be told apart from it, and marshaling writes back only these. + refSiblings *PathItem + // refResolved records that the loader has merged the referenced path item + // in, so that it does not do it twice. + refResolved bool } // MethodQuery is the HTTP QUERY method, introduced as a fixed Path Item @@ -80,7 +88,19 @@ func (pathItem PathItem) MarshalJSON() ([]byte, error) { // MarshalYAML returns the YAML encoding of PathItem. func (pathItem PathItem) MarshalYAML() (any, error) { if ref := pathItem.Ref; ref != "" { - return Ref{Ref: ref}, nil + if pathItem.refResolved { + if pathItem.refSiblings == nil { + return Ref{Ref: ref}, nil + } + pathItem = *pathItem.refSiblings + } + pathItem.Ref = "" + m, err := pathItem.MarshalYAML() + if err != nil { + return nil, err + } + m.(map[string]any)["$ref"] = ref + return m, nil } m := make(map[string]any, 15+len(pathItem.Extensions)) @@ -140,7 +160,9 @@ func (pathItem *PathItem) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "$ref") delete(x.Extensions, "summary") delete(x.Extensions, "description") @@ -381,6 +403,70 @@ func (pathItem *PathItem) Validate(ctx context.Context, opts ...ValidationOption } // isEmpty's introduced in 546590b1 +// mergeRefSiblings sets on pathItem, a copy of the item its $ref points to, +// the fields that siblings, the item holding that $ref, sets. +// +// The specification only defines the result when no field is set on both: "In +// case a Path Item Object field appears both in the defined object and the +// referenced object, the behavior is undefined". The sibling then wins, like +// the summary and description of a Reference Object do over those of the +// object it points to, which is what OpenAPI 3.1 says this $ref is likely to +// be aligned with. It never modifies the maps of the referenced item, which +// other path items may share. +func (pathItem *PathItem) mergeRefSiblings(siblings *PathItem) { + if len(siblings.Extensions) != 0 { + extensions := make(map[string]any, len(pathItem.Extensions)+len(siblings.Extensions)) + maps.Copy(extensions, pathItem.Extensions) + maps.Copy(extensions, siblings.Extensions) + pathItem.Extensions = extensions + } + if x := siblings.Summary; x != "" { + pathItem.Summary = x + } + if x := siblings.Description; x != "" { + pathItem.Description = x + } + if x := siblings.Connect; x != nil { + pathItem.Connect = x + } + if x := siblings.Delete; x != nil { + pathItem.Delete = x + } + if x := siblings.Get; x != nil { + pathItem.Get = x + } + if x := siblings.Head; x != nil { + pathItem.Head = x + } + if x := siblings.Options; x != nil { + pathItem.Options = x + } + if x := siblings.Patch; x != nil { + pathItem.Patch = x + } + if x := siblings.Post; x != nil { + pathItem.Post = x + } + if x := siblings.Put; x != nil { + pathItem.Put = x + } + if x := siblings.Trace; x != nil { + pathItem.Trace = x + } + if x := siblings.Query; x != nil { + pathItem.Query = x + } + if x := siblings.AdditionalOperations; len(x) != 0 { + pathItem.AdditionalOperations = x + } + if x := siblings.Servers; len(x) != 0 { + pathItem.Servers = x + } + if x := siblings.Parameters; len(x) != 0 { + pathItem.Parameters = x + } +} + func (pathItem *PathItem) isEmpty() bool { // NOTE: ignores pathItem.Extensions // NOTE: ignores pathItem.Ref diff --git a/openapi3/path_item_ref_siblings_test.go b/openapi3/path_item_ref_siblings_test.go new file mode 100644 index 000000000..aea0a7cd6 --- /dev/null +++ b/openapi3/path_item_ref_siblings_test.go @@ -0,0 +1,96 @@ +package openapi3_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +func loadPathItemRefSiblings(t *testing.T, version, siblings string) (*openapi3.T, error) { + t.Helper() + loader := openapi3.NewLoader() + return loader.LoadFromData(fmt.Appendf(nil, `{ + "openapi": %q, + "info": {"title": "t", "version": "1"}, + "paths": { + "/x": {"$ref": "#/paths/~1y", %s}, + "/y": { + "summary": "target", + "get": {"responses": {"200": {"description": "target get"}}} + } + } +}`, version, siblings)) +} + +// A Path Item Object's $ref is not a Reference Object: the fields written next +// to it are part of the same path item. The specification leaves only the +// result for a field set on both sides undefined. +func TestPathItemRefSiblings(t *testing.T) { + t.Parallel() + for _, version := range []string{"3.0.3", "3.1.0", "3.2.0"} { + t.Run(version, func(t *testing.T) { + t.Parallel() + t.Run("fields set on one side only are merged", func(t *testing.T) { + t.Parallel() + doc, err := loadPathItemRefSiblings(t, version, `"post": {"responses": {"200": {"description": "local post"}}}`) + require.NoError(t, err) + require.NoError(t, doc.Validate(t.Context())) + + x := doc.Paths.Value("/x") + require.Equal(t, "target", x.Summary) + require.Equal(t, "target get", *x.Get.Responses.Value("200").Value.Description) + require.Equal(t, "local post", *x.Post.Responses.Value("200").Value.Description) + + // The referenced path item is not changed. + require.Nil(t, doc.Paths.Value("/y").Post) + }) + + t.Run("a field set on both sides is taken from next to the $ref", func(t *testing.T) { + t.Parallel() + doc, err := loadPathItemRefSiblings(t, version, `"summary": "local", "get": {"responses": {"200": {"description": "local get"}}}`) + require.NoError(t, err) + require.NoError(t, doc.Validate(t.Context())) + + x := doc.Paths.Value("/x") + require.Equal(t, "local", x.Summary) + require.Equal(t, "local get", *x.Get.Responses.Value("200").Value.Description) + + y := doc.Paths.Value("/y") + require.Equal(t, "target", y.Summary) + require.Equal(t, "target get", *y.Get.Responses.Value("200").Value.Description) + }) + + t.Run("only the fields next to the $ref are marshaled back", func(t *testing.T) { + t.Parallel() + doc, err := loadPathItemRefSiblings(t, version, `"summary": "local", "x-local": 1`) + require.NoError(t, err) + + data, err := doc.Paths.Value("/x").MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, `{"$ref": "#/paths/~1y", "summary": "local", "x-local": 1}`, string(data)) + + data, err = doc.MarshalJSON() + require.NoError(t, err) + loader := openapi3.NewLoader() + reloaded, err := loader.LoadFromData(data) + require.NoError(t, err) + require.Equal(t, "local", reloaded.Paths.Value("/x").Summary) + require.NotNil(t, reloaded.Paths.Value("/x").Get) + }) + + t.Run("a $ref to nothing is reported despite the siblings", func(t *testing.T) { + t.Parallel() + loader := openapi3.NewLoader() + _, err := loader.LoadFromData(fmt.Appendf(nil, `{ + "openapi": %q, + "info": {"title": "t", "version": "1"}, + "paths": {"/x": {"$ref": "#/paths/~1nowhere", "summary": "local"}} +}`, version)) + require.Error(t, err) + }) + }) + } +} diff --git a/openapi3/ref_siblings_marshal_test.go b/openapi3/ref_siblings_marshal_test.go new file mode 100644 index 000000000..7480d370b --- /dev/null +++ b/openapi3/ref_siblings_marshal_test.go @@ -0,0 +1,121 @@ +package openapi3_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +// TestRefSiblingsSurviveMarshal covers what a $ref carries next to it: it has +// to be written back out. +// +// For 3.1+ the loader overlays sibling keywords onto the referenced schema, +// which is what JSON Schema 2020-12 prescribes. Marshaling used to throw the +// siblings away and emit a bare {"$ref": ...}, so a document meant one thing +// before being written out and another after being read back, with nothing to +// report the change. +// +// The generated *Ref types now keep their non-extension siblings verbatim and +// copy them back over the $ref. They do not need to know the document version: +// the version decision was already made at load. +func TestRefSiblingsSurviveMarshal(t *testing.T) { + t.Parallel() + + t.Run("schema", func(t *testing.T) { + t.Parallel() + + const spec = ` +openapi: 3.1.0 +info: + title: schema sibling demo + version: "1" +paths: {} +components: + schemas: + Base: + type: string + Alias: + $ref: "#/components/schemas/Base" + description: what Alias means +` + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + // Alias resolves to Base's type with the sibling description layered on + // top, and Base itself is left alone. + alias := doc.Components.Schemas["Alias"] + require.Equal(t, `#/components/schemas/Base`, alias.Ref) + require.Equal(t, &openapi3.Types{"string"}, alias.Value.Type) + require.Equal(t, "what Alias means", alias.Value.Description) + require.Empty(t, doc.Components.Schemas["Base"].Value.Description, + "the overlay must not leak into the referenced schema") + + once, err := doc.MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, `{ + "openapi": "3.1.0", + "info": {"title": "schema sibling demo", "version": "1"}, + "paths": {}, + "components": { + "schemas": { + "Base": {"type": "string"}, + "Alias": { + "$ref": "#/components/schemas/Base", + "description": "what Alias means" + } + } + } + }`, string(once)) + + // So the meaning of Alias survives a save/load cycle. + loader = openapi3.NewLoader() + reloaded, err := loader.LoadFromData(once) + require.NoError(t, err) + require.Equal(t, "what Alias means", + reloaded.Components.Schemas["Alias"].Value.Description) + }) + + t.Run("a sibling that is not a schema keyword is kept too", func(t *testing.T) { + t.Parallel() + + // Preserving only recognized keywords would still lose data. This is + // the shape the fuzzer reduced to; see + // testdata/fuzz/FuzzLoadFromData/schema-ref-junk-sibling. + const spec = ` +openapi: 3.0.0 +info: + title: junk sibling + version: "1" +paths: {} +components: + schemas: + Base: + type: string + Alias: + $ref: "#/components/schemas/Base" + nonsense: 1 +` + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + // A Reference Object ignores it. + before := doc.Validate(t.Context()) + require.NoError(t, before) + + once, err := doc.MarshalJSON() + require.NoError(t, err) + require.Contains(t, string(once), `"nonsense":1`) + + // The same document validates the same way after a round trip. + loader = openapi3.NewLoader() + reloaded, err := loader.LoadFromData(once) + require.NoError(t, err) + require.Equal(t, before, reloaded.Validate(t.Context())) + }) +} diff --git a/openapi3/reference_object_siblings_test.go b/openapi3/reference_object_siblings_test.go new file mode 100644 index 000000000..3f4941d19 --- /dev/null +++ b/openapi3/reference_object_siblings_test.go @@ -0,0 +1,97 @@ +package openapi3_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +// Every version's Reference Object says "any properties added SHALL be +// ignored". From 3.1 on summary and description are fixed fields that override +// the referenced component's. +func TestReferenceObjectSiblingsAreIgnored(t *testing.T) { + t.Parallel() + for _, version := range []string{"3.0.3", "3.1.0", "3.2.0"} { + t.Run(version, func(t *testing.T) { + t.Parallel() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData(fmt.Appendf(nil, `{ + "openapi": %q, + "info": {"title": "t", "version": "1"}, + "paths": {"/x": {"get": {"responses": {"200": { + "$ref": "#/components/responses/R", + "description": "local", + "unknown": 1 + }}}}}, + "components": {"responses": {"R": {"description": "target"}}} +}`, version)) + require.NoError(t, err) + require.NoError(t, doc.Validate(t.Context())) + + want := "local" + if doc.IsOpenAPI30() { + want = "target" + } + response := doc.Paths.Value("/x").Get.Responses.Value("200").Value + require.Equal(t, want, *response.Description) + }) + } +} + +// InternalizeRefs replaces a component that is a $ref by what it refers to, +// which leaves out the fields that were next to the $ref. +func TestReferenceObjectSiblingsAreGoneOnceInternalized(t *testing.T) { + t.Parallel() + for _, version := range []string{"3.0.3", "3.1.0"} { + t.Run(version, func(t *testing.T) { + t.Parallel() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData(fmt.Appendf(nil, `{ + "openapi": %q, + "info": {"title": "t", "version": "1"}, + "paths": {}, + "components": { + "schemas": { + "A": {"type": "string"}, + "B": {"$ref": "#/components/schemas/A", "minLength": 2} + }, + "parameters": { + "P": {"name": "p", "in": "query", "schema": {"type": "string"}}, + "Q": {"$ref": "#/components/parameters/P", "description": "local", "unknown": 1} + } + } +}`, version)) + require.NoError(t, err) + require.NoError(t, doc.Validate(t.Context())) + + doc.InternalizeRefs(t.Context(), nil) + require.NoError(t, doc.Validate(t.Context())) + }) + } +} + +func TestProhibitExtensionsWithRefInEveryVersion(t *testing.T) { + t.Parallel() + for _, version := range []string{"3.0.3", "3.1.0"} { + t.Run(version, func(t *testing.T) { + t.Parallel() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData(fmt.Appendf(nil, `{ + "openapi": %q, + "info": {"title": "t", "version": "1"}, + "paths": {}, + "components": {"schemas": { + "A": {"type": "string"}, + "B": {"$ref": "#/components/schemas/A", "x-order": 1} + }} +}`, version)) + require.NoError(t, err) + require.NoError(t, doc.Validate(t.Context())) + require.ErrorContains(t, doc.Validate(t.Context(), openapi3.ProhibitExtensionsWithRef()), "extra sibling fields: [x-order]") + require.NoError(t, doc.Validate(t.Context(), openapi3.ProhibitExtensionsWithRef(), openapi3.AllowExtraSiblingFields("x-order"))) + }) + } +} diff --git a/openapi3/refs.go b/openapi3/refs.go index 44a0645c7..2add9d87e 100644 --- a/openapi3/refs.go +++ b/openapi3/refs.go @@ -4,10 +4,12 @@ package openapi3 import ( "context" "encoding/json" + "maps" "net/url" "strings" "github.com/go-openapi/jsonpointer" + yaml "go.yaml.in/yaml/v3" ) // CallbackRef represents either a Callback or a $ref to a Callback. @@ -24,6 +26,10 @@ type CallbackRef struct { Ref string Value *Callback extra []string + // siblingExtras holds, verbatim, the non-`x-` fields written next to $ref. + // Marshaling writes them back out, so a document survives a round trip: + // an OpenAPI 3.1 schema's sibling keywords constrain the referenced one. + siblingExtras map[string]any refPath *url.URL } @@ -54,12 +60,22 @@ func (x *CallbackRef) setRefPath(u *url.URL) { // MarshalYAML returns the YAML encoding of CallbackRef. func (x CallbackRef) MarshalYAML() (any, error) { if ref := x.Ref; ref != "" { - return &Ref{ + out, err := Ref{ Ref: ref, Extensions: x.Extensions, Summary: x.Summary, Description: x.Description, - }, nil + }.MarshalYAML() + if err != nil { + return nil, err + } + if m, ok := out.(map[string]any); ok { + maps.Copy(m, x.siblingExtras) + } + return out, nil + } + if x.Value == nil { + return nil, nil } return x.Value.MarshalYAML() } @@ -73,12 +89,23 @@ 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, &x.extra, &x.siblingExtras) { + 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 if err := unmarshalJSON(data, &refOnly); err == nil && refOnly.Ref != "" { extra := map[string]any{} - _ = unmarshalJSON(data, &extra) + if err := unmarshalJSON(data, &extra); err != nil { + return unmarshalError(err) + } delete(extra, "$ref") x.Ref = refOnly.Ref x.Origin = refOnly.Origin @@ -88,8 +115,12 @@ func (x *CallbackRef) UnmarshalJSON(data []byte) error { delete(extra, "description") if len(extra) != 0 { x.extra = componentNames(extra) - for k := range extra { + for k, v := range extra { if !strings.HasPrefix(k, "x-") { + if x.siblingExtras == nil { + x.siblingExtras = make(map[string]any, len(extra)) + } + x.siblingExtras[k] = v delete(extra, k) } } @@ -102,46 +133,24 @@ func (x *CallbackRef) UnmarshalJSON(data []byte) error { return unmarshalJSON(data, &x.Value) } -// validateExtras returns an error if CallbackRef has sibling fields -// alongside $ref that are not allowed by the validation options. +// validateExtras returns an error if CallbackRef has `x-` extensions +// next to its $ref while ProhibitExtensionsWithRef is set. Every other field +// next to a $ref is not an error: every version's Reference Object says "any +// properties added SHALL be ignored", from 3.1 on summary and description +// override the referenced component's, and an OpenAPI 3.1 schema applies its +// sibling keywords alongside the referenced schema. func (x *CallbackRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) - var extras []string - - allowed := validationOpts.extraSiblingFieldsAllowed - if allowed == nil { - allowed = make(map[string]struct{}) - } - if x.Ref == "" && !validationOpts.isOpenAPI31OrLater { - if _, ok := allowed["description"]; x.Description != nil && !ok { - extras = append(extras, "description") - } - if _, ok := allowed["summary"]; x.Summary != nil && !ok { - extras = append(extras, "summary") - } - } - // OpenAPI 3.0 Reference Objects ignore added properties. Keep the - // explicit extension policy below and validation of inline objects. - if x.Ref == "" || validationOpts.isOpenAPI31OrLater { - for _, ex := range x.extra { - if _, ok := allowed[ex]; !ok { - if _, ok := x.Extensions[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below - } - } + if x.Ref == "" || !validationOpts.schemaExtensionsInRefProhibited { + return nil } - if validationOpts.schemaExtensionsInRefProhibited { - for _, ex := range componentNames(x.Extensions) { - if _, ok := allowed[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below + var extras []string + for _, ex := range componentNames(x.Extensions) { + if _, ok := validationOpts.extraSiblingFieldsAllowed[ex]; !ok { + extras = append(extras, ex) } } - if len(extras) != 0 { return newExtraSiblingFields(extras, x.Origin) } @@ -193,6 +202,10 @@ type ExampleRef struct { Ref string Value *Example extra []string + // siblingExtras holds, verbatim, the non-`x-` fields written next to $ref. + // Marshaling writes them back out, so a document survives a round trip: + // an OpenAPI 3.1 schema's sibling keywords constrain the referenced one. + siblingExtras map[string]any refPath *url.URL } @@ -223,12 +236,22 @@ func (x *ExampleRef) setRefPath(u *url.URL) { // MarshalYAML returns the YAML encoding of ExampleRef. func (x ExampleRef) MarshalYAML() (any, error) { if ref := x.Ref; ref != "" { - return &Ref{ + out, err := Ref{ Ref: ref, Extensions: x.Extensions, Summary: x.Summary, Description: x.Description, - }, nil + }.MarshalYAML() + if err != nil { + return nil, err + } + if m, ok := out.(map[string]any); ok { + maps.Copy(m, x.siblingExtras) + } + return out, nil + } + if x.Value == nil { + return nil, nil } return x.Value.MarshalYAML() } @@ -242,12 +265,23 @@ 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, &x.extra, &x.siblingExtras) { + 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 if err := unmarshalJSON(data, &refOnly); err == nil && refOnly.Ref != "" { extra := map[string]any{} - _ = unmarshalJSON(data, &extra) + if err := unmarshalJSON(data, &extra); err != nil { + return unmarshalError(err) + } delete(extra, "$ref") x.Ref = refOnly.Ref x.Origin = refOnly.Origin @@ -257,8 +291,12 @@ func (x *ExampleRef) UnmarshalJSON(data []byte) error { delete(extra, "description") if len(extra) != 0 { x.extra = componentNames(extra) - for k := range extra { + for k, v := range extra { if !strings.HasPrefix(k, "x-") { + if x.siblingExtras == nil { + x.siblingExtras = make(map[string]any, len(extra)) + } + x.siblingExtras[k] = v delete(extra, k) } } @@ -271,46 +309,24 @@ func (x *ExampleRef) UnmarshalJSON(data []byte) error { return unmarshalJSON(data, &x.Value) } -// validateExtras returns an error if ExampleRef has sibling fields -// alongside $ref that are not allowed by the validation options. +// validateExtras returns an error if ExampleRef has `x-` extensions +// next to its $ref while ProhibitExtensionsWithRef is set. Every other field +// next to a $ref is not an error: every version's Reference Object says "any +// properties added SHALL be ignored", from 3.1 on summary and description +// override the referenced component's, and an OpenAPI 3.1 schema applies its +// sibling keywords alongside the referenced schema. func (x *ExampleRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) - var extras []string - - allowed := validationOpts.extraSiblingFieldsAllowed - if allowed == nil { - allowed = make(map[string]struct{}) - } - if x.Ref == "" && !validationOpts.isOpenAPI31OrLater { - if _, ok := allowed["description"]; x.Description != nil && !ok { - extras = append(extras, "description") - } - if _, ok := allowed["summary"]; x.Summary != nil && !ok { - extras = append(extras, "summary") - } - } - // OpenAPI 3.0 Reference Objects ignore added properties. Keep the - // explicit extension policy below and validation of inline objects. - if x.Ref == "" || validationOpts.isOpenAPI31OrLater { - for _, ex := range x.extra { - if _, ok := allowed[ex]; !ok { - if _, ok := x.Extensions[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below - } - } + if x.Ref == "" || !validationOpts.schemaExtensionsInRefProhibited { + return nil } - if validationOpts.schemaExtensionsInRefProhibited { - for _, ex := range componentNames(x.Extensions) { - if _, ok := allowed[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below + var extras []string + for _, ex := range componentNames(x.Extensions) { + if _, ok := validationOpts.extraSiblingFieldsAllowed[ex]; !ok { + extras = append(extras, ex) } } - if len(extras) != 0 { return newExtraSiblingFields(extras, x.Origin) } @@ -362,6 +378,10 @@ type HeaderRef struct { Ref string Value *Header extra []string + // siblingExtras holds, verbatim, the non-`x-` fields written next to $ref. + // Marshaling writes them back out, so a document survives a round trip: + // an OpenAPI 3.1 schema's sibling keywords constrain the referenced one. + siblingExtras map[string]any refPath *url.URL } @@ -392,12 +412,22 @@ func (x *HeaderRef) setRefPath(u *url.URL) { // MarshalYAML returns the YAML encoding of HeaderRef. func (x HeaderRef) MarshalYAML() (any, error) { if ref := x.Ref; ref != "" { - return &Ref{ + out, err := Ref{ Ref: ref, Extensions: x.Extensions, Summary: x.Summary, Description: x.Description, - }, nil + }.MarshalYAML() + if err != nil { + return nil, err + } + if m, ok := out.(map[string]any); ok { + maps.Copy(m, x.siblingExtras) + } + return out, nil + } + if x.Value == nil { + return nil, nil } return x.Value.MarshalYAML() } @@ -411,12 +441,23 @@ 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, &x.extra, &x.siblingExtras) { + 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 if err := unmarshalJSON(data, &refOnly); err == nil && refOnly.Ref != "" { extra := map[string]any{} - _ = unmarshalJSON(data, &extra) + if err := unmarshalJSON(data, &extra); err != nil { + return unmarshalError(err) + } delete(extra, "$ref") x.Ref = refOnly.Ref x.Origin = refOnly.Origin @@ -426,8 +467,12 @@ func (x *HeaderRef) UnmarshalJSON(data []byte) error { delete(extra, "description") if len(extra) != 0 { x.extra = componentNames(extra) - for k := range extra { + for k, v := range extra { if !strings.HasPrefix(k, "x-") { + if x.siblingExtras == nil { + x.siblingExtras = make(map[string]any, len(extra)) + } + x.siblingExtras[k] = v delete(extra, k) } } @@ -440,46 +485,24 @@ func (x *HeaderRef) UnmarshalJSON(data []byte) error { return unmarshalJSON(data, &x.Value) } -// validateExtras returns an error if HeaderRef has sibling fields -// alongside $ref that are not allowed by the validation options. +// validateExtras returns an error if HeaderRef has `x-` extensions +// next to its $ref while ProhibitExtensionsWithRef is set. Every other field +// next to a $ref is not an error: every version's Reference Object says "any +// properties added SHALL be ignored", from 3.1 on summary and description +// override the referenced component's, and an OpenAPI 3.1 schema applies its +// sibling keywords alongside the referenced schema. func (x *HeaderRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) - var extras []string - - allowed := validationOpts.extraSiblingFieldsAllowed - if allowed == nil { - allowed = make(map[string]struct{}) - } - if x.Ref == "" && !validationOpts.isOpenAPI31OrLater { - if _, ok := allowed["description"]; x.Description != nil && !ok { - extras = append(extras, "description") - } - if _, ok := allowed["summary"]; x.Summary != nil && !ok { - extras = append(extras, "summary") - } - } - // OpenAPI 3.0 Reference Objects ignore added properties. Keep the - // explicit extension policy below and validation of inline objects. - if x.Ref == "" || validationOpts.isOpenAPI31OrLater { - for _, ex := range x.extra { - if _, ok := allowed[ex]; !ok { - if _, ok := x.Extensions[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below - } - } + if x.Ref == "" || !validationOpts.schemaExtensionsInRefProhibited { + return nil } - if validationOpts.schemaExtensionsInRefProhibited { - for _, ex := range componentNames(x.Extensions) { - if _, ok := allowed[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below + var extras []string + for _, ex := range componentNames(x.Extensions) { + if _, ok := validationOpts.extraSiblingFieldsAllowed[ex]; !ok { + extras = append(extras, ex) } } - if len(extras) != 0 { return newExtraSiblingFields(extras, x.Origin) } @@ -531,6 +554,10 @@ type LinkRef struct { Ref string Value *Link extra []string + // siblingExtras holds, verbatim, the non-`x-` fields written next to $ref. + // Marshaling writes them back out, so a document survives a round trip: + // an OpenAPI 3.1 schema's sibling keywords constrain the referenced one. + siblingExtras map[string]any refPath *url.URL } @@ -561,12 +588,22 @@ func (x *LinkRef) setRefPath(u *url.URL) { // MarshalYAML returns the YAML encoding of LinkRef. func (x LinkRef) MarshalYAML() (any, error) { if ref := x.Ref; ref != "" { - return &Ref{ + out, err := Ref{ Ref: ref, Extensions: x.Extensions, Summary: x.Summary, Description: x.Description, - }, nil + }.MarshalYAML() + if err != nil { + return nil, err + } + if m, ok := out.(map[string]any); ok { + maps.Copy(m, x.siblingExtras) + } + return out, nil + } + if x.Value == nil { + return nil, nil } return x.Value.MarshalYAML() } @@ -580,12 +617,23 @@ 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, &x.extra, &x.siblingExtras) { + 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 if err := unmarshalJSON(data, &refOnly); err == nil && refOnly.Ref != "" { extra := map[string]any{} - _ = unmarshalJSON(data, &extra) + if err := unmarshalJSON(data, &extra); err != nil { + return unmarshalError(err) + } delete(extra, "$ref") x.Ref = refOnly.Ref x.Origin = refOnly.Origin @@ -595,8 +643,12 @@ func (x *LinkRef) UnmarshalJSON(data []byte) error { delete(extra, "description") if len(extra) != 0 { x.extra = componentNames(extra) - for k := range extra { + for k, v := range extra { if !strings.HasPrefix(k, "x-") { + if x.siblingExtras == nil { + x.siblingExtras = make(map[string]any, len(extra)) + } + x.siblingExtras[k] = v delete(extra, k) } } @@ -609,46 +661,24 @@ func (x *LinkRef) UnmarshalJSON(data []byte) error { return unmarshalJSON(data, &x.Value) } -// validateExtras returns an error if LinkRef has sibling fields -// alongside $ref that are not allowed by the validation options. +// validateExtras returns an error if LinkRef has `x-` extensions +// next to its $ref while ProhibitExtensionsWithRef is set. Every other field +// next to a $ref is not an error: every version's Reference Object says "any +// properties added SHALL be ignored", from 3.1 on summary and description +// override the referenced component's, and an OpenAPI 3.1 schema applies its +// sibling keywords alongside the referenced schema. func (x *LinkRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) - var extras []string - - allowed := validationOpts.extraSiblingFieldsAllowed - if allowed == nil { - allowed = make(map[string]struct{}) - } - if x.Ref == "" && !validationOpts.isOpenAPI31OrLater { - if _, ok := allowed["description"]; x.Description != nil && !ok { - extras = append(extras, "description") - } - if _, ok := allowed["summary"]; x.Summary != nil && !ok { - extras = append(extras, "summary") - } - } - // OpenAPI 3.0 Reference Objects ignore added properties. Keep the - // explicit extension policy below and validation of inline objects. - if x.Ref == "" || validationOpts.isOpenAPI31OrLater { - for _, ex := range x.extra { - if _, ok := allowed[ex]; !ok { - if _, ok := x.Extensions[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below - } - } + if x.Ref == "" || !validationOpts.schemaExtensionsInRefProhibited { + return nil } - if validationOpts.schemaExtensionsInRefProhibited { - for _, ex := range componentNames(x.Extensions) { - if _, ok := allowed[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below + var extras []string + for _, ex := range componentNames(x.Extensions) { + if _, ok := validationOpts.extraSiblingFieldsAllowed[ex]; !ok { + extras = append(extras, ex) } } - if len(extras) != 0 { return newExtraSiblingFields(extras, x.Origin) } @@ -700,6 +730,10 @@ type ParameterRef struct { Ref string Value *Parameter extra []string + // siblingExtras holds, verbatim, the non-`x-` fields written next to $ref. + // Marshaling writes them back out, so a document survives a round trip: + // an OpenAPI 3.1 schema's sibling keywords constrain the referenced one. + siblingExtras map[string]any refPath *url.URL } @@ -730,12 +764,22 @@ func (x *ParameterRef) setRefPath(u *url.URL) { // MarshalYAML returns the YAML encoding of ParameterRef. func (x ParameterRef) MarshalYAML() (any, error) { if ref := x.Ref; ref != "" { - return &Ref{ + out, err := Ref{ Ref: ref, Extensions: x.Extensions, Summary: x.Summary, Description: x.Description, - }, nil + }.MarshalYAML() + if err != nil { + return nil, err + } + if m, ok := out.(map[string]any); ok { + maps.Copy(m, x.siblingExtras) + } + return out, nil + } + if x.Value == nil { + return nil, nil } return x.Value.MarshalYAML() } @@ -749,12 +793,23 @@ 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, &x.extra, &x.siblingExtras) { + 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 if err := unmarshalJSON(data, &refOnly); err == nil && refOnly.Ref != "" { extra := map[string]any{} - _ = unmarshalJSON(data, &extra) + if err := unmarshalJSON(data, &extra); err != nil { + return unmarshalError(err) + } delete(extra, "$ref") x.Ref = refOnly.Ref x.Origin = refOnly.Origin @@ -764,8 +819,12 @@ func (x *ParameterRef) UnmarshalJSON(data []byte) error { delete(extra, "description") if len(extra) != 0 { x.extra = componentNames(extra) - for k := range extra { + for k, v := range extra { if !strings.HasPrefix(k, "x-") { + if x.siblingExtras == nil { + x.siblingExtras = make(map[string]any, len(extra)) + } + x.siblingExtras[k] = v delete(extra, k) } } @@ -778,46 +837,24 @@ func (x *ParameterRef) UnmarshalJSON(data []byte) error { return unmarshalJSON(data, &x.Value) } -// validateExtras returns an error if ParameterRef has sibling fields -// alongside $ref that are not allowed by the validation options. +// validateExtras returns an error if ParameterRef has `x-` extensions +// next to its $ref while ProhibitExtensionsWithRef is set. Every other field +// next to a $ref is not an error: every version's Reference Object says "any +// properties added SHALL be ignored", from 3.1 on summary and description +// override the referenced component's, and an OpenAPI 3.1 schema applies its +// sibling keywords alongside the referenced schema. func (x *ParameterRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) - var extras []string - - allowed := validationOpts.extraSiblingFieldsAllowed - if allowed == nil { - allowed = make(map[string]struct{}) - } - if x.Ref == "" && !validationOpts.isOpenAPI31OrLater { - if _, ok := allowed["description"]; x.Description != nil && !ok { - extras = append(extras, "description") - } - if _, ok := allowed["summary"]; x.Summary != nil && !ok { - extras = append(extras, "summary") - } - } - // OpenAPI 3.0 Reference Objects ignore added properties. Keep the - // explicit extension policy below and validation of inline objects. - if x.Ref == "" || validationOpts.isOpenAPI31OrLater { - for _, ex := range x.extra { - if _, ok := allowed[ex]; !ok { - if _, ok := x.Extensions[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below - } - } + if x.Ref == "" || !validationOpts.schemaExtensionsInRefProhibited { + return nil } - if validationOpts.schemaExtensionsInRefProhibited { - for _, ex := range componentNames(x.Extensions) { - if _, ok := allowed[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below + var extras []string + for _, ex := range componentNames(x.Extensions) { + if _, ok := validationOpts.extraSiblingFieldsAllowed[ex]; !ok { + extras = append(extras, ex) } } - if len(extras) != 0 { return newExtraSiblingFields(extras, x.Origin) } @@ -869,6 +906,10 @@ type RequestBodyRef struct { Ref string Value *RequestBody extra []string + // siblingExtras holds, verbatim, the non-`x-` fields written next to $ref. + // Marshaling writes them back out, so a document survives a round trip: + // an OpenAPI 3.1 schema's sibling keywords constrain the referenced one. + siblingExtras map[string]any refPath *url.URL } @@ -899,12 +940,22 @@ func (x *RequestBodyRef) setRefPath(u *url.URL) { // MarshalYAML returns the YAML encoding of RequestBodyRef. func (x RequestBodyRef) MarshalYAML() (any, error) { if ref := x.Ref; ref != "" { - return &Ref{ + out, err := Ref{ Ref: ref, Extensions: x.Extensions, Summary: x.Summary, Description: x.Description, - }, nil + }.MarshalYAML() + if err != nil { + return nil, err + } + if m, ok := out.(map[string]any); ok { + maps.Copy(m, x.siblingExtras) + } + return out, nil + } + if x.Value == nil { + return nil, nil } return x.Value.MarshalYAML() } @@ -918,12 +969,23 @@ 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, &x.extra, &x.siblingExtras) { + 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 if err := unmarshalJSON(data, &refOnly); err == nil && refOnly.Ref != "" { extra := map[string]any{} - _ = unmarshalJSON(data, &extra) + if err := unmarshalJSON(data, &extra); err != nil { + return unmarshalError(err) + } delete(extra, "$ref") x.Ref = refOnly.Ref x.Origin = refOnly.Origin @@ -933,8 +995,12 @@ func (x *RequestBodyRef) UnmarshalJSON(data []byte) error { delete(extra, "description") if len(extra) != 0 { x.extra = componentNames(extra) - for k := range extra { + for k, v := range extra { if !strings.HasPrefix(k, "x-") { + if x.siblingExtras == nil { + x.siblingExtras = make(map[string]any, len(extra)) + } + x.siblingExtras[k] = v delete(extra, k) } } @@ -947,46 +1013,24 @@ func (x *RequestBodyRef) UnmarshalJSON(data []byte) error { return unmarshalJSON(data, &x.Value) } -// validateExtras returns an error if RequestBodyRef has sibling fields -// alongside $ref that are not allowed by the validation options. +// validateExtras returns an error if RequestBodyRef has `x-` extensions +// next to its $ref while ProhibitExtensionsWithRef is set. Every other field +// next to a $ref is not an error: every version's Reference Object says "any +// properties added SHALL be ignored", from 3.1 on summary and description +// override the referenced component's, and an OpenAPI 3.1 schema applies its +// sibling keywords alongside the referenced schema. func (x *RequestBodyRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) - var extras []string - - allowed := validationOpts.extraSiblingFieldsAllowed - if allowed == nil { - allowed = make(map[string]struct{}) - } - if x.Ref == "" && !validationOpts.isOpenAPI31OrLater { - if _, ok := allowed["description"]; x.Description != nil && !ok { - extras = append(extras, "description") - } - if _, ok := allowed["summary"]; x.Summary != nil && !ok { - extras = append(extras, "summary") - } - } - // OpenAPI 3.0 Reference Objects ignore added properties. Keep the - // explicit extension policy below and validation of inline objects. - if x.Ref == "" || validationOpts.isOpenAPI31OrLater { - for _, ex := range x.extra { - if _, ok := allowed[ex]; !ok { - if _, ok := x.Extensions[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below - } - } + if x.Ref == "" || !validationOpts.schemaExtensionsInRefProhibited { + return nil } - if validationOpts.schemaExtensionsInRefProhibited { - for _, ex := range componentNames(x.Extensions) { - if _, ok := allowed[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below + var extras []string + for _, ex := range componentNames(x.Extensions) { + if _, ok := validationOpts.extraSiblingFieldsAllowed[ex]; !ok { + extras = append(extras, ex) } } - if len(extras) != 0 { return newExtraSiblingFields(extras, x.Origin) } @@ -1038,6 +1082,10 @@ type ResponseRef struct { Ref string Value *Response extra []string + // siblingExtras holds, verbatim, the non-`x-` fields written next to $ref. + // Marshaling writes them back out, so a document survives a round trip: + // an OpenAPI 3.1 schema's sibling keywords constrain the referenced one. + siblingExtras map[string]any refPath *url.URL } @@ -1068,12 +1116,22 @@ func (x *ResponseRef) setRefPath(u *url.URL) { // MarshalYAML returns the YAML encoding of ResponseRef. func (x ResponseRef) MarshalYAML() (any, error) { if ref := x.Ref; ref != "" { - return &Ref{ + out, err := Ref{ Ref: ref, Extensions: x.Extensions, Summary: x.Summary, Description: x.Description, - }, nil + }.MarshalYAML() + if err != nil { + return nil, err + } + if m, ok := out.(map[string]any); ok { + maps.Copy(m, x.siblingExtras) + } + return out, nil + } + if x.Value == nil { + return nil, nil } return x.Value.MarshalYAML() } @@ -1087,12 +1145,23 @@ 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, &x.extra, &x.siblingExtras) { + 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 if err := unmarshalJSON(data, &refOnly); err == nil && refOnly.Ref != "" { extra := map[string]any{} - _ = unmarshalJSON(data, &extra) + if err := unmarshalJSON(data, &extra); err != nil { + return unmarshalError(err) + } delete(extra, "$ref") x.Ref = refOnly.Ref x.Origin = refOnly.Origin @@ -1102,8 +1171,12 @@ func (x *ResponseRef) UnmarshalJSON(data []byte) error { delete(extra, "description") if len(extra) != 0 { x.extra = componentNames(extra) - for k := range extra { + for k, v := range extra { if !strings.HasPrefix(k, "x-") { + if x.siblingExtras == nil { + x.siblingExtras = make(map[string]any, len(extra)) + } + x.siblingExtras[k] = v delete(extra, k) } } @@ -1116,46 +1189,24 @@ func (x *ResponseRef) UnmarshalJSON(data []byte) error { return unmarshalJSON(data, &x.Value) } -// validateExtras returns an error if ResponseRef has sibling fields -// alongside $ref that are not allowed by the validation options. +// validateExtras returns an error if ResponseRef has `x-` extensions +// next to its $ref while ProhibitExtensionsWithRef is set. Every other field +// next to a $ref is not an error: every version's Reference Object says "any +// properties added SHALL be ignored", from 3.1 on summary and description +// override the referenced component's, and an OpenAPI 3.1 schema applies its +// sibling keywords alongside the referenced schema. func (x *ResponseRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) - var extras []string - - allowed := validationOpts.extraSiblingFieldsAllowed - if allowed == nil { - allowed = make(map[string]struct{}) - } - if x.Ref == "" && !validationOpts.isOpenAPI31OrLater { - if _, ok := allowed["description"]; x.Description != nil && !ok { - extras = append(extras, "description") - } - if _, ok := allowed["summary"]; x.Summary != nil && !ok { - extras = append(extras, "summary") - } - } - // OpenAPI 3.0 Reference Objects ignore added properties. Keep the - // explicit extension policy below and validation of inline objects. - if x.Ref == "" || validationOpts.isOpenAPI31OrLater { - for _, ex := range x.extra { - if _, ok := allowed[ex]; !ok { - if _, ok := x.Extensions[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below - } - } + if x.Ref == "" || !validationOpts.schemaExtensionsInRefProhibited { + return nil } - if validationOpts.schemaExtensionsInRefProhibited { - for _, ex := range componentNames(x.Extensions) { - if _, ok := allowed[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below + var extras []string + for _, ex := range componentNames(x.Extensions) { + if _, ok := validationOpts.extraSiblingFieldsAllowed[ex]; !ok { + extras = append(extras, ex) } } - if len(extras) != 0 { return newExtraSiblingFields(extras, x.Origin) } @@ -1204,9 +1255,19 @@ type SchemaRef struct { Ref string Value *Schema extra []string + // siblingExtras holds, verbatim, the non-`x-` fields written next to $ref. + // Marshaling writes them back out, so a document survives a round trip: + // an OpenAPI 3.1 schema's sibling keywords constrain the referenced one. + siblingExtras map[string]any // sibling holds keyword siblings of a $ref (OAS 3.1 / JSON Schema 2020-12). // It is populated during unmarshal and applied to Value after $ref resolution. sibling *Schema + // siblingErr is why the siblings could not be read as a Schema. OAS 3.1 + // reports it, while OAS 3.0 ignores the fields next to a $ref anyway. + siblingErr error + // target is the referenced schema, which Value is a copy of once siblings + // are applied to it. + target *Schema refPath *url.URL } @@ -1237,10 +1298,37 @@ func (x *SchemaRef) setRefPath(u *url.URL) { // MarshalYAML returns the YAML encoding of SchemaRef. func (x SchemaRef) MarshalYAML() (any, error) { if ref := x.Ref; ref != "" { - return &Ref{ + out, err := Ref{ Ref: ref, Extensions: x.Extensions, - }, nil + }.MarshalYAML() + if err != nil { + return nil, err + } + if m, ok := out.(map[string]any); ok { + maps.Copy(m, x.siblingExtras) + if x.sibling != nil && len(x.siblingExtras) != 0 { + // The $refs within the siblings may have been rewritten since, as + // InternalizeRefs does, so write out what the siblings now hold. A + // field the Schema leaves out, such as an empty pattern, is still + // written as it was. + current, err := x.sibling.MarshalYAML() + if err != nil { + return nil, err + } + if current, ok := current.(map[string]any); ok { + for k := range x.siblingExtras { + if v, ok := current[k]; ok { + m[k] = v + } + } + } + } + } + return out, nil + } + if x.Value == nil { + return nil, nil } return x.Value.MarshalYAML() } @@ -1254,12 +1342,41 @@ 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, &x.extra, &x.siblingExtras) { + 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 _, k := range x.extra { + if !strings.HasPrefix(k, "x-") { + hasSiblings = true + break + } + } + if hasSiblings { + var sibling Schema + if err := node.Decode(&sibling); err == nil { + x.sibling = &sibling + } else { + x.siblingErr = err + } + } + return nil +} + // UnmarshalJSON sets SchemaRef to a copy of data. func (x *SchemaRef) UnmarshalJSON(data []byte) error { var refOnly Ref if err := unmarshalJSON(data, &refOnly); err == nil && refOnly.Ref != "" { extra := map[string]any{} - _ = unmarshalJSON(data, &extra) + if err := unmarshalJSON(data, &extra); err != nil { + return unmarshalError(err) + } delete(extra, "$ref") x.Ref = refOnly.Ref x.Origin = refOnly.Origin @@ -1279,10 +1396,16 @@ func (x *SchemaRef) UnmarshalJSON(data []byte) error { var sibling Schema if err := unmarshalJSON(data, &sibling); err == nil { x.sibling = &sibling + } else { + x.siblingErr = err } } - for k := range extra { + for k, v := range extra { if !strings.HasPrefix(k, "x-") { + if x.siblingExtras == nil { + x.siblingExtras = make(map[string]any, len(extra)) + } + x.siblingExtras[k] = v delete(extra, k) } } @@ -1295,42 +1418,26 @@ func (x *SchemaRef) UnmarshalJSON(data []byte) error { return unmarshalJSON(data, &x.Value) } -// validateExtras returns an error if SchemaRef has sibling fields -// alongside $ref that are not allowed by the validation options. +// validateExtras returns an error if SchemaRef has `x-` extensions +// next to its $ref while ProhibitExtensionsWithRef is set. Every other field +// next to a $ref is not an error: every version's Reference Object says "any +// properties added SHALL be ignored", from 3.1 on summary and description +// override the referenced component's, and an OpenAPI 3.1 schema applies its +// sibling keywords alongside the referenced schema. func (x *SchemaRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) - var extras []string - - allowed := validationOpts.extraSiblingFieldsAllowed - if allowed == nil { - allowed = make(map[string]struct{}) - } - // OpenAPI 3.0 Reference Objects ignore added properties. Keep the - // explicit extension policy below and validation of inline objects. - if x.Ref == "" || validationOpts.isOpenAPI31OrLater { - for _, ex := range x.extra { - if _, ok := allowed[ex]; !ok { - if _, ok := x.Extensions[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below - } - } + if x.Ref == "" || !validationOpts.schemaExtensionsInRefProhibited { + return nil } - if validationOpts.schemaExtensionsInRefProhibited { - for _, ex := range componentNames(x.Extensions) { - if _, ok := allowed[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below + var extras []string + for _, ex := range componentNames(x.Extensions) { + if _, ok := validationOpts.extraSiblingFieldsAllowed[ex]; !ok { + extras = append(extras, ex) } } - if len(extras) != 0 { - if !validationOpts.isOpenAPI31OrLater { - return newExtraSiblingFields(extras, x.Origin) - } + return newExtraSiblingFields(extras, x.Origin) } return nil } @@ -1380,6 +1487,10 @@ type SecuritySchemeRef struct { Ref string Value *SecurityScheme extra []string + // siblingExtras holds, verbatim, the non-`x-` fields written next to $ref. + // Marshaling writes them back out, so a document survives a round trip: + // an OpenAPI 3.1 schema's sibling keywords constrain the referenced one. + siblingExtras map[string]any refPath *url.URL } @@ -1410,12 +1521,22 @@ func (x *SecuritySchemeRef) setRefPath(u *url.URL) { // MarshalYAML returns the YAML encoding of SecuritySchemeRef. func (x SecuritySchemeRef) MarshalYAML() (any, error) { if ref := x.Ref; ref != "" { - return &Ref{ + out, err := Ref{ Ref: ref, Extensions: x.Extensions, Summary: x.Summary, Description: x.Description, - }, nil + }.MarshalYAML() + if err != nil { + return nil, err + } + if m, ok := out.(map[string]any); ok { + maps.Copy(m, x.siblingExtras) + } + return out, nil + } + if x.Value == nil { + return nil, nil } return x.Value.MarshalYAML() } @@ -1429,12 +1550,23 @@ 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, &x.extra, &x.siblingExtras) { + 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 if err := unmarshalJSON(data, &refOnly); err == nil && refOnly.Ref != "" { extra := map[string]any{} - _ = unmarshalJSON(data, &extra) + if err := unmarshalJSON(data, &extra); err != nil { + return unmarshalError(err) + } delete(extra, "$ref") x.Ref = refOnly.Ref x.Origin = refOnly.Origin @@ -1444,8 +1576,12 @@ func (x *SecuritySchemeRef) UnmarshalJSON(data []byte) error { delete(extra, "description") if len(extra) != 0 { x.extra = componentNames(extra) - for k := range extra { + for k, v := range extra { if !strings.HasPrefix(k, "x-") { + if x.siblingExtras == nil { + x.siblingExtras = make(map[string]any, len(extra)) + } + x.siblingExtras[k] = v delete(extra, k) } } @@ -1458,46 +1594,24 @@ func (x *SecuritySchemeRef) UnmarshalJSON(data []byte) error { return unmarshalJSON(data, &x.Value) } -// validateExtras returns an error if SecuritySchemeRef has sibling fields -// alongside $ref that are not allowed by the validation options. +// validateExtras returns an error if SecuritySchemeRef has `x-` extensions +// next to its $ref while ProhibitExtensionsWithRef is set. Every other field +// next to a $ref is not an error: every version's Reference Object says "any +// properties added SHALL be ignored", from 3.1 on summary and description +// override the referenced component's, and an OpenAPI 3.1 schema applies its +// sibling keywords alongside the referenced schema. func (x *SecuritySchemeRef) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) - var extras []string - - allowed := validationOpts.extraSiblingFieldsAllowed - if allowed == nil { - allowed = make(map[string]struct{}) - } - if x.Ref == "" && !validationOpts.isOpenAPI31OrLater { - if _, ok := allowed["description"]; x.Description != nil && !ok { - extras = append(extras, "description") - } - if _, ok := allowed["summary"]; x.Summary != nil && !ok { - extras = append(extras, "summary") - } - } - // OpenAPI 3.0 Reference Objects ignore added properties. Keep the - // explicit extension policy below and validation of inline objects. - if x.Ref == "" || validationOpts.isOpenAPI31OrLater { - for _, ex := range x.extra { - if _, ok := allowed[ex]; !ok { - if _, ok := x.Extensions[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below - } - } + if x.Ref == "" || !validationOpts.schemaExtensionsInRefProhibited { + return nil } - if validationOpts.schemaExtensionsInRefProhibited { - for _, ex := range componentNames(x.Extensions) { - if _, ok := allowed[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below + var extras []string + for _, ex := range componentNames(x.Extensions) { + if _, ok := validationOpts.extraSiblingFieldsAllowed[ex]; !ok { + extras = append(extras, ex) } } - if len(extras) != 0 { return newExtraSiblingFields(extras, x.Origin) } diff --git a/openapi3/refs.tmpl b/openapi3/refs.tmpl index e9b3aa642..8f0eade2c 100644 --- a/openapi3/refs.tmpl +++ b/openapi3/refs.tmpl @@ -4,10 +4,12 @@ package {{ .Package }} import ( "context" "encoding/json" + "maps" "net/url" "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 }}. @@ -27,10 +29,20 @@ type {{ $type.Name }}Ref struct { Ref string Value *{{ $type.Name }} extra []string + // siblingExtras holds, verbatim, the non-`x-` fields written next to $ref. + // Marshaling writes them back out, so a document survives a round trip: + // an OpenAPI 3.1 schema's sibling keywords constrain the referenced one. + siblingExtras map[string]any {{- if eq $type.Name "Schema" }} // sibling holds keyword siblings of a $ref (OAS 3.1 / JSON Schema 2020-12). // It is populated during unmarshal and applied to Value after $ref resolution. sibling *Schema + // siblingErr is why the siblings could not be read as a Schema. OAS 3.1 + // reports it, while OAS 3.0 ignores the fields next to a $ref anyway. + siblingErr error + // target is the referenced schema, which Value is a copy of once siblings + // are applied to it. + target *Schema {{- end }} refPath *url.URL @@ -62,14 +74,43 @@ func (x *{{ $type.Name }}Ref) setRefPath(u *url.URL) { // MarshalYAML returns the YAML encoding of {{ $type.Name }}Ref. func (x {{ $type.Name }}Ref) MarshalYAML() (any, error) { if ref := x.Ref; ref != "" { - return &Ref{ + out, err := Ref{ Ref: ref, Extensions: x.Extensions, {{- if ne $type.Name "Schema" }} Summary: x.Summary, Description: x.Description, {{- end }} - }, nil + }.MarshalYAML() + if err != nil { + return nil, err + } + if m, ok := out.(map[string]any); ok { + maps.Copy(m, x.siblingExtras) + {{- if eq $type.Name "Schema" }} + if x.sibling != nil && len(x.siblingExtras) != 0 { + // The $refs within the siblings may have been rewritten since, as + // InternalizeRefs does, so write out what the siblings now hold. A + // field the Schema leaves out, such as an empty pattern, is still + // written as it was. + current, err := x.sibling.MarshalYAML() + if err != nil { + return nil, err + } + if current, ok := current.(map[string]any); ok { + for k := range x.siblingExtras { + if v, ok := current[k]; ok { + m[k] = v + } + } + } + } + {{- end }} + } + return out, nil + } + if x.Value == nil { + return nil, nil } return x.Value.MarshalYAML() } @@ -83,12 +124,43 @@ 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, &x.extra, &x.siblingExtras) { + 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 _, k := range x.extra { + if !strings.HasPrefix(k, "x-") { + hasSiblings = true + break + } + } + if hasSiblings { + var sibling Schema + if err := node.Decode(&sibling); err == nil { + x.sibling = &sibling + } else { + x.siblingErr = err + } + } +{{- 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 if err := unmarshalJSON(data, &refOnly); err == nil && refOnly.Ref != "" { extra := map[string]any{} - _ = unmarshalJSON(data, &extra) + if err := unmarshalJSON(data, &extra); err != nil { + return unmarshalError(err) + } delete(extra, "$ref") x.Ref = refOnly.Ref x.Origin = refOnly.Origin @@ -115,11 +187,17 @@ func (x *{{ $type.Name }}Ref) UnmarshalJSON(data []byte) error { var sibling Schema if err := unmarshalJSON(data, &sibling); err == nil { x.sibling = &sibling + } else { + x.siblingErr = err } } {{- end }} - for k := range extra { + for k, v := range extra { if !strings.HasPrefix(k, "x-") { + if x.siblingExtras == nil { + x.siblingExtras = make(map[string]any, len(extra)) + } + x.siblingExtras[k] = v delete(extra, k) } } @@ -132,56 +210,26 @@ func (x *{{ $type.Name }}Ref) UnmarshalJSON(data []byte) error { return unmarshalJSON(data, &x.Value) } -// validateExtras returns an error if {{ $type.Name }}Ref has sibling fields -// alongside $ref that are not allowed by the validation options. +// validateExtras returns an error if {{ $type.Name }}Ref has `x-` extensions +// next to its $ref while ProhibitExtensionsWithRef is set. Every other field +// next to a $ref is not an error: every version's Reference Object says "any +// properties added SHALL be ignored", from 3.1 on summary and description +// override the referenced component's, and an OpenAPI 3.1 schema applies its +// sibling keywords alongside the referenced schema. func (x *{{ $type.Name }}Ref) validateExtras(ctx context.Context) error { validationOpts := getValidationOptions(ctx) - var extras []string - - allowed := validationOpts.extraSiblingFieldsAllowed - if allowed == nil { - allowed = make(map[string]struct{}) - } - {{- if ne $type.Name "Schema" }} - if x.Ref == "" && !validationOpts.isOpenAPI31OrLater { - if _, ok := allowed["description"]; x.Description != nil && !ok { - extras = append(extras, "description") - } - if _, ok := allowed["summary"]; x.Summary != nil && !ok { - extras = append(extras, "summary") - } - } - {{- end }} - // OpenAPI 3.0 Reference Objects ignore added properties. Keep the - // explicit extension policy below and validation of inline objects. - if x.Ref == "" || validationOpts.isOpenAPI31OrLater { - for _, ex := range x.extra { - if _, ok := allowed[ex]; !ok { - if _, ok := x.Extensions[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below - } - } + if x.Ref == "" || !validationOpts.schemaExtensionsInRefProhibited { + return nil } - if validationOpts.schemaExtensionsInRefProhibited { - for _, ex := range componentNames(x.Extensions) { - if _, ok := allowed[ex]; !ok { - extras = append(extras, ex) - } - // extras in the Extensions checked below + var extras []string + for _, ex := range componentNames(x.Extensions) { + if _, ok := validationOpts.extraSiblingFieldsAllowed[ex]; !ok { + extras = append(extras, ex) } } - if len(extras) != 0 { -{{- if eq $type.Name "Schema" }} - if !validationOpts.isOpenAPI31OrLater { - return newExtraSiblingFields(extras, x.Origin) - } -{{- else }} return newExtraSiblingFields(extras, x.Origin) -{{- end }} } return nil } diff --git a/openapi3/refs_test.go b/openapi3/refs_test.go index 2dbf6aa4c..44100a537 100644 --- a/openapi3/refs_test.go +++ b/openapi3/refs_test.go @@ -15,7 +15,11 @@ func TestCallbackRef_Extensions(t *testing.T) { t.Parallel() data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) - expectMarshalJson := []byte(`{"$ref":"#/components/schemas/Pet","x-order":1}`) + // Marshaling is a round trip. `something` is not an extension and Validate + // ignores it below, but it is still written back out: a document that comes + // back missing a field it was written with means something different, and + // nothing would report the change. + expectMarshalJson := data ref := openapi3.CallbackRef{} err := json.Unmarshal(data, &ref) @@ -41,16 +45,16 @@ func TestCallbackRef_Extensions(t *testing.T) { err = ref.Validate(t.Context(), openapi3.ProhibitExtensionsWithRef(), openapi3.AllowExtraSiblingFields("x-order")) require.ErrorContains(t, err, "found unresolved ref") - // OpenAPI 3.1 Reference Objects still reject unknown siblings; Schema + // OpenAPI 3.1 Reference Objects ignore unknown siblings too; Schema // Objects allow sibling keywords under JSON Schema semantics. err = ref.Validate(t.Context(), openapi3.IsOpenAPI31OrLater()) - require.EqualError(t, err, "extra sibling fields: [something]") + require.ErrorContains(t, err, "found unresolved ref") // Verify round trip JSON // Compare as string to make error message more readable if different outJson, err := ref.MarshalJSON() assert.NoError(t, err) - assert.Equal(t, string(outJson), string(expectMarshalJson), "MarshalJSON output is not the same as input data") + assert.Equal(t, string(expectMarshalJson), string(outJson), "MarshalJSON output is not the same as input data") // non-extension not json lookable _, err = ref.JSONLookup("something") @@ -72,7 +76,11 @@ func TestExampleRef_Extensions(t *testing.T) { t.Parallel() data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) - expectMarshalJson := []byte(`{"$ref":"#/components/schemas/Pet","x-order":1}`) + // Marshaling is a round trip. `something` is not an extension and Validate + // ignores it below, but it is still written back out: a document that comes + // back missing a field it was written with means something different, and + // nothing would report the change. + expectMarshalJson := data ref := openapi3.ExampleRef{} err := json.Unmarshal(data, &ref) @@ -98,16 +106,16 @@ func TestExampleRef_Extensions(t *testing.T) { err = ref.Validate(t.Context(), openapi3.ProhibitExtensionsWithRef(), openapi3.AllowExtraSiblingFields("x-order")) require.ErrorContains(t, err, "found unresolved ref") - // OpenAPI 3.1 Reference Objects still reject unknown siblings; Schema + // OpenAPI 3.1 Reference Objects ignore unknown siblings too; Schema // Objects allow sibling keywords under JSON Schema semantics. err = ref.Validate(t.Context(), openapi3.IsOpenAPI31OrLater()) - require.EqualError(t, err, "extra sibling fields: [something]") + require.ErrorContains(t, err, "found unresolved ref") // Verify round trip JSON // Compare as string to make error message more readable if different outJson, err := ref.MarshalJSON() assert.NoError(t, err) - assert.Equal(t, string(outJson), string(expectMarshalJson), "MarshalJSON output is not the same as input data") + assert.Equal(t, string(expectMarshalJson), string(outJson), "MarshalJSON output is not the same as input data") // non-extension not json lookable _, err = ref.JSONLookup("something") @@ -129,7 +137,11 @@ func TestHeaderRef_Extensions(t *testing.T) { t.Parallel() data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) - expectMarshalJson := []byte(`{"$ref":"#/components/schemas/Pet","x-order":1}`) + // Marshaling is a round trip. `something` is not an extension and Validate + // ignores it below, but it is still written back out: a document that comes + // back missing a field it was written with means something different, and + // nothing would report the change. + expectMarshalJson := data ref := openapi3.HeaderRef{} err := json.Unmarshal(data, &ref) @@ -155,16 +167,16 @@ func TestHeaderRef_Extensions(t *testing.T) { err = ref.Validate(t.Context(), openapi3.ProhibitExtensionsWithRef(), openapi3.AllowExtraSiblingFields("x-order")) require.ErrorContains(t, err, "found unresolved ref") - // OpenAPI 3.1 Reference Objects still reject unknown siblings; Schema + // OpenAPI 3.1 Reference Objects ignore unknown siblings too; Schema // Objects allow sibling keywords under JSON Schema semantics. err = ref.Validate(t.Context(), openapi3.IsOpenAPI31OrLater()) - require.EqualError(t, err, "extra sibling fields: [something]") + require.ErrorContains(t, err, "found unresolved ref") // Verify round trip JSON // Compare as string to make error message more readable if different outJson, err := ref.MarshalJSON() assert.NoError(t, err) - assert.Equal(t, string(outJson), string(expectMarshalJson), "MarshalJSON output is not the same as input data") + assert.Equal(t, string(expectMarshalJson), string(outJson), "MarshalJSON output is not the same as input data") // non-extension not json lookable _, err = ref.JSONLookup("something") @@ -177,7 +189,11 @@ func TestLinkRef_Extensions(t *testing.T) { t.Parallel() data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) - expectMarshalJson := []byte(`{"$ref":"#/components/schemas/Pet","x-order":1}`) + // Marshaling is a round trip. `something` is not an extension and Validate + // ignores it below, but it is still written back out: a document that comes + // back missing a field it was written with means something different, and + // nothing would report the change. + expectMarshalJson := data ref := openapi3.LinkRef{} err := json.Unmarshal(data, &ref) @@ -203,16 +219,16 @@ func TestLinkRef_Extensions(t *testing.T) { err = ref.Validate(t.Context(), openapi3.ProhibitExtensionsWithRef(), openapi3.AllowExtraSiblingFields("x-order")) require.ErrorContains(t, err, "found unresolved ref") - // OpenAPI 3.1 Reference Objects still reject unknown siblings; Schema + // OpenAPI 3.1 Reference Objects ignore unknown siblings too; Schema // Objects allow sibling keywords under JSON Schema semantics. err = ref.Validate(t.Context(), openapi3.IsOpenAPI31OrLater()) - require.EqualError(t, err, "extra sibling fields: [something]") + require.ErrorContains(t, err, "found unresolved ref") // Verify round trip JSON // Compare as string to make error message more readable if different outJson, err := ref.MarshalJSON() assert.NoError(t, err) - assert.Equal(t, string(outJson), string(expectMarshalJson), "MarshalJSON output is not the same as input data") + assert.Equal(t, string(expectMarshalJson), string(outJson), "MarshalJSON output is not the same as input data") // non-extension not json lookable _, err = ref.JSONLookup("something") @@ -234,7 +250,11 @@ func TestParameterRef_Extensions(t *testing.T) { t.Parallel() data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) - expectMarshalJson := []byte(`{"$ref":"#/components/schemas/Pet","x-order":1}`) + // Marshaling is a round trip. `something` is not an extension and Validate + // ignores it below, but it is still written back out: a document that comes + // back missing a field it was written with means something different, and + // nothing would report the change. + expectMarshalJson := data ref := openapi3.ParameterRef{} err := json.Unmarshal(data, &ref) @@ -260,16 +280,16 @@ func TestParameterRef_Extensions(t *testing.T) { err = ref.Validate(t.Context(), openapi3.ProhibitExtensionsWithRef(), openapi3.AllowExtraSiblingFields("x-order")) require.ErrorContains(t, err, "found unresolved ref") - // OpenAPI 3.1 Reference Objects still reject unknown siblings; Schema + // OpenAPI 3.1 Reference Objects ignore unknown siblings too; Schema // Objects allow sibling keywords under JSON Schema semantics. err = ref.Validate(t.Context(), openapi3.IsOpenAPI31OrLater()) - require.EqualError(t, err, "extra sibling fields: [something]") + require.ErrorContains(t, err, "found unresolved ref") // Verify round trip JSON // Compare as string to make error message more readable if different outJson, err := ref.MarshalJSON() assert.NoError(t, err) - assert.Equal(t, string(outJson), string(expectMarshalJson), "MarshalJSON output is not the same as input data") + assert.Equal(t, string(expectMarshalJson), string(outJson), "MarshalJSON output is not the same as input data") // non-extension not json lookable _, err = ref.JSONLookup("something") @@ -291,7 +311,11 @@ func TestRequestBodyRef_Extensions(t *testing.T) { t.Parallel() data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) - expectMarshalJson := []byte(`{"$ref":"#/components/schemas/Pet","x-order":1}`) + // Marshaling is a round trip. `something` is not an extension and Validate + // ignores it below, but it is still written back out: a document that comes + // back missing a field it was written with means something different, and + // nothing would report the change. + expectMarshalJson := data ref := openapi3.RequestBodyRef{} err := json.Unmarshal(data, &ref) @@ -317,16 +341,16 @@ func TestRequestBodyRef_Extensions(t *testing.T) { err = ref.Validate(t.Context(), openapi3.ProhibitExtensionsWithRef(), openapi3.AllowExtraSiblingFields("x-order")) require.ErrorContains(t, err, "found unresolved ref") - // OpenAPI 3.1 Reference Objects still reject unknown siblings; Schema + // OpenAPI 3.1 Reference Objects ignore unknown siblings too; Schema // Objects allow sibling keywords under JSON Schema semantics. err = ref.Validate(t.Context(), openapi3.IsOpenAPI31OrLater()) - require.EqualError(t, err, "extra sibling fields: [something]") + require.ErrorContains(t, err, "found unresolved ref") // Verify round trip JSON // Compare as string to make error message more readable if different outJson, err := ref.MarshalJSON() assert.NoError(t, err) - assert.Equal(t, string(outJson), string(expectMarshalJson), "MarshalJSON output is not the same as input data") + assert.Equal(t, string(expectMarshalJson), string(outJson), "MarshalJSON output is not the same as input data") // non-extension not json lookable _, err = ref.JSONLookup("something") @@ -348,7 +372,11 @@ func TestResponseRef_Extensions(t *testing.T) { t.Parallel() data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) - expectMarshalJson := []byte(`{"$ref":"#/components/schemas/Pet","x-order":1}`) + // Marshaling is a round trip. `something` is not an extension and Validate + // ignores it below, but it is still written back out: a document that comes + // back missing a field it was written with means something different, and + // nothing would report the change. + expectMarshalJson := data ref := openapi3.ResponseRef{} err := json.Unmarshal(data, &ref) @@ -374,16 +402,16 @@ func TestResponseRef_Extensions(t *testing.T) { err = ref.Validate(t.Context(), openapi3.ProhibitExtensionsWithRef(), openapi3.AllowExtraSiblingFields("x-order")) require.ErrorContains(t, err, "found unresolved ref") - // OpenAPI 3.1 Reference Objects still reject unknown siblings; Schema + // OpenAPI 3.1 Reference Objects ignore unknown siblings too; Schema // Objects allow sibling keywords under JSON Schema semantics. err = ref.Validate(t.Context(), openapi3.IsOpenAPI31OrLater()) - require.EqualError(t, err, "extra sibling fields: [something]") + require.ErrorContains(t, err, "found unresolved ref") // Verify round trip JSON // Compare as string to make error message more readable if different outJson, err := ref.MarshalJSON() assert.NoError(t, err) - assert.Equal(t, string(outJson), string(expectMarshalJson), "MarshalJSON output is not the same as input data") + assert.Equal(t, string(expectMarshalJson), string(outJson), "MarshalJSON output is not the same as input data") // non-extension not json lookable _, err = ref.JSONLookup("something") @@ -405,7 +433,11 @@ func TestSchemaRef_Extensions(t *testing.T) { t.Parallel() data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) - expectMarshalJson := []byte(`{"$ref":"#/components/schemas/Pet","x-order":1}`) + // Marshaling is a round trip. `something` is not an extension and Validate + // ignores it below, but it is still written back out: a document that comes + // back missing a field it was written with means something different, and + // nothing would report the change. + expectMarshalJson := data ref := openapi3.SchemaRef{} err := json.Unmarshal(data, &ref) @@ -431,7 +463,7 @@ func TestSchemaRef_Extensions(t *testing.T) { err = ref.Validate(t.Context(), openapi3.ProhibitExtensionsWithRef(), openapi3.AllowExtraSiblingFields("x-order")) require.ErrorContains(t, err, "found unresolved ref") - // OpenAPI 3.1 Reference Objects still reject unknown siblings; Schema + // OpenAPI 3.1 Reference Objects ignore unknown siblings too; Schema // Objects allow sibling keywords under JSON Schema semantics. err = ref.Validate(t.Context(), openapi3.IsOpenAPI31OrLater()) require.ErrorContains(t, err, "found unresolved ref") @@ -440,7 +472,7 @@ func TestSchemaRef_Extensions(t *testing.T) { // Compare as string to make error message more readable if different outJson, err := ref.MarshalJSON() assert.NoError(t, err) - assert.Equal(t, string(outJson), string(expectMarshalJson), "MarshalJSON output is not the same as input data") + assert.Equal(t, string(expectMarshalJson), string(outJson), "MarshalJSON output is not the same as input data") // non-extension not json lookable _, err = ref.JSONLookup("something") @@ -462,7 +494,11 @@ func TestSecuritySchemeRef_Extensions(t *testing.T) { t.Parallel() data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) - expectMarshalJson := []byte(`{"$ref":"#/components/schemas/Pet","x-order":1}`) + // Marshaling is a round trip. `something` is not an extension and Validate + // ignores it below, but it is still written back out: a document that comes + // back missing a field it was written with means something different, and + // nothing would report the change. + expectMarshalJson := data ref := openapi3.SecuritySchemeRef{} err := json.Unmarshal(data, &ref) @@ -488,16 +524,16 @@ func TestSecuritySchemeRef_Extensions(t *testing.T) { err = ref.Validate(t.Context(), openapi3.ProhibitExtensionsWithRef(), openapi3.AllowExtraSiblingFields("x-order")) require.ErrorContains(t, err, "found unresolved ref") - // OpenAPI 3.1 Reference Objects still reject unknown siblings; Schema + // OpenAPI 3.1 Reference Objects ignore unknown siblings too; Schema // Objects allow sibling keywords under JSON Schema semantics. err = ref.Validate(t.Context(), openapi3.IsOpenAPI31OrLater()) - require.EqualError(t, err, "extra sibling fields: [something]") + require.ErrorContains(t, err, "found unresolved ref") // Verify round trip JSON // Compare as string to make error message more readable if different outJson, err := ref.MarshalJSON() assert.NoError(t, err) - assert.Equal(t, string(outJson), string(expectMarshalJson), "MarshalJSON output is not the same as input data") + assert.Equal(t, string(expectMarshalJson), string(outJson), "MarshalJSON output is not the same as input data") // non-extension not json lookable _, err = ref.JSONLookup("something") diff --git a/openapi3/refs_test.tmpl b/openapi3/refs_test.tmpl index 083e52207..ae1b06788 100644 --- a/openapi3/refs_test.tmpl +++ b/openapi3/refs_test.tmpl @@ -15,7 +15,11 @@ func Test{{ $type.Name }}Ref_Extensions(t *testing.T) { t.Parallel() data := []byte(`{"$ref":"#/components/schemas/Pet","something":"integer","x-order":1}`) - expectMarshalJson := []byte(`{"$ref":"#/components/schemas/Pet","x-order":1}`) + // Marshaling is a round trip. `something` is not an extension and Validate + // ignores it below, but it is still written back out: a document that comes + // back missing a field it was written with means something different, and + // nothing would report the change. + expectMarshalJson := data ref := openapi3.{{ $type.Name }}Ref{} err := json.Unmarshal(data, &ref) @@ -41,20 +45,16 @@ func Test{{ $type.Name }}Ref_Extensions(t *testing.T) { err = ref.Validate(t.Context(), openapi3.ProhibitExtensionsWithRef(), openapi3.AllowExtraSiblingFields("x-order")) require.ErrorContains(t, err, "found unresolved ref") - // OpenAPI 3.1 Reference Objects still reject unknown siblings; Schema + // OpenAPI 3.1 Reference Objects ignore unknown siblings too; Schema // Objects allow sibling keywords under JSON Schema semantics. err = ref.Validate(t.Context(), openapi3.IsOpenAPI31OrLater()) -{{- if eq $type.Name "Schema" }} require.ErrorContains(t, err, "found unresolved ref") -{{- else }} - require.EqualError(t, err, "extra sibling fields: [something]") -{{- end }} // Verify round trip JSON // Compare as string to make error message more readable if different outJson, err := ref.MarshalJSON() assert.NoError(t, err) - assert.Equal(t, string(outJson), string(expectMarshalJson), "MarshalJSON output is not the same as input data") + assert.Equal(t, string(expectMarshalJson), string(outJson), "MarshalJSON output is not the same as input data") // non-extension not json lookable _, err = ref.JSONLookup("something") diff --git a/openapi3/request_body.go b/openapi3/request_body.go index 965d5b2e8..91d7b5997 100644 --- a/openapi3/request_body.go +++ b/openapi3/request_body.go @@ -93,8 +93,12 @@ func (requestBody RequestBody) MarshalYAML() (any, error) { if x := requestBody.Required; x { m["required"] = x } - if x := requestBody.Content; true { + // Required, so written even when missing. A nil map would be written + // "null" as JSON and "{}" as YAML: write null both ways. + if x := requestBody.Content; x != nil { m["content"] = x + } else { + m["content"] = nil } return m, nil } @@ -106,7 +110,9 @@ func (requestBody *RequestBody) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "description") delete(x.Extensions, "required") delete(x.Extensions, "content") diff --git a/openapi3/response.go b/openapi3/response.go index ba0f9c0c7..70c3cf429 100644 --- a/openapi3/response.go +++ b/openapi3/response.go @@ -172,7 +172,9 @@ func (response *Response) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "description") delete(x.Extensions, "headers") delete(x.Extensions, "content") diff --git a/openapi3/schema.go b/openapi3/schema.go index 9405d8a6b..4f915ed8f 100644 --- a/openapi3/schema.go +++ b/openapi3/schema.go @@ -402,24 +402,6 @@ func (types *Types) UnmarshalJSON(data []byte) error { return nil } -// UnmarshalYAML accepts a single type as well as a sequence of types, mirroring -// UnmarshalJSON. Swagger 2 and OpenAPI 3.0 documents always write `type` as a -// scalar, so decoding such a document into openapi2.T or openapi3.T straight -// from YAML would otherwise fail with "cannot unmarshal !!str into Types". -func (types *Types) UnmarshalYAML(unmarshal func(any) error) error { - var single string - if err := unmarshal(&single); err == nil { - *types = Types{single} - return nil - } - var multiple []string - if err := unmarshal(&multiple); err != nil { - return err - } - *types = Types(multiple) - return nil -} - // BoolSchema represents a JSON Schema keyword that can be either a boolean or a schema object. // Used for additionalProperties, unevaluatedProperties, and unevaluatedItems. type BoolSchema struct { @@ -729,7 +711,17 @@ func (schema Schema) MarshalYAML() (any, error) { m["else"] = x } if x := schema.DependentRequired; len(x) != 0 { - m["dependentRequired"] = x + // A nil list of names is written null, as JSON writes it, rather + // than the "[]" YAML would make of it. + dr := make(map[string]any, len(x)) + for name, names := range x { + if names == nil { + dr[name] = nil + } else { + dr[name] = names + } + } + m["dependentRequired"] = dr } if x := schema.Defs; len(x) != 0 { m["$defs"] = x @@ -792,7 +784,9 @@ func (schema *Schema) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "oneOf") delete(x.Extensions, "anyOf") @@ -1284,7 +1278,8 @@ func (schema *Schema) WithMinLengthDecodedBase64(i int64) *Schema { func (schema *Schema) WithMaxLengthDecodedBase64(i int64) *Schema { n := uint64(i) - schema.MinLength = (n*8 + 5) / 6 + v := (n*8 + 5) / 6 + schema.MaxLength = &v return schema } @@ -1396,6 +1391,19 @@ func (schema *Schema) permitsNull(settings *schemaValidationSettings) bool { // IsEmpty tells whether schema is equivalent to the empty schema `{}`. func (schema *Schema) IsEmpty() bool { + return schema.isEmpty(make(map[*Schema]struct{})) +} + +// isEmpty is IsEmpty over a schema graph, which may be cyclic. A schema met +// again on the way down is already being judged: it adds no constraint of its +// own there, so it counts as empty and the others decide. +func (schema *Schema) isEmpty(visiting map[*Schema]struct{}) bool { + if _, ok := visiting[schema]; ok { + return true + } + visiting[schema] = struct{}{} + defer delete(visiting, schema) + if schema.Type != nil || schema.Format != "" || len(schema.Enum) != 0 || schema.UniqueItems || schema.ExclusiveMin.IsSet() || schema.ExclusiveMax.IsSet() || schema.Nullable || schema.ReadOnly || schema.WriteOnly || schema.AllowEmptyValue || @@ -1407,54 +1415,54 @@ func (schema *Schema) IsEmpty() bool { schema.Const != nil { return false } - if n := schema.Not; n != nil && n.Value != nil && !n.Value.IsEmpty() { + if n := schema.Not; n != nil && n.Value != nil && !n.Value.isEmpty(visiting) { return false } - if ap := schema.AdditionalProperties.Schema; ap != nil && ap.Value != nil && !ap.Value.IsEmpty() { + if ap := schema.AdditionalProperties.Schema; ap != nil && ap.Value != nil && !ap.Value.isEmpty(visiting) { return false } if apa := schema.AdditionalProperties.Has; apa != nil && !*apa { return false } - if items := schema.Items; items != nil && items.Value != nil && !items.Value.IsEmpty() { + if items := schema.Items; items != nil && items.Value != nil && !items.Value.isEmpty(visiting) { return false } for _, s := range schema.PrefixItems { - if ss := s.Value; ss != nil && !ss.IsEmpty() { + if ss := s.Value; ss != nil && !ss.isEmpty(visiting) { return false } } - if c := schema.Contains; c != nil && c.Value != nil && !c.Value.IsEmpty() { + if c := schema.Contains; c != nil && c.Value != nil && !c.Value.isEmpty(visiting) { return false } if schema.MinContains != nil || schema.MaxContains != nil { return false } for _, s := range schema.Properties { - if ss := s.Value; ss != nil && !ss.IsEmpty() { + if ss := s.Value; ss != nil && !ss.isEmpty(visiting) { return false } } for _, s := range schema.PatternProperties { - if ss := s.Value; ss != nil && !ss.IsEmpty() { + if ss := s.Value; ss != nil && !ss.isEmpty(visiting) { return false } } for _, s := range schema.DependentSchemas { - if ss := s.Value; ss != nil && !ss.IsEmpty() { + if ss := s.Value; ss != nil && !ss.isEmpty(visiting) { return false } } - if pn := schema.PropertyNames; pn != nil && pn.Value != nil && !pn.Value.IsEmpty() { + if pn := schema.PropertyNames; pn != nil && pn.Value != nil && !pn.Value.isEmpty(visiting) { return false } - if ui := schema.UnevaluatedItems.Schema; ui != nil && ui.Value != nil && !ui.Value.IsEmpty() { + if ui := schema.UnevaluatedItems.Schema; ui != nil && ui.Value != nil && !ui.Value.isEmpty(visiting) { return false } if uih := schema.UnevaluatedItems.Has; uih != nil && !*uih { return false } - if up := schema.UnevaluatedProperties.Schema; up != nil && up.Value != nil && !up.Value.IsEmpty() { + if up := schema.UnevaluatedProperties.Schema; up != nil && up.Value != nil && !up.Value.isEmpty(visiting) { return false } if uph := schema.UnevaluatedProperties.Has; uph != nil && !*uph { @@ -1464,27 +1472,27 @@ func (schema *Schema) IsEmpty() bool { return false } for _, s := range schema.OneOf { - if ss := s.Value; ss != nil && !ss.IsEmpty() { + if ss := s.Value; ss != nil && !ss.isEmpty(visiting) { return false } } for _, s := range schema.AnyOf { - if ss := s.Value; ss != nil && !ss.IsEmpty() { + if ss := s.Value; ss != nil && !ss.isEmpty(visiting) { return false } } for _, s := range schema.AllOf { - if ss := s.Value; ss != nil && !ss.IsEmpty() { + if ss := s.Value; ss != nil && !ss.isEmpty(visiting) { return false } } - if f := schema.If; f != nil && f.Value != nil && !f.Value.IsEmpty() { + if f := schema.If; f != nil && f.Value != nil && !f.Value.isEmpty(visiting) { return false } - if t := schema.Then; t != nil && t.Value != nil && !t.Value.IsEmpty() { + if t := schema.Then; t != nil && t.Value != nil && !t.Value.isEmpty(visiting) { return false } - if e := schema.Else; e != nil && e.Value != nil && !e.Value.IsEmpty() { + if e := schema.Else; e != nil && e.Value != nil && !e.Value.isEmpty(visiting) { return false } if len(schema.DependentRequired) != 0 { @@ -1502,7 +1510,7 @@ func (schema *Schema) IsEmpty() bool { if schema.ContentMediaType != "" || schema.ContentEncoding != "" { return false } - if cs := schema.ContentSchema; cs != nil && cs.Value != nil && !cs.Value.IsEmpty() { + if cs := schema.ContentSchema; cs != nil && cs.Value != nil && !cs.Value.isEmpty(visiting) { return false } // Last, so a schema carrying both a boolean and other keywords is judged by @@ -1572,6 +1580,12 @@ func (schema *Schema) validate(ctx context.Context, visited map[*Schema]struct{} } } + // The value of `multipleOf` MUST be strictly greater than 0 (JSON Schema + // 2020-12 §6.2.1 for OAS 3.1, draft-04 for OAS 3.0). + if x := schema.MultipleOf; x != nil && *x <= 0 { + return visited, &SchemaMultipleOfNotPositiveError{MultipleOf: *x, Origin: schema.Origin} + } + // Reject fields that only exist in OAS 3.1 / JSON Schema 2020-12 when the // document is OAS 3.0. Fields explicitly allowed via AllowExtraSiblingFields // are skipped (opt-in escape hatch for 3.0 docs that reference external @@ -1768,6 +1782,20 @@ func (schema *Schema) validate(ctx context.Context, visited map[*Schema]struct{} } } + if d := schema.Discriminator; d != nil { + if err := d.Validate(ctx); err != nil { + return visited, err + } + for _, k := range componentNames(d.Mapping) { + if v := d.Mapping[k].Value; v != nil { + var err error + if visited, err = v.validate(ctx, visited); err != nil { + return visited, err + } + } + } + } + if ref := schema.Not; ref != nil { v := ref.Value if v == nil { @@ -1855,15 +1883,6 @@ func (schema *Schema) validate(ctx context.Context, visited map[*Schema]struct{} } } } - if !validationOpts.schemaPatternValidationDisabled && schema.Pattern != "" { - // Only successfully compiled patterns are cached. - // The cache does not record which compiler was used, so a custom one always compiles. - if _, ok := compiledPatterns.Load(schema.Pattern); !ok || validationOpts.regexCompilerFunc != nil { - if _, err := schema.compilePattern(nil, validationOpts.regexCompilerFunc); err != nil { - return visited, err - } - } - } case TypeArray: if schema.Items == nil && !validationOpts.jsonSchema2020ValidationEnabled && len(schema.PrefixItems) == 0 { return visited, newSchemaItemsRequired(schema.Origin) @@ -1878,6 +1897,18 @@ func (schema *Schema) validate(ctx context.Context, visited map[*Schema]struct{} } } + // `pattern` applies to every string value, also when `type` is omitted. + if !validationOpts.schemaPatternValidationDisabled && schema.Pattern != "" && + (schema.Type.IsEmpty() || schema.Type.Includes(TypeString)) { + // Only successfully compiled patterns are cached. + // The cache does not record which compiler was used, so a custom one always compiles. + if _, ok := compiledPatterns.Load(schema.Pattern); !ok || validationOpts.regexCompilerFunc != nil { + if _, err := schema.compilePattern(nil, validationOpts.regexCompilerFunc); err != nil { + return visited, err + } + } + } + if ref := schema.Items; ref != nil { if err := ref.validateExtras(ctx); err != nil { return visited, err diff --git a/openapi3/schema_jsonschema_validator.go b/openapi3/schema_jsonschema_validator.go index 515477eb7..c52480d0d 100644 --- a/openapi3/schema_jsonschema_validator.go +++ b/openapi3/schema_jsonschema_validator.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "github.com/go-openapi/jsonpointer" "github.com/santhosh-tekuri/jsonschema/v6" "github.com/santhosh-tekuri/jsonschema/v6/kind" ) @@ -18,6 +19,8 @@ type jsonSchemaValidator struct { compiler *jsonschema.Compiler schema *jsonschema.Schema hasInternalRefs bool + root *Schema + defs map[string]*Schema } // newJSONSchemaValidator creates a new validator using JSON Schema 2020-12 @@ -36,7 +39,8 @@ func newJSONSchemaValidator(schema *Schema, settings *schemaValidationSettings) // Boolean schemas are valid JSON Schema resources as well. schemaMap, _ := schemaDocument.(map[string]any) hasInternalRefs := containsInternalSchemaRef(schemaDocument) - if err := addInternalSchemaRefs(schemaMap, schema); err != nil { + defs, err := addInternalSchemaRefs(schemaMap, schema) + if err != nil { return nil, fmt.Errorf("failed to prepare schema references: %w", err) } @@ -63,6 +67,8 @@ func newJSONSchemaValidator(schema *Schema, settings *schemaValidationSettings) compiler: compiler, schema: compiledSchema, hasInternalRefs: hasInternalRefs, + root: schema, + defs: defs, }, nil } @@ -89,11 +95,12 @@ func containsInternalSchemaRef(node any) bool { // resolved Schema. VisitJSON receives a subtree rather than the containing // OpenAPI document, so copy each resolved target into local $defs and rewrite // its reference. This also handles references to a nested property or item. -func addInternalSchemaRefs(schemaMap map[string]any, schema *Schema) error { +func addInternalSchemaRefs(schemaMap map[string]any, schema *Schema) (map[string]*Schema, error) { if schemaMap == nil || schema == nil { - return nil + return nil, nil } refs := make(map[string]any) + values := make(map[string]*Schema) // Recursive schemas reference an already-visited target, which WalkSubtree would not report. err := NewSchemaRef("", schema).walkSubtreeRefs(func(_ string, ref *SchemaRef) error { if ref.Ref == "" || !strings.HasPrefix(ref.Ref, "#/components/schemas/") || ref.Value == nil { @@ -111,25 +118,28 @@ func addInternalSchemaRefs(schemaMap map[string]any, schema *Schema) error { return err } refs[ref.Ref] = target + values[ref.Ref] = ref.Value return nil }) if err != nil { - return err + return nil, err } if len(refs) == 0 { - return nil + return nil, nil } defs := make(map[string]any, len(refs)) ids := make(map[string]string, len(refs)) + defValues := make(map[string]*Schema, len(refs)) for ref := range refs { ids[ref] = fmt.Sprintf("ref%d", len(ids)) + defValues[ids[ref]] = values[ref] } rewriteInternalSchemaRefs(schemaMap, refs, ids, defs) if len(defs) > 0 { schemaMap["$defs"] = defs rewriteInternalSchemaRefs(defs, refs, ids, defs) } - return nil + return defValues, nil } // rewriteInternalSchemaRefs rewrites document-relative component references to @@ -252,30 +262,30 @@ func validateNumberFormat(format string, settings *schemaValidationSettings, val func (v *jsonSchemaValidator) validate(settings *schemaValidationSettings, value any) error { if err := v.schema.Validate(value); err != nil { // Convert jsonschema error to SchemaError - return convertJSONSchemaError(settings, err, value) + return v.convertJSONSchemaError(settings, err, value) } return nil } // convertJSONSchemaError converts a jsonschema validation error to OpenAPI SchemaError format. // root is the validated value, from which the failing value is looked up. -func convertJSONSchemaError(settings *schemaValidationSettings, err error, root any) error { +func (v *jsonSchemaValidator) convertJSONSchemaError(settings *schemaValidationSettings, err error, root any) error { // TODO: Go 1.26 // if err, ok := errors.AsType[*jsonschema.ValidationError](err); ok { - // return formatValidationError(settings, err, "", root) + // return v.formatValidationError(settings, err, "", root) var validationErr *jsonschema.ValidationError if errors.As(err, &validationErr) { - return formatValidationError(settings, validationErr, "", root) + return v.formatValidationError(settings, validationErr, "", root) } return err } // formatValidationError recursively formats validation errors into SchemaErrors // that carry the same structure as the ones of the built-in validator: the path -// of the failing value (JSONPointer), the failing keyword (SchemaField) and the -// failing value (Value). The jsonschema error itself stays reachable with -// errors.As. -func formatValidationError(settings *schemaValidationSettings, verr *jsonschema.ValidationError, parentPath string, root any) error { +// of the failing value (JSONPointer), the failing keyword (SchemaField), the +// failing schema (Schema) and the failing value (Value). The jsonschema error +// itself stays reachable with errors.As. +func (v *jsonSchemaValidator) formatValidationError(settings *schemaValidationSettings, verr *jsonschema.ValidationError, parentPath string, root any) error { // Build the path from InstanceLocation slice path := "/" + strings.Join(verr.InstanceLocation, "/") if parentPath != "" && path != "/" { @@ -299,7 +309,7 @@ func formatValidationError(settings *schemaValidationSettings, verr *jsonschema. } value, _ := valueAt(root, failing.InstanceLocation) - schemaErr := settings.newSchemaError(nil, value). + schemaErr := settings.newSchemaError(v.schemaAt(failing.SchemaURL), value). withReason(msg.String()). withCause(verr) for _, key := range slices.Backward(failing.InstanceLocation) { @@ -313,7 +323,7 @@ func formatValidationError(settings *schemaValidationSettings, verr *jsonschema. if len(verr.Causes) > 0 { var subErrors MultiError for _, cause := range verr.Causes { - if subErr := formatValidationError(settings, cause, path, root); subErr != nil { + if subErr := v.formatValidationError(settings, cause, path, root); subErr != nil { subErrors = append(subErrors, subErr) } } @@ -325,6 +335,30 @@ func formatValidationError(settings *schemaValidationSettings, verr *jsonschema. return schemaErr } +// schemaAt is the schema at schemaURL, the location of a schema of the +// compiled document, or nil when it is not one of v's schemas. +func (v *jsonSchemaValidator) schemaAt(schemaURL string) *Schema { + _, fragment, _ := strings.Cut(schemaURL, "#") + root := v.root + if rest, ok := strings.CutPrefix(fragment, "/$defs/"); ok { + id, rest, _ := strings.Cut(rest, "/") + if root = v.defs[id]; root == nil { + return nil + } + fragment = strings.TrimSuffix("/"+rest, "/") + } + ptr, err := jsonpointer.New(fragment) + if err != nil { + return nil + } + found, _, err := ptr.Get(root) + if err != nil { + return nil + } + schema, _ := found.(*Schema) + return schema +} + // isWrapperErrorKind reports whether k only groups the errors of its causes. func isWrapperErrorKind(k jsonschema.ErrorKind) bool { switch k.(type) { diff --git a/openapi3/schema_ref_siblings_test.go b/openapi3/schema_ref_siblings_test.go new file mode 100644 index 000000000..5cc07e2ea --- /dev/null +++ b/openapi3/schema_ref_siblings_test.go @@ -0,0 +1,129 @@ +package openapi3_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +func loadSchemaRefSiblings(t *testing.T, version, schemas string) *openapi3.T { + t.Helper() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData(fmt.Appendf(nil, `{ + "openapi": %q, + "info": {"title": "t", "version": "1"}, + "paths": {}, + "components": {"schemas": %s} +}`, version, schemas)) + require.NoError(t, err) + require.NoError(t, doc.Validate(t.Context())) + return doc +} + +// In OpenAPI 3.1 a Schema Object is a JSON Schema 2020-12 schema, in which the +// keywords next to $ref apply alongside it: a value must satisfy both. +func TestSchemaRefSiblingsApplyAlongsideTheRef(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + sibling string + valid []any + invalid []any + }{{ + name: "a looser bound does not relax the referenced one", + sibling: `"maxLength": 10`, + valid: []any{"abc"}, + invalid: []any{"abcd", "abcdefghijk"}, + }, { + name: "a tighter bound applies", + sibling: `"minLength": 2`, + valid: []any{"ab"}, + invalid: []any{"a", "abcd"}, + }, { + name: "a different type matches nothing", + sibling: `"type": "integer"`, + invalid: []any{"a", 1.0}, + }} { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + doc := loadSchemaRefSiblings(t, "3.1.0", `{ + "Base": {"type": "string", "maxLength": 3}, + "S": {"properties": {"p": {"$ref": "#/components/schemas/Base", `+tc.sibling+`}}} +}`) + value := doc.Components.Schemas["S"].Value.Properties["p"].Value + for _, v := range tc.valid { + require.NoError(t, value.VisitJSON(v), "%v", v) + } + for _, v := range tc.invalid { + require.Error(t, value.VisitJSON(v), "%v", v) + } + // The referenced schema keeps its own shape, and is not changed. + require.True(t, value.Type.Is("string")) + require.Equal(t, uint64(3), *value.MaxLength) + require.Len(t, doc.Components.Schemas["Base"].Value.AllOf, 0) + }) + } + + t.Run("properties and required add to the referenced ones", func(t *testing.T) { + t.Parallel() + doc := loadSchemaRefSiblings(t, "3.1.0", `{ + "Base": {"type": "object", "properties": {"a": {"type": "string"}}, "required": ["a"]}, + "S": {"properties": {"p": { + "$ref": "#/components/schemas/Base", + "properties": {"b": {"$ref": "#/components/schemas/Int"}}, + "required": ["b"] + }}}, + "Int": {"type": "integer"} +}`) + value := doc.Components.Schemas["S"].Value.Properties["p"].Value + require.NoError(t, value.VisitJSON(map[string]any{"a": "x", "b": 1.0})) + require.Error(t, value.VisitJSON(map[string]any{"a": "x"})) + require.Error(t, value.VisitJSON(map[string]any{"b": 1.0})) + require.Error(t, value.VisitJSON(map[string]any{"a": "x", "b": "y"})) + require.Contains(t, value.Properties, "a") + }) + + t.Run("annotations override the referenced ones", func(t *testing.T) { + t.Parallel() + doc := loadSchemaRefSiblings(t, "3.1.0", `{ + "Base": {"type": "object", "description": "base", "properties": {"a": {"type": "string"}}}, + "S": {"properties": {"p": {"$ref": "#/components/schemas/Base", "description": "local", "readOnly": true}}} +}`) + value := doc.Components.Schemas["S"].Value.Properties["p"].Value + require.Equal(t, "local", value.Description) + require.True(t, value.ReadOnly) + require.Contains(t, value.Properties, "a") + require.Empty(t, value.AllOf) + require.Equal(t, "base", doc.Components.Schemas["Base"].Value.Description) + }) + + t.Run("through a cycle", func(t *testing.T) { + t.Parallel() + doc := loadSchemaRefSiblings(t, "3.1.0", `{ + "Author": {"type": "object", "properties": {"name": {"type": "string"}, "book": {"$ref": "#/components/schemas/Book", "required": ["title"]}}}, + "Book": {"type": "object", "properties": {"title": {"type": "string"}, "author": {"$ref": "#/components/schemas/Author", "required": ["name"]}}} +}`) + book := doc.Components.Schemas["Author"].Value.Properties["book"].Value + require.Error(t, book.VisitJSON(map[string]any{})) + require.NoError(t, book.VisitJSON(map[string]any{"title": "t"})) + author := doc.Components.Schemas["Book"].Value.Properties["author"].Value + require.Error(t, author.VisitJSON(map[string]any{})) + require.NoError(t, author.VisitJSON(map[string]any{"name": "n"})) + }) +} + +// In OpenAPI 3.0 a $ref is a Reference Object, whose added properties are +// ignored. +func TestSchemaRefSiblingsAreIgnoredIn30(t *testing.T) { + t.Parallel() + doc := loadSchemaRefSiblings(t, "3.0.3", `{ + "Base": {"type": "string", "maxLength": 3, "description": "base"}, + "S": {"properties": {"p": {"$ref": "#/components/schemas/Base", "maxLength": 1, "description": "local"}}} +}`) + value := doc.Components.Schemas["S"].Value.Properties["p"].Value + require.Same(t, doc.Components.Schemas["Base"].Value, value) + require.NoError(t, value.VisitJSON("abc")) +} diff --git a/openapi3/schema_test.go b/openapi3/schema_test.go index 805b85431..b32e92ecb 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 { @@ -1651,3 +1651,53 @@ func TestSchemaMultipleOfAcrossMagnitudes(t *testing.T) { require.Equal(t, "number must be a multiple of 0.01", schemaErr.Reason) }) } + +func TestSchemaLengthDecodedBase64(t *testing.T) { + t.Parallel() + + encoded := func(n int) string { return base64.RawStdEncoding.EncodeToString(make([]byte /*,*/, n)) } + + t.Run("min", func(t *testing.T) { + schema := NewBytesSchema().WithMinLengthDecodedBase64(3) + require.Equal(t, uint64(4), schema.MinLength) + require.Nil(t, schema.MaxLength) + require.NoError(t, schema.VisitJSON(encoded(3))) + require.ErrorContains(t, schema.VisitJSON(encoded(2)), "minimum string length is 4") + }) + + t.Run("max", func(t *testing.T) { + schema := NewBytesSchema().WithMaxLengthDecodedBase64(3) + require.Zero(t, schema.MinLength) + require.NotNil(t, schema.MaxLength) + require.Equal(t, uint64(4), *schema.MaxLength) + require.NoError(t, schema.VisitJSON(encoded(3))) + require.NoError(t, schema.VisitJSON("")) + require.ErrorContains(t, schema.VisitJSON(encoded(6)), "maximum string length is 4") + }) + + t.Run("min and max", func(t *testing.T) { + schema := NewBytesSchema().WithMinLengthDecodedBase64(1).WithMaxLengthDecodedBase64(3) + require.Equal(t, uint64(2), schema.MinLength) + require.NotNil(t, schema.MaxLength) + require.Equal(t, uint64(4), *schema.MaxLength) + }) + + t.Run("exact", func(t *testing.T) { + schema := NewBytesSchema().WithLengthDecodedBase64(3) + require.Equal(t, uint64(4), schema.MinLength) + require.NotNil(t, schema.MaxLength) + require.Equal(t, uint64(4), *schema.MaxLength) + }) +} + +func TestSchemaIsEmptyCyclic(t *testing.T) { + // a: {oneOf: [b]}, b: {oneOf: [a]} constrains nothing, and must not + // recurse forever finding that out. + a, b := &Schema{}, &Schema{} + a.OneOf = SchemaRefs{{Value: b}} + b.OneOf = SchemaRefs{{Value: a}} + require.True(t, a.IsEmpty()) + + b.OneOf = append(b.OneOf, &SchemaRef{Value: &Schema{Type: &Types{TypeString}}}) + require.False(t, a.IsEmpty()) +} diff --git a/openapi3/security_requirements.go b/openapi3/security_requirements.go index 3f5bd9510..90a5de5b6 100644 --- a/openapi3/security_requirements.go +++ b/openapi3/security_requirements.go @@ -43,6 +43,24 @@ func (security SecurityRequirement) Authenticate(provider string, scopes ...stri return security } +// MarshalYAML returns the YAML encoding of SecurityRequirement. A nil +// SecurityRequirement or list of scopes is written null, as JSON writes it, +// rather than the "{}" or "[]" YAML would make of it. +func (security SecurityRequirement) MarshalYAML() (any, error) { + if security == nil { + return nil, nil + } + m := make(map[string]any, len(security)) + for name, scopes := range security { + if scopes == nil { + m[name] = nil + } else { + m[name] = scopes + } + } + return m, nil +} + // Validate returns an error if SecurityRequirement does not comply with the OpenAPI spec. func (security *SecurityRequirement) Validate(ctx context.Context, opts ...ValidationOption) error { // ctx = WithValidationOptions(ctx, opts...) diff --git a/openapi3/security_scheme.go b/openapi3/security_scheme.go index 2f03a95ea..9b9ccbc43 100644 --- a/openapi3/security_scheme.go +++ b/openapi3/security_scheme.go @@ -101,7 +101,9 @@ func (ss *SecurityScheme) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "type") delete(x.Extensions, "description") delete(x.Extensions, "name") @@ -290,7 +292,9 @@ func (flows *OAuthFlows) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "implicit") delete(x.Extensions, "password") delete(x.Extensions, "clientCredentials") @@ -367,7 +371,13 @@ func (flow OAuthFlow) MarshalYAML() (any, error) { if x := flow.RefreshURL; x != "" { m["refreshUrl"] = x } - m["scopes"] = flow.Scopes + // Required, so written even when missing: as null, not as the "{}" YAML + // would make of a nil map. + if x := flow.Scopes; x != nil { + m["scopes"] = x + } else { + m["scopes"] = nil + } return m, nil } @@ -378,7 +388,9 @@ func (flow *OAuthFlow) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "authorizationUrl") delete(x.Extensions, "tokenUrl") diff --git a/openapi3/server.go b/openapi3/server.go index 4fed3fc5c..3a53a91a7 100644 --- a/openapi3/server.go +++ b/openapi3/server.go @@ -112,7 +112,9 @@ func (server *Server) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "url") delete(x.Extensions, "description") delete(x.Extensions, "variables") @@ -320,7 +322,9 @@ func (serverVariable *ServerVariable) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "enum") delete(x.Extensions, "default") delete(x.Extensions, "description") diff --git a/openapi3/tag.go b/openapi3/tag.go index 1d6b22606..94266d1d5 100644 --- a/openapi3/tag.go +++ b/openapi3/tag.go @@ -90,7 +90,9 @@ func (t *Tag) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "name") delete(x.Extensions, "description") delete(x.Extensions, "externalDocs") diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_3_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_3_openapi_yaml__validate index 9e1d679e8..a85b907dd 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_3_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_3_openapi_yaml__validate @@ -1,6 +1,9 @@ invalid paths: invalid path /closeAccount: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string Schema: - null + { + "description": "The error code mapped to the error message.", + "type": "string" + } Value: 10003 diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_4_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_4_openapi_yaml__validate index 9e1d679e8..a85b907dd 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_4_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_4_openapi_yaml__validate @@ -1,6 +1,9 @@ invalid paths: invalid path /closeAccount: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string Schema: - null + { + "description": "The error code mapped to the error message.", + "type": "string" + } Value: 10003 diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_5_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_5_openapi_yaml__validate index aca52ea9d..919417f97 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_5_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_5_openapi_yaml__validate @@ -1,6 +1,10 @@ invalid paths: invalid path /checkAccountHolder: invalid operation POST: invalid example: example basic: validation failed due to: error at "/tier": at '/tier': got string, want integer Schema: - null + { + "description": "The tier required for the account holder.", + "format": "int32", + "type": "integer" + } Value: "2" diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_6_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_6_openapi_yaml__validate index aca52ea9d..919417f97 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_6_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_AccountService_6_openapi_yaml__validate @@ -1,6 +1,10 @@ invalid paths: invalid path /checkAccountHolder: invalid operation POST: invalid example: example basic: validation failed due to: error at "/tier": at '/tier': got string, want integer Schema: - null + { + "description": "The tier required for the account holder.", + "format": "int32", + "type": "integer" + } Value: "2" 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 a1a6687fc..48e8eec36 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_37_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_37_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_37_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 5ec602e08..9f86c2794 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_40_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_40_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_40_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 4947bf2ca..2d9c11a39 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_41_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_41_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_41_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 169959c1f..d6c01a604 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_46_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_46_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_46_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 7b02b8bcc..500982bc5 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_49_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_49_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_49_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 0135742f2..e8ab21fb9 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_50_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_50_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_50_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 6a6fc8a33..4e3dfd3a6 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_51_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_51_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_51_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 834eb34f7..9db446af8 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_52_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_52_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_52_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 834eb34f7..9db446af8 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_53_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_53_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 834eb34f7..9db446af8 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_64_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_64_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 0ccf23e32..d5ff92e37 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_65_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_65_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_65_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 0ccf23e32..d5ff92e37 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_66_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_66_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 3ca4d0fd1..f6c8c8b4f 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_67_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_67_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_67_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 27cdc5e24..6bfabead9 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_68_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_68_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_68_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 1c5b3634b..7a2d5cdcf 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_69_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_69_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_69_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 c63f88db9..885898644 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_70_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_70_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_70_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 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 9529289ce..db8e4798b 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_CheckoutService_v71_71_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_v71_71_openapi_yaml__validate new file mode 100644 index 000000000..5db7cdee5 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_CheckoutService_v71_71_openapi_yaml__validate @@ -0,0 +1,9 @@ +invalid paths: invalid path /cancels: invalid operation POST: invalid example: example generic: validation failed due to: error at "/errorCode": at '/errorCode': got number, want string +Schema: + { + "description": "The error code mapped to the error message.", + "type": "string" + } + +Value: + 14030 diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_FundService_3_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_FundService_3_openapi_yaml__validate index d4a06941f..231dd5261 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_FundService_3_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_FundService_3_openapi_yaml__validate @@ -1,6 +1,9 @@ invalid paths: invalid path /debitAccountHolder: invalid operation POST: invalid example: example debit-account-holder: validation failed due to: error at "/submittedAsync": at '/submittedAsync': got string, want boolean Schema: - null + { + "description": "Indicates whether the request is processed asynchronously. Depending on the request's platform settings, the following scenarios may be applied:\n* **true**: The request is queued and will be executed when the providing service is available in the order in which the requests are received.\n* **false**: The processing of the request is immediately attempted; it may result in an error if the providing service is unavailable.", + "type": "boolean" + } Value: "false" diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_LegalEntityService_1_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_LegalEntityService_1_openapi_yaml__validate index dca7a33dc..2e436140e 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_LegalEntityService_1_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_LegalEntityService_1_openapi_yaml__validate @@ -1,6 +1,8 @@ invalid paths: invalid path /documents: invalid operation POST: invalid example: validation failed due to: at '': got number, want string Schema: - null + { + "type": "string" + } Value: 13008 diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_LegalEntityService_2_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_LegalEntityService_2_openapi_yaml__validate index dca7a33dc..2e436140e 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_LegalEntityService_2_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_LegalEntityService_2_openapi_yaml__validate @@ -1,6 +1,8 @@ invalid paths: invalid path /documents: invalid operation POST: invalid example: validation failed due to: at '': got number, want string Schema: - null + { + "type": "string" + } Value: 13008 diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_LegalEntityService_3_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_LegalEntityService_3_openapi_yaml__validate index dca7a33dc..2e436140e 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_LegalEntityService_3_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_LegalEntityService_3_openapi_yaml__validate @@ -1,6 +1,8 @@ invalid paths: invalid path /documents: invalid operation POST: invalid example: validation failed due to: at '': got number, want string Schema: - null + { + "type": "string" + } Value: 13008 diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_3_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_3_openapi_yaml__validate index 77e3029b9..5c83785e4 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_3_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_3_openapi_yaml__validate @@ -1,6 +1,14 @@ invalid webhooks: webhook "/ACCOUNT_CLOSED": invalid operation POST: invalid example: example accountClosed: validation failed due to: at '': got string, want object Schema: - null + { + "properties": { + "notificationResponse": { + "description": "Set this parameter to **[accepted]** to acknowledge that you received a notification from Adyen.", + "type": "string" + } + }, + "type": "object" + } Value: "[accepted]" diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_4_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_4_openapi_yaml__validate index 77e3029b9..5c83785e4 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_4_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_4_openapi_yaml__validate @@ -1,6 +1,14 @@ invalid webhooks: webhook "/ACCOUNT_CLOSED": invalid operation POST: invalid example: example accountClosed: validation failed due to: at '': got string, want object Schema: - null + { + "properties": { + "notificationResponse": { + "description": "Set this parameter to **[accepted]** to acknowledge that you received a notification from Adyen.", + "type": "string" + } + }, + "type": "object" + } Value: "[accepted]" diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_5_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_5_openapi_yaml__validate index 77e3029b9..5c83785e4 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_5_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_5_openapi_yaml__validate @@ -1,6 +1,14 @@ invalid webhooks: webhook "/ACCOUNT_CLOSED": invalid operation POST: invalid example: example accountClosed: validation failed due to: at '': got string, want object Schema: - null + { + "properties": { + "notificationResponse": { + "description": "Set this parameter to **[accepted]** to acknowledge that you received a notification from Adyen.", + "type": "string" + } + }, + "type": "object" + } Value: "[accepted]" diff --git a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_6_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_6_openapi_yaml__validate index 77e3029b9..5c83785e4 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_6_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_MarketPayNotificationService_6_openapi_yaml__validate @@ -1,6 +1,14 @@ invalid webhooks: webhook "/ACCOUNT_CLOSED": invalid operation POST: invalid example: example accountClosed: validation failed due to: at '': got string, want object Schema: - null + { + "properties": { + "notificationResponse": { + "description": "Set this parameter to **[accepted]** to acknowledge that you received a notification from Adyen.", + "type": "string" + } + }, + "type": "object" + } Value: "[accepted]" 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 77b330a2e..85600e32e 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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 e76554e11..983274dee 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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 0d49afad8..faf37dfc2 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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 0d49afad8..faf37dfc2 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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 0d49afad8..faf37dfc2 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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 ef908225a..328d6ec93 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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 ebacdebdb..a92f49f7f 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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 ebacdebdb..a92f49f7f 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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 ebacdebdb..a92f49f7f 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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 ebacdebdb..a92f49f7f 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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 95c0d1d8c..1794e7a0d 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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 ad9d6c220..12a6692cd 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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_46_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PayoutService_46_openapi_yaml__validate new file mode 100644 index 000000000..39c452e08 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PayoutService_46_openapi_yaml__validate @@ -0,0 +1,10 @@ +invalid components: schema "BrowserInfo": invalid default: value must be a boolean +Schema: + { + "default": "true", + "description": "Boolean value indicating if the shopper's browser is able to execute JavaScript. A default 'true' value is assumed if the field is not present.", + "type": "boolean" + } + +Value: + "true" 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 ad9d6c220..12a6692cd 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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__validate b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PayoutService_49_openapi_yaml__validate new file mode 100644 index 000000000..39c452e08 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/adyen_com_PayoutService_49_openapi_yaml__validate @@ -0,0 +1,10 @@ +invalid components: schema "BrowserInfo": invalid default: value must be a boolean +Schema: + { + "default": "true", + "description": "Boolean value indicating if the shopper's browser is able to execute JavaScript. A default 'true' value is assumed if the field is not present.", + "type": "boolean" + } + +Value: + "true" 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 b62a6a241..ff3d508b9 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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/amadeus_com_amadeus_trip_parser_3_0_1_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/amadeus_com_amadeus_trip_parser_3_0_1_openapi_yaml__validate new file mode 100644 index 000000000..492e1e113 --- /dev/null +++ b/openapi3/testdata/apis_guru_openapi_directory/amadeus_com_amadeus_trip_parser_3_0_1_openapi_yaml__validate @@ -0,0 +1,11 @@ +invalid components: schema "car": invalid example: string doesn't match the regular expression "{0-9]{1,15}" +Schema: + { + "description": "Phone number. Composed of digits only. The number of digits depends on the country.", + "example": "0336123123", + "pattern": "{0-9]{1,15}", + "type": "string" + } + +Value: + "0336123123" diff --git a/openapi3/testdata/apis_guru_openapi_directory/apicurio_local_registry_1_3_2_Final_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/apicurio_local_registry_1_3_2_Final_openapi_yaml__validate index 18c3ac135..16a559ed7 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/apicurio_local_registry_1_3_2_Final_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/apicurio_local_registry_1_3_2_Final_openapi_yaml__validate @@ -56,13 +56,16 @@ Schema: "type": "string" }, "properties": { - "$ref": "#/components/schemas/Properties" + "$ref": "#/components/schemas/Properties", + "description": "" }, "state": { - "$ref": "#/components/schemas/ArtifactState" + "$ref": "#/components/schemas/ArtifactState", + "description": "" }, "type": { - "$ref": "#/components/schemas/ArtifactType" + "$ref": "#/components/schemas/ArtifactType", + "description": "" }, "version": { "format": "int64", diff --git a/openapi3/testdata/apis_guru_openapi_directory/apicurio_local_registry_2_4_x_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/apicurio_local_registry_2_4_x_openapi_yaml__validate index 435f52950..a2d94bdd9 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/apicurio_local_registry_2_4_x_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/apicurio_local_registry_2_4_x_openapi_yaml__validate @@ -67,10 +67,12 @@ Schema: "type": "integer" }, "groupId": { - "$ref": "#/components/schemas/GroupId" + "$ref": "#/components/schemas/GroupId", + "description": "" }, "id": { - "$ref": "#/components/schemas/ArtifactId" + "$ref": "#/components/schemas/ArtifactId", + "description": "" }, "labels": { "items": { @@ -89,7 +91,8 @@ Schema: "type": "string" }, "properties": { - "$ref": "#/components/schemas/Properties" + "$ref": "#/components/schemas/Properties", + "description": "" }, "references": { "items": { @@ -98,10 +101,12 @@ Schema: "type": "array" }, "state": { - "$ref": "#/components/schemas/ArtifactState" + "$ref": "#/components/schemas/ArtifactState", + "description": "" }, "type": { - "$ref": "#/components/schemas/ArtifactType" + "$ref": "#/components/schemas/ArtifactType", + "description": "" }, "version": { "type": "string" @@ -208,10 +213,12 @@ Schema: "type": "integer" }, "groupId": { - "$ref": "#/components/schemas/GroupId" + "$ref": "#/components/schemas/GroupId", + "description": "" }, "id": { - "$ref": "#/components/schemas/ArtifactId" + "$ref": "#/components/schemas/ArtifactId", + "description": "" }, "labels": { "items": { @@ -230,7 +237,8 @@ Schema: "type": "string" }, "properties": { - "$ref": "#/components/schemas/Properties" + "$ref": "#/components/schemas/Properties", + "description": "" }, "references": { "items": { @@ -239,10 +247,12 @@ Schema: "type": "array" }, "state": { - "$ref": "#/components/schemas/ArtifactState" + "$ref": "#/components/schemas/ArtifactState", + "description": "" }, "type": { - "$ref": "#/components/schemas/ArtifactType" + "$ref": "#/components/schemas/ArtifactType", + "description": "" }, "version": { "type": "string" 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 d5df1457e..25c9f4717 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: jsontext: invalid character 'o' at start of value, yaml error: error converting YAML to JSON: yaml: line 1142: did not find expected key +failed to unmarshal data: json error: jsontext: invalid character 'o' at start 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 726d322af..772d2ac4e 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: jsontext: invalid character 'o' at start 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: jsontext: invalid character 'o' at start 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 e639668ef..b42c162d7 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: jsontext: invalid character 'o' at start of value, yaml error: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal object into Go struct field .components.schemas.ExcelStatus.examples of type []interface {} +failed to unmarshal data: json error: jsontext: invalid character 'o' at start of value, yaml error: yaml: unmarshal errors: + line 4692: cannot unmarshal !!map into []interface {} 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 4ec5fbaaf..d171aca85 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: jsontext: invalid character 'o' at start of value, yaml error: error converting YAML to JSON: yaml: control characters are not allowed +failed to unmarshal data: json error: jsontext: invalid character 'o' at start of value, yaml error: yaml: control characters are not allowed diff --git a/openapi3/testdata/apis_guru_openapi_directory/exoapi_dev_1_0_0_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/exoapi_dev_1_0_0_openapi_yaml__validate index 3c74aa999..c701924a1 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/exoapi_dev_1_0_0_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/exoapi_dev_1_0_0_openapi_yaml__validate @@ -1,6 +1,13 @@ invalid paths: invalid path /html-renderer: invalid operation POST: invalid default: validation failed due to: at '': got number, want string Schema: - null + { + "default": 0, + "description": "PDF page margin (in px, mm, in or cm)", + "examples": [ + "1in" + ], + "type": "string" + } Value: 0 diff --git a/openapi3/testdata/apis_guru_openapi_directory/rentcast_io_1_0_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/rentcast_io_1_0_openapi_yaml__validate index ee3b991c4..4ba23441f 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/rentcast_io_1_0_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/rentcast_io_1_0_openapi_yaml__validate @@ -1,6 +1,128 @@ invalid paths: invalid path /avm/rent/long-term: invalid operation GET: invalid example: example Response: validation failed due to: at '': got string, want object Schema: - null + { + "properties": { + "comparables": { + "items": { + "properties": { + "addressLine1": { + "example": "7410 Adaja Riv", + "type": "string" + }, + "bathrooms": { + "default": 0, + "example": 2, + "type": "integer" + }, + "bedrooms": { + "default": 0, + "example": 4, + "type": "integer" + }, + "city": { + "example": "San Antonio", + "type": "string" + }, + "correlation": { + "default": 0, + "example": 0.988, + "type": "number" + }, + "county": { + "example": "Bexar", + "type": "string" + }, + "daysOld": { + "default": 0, + "example": 11, + "type": "integer" + }, + "distance": { + "default": 0, + "example": 0.5282, + "type": "number" + }, + "formattedAddress": { + "example": "7410 Adaja Riv, San Antonio, TX 78244", + "type": "string" + }, + "id": { + "example": "7410-Adaja-Riv,-San-Antonio,-TX-78244", + "type": "string" + }, + "lastSeenDate": { + "example": "2023-02-17T00:00:00.000Z", + "type": "string" + }, + "latitude": { + "default": 0, + "example": 29.481005, + "type": "number" + }, + "listedDate": { + "example": "2023-01-22T00:00:00.000Z", + "type": "string" + }, + "longitude": { + "default": 0, + "example": -98.344856, + "type": "number" + }, + "price": { + "default": 0, + "example": 1850, + "type": "integer" + }, + "propertyType": { + "example": "Single Family", + "type": "string" + }, + "squareFootage": { + "default": 0, + "example": 1627, + "type": "integer" + }, + "state": { + "example": "TX", + "type": "string" + }, + "zipCode": { + "example": "78244", + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "latitude": { + "default": 0, + "example": 29.475962, + "type": "number" + }, + "longitude": { + "default": 0, + "example": -98.351442, + "type": "number" + }, + "rent": { + "default": 0, + "example": 1800, + "type": "integer" + }, + "rentRangeHigh": { + "default": 0, + "example": 1860, + "type": "integer" + }, + "rentRangeLow": { + "default": 0, + "example": 1740, + "type": "integer" + } + }, + "type": "object" + } Value: "{\"rent\":1800,\"rentRangeLow\":1740,\"rentRangeHigh\":1860,\"longitude\":-98.351442,\"latitude\":29.475962,\"comparables\":[{\"id\":\"7410-Adaja-Riv,-San-Antonio,-TX-78244\",\"formattedAddress\":\"7410 Adaja Riv, San Antonio, TX 78244\",\"addressLine1\":\"7410 Adaja Riv\",\"city\":\"San Antonio\",\"state\":\"TX\",\"zipCode\":\"78244\",\"county\":\"Bexar\",\"longitude\":-98.344856,\"latitude\":29.481005,\"propertyType\":\"Single Family\",\"bedrooms\":4,\"bathrooms\":2,\"squareFootage\":1627,\"price\":1850,\"listedDate\":\"2023-01-22T00:00:00.000Z\",\"lastSeenDate\":\"2023-02-17T00:00:00.000Z\",\"daysOld\":11,\"distance\":0.5282,\"correlation\":0.988},{\"id\":\"7303-Avila-Vis,-San-Antonio,-TX-78244\",\"formattedAddress\":\"7303 Avila Vis, San Antonio, TX 78244\",\"addressLine1\":\"7303 Avila Vis\",\"city\":\"San Antonio\",\"state\":\"TX\",\"zipCode\":\"78244\",\"county\":\"Bexar\",\"longitude\":-98.34565,\"latitude\":29.483196,\"propertyType\":\"Single Family\",\"bedrooms\":4,\"bathrooms\":2,\"squareFootage\":1627,\"price\":1850,\"listedDate\":\"2023-02-03T00:00:00.000Z\",\"lastSeenDate\":\"2023-02-17T00:00:00.000Z\",\"daysOld\":11,\"distance\":0.6099,\"correlation\":0.9867},{\"id\":\"7331-Avila-Vis,-San-Antonio,-TX-78244\",\"formattedAddress\":\"7331 Avila Vis, San Antonio, TX 78244\",\"addressLine1\":\"7331 Avila Vis\",\"city\":\"San Antonio\",\"state\":\"TX\",\"zipCode\":\"78244\",\"county\":\"Bexar\",\"longitude\":-98.34481,\"latitude\":29.482922,\"propertyType\":\"Single Family\",\"bedrooms\":4,\"bathrooms\":2,\"squareFootage\":1627,\"price\":1810,\"listedDate\":\"2023-02-21T00:00:00.000Z\",\"lastSeenDate\":\"2023-02-27T00:00:00.000Z\",\"daysOld\":1,\"distance\":0.6255,\"correlation\":0.9865},{\"id\":\"7411-Avila-Vis,-San-Antonio,-TX-78244\",\"formattedAddress\":\"7411 Avila Vis, San Antonio, TX 78244\",\"addressLine1\":\"7411 Avila Vis\",\"city\":\"San Antonio\",\"state\":\"TX\",\"zipCode\":\"78244\",\"county\":\"Bexar\",\"longitude\":-98.344398,\"latitude\":29.482676,\"propertyType\":\"Single Family\",\"bedrooms\":4,\"bathrooms\":2,\"squareFootage\":1627,\"price\":1815,\"listedDate\":\"2023-01-16T00:00:00.000Z\",\"lastSeenDate\":\"2023-02-10T00:00:00.000Z\",\"daysOld\":18,\"distance\":0.629,\"correlation\":0.9864},{\"id\":\"7327-Plata-Cir,-San-Antonio,-TX-78244\",\"formattedAddress\":\"7327 Plata Cir, San Antonio, TX 78244\",\"addressLine1\":\"7327 Plata Cir\",\"city\":\"San Antonio\",\"state\":\"TX\",\"zipCode\":\"78244\",\"county\":\"Bexar\",\"longitude\":-98.344406,\"latitude\":29.480621,\"propertyType\":\"Single Family\",\"bedrooms\":4,\"bathrooms\":2,\"squareFootage\":1492,\"price\":1680,\"listedDate\":\"2023-01-12T00:00:00.000Z\",\"lastSeenDate\":\"2023-02-08T00:00:00.000Z\",\"daysOld\":20,\"distance\":0.5323,\"correlation\":0.9758}]}" 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 4ec5fbaaf..d171aca85 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: jsontext: invalid character 'o' at start of value, yaml error: error converting YAML to JSON: yaml: control characters are not allowed +failed to unmarshal data: json error: jsontext: invalid character 'o' at start 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__validate b/openapi3/testdata/apis_guru_openapi_directory/vercel_com_0_0_1_openapi_yaml__validate index 95868ff6c..0b2671730 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/vercel_com_0_0_1_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/vercel_com_0_0_1_openapi_yaml__validate @@ -1,6 +1,19 @@ invalid paths: invalid path /v1/teams/{teamId}/members/{uid}: invalid operation PATCH: invalid default: validation failed due to: at '': got array, want string Schema: - null + { + "default": [ + "MEMBER", + "VIEWER" + ], + "description": "The role in the team of the member.", + "examples": [ + [ + "MEMBER", + "VIEWER" + ] + ], + "type": "string" + } Value: [ diff --git a/openapi3/testdata/apis_guru_openapi_directory/webscraping_ai_3_0_0_openapi_yaml__validate b/openapi3/testdata/apis_guru_openapi_directory/webscraping_ai_3_0_0_openapi_yaml__validate index ce3debc9b..871764345 100644 --- a/openapi3/testdata/apis_guru_openapi_directory/webscraping_ai_3_0_0_openapi_yaml__validate +++ b/openapi3/testdata/apis_guru_openapi_directory/webscraping_ai_3_0_0_openapi_yaml__validate @@ -1,6 +1,11 @@ invalid components: parameter "headers": invalid example: validation failed due to: at '': got string, want object Schema: - null + { + "additionalProperties": { + "type": "string" + }, + "type": "object" + } Value: "{\"Cookie\":\"session=some_id\"}" diff --git a/openapi3/testdata/fuzz/FuzzInternalizeRefs/294f1ea5661836e3 b/openapi3/testdata/fuzz/FuzzInternalizeRefs/294f1ea5661836e3 new file mode 100644 index 000000000..b3cc8255b --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzInternalizeRefs/294f1ea5661836e3 @@ -0,0 +1,3 @@ +go test fuzz v1 +[]byte("paths:\n 0:\n get:\n responses:\n 0:\n $ref: '#/components/0000000000'\ncomponents:") +[]byte("0") diff --git a/openapi3/testdata/fuzz/FuzzInternalizeRefs/2d1608a3eaf3c0a4 b/openapi3/testdata/fuzz/FuzzInternalizeRefs/2d1608a3eaf3c0a4 new file mode 100644 index 000000000..67c6a450e --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzInternalizeRefs/2d1608a3eaf3c0a4 @@ -0,0 +1,3 @@ +go test fuzz v1 +[]byte("components:\n schemas:\n 0:\n $ref: 0#") +[]byte("") diff --git a/openapi3/testdata/fuzz/FuzzInternalizeRefs/b711821bf5eb26eb b/openapi3/testdata/fuzz/FuzzInternalizeRefs/b711821bf5eb26eb new file mode 100644 index 000000000..341f8a4a9 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzInternalizeRefs/b711821bf5eb26eb @@ -0,0 +1,3 @@ +go test fuzz v1 +[]byte("components:\n schemas:\n 0:\n $ref: A0000#") +[]byte("$ref: A") diff --git a/openapi3/testdata/fuzz/FuzzInternalizeRefs/e2a9b4711a836c5c b/openapi3/testdata/fuzz/FuzzInternalizeRefs/e2a9b4711a836c5c new file mode 100644 index 000000000..0573fb943 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzInternalizeRefs/e2a9b4711a836c5c @@ -0,0 +1,3 @@ +go test fuzz v1 +[]byte("{\"components\":{\"schemas\":{\"00000000\":{\"discriminator\":{\"mapping\":{0: /}}}}},\"info\":{\"title\":A,\"version\":0A},\"openapi\":A}") +[]byte("{}") diff --git a/openapi3/testdata/fuzz/FuzzInternalizeRefs/e5b6d28717abfbc3 b/openapi3/testdata/fuzz/FuzzInternalizeRefs/e5b6d28717abfbc3 new file mode 100644 index 000000000..6b4fe629b --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzInternalizeRefs/e5b6d28717abfbc3 @@ -0,0 +1,3 @@ +go test fuzz v1 +[]byte("{\"components\":{\"schemas\":{\"0\":{\"discriminator\":{\"mapping\":{0: /}}}}},\"info\":{\"title\":A,\"version\":A},\"openapi\":A}") +[]byte("{0}") diff --git a/openapi3/testdata/fuzz/FuzzInternalizeRefsKeepsMeaning/81a933eb969de984 b/openapi3/testdata/fuzz/FuzzInternalizeRefsKeepsMeaning/81a933eb969de984 new file mode 100644 index 000000000..321565735 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzInternalizeRefsKeepsMeaning/81a933eb969de984 @@ -0,0 +1,4 @@ +go test fuzz v1 +[]byte("openapi: 3.1.0\ninfo:\n title: external recursive discminated union\n version: 1.0.0\npaths: {}\ncomponents:\n schemas:\n irobe:\n type: object\n properties: \n deep:\n $ref: './ext.yml#/components/schemas/Comp'") +[]byte("openapi: 3.1.0\ninfo:\n title: recursive discriminated union\n version: 1.0.0\npaths: {}\ncomponents:\n schemas:\n Comp:\n oneOf:\n - $ref: '#/components/schemas/Container'\n discriminator:\n propertyName: type\n mapping:\n container: '#/components/schemas/Container'\n Container:\n type: object\n required: [type]\n properties:\n type:\n type: string\n children:\n type: array\n items : \n $ref: '#/components/schemas/Comp'") +[]byte("[{\"\x81a\xea7ޫ\x86\xb0G;t\":{}}]") diff --git a/openapi3/testdata/fuzz/FuzzLoadFromData/56b819f42562dabd b/openapi3/testdata/fuzz/FuzzLoadFromData/56b819f42562dabd new file mode 100644 index 000000000..5abb93b83 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzLoadFromData/56b819f42562dabd @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("openapi: \"3.1\"\ncomponents:\n schemas: \n 0:\n if:\n 0:\n $ref: A") diff --git a/openapi3/testdata/fuzz/FuzzLoadFromData/8fffb2caad2dd727 b/openapi3/testdata/fuzz/FuzzLoadFromData/8fffb2caad2dd727 new file mode 100644 index 000000000..fa28f892c --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzLoadFromData/8fffb2caad2dd727 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("openapi: 0\ncomponents:\n schemas:\n 00:\n type: string\n example: !!timestamp 0000-1-10") diff --git a/openapi3/testdata/fuzz/FuzzLoadFromData/alias-cycle b/openapi3/testdata/fuzz/FuzzLoadFromData/alias-cycle new file mode 100644 index 000000000..dba300040 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzLoadFromData/alias-cycle @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("components:\n schemas:\n e: &base\n properties:\n 2: *base\n") diff --git a/openapi3/testdata/fuzz/FuzzLoadFromData/c306b42e54f99d7f b/openapi3/testdata/fuzz/FuzzLoadFromData/c306b42e54f99d7f new file mode 100644 index 000000000..56cda7913 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzLoadFromData/c306b42e54f99d7f @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("components:\n schemas:\n 0:\n $ref:\n") diff --git a/openapi3/testdata/fuzz/FuzzLoadFromData/e9783ed365c6dc4c b/openapi3/testdata/fuzz/FuzzLoadFromData/e9783ed365c6dc4c new file mode 100644 index 000000000..25a7f3ad2 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzLoadFromData/e9783ed365c6dc4c @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("openapi: A\ncomponents:\n schemas: \n 00: \n 0:\n $ref: 00A") diff --git a/openapi3/testdata/fuzz/FuzzLoadFromData/merge-unhashable-key b/openapi3/testdata/fuzz/FuzzLoadFromData/merge-unhashable-key new file mode 100644 index 000000000..2cc7d6efd --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzLoadFromData/merge-unhashable-key @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("<<:\n ? [1]\n : v\n1: x\n") diff --git a/openapi3/testdata/fuzz/FuzzLoadFromData/self-merge b/openapi3/testdata/fuzz/FuzzLoadFromData/self-merge new file mode 100644 index 000000000..3f1e6d8a5 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzLoadFromData/self-merge @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("openapi: 3.0.0\ninfo: {title: t, version: v}\npaths: &a\n <<: *a\n") diff --git a/openapi3/testdata/fuzz/FuzzLoadWithOrigins/5b22ca46d0928a97 b/openapi3/testdata/fuzz/FuzzLoadWithOrigins/5b22ca46d0928a97 new file mode 100644 index 000000000..0df387c7b --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzLoadWithOrigins/5b22ca46d0928a97 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("1:\n0:\n.0: 70") diff --git a/openapi3/testdata/fuzz/FuzzLoadWithOrigins/77207dd31798d4c6 b/openapi3/testdata/fuzz/FuzzLoadWithOrigins/77207dd31798d4c6 new file mode 100644 index 000000000..506256284 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzLoadWithOrigins/77207dd31798d4c6 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("? 0") diff --git a/openapi3/testdata/fuzz/FuzzLoadWithOrigins/explicit-key-no-value b/openapi3/testdata/fuzz/FuzzLoadWithOrigins/explicit-key-no-value new file mode 100644 index 000000000..61659bf59 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzLoadWithOrigins/explicit-key-no-value @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("? 00") diff --git a/openapi3/testdata/fuzz/FuzzLoadWithOrigins/f64677a72b839120 b/openapi3/testdata/fuzz/FuzzLoadWithOrigins/f64677a72b839120 new file mode 100644 index 000000000..4343b96c4 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzLoadWithOrigins/f64677a72b839120 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("0:\n 00:\n1: !000000\n 0:\n 000:\n 00001: \n 000000000000:\n 0000:\n 0000000000000000: A\n 2:\n 0:\n 0:\n000002:\n 00:\n7:\n8: \n 0:\n 0:\n000000000009:\n0000000000A:\n 0000000000:\n9:\n 12") diff --git a/openapi3/testdata/fuzz/FuzzMarshalYAML/025f382fc8664983 b/openapi3/testdata/fuzz/FuzzMarshalYAML/025f382fc8664983 new file mode 100644 index 000000000..d03b234fb --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzMarshalYAML/025f382fc8664983 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("openapi: 3.0.0\ninfo:\n title: Security Requirement Example\n version: 1.0.0\npaths:\n /subscribe:\n post:\n requestBody:\n content:\n application/json:\n schema:\n type: object\n properties:\n inProgressUrl:\n type: string\n failedUrl:\n type: string\n successUrl:\n type: string\n responses:\n \"200\":\n description: OK\n security:\n - petstore_auth:\n - write:pets\n - read:pets\ncomponents:\n securitySchemes:\n petstore_auth:\n type: oauth2\n flows:\n implicit:\n authorizationUrl: http://example.org/api/oauth/dialog\n scope{:\n write:pets: modify pets in your account\n read:pets: read your pets") diff --git a/openapi3/testdata/fuzz/FuzzMarshalYAML/44d4c659af5084cf b/openapi3/testdata/fuzz/FuzzMarshalYAML/44d4c659af5084cf new file mode 100644 index 000000000..72ee018c4 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzMarshalYAML/44d4c659af5084cf @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("components:\n schemas:\n 0: \n dependentRequired:\n 0:") diff --git a/openapi3/testdata/fuzz/FuzzMarshalYAML/726e0d247bb78c19 b/openapi3/testdata/fuzz/FuzzMarshalYAML/726e0d247bb78c19 new file mode 100644 index 000000000..d58dbf563 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzMarshalYAML/726e0d247bb78c19 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("openapi: 3.0.0\ninfo:\n title: Security Requirement Example\n version: 1.0.0\npaths:\n /subscribe:\n post:\n requestBody:\n content:\n application/json:\n schema:\n type: object\n properties:\n inProgressUrl:\n type: string\n failedUrl:\n type: string\n successUrl:\n type: string\n responses:\n \"200\":\n description: OK\n security:\n - petstore_auth:\n - write:pets\n 99999999999999999999999999999999tySchemes:\n petstore_auth:\n type: oauth2\n flows:\n implicit:\n authorizationUrl: http://example.org/api/oauth/dialog\n scope{:\n write:pets: modify pets in your account\n read:pets: read your pets") diff --git a/openapi3/testdata/fuzz/FuzzMarshalYAML/ad3fea4f960da6cb b/openapi3/testdata/fuzz/FuzzMarshalYAML/ad3fea4f960da6cb new file mode 100644 index 000000000..3bd2a6901 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzMarshalYAML/ad3fea4f960da6cb @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("openapi: 3.0.0\nservers:\n - url: https://rest-api.d7networks.com/secure\ninfo:\n contact:\n email: int@d7networks.com\n name: Emanuel\n url: https://d7networks.com/\n x-twitter: d7sms\n description: D7 SMS allows you to reach your customers via SMS over D7's own connectivity to global mobile networks. D7 provides reliable and cost-effective SMS services to businesses across all industries and aims to connect all countries and territories via direct connections.\n title: D7SMS\n version: 1.0.2\n x-apisguru-categories:\n - messaging\n x-logo:\n url: https://twitter.com/d7sms/profile_image?size=original\n x-origin:\n - format: openapi\n url: https://api.swaggerhub.com/apis/Direct7Networks/D7SMS/1.0.2\n version: \"3.0\"\n x-providerName: d7networks.com\nsecurity:\n - auth: []\ntags: []\npaths:\n /balance:\n get:\n deprecated: false\n description: Check account balance\n operationId: BalanceGet\n responses:\n \"200\":\n description: \"\"\n headers: {}\n \"500\":\n content:\n application/json:\n schema: {}\n description: Internal Server Error\n summary: Balance\n /send:\n post:\n deprecated: false\n description: Send SMS to recipients using D7 SMS Gateway\n operationId: SendPost\n parameters:\n - description: \"\"\n in: header\n name: Content-Type\n required: true\n schema:\n type: string\n - description: \"\"\n in: header\n name: Accept\n required: true\n schema:\n type: string\n requestBody:\n conte application/json:\n schema:\n $ref: \"#/components/schemas/SendSMSRequest\"\n description: Message Body\n required: true\n responses:\n \"200\":\n description: \"\"\n headers: {}\n \"500\":\n content:\n application/json:\n schema: {}\n description: Internal Server Error\n summary: SendSMS\n /sendbatch:\n post:\n deprecated: false\n description: Send Bulk SMS to multiple recipients using D7 SMS Gateway\n operationId: SendbatchPost\n parameters:\n - description: \"\"\n in: header\n name: Content-Type\n required: true\n schema:\n type: string\n - description: \"\"\n in: header\n name: Accept\n required: true\n schema:\n type: string\n requestBody:\n content:\n application/json:\n schema:\n $ref: \"#/components/schemas/BulkSMSRequest\"\n description: Message Body\n required: true\n responses:\n \"200\":\n description: \"\"\n headers: {}\n summary: Bulk SMS\ncomponents:\n schemas:\n BulkSMSRequest:\n description: Bulk SMS Request\n example:\n messages:\n - content: Same content goes to three numbers\n from: SignSMS\n to:\n - \"971562316353\"\n - \"971562316354\"\n - \"971562316355\"\n properties:\n messages:\n description: Sendbatch message body\n items:\n $ref: \"#/components/schemas/Message\"\n type: array\n required:\n - messages\n title: BulkSMSRequest\n type: object\n Message:\n description: \"\"\n example:\n content: Same content goes to three numbers\n from: SignSMS\n to:\n - \"971562316353\"\n - \"971562316354\"\n - \"971562316355\"\n properties:\n content:\n type: string\n from:\n type: string\n to:\n description: Destination Number\n items:\n type: string\n type: array\n required:\n - to\n - content\n - from\n title: Message\n type: object\n SendSMSRequest:\n description: Send SMS Request\n example:\n content: Send single SMS Testing\n from: SignSMS\n to: 971562316353\n properties:\n content:\n description: Message Content\n type: string\n from:\n description: Sender ID / Number\n type: string\n to:\n description: Destination Mobile Number\n format: int64\n type: integer\n required:\n - to\n - from\n - content\n title: SendSMSRequest\n type: object\n securitySchemes:\n auth:\n scheme: basic\n type: http\n") diff --git a/openapi3/testdata/fuzz/FuzzMarshalYAML/null-security-requirement b/openapi3/testdata/fuzz/FuzzMarshalYAML/null-security-requirement new file mode 100644 index 000000000..0e489d8fd --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzMarshalYAML/null-security-requirement @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("openapi: 3.0.0\ninfo: {title: t, version: \"1\"}\npaths:\n /:\n get:\n responses: {\"200\": {description: d}}\n security: [null]\nsecurity: [null]\n") diff --git a/openapi3/testdata/fuzz/FuzzYAMLMatchesJSON/886c20bb0385de8e b/openapi3/testdata/fuzz/FuzzYAMLMatchesJSON/886c20bb0385de8e new file mode 100644 index 000000000..c36c0354b --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzYAMLMatchesJSON/886c20bb0385de8e @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("{\"0\":{\"\":{\"\":{\"0\":1e700}}}}") diff --git a/openapi3/testdata/fuzz/FuzzYAMLMatchesJSON/bddb72bfc04e1cae b/openapi3/testdata/fuzz/FuzzYAMLMatchesJSON/bddb72bfc04e1cae new file mode 100644 index 000000000..f932a6808 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzYAMLMatchesJSON/bddb72bfc04e1cae @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("paths:\n 0:\n get:\n responses:\n 0:") diff --git a/openapi3/testdata/fuzz/FuzzYAMLMatchesJSON/dd86b380d656a764 b/openapi3/testdata/fuzz/FuzzYAMLMatchesJSON/dd86b380d656a764 new file mode 100644 index 000000000..6288c0732 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzYAMLMatchesJSON/dd86b380d656a764 @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("{\"<<\":{}}") diff --git a/openapi3/testdata/fuzz/FuzzYAMLMatchesJSON/null-in-string-sequence b/openapi3/testdata/fuzz/FuzzYAMLMatchesJSON/null-in-string-sequence new file mode 100644 index 000000000..1341be8f1 --- /dev/null +++ b/openapi3/testdata/fuzz/FuzzYAMLMatchesJSON/null-in-string-sequence @@ -0,0 +1,2 @@ +go test fuzz v1 +[]byte("{\"components\":{\"schemas\":{\"S\":{\"required\":[null]}}}}") diff --git a/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml b/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml index 33387fdf0..a987ab095 100644 --- a/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml +++ b/openapi3/testdata/recursiveRef/openapi.yml.internalized.yml @@ -26,14 +26,6 @@ "example": "bar", "type": "string" }, - "components_Cat": { - "properties": { - "cat": { - "$ref": "#/components/schemas/components_Cat" - } - }, - "type": "object" - }, "components_Foo": { "properties": { "bar": { @@ -77,7 +69,7 @@ "Cat": { "properties": { "cat": { - "$ref": "#/components/schemas/components_Cat" + "$ref": "#/components/schemas/Cat" } }, "type": "object" diff --git a/openapi3/validate_options_fuzz_test.go b/openapi3/validate_options_fuzz_test.go new file mode 100644 index 000000000..47285af55 --- /dev/null +++ b/openapi3/validate_options_fuzz_test.go @@ -0,0 +1,47 @@ +package openapi3_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" +) + +// Turning a check off never rejects a document the default checks accept, +// and collecting every error instead of the first changes nothing about +// whether there is one. +func FuzzValidateOptions(f *testing.F) { + seedFromTestdata(f) + f.Add([]byte(`{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{},"components":{"schemas":{"a":{"type":"string","format":"email","pattern":"^a","default":"b","example":"c"}}}}`)) + f.Add([]byte(`{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{},"components":{"schemas":{"a":{"$ref":"#/components/schemas/b","x-a":1,"description":"d"},"b":{}}}}`)) + + relaxed := map[string]openapi3.ValidationOption{ + "DisableSchemaFormatValidation": openapi3.DisableSchemaFormatValidation(), + "DisableSchemaPatternValidation": openapi3.DisableSchemaPatternValidation(), + "DisableSchemaDefaultsValidation": openapi3.DisableSchemaDefaultsValidation(), + "DisableExamplesValidation": openapi3.DisableExamplesValidation(), + "AllowExtensionsWithRef": openapi3.AllowExtensionsWithRef(), + "AllowExtraSiblingFields": openapi3.AllowExtraSiblingFields("description", "summary", "type"), + } + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > maxFuzzInput { + t.Skip() + } + doc, err := newFuzzLoader().LoadFromData(data) + if err != nil { + return + } + + strict := doc.Validate(t.Context()) + multi := doc.Validate(t.Context(), openapi3.EnableMultiError()) + require.Equal(t, strict == nil, multi == nil, "first: %v\nall: %v", strict, multi) + if strict != nil { + return + } + for name, option := range relaxed { + require.NoError(t, doc.Validate(t.Context(), option), name) + } + }) +} diff --git a/openapi3/validation_code.go b/openapi3/validation_code.go index 6bacdee55..185092acb 100644 --- a/openapi3/validation_code.go +++ b/openapi3/validation_code.go @@ -150,6 +150,9 @@ func (e *SchemaAdditionalPropertiesBothForms) Code() string { func (e *SchemaFieldFor31Plus) Code() string { return "schema-field-for-3-1-plus" } func (e *SchemaItemsRequired) Code() string { return "schema-items-required" } func (e *SchemaPatternRegexError) Code() string { return "schema-pattern-regex-invalid" } +func (e *SchemaMultipleOfNotPositiveError) Code() string { + return "schema-multiple-of-not-positive" +} func (e *SchemaReadOnlyWriteOnlyExclusive) Code() string { return "read-only-write-only-mutually-exclusive" } @@ -273,6 +276,7 @@ var validationErrorCodes = []string{ "responses-required", "schema-field-for-3-1-plus", "schema-items-required", + "schema-multiple-of-not-positive", "schema-pattern-regex-invalid", "schema-type-unsupported", "security-requirement-scheme-undefined", diff --git a/openapi3/validation_code_test.go b/openapi3/validation_code_test.go index 2b446cce7..110a3987b 100644 --- a/openapi3/validation_code_test.go +++ b/openapi3/validation_code_test.go @@ -101,6 +101,7 @@ func codedErrorInventory() []openapi3.CodedError { &openapi3.SchemaAdditionalPropertiesBothForms{}, &openapi3.SchemaFieldFor31Plus{}, &openapi3.SchemaItemsRequired{}, + &openapi3.SchemaMultipleOfNotPositiveError{}, &openapi3.SchemaPatternRegexError{}, &openapi3.SchemaReadOnlyWriteOnlyExclusive{}, &openapi3.SchemaBooleanFieldsExclusive{}, diff --git a/openapi3/validation_error.go b/openapi3/validation_error.go index 77a4f6c0a..fef444728 100644 --- a/openapi3/validation_error.go +++ b/openapi3/validation_error.go @@ -653,6 +653,21 @@ func (e *DuplicateRequiredFieldError) Error() string { return fmt.Sprintf("duplicate field %q in required", e.Field) } +// SchemaMultipleOfNotPositiveError clusters "multipleOf must be greater than +// zero" failures. The value of a schema's `multipleOf` MUST be strictly greater +// than 0 (JSON Schema 2020-12 §6.2.1 for OpenAPI 3.1, draft-04 for OpenAPI 3.0). +type SchemaMultipleOfNotPositiveError struct { + // MultipleOf is the rejected `multipleOf` value. + MultipleOf float64 + // Origin is the source location of the offending schema when the + // document was loaded with Loader.IncludeOrigin = true. + Origin *Origin +} + +func (e *SchemaMultipleOfNotPositiveError) Error() string { + return fmt.Sprintf("multipleOf must be greater than zero, got %g", e.MultipleOf) +} + // DuplicateTagError clusters "more than one tag has name X" failures. Each tag // name in the document-root `tags` list MUST be unique (OpenAPI Object). type DuplicateTagError struct { diff --git a/openapi3/validation_error_test.go b/openapi3/validation_error_test.go index b67038137..1588dcf6d 100644 --- a/openapi3/validation_error_test.go +++ b/openapi3/validation_error_test.go @@ -1317,6 +1317,54 @@ components: require.Equal(t, "pattern", se.SchemaField) } +// `pattern` applies to string values whether or not `type` says string, so +// an uncompilable one is rejected on any schema that accepts strings. +func TestValidationError_SchemaPatternRegex_WithoutStringType(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + version string + typ string + }{ + {version: "3.0.3"}, + {version: "3.1.0"}, + {version: "3.1.0", typ: "type: [string, 'null']"}, + {version: "3.1.0", typ: "type: [integer, string]"}, + } { + t.Run(tc.version+" "+tc.typ, func(t *testing.T) { + doc := loadDocFromYAML(t, ` +openapi: `+tc.version+` +info: { title: t, version: "1" } +paths: {} +components: + schemas: + Bad: + `+tc.typ+` + pattern: "(?!foo)bar" +`) + err := doc.Validate(t.Context()) + require.Error(t, err) + + var spre *openapi3.SchemaPatternRegexError + require.True(t, errors.As(err, &spre)) + require.Equal(t, "(?!foo)bar", spre.Pattern) + }) + } + + // A schema that never sees a string never runs its pattern. + doc := loadDocFromYAML(t, ` +openapi: 3.0.3 +info: { title: t, version: "1" } +paths: {} +components: + schemas: + Number: + type: integer + pattern: "(?!foo)bar" +`) + require.NoError(t, doc.Validate(t.Context())) +} + // Origin tracking for SchemaPatternRegexError. func TestValidationError_SchemaPatternRegex_CarriesOrigin(t *testing.T) { t.Parallel() @@ -2404,6 +2452,53 @@ components: require.ErrorContains(t, dup, `duplicate field "id" in required`) } +func TestValidationError_SchemaMultipleOfNotPositiveError(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + version string + multipleOf string + expected float64 + }{ + {version: "3.0.3", multipleOf: "0", expected: 0}, + {version: "3.0.3", multipleOf: "-2", expected: -2}, + {version: "3.1.0", multipleOf: "0", expected: 0}, + {version: "3.1.0", multipleOf: "-0.5", expected: -0.5}, + } { + t.Run(tc.version+" "+tc.multipleOf, func(t *testing.T) { + doc := loadDocFromYAML(t, ` +openapi: `+tc.version+` +info: { title: t, version: "1" } +paths: {} +components: + schemas: + Bad: + type: number + multipleOf: `+tc.multipleOf+` +`) + err := doc.Validate(t.Context()) + require.Error(t, err) + + var nonPositive *openapi3.SchemaMultipleOfNotPositiveError + require.True(t, errors.As(err, &nonPositive)) + require.Equal(t, tc.expected, nonPositive.MultipleOf) + require.ErrorContains(t, nonPositive, "multipleOf must be greater than zero, got "+tc.multipleOf) + }) + } + + doc := loadDocFromYAML(t, ` +openapi: 3.0.3 +info: { title: t, version: "1" } +paths: {} +components: + schemas: + Good: + type: number + multipleOf: 0.01 +`) + require.NoError(t, doc.Validate(t.Context())) +} + func TestValidationError_DuplicateTagError(t *testing.T) { t.Parallel() diff --git a/openapi3/validation_options.go b/openapi3/validation_options.go index 6d20d4c6f..6bb4e723f 100644 --- a/openapi3/validation_options.go +++ b/openapi3/validation_options.go @@ -23,7 +23,9 @@ type ValidationOptions struct { type validationOptionsKey struct{} -// AllowExtraSiblingFields called as AllowExtraSiblingFields("description") makes Validate not return an error when said field appears next to a $ref. +// AllowExtraSiblingFields called as AllowExtraSiblingFields("description") makes Validate not return an error when said field appears +// where it is not expected: as an unknown field of an object, as an OpenAPI 3.1 schema keyword in an OpenAPI 3.0 document, +// or as an extension next to a $ref under ProhibitExtensionsWithRef. Other fields next to a $ref are never an error. func AllowExtraSiblingFields(fields ...string) ValidationOption { return func(options *ValidationOptions) { if options.extraSiblingFieldsAllowed == nil && len(fields) != 0 { @@ -126,7 +128,7 @@ func AllowExtensionsWithRef() ValidationOption { // ProhibitExtensionsWithRef causes the validation to return an // error if extensions (fields starting with 'x-') are found as -// siblings for $ref fields, even in OpenAPI 3.0. Use AllowExtraSiblingFields +// siblings for $ref fields, in every OpenAPI version. Use AllowExtraSiblingFields // to allow specific extensions. func ProhibitExtensionsWithRef() ValidationOption { return func(options *ValidationOptions) { diff --git a/openapi3/xml.go b/openapi3/xml.go index b0b98a766..09ff60302 100644 --- a/openapi3/xml.go +++ b/openapi3/xml.go @@ -57,7 +57,9 @@ func (xml *XML) UnmarshalJSON(data []byte) error { if err := unmarshalJSON(data, &x); err != nil { return unmarshalError(err) } - _ = unmarshalJSON(data, &x.Extensions) + if err := unmarshalJSON(data, &x.Extensions); err != nil { + return unmarshalError(err) + } delete(x.Extensions, "name") delete(x.Extensions, "namespace") delete(x.Extensions, "prefix") diff --git a/openapi3/yaml_fuzz_test.go b/openapi3/yaml_fuzz_test.go new file mode 100644 index 000000000..fe0fae3f8 --- /dev/null +++ b/openapi3/yaml_fuzz_test.go @@ -0,0 +1,225 @@ +package openapi3 + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + yaml "go.yaml.in/yaml/v3" + + "github.com/getkin/kin-openapi/internal/fuzzing" +) + +// maxYAMLFuzzInput bounds fuzzer inputs, see maxFuzzInput. +const maxYAMLFuzzInput = 32 << 10 + +// yamlFuzzSeeds exercise what only the YAML decode path sees: anchors and +// aliases, merge keys, tags, block scalars, non-string keys and YAML 1.1 +// implicit resolution. Shared by every fuzz target that loads documents. +var yamlFuzzSeeds = []string{ + // Anchors and aliases, on a schema, a parameter and a $ref. + `openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /a/{id}: + parameters: + - &id {name: id, in: path, required: true, schema: {type: string}} + get: + parameters: [*id] + responses: + 200: &ok + description: ok + content: + application/json: + schema: &s {type: object, properties: {a: {type: integer}}} + 201: *ok +components: + schemas: + A: *s + B: {$ref: &r '#/components/schemas/A'} + C: {$ref: *r} +`, + // Merge keys: in a path item, in Paths itself, from a sequence of sources. + `openapi: 3.0.0 +info: {title: t, version: "1"} +x-base: &base + description: base + responses: {"200": {description: ok}} +x-more: &more + summary: more + description: overridden +paths: + /m: + get: + <<: [*base, *more] + operationId: m + <<: {/n: {get: {responses: {default: {description: d}}}}} +components: + schemas: + S: + <<: {type: object, required: [a]} + properties: {a: {type: string}} +`, + // Block scalars, including one ending the block, and comments. + `openapi: 3.0.0 +info: + title: t + version: "1" + description: | + line one + + line three +paths: + /b: + get: + responses: + "200": + description: >- + folded + text # not a comment + # trailing comment +`, + // YAML 1.1 implicit resolution: timestamps, hex, underscores, bools, + // non-string keys and a tagged scalar. + `openapi: 3.0.0 +info: {title: t, version: 1.0} +paths: {} +components: + schemas: + T: + type: string + format: date-time + example: 2020-06-11T16:32:50-03:00 + x-ts: !!timestamp 2001-01-01 + x-keys: {18_24: a, 0x2A: b, 1.5: c, true: d, .0: e, 0: f} + I: + type: integer + enum: [0x2A, 4_2, 052, 42] + example: 42 + default: 0x2A + nullable: yes + maximum: 1e3 +`, + // OpenAPI 3.1: boolean schemas, type lists, exclusive bounds as numbers, + // explicitly null responses, webhooks. + `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + /w: + get: + responses: ~ +webhooks: + hook: + post: + requestBody: + content: + application/json: + schema: + type: [object, "null"] + additionalProperties: false + unevaluatedProperties: true + properties: + n: {type: number, exclusiveMinimum: 0, exclusiveMaximum: 10} + any: true + none: false +`, + // Flow style, CRLF line endings, a document marker. + "---\r\n{openapi: 3.0.0, info: {title: t, version: \"1\"},\r\n paths: {/f: {get: {responses: {\"200\": {description: ok}}}}}}\r\n", + // $ref siblings and extensions on maplike collections. + `openapi: 3.1.0 +info: {title: t, version: "1"} +paths: + x-p: 1 + /r: + $ref: '#/components/pathItems/R' + summary: s +components: + pathItems: + R: + get: + responses: + x-r: 2 + "200": {$ref: '#/components/responses/OK', description: d} + callbacks: + cb: + x-c: 3 + '{$request.body#/u}': {post: {responses: {"200": {description: ok}}}} + responses: + OK: {description: ok} +`, +} + +// decodeYAML decodes YAML (or JSON, which parses as YAML) straight into v, +// the way unmarshal does before falling back to JSON. +func decodeYAML(data []byte, v any) error { + var root yaml.Node + if err := yaml.Unmarshal(data, &root); err != nil { + return err + } + if err := prepareScalars(&root); err != nil { + return err + } + return root.Decode(v) +} + +func requireSameDoc(t *testing.T, expected, actual *T, msgAndArgs ...any) { + t.Helper() + e, err := expected.MarshalJSON() + require.NoError(t, err) + a, err := actual.MarshalJSON() + require.NoError(t, err) + require.JSONEq(t, string(e), string(a), msgAndArgs...) +} + +// The YAML decode path must agree with the JSON one: +// - a JSON document both paths accept decodes to the same thing, and +// - any document, re-encoded as block YAML, decodes back to itself. +func FuzzYAMLMatchesJSON(f *testing.F) { + for _, seed := range yamlFuzzSeeds { + f.Add([]byte(seed)) + } + for _, seed := range fuzzing.Testdata(f, "testdata") { + f.Add(seed) + } + for _, seed := range fuzzing.APIsGuru(f, "testdata", "openapi:") { + f.Add(seed) + } + f.Add([]byte(`{"openapi":"3.0.0","info":{"title":"t","version":"1"},"paths":{"/a":{"get":{"responses":{"200":{"description":"ok"}}}}}}`)) + f.Add([]byte(`{"openapi":"3.0.0","paths":{"x-a":{"<<":1},"/x":{}},"components":{"schemas":{"<<":{"type":"string"}}}}`)) + f.Add([]byte(`{"openapi":"3.1.0","components":{"schemas":{"a":{"enum":[1,1.5,"1",true,null],"example":12345678901234567890,"x-e":{"1":[{}]}}}}}`)) + f.Add([]byte(`{"openapi":"3.0.0","info":{"title":"é\/\t","version":"2020-01-01"},"paths":{}}`)) + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > maxYAMLFuzzInput { + t.Skip() + } + + if json.Valid(data) { + var viaJSON, viaYAML T + if unmarshalJSON(data, &viaJSON) == nil && decodeYAML(data, &viaYAML) == nil { + requireSameDoc(t, &viaJSON, &viaYAML, "data:\n%s", data) + } + } + + var doc T + if decodeYAML(data, &doc) != nil { + return + } + j, err := doc.MarshalJSON() + require.NoError(t, err) + var generic any + require.NoError(t, json.Unmarshal(j, &generic)) + if fuzzing.LosesInYAML(generic) { + return + } + y, err := yaml.Marshal(generic) + require.NoError(t, err) + + var back T + require.NoError(t, decodeYAML(y, &back), "yaml:\n%s", y) + requireSameDoc(t, &doc, &back, "yaml:\n%s\ndata:\n%s", y, data) + }) +} + +// YAMLFuzzSeeds exposes yamlFuzzSeeds to the external test package. +var YAMLFuzzSeeds = yamlFuzzSeeds diff --git a/openapi3filter/body_decoder_fuzz_test.go b/openapi3filter/body_decoder_fuzz_test.go new file mode 100644 index 000000000..f55a21183 --- /dev/null +++ b/openapi3filter/body_decoder_fuzz_test.go @@ -0,0 +1,83 @@ +package openapi3filter_test + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" +) + +var fuzzedBodyDecoders = []string{ + "application/json", + "application/octet-stream", + "application/x-www-form-urlencoded", + "application/yaml", + "multipart/form-data", + "text/csv", + "text/plain", +} + +// The registered body decoders never panic, and return only what the +// BodyDecoder contract allows: a primitive, []any or map[string]any. +func FuzzBodyDecoder(f *testing.F) { + const object = `{"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"array","items":{"type":"string"}},"o":{"type":"object","properties":{"x":{"type":"boolean"}}},"f":{"type":"string","format":"binary"}}}` + f.Add(uint8(0), "application/json", []byte(`{"a":1,"b":["x"]}`), []byte(object)) + f.Add(uint8(1), "application/octet-stream", []byte("\x00\x01"), []byte(`{"type":"string","format":"binary"}`)) + f.Add(uint8(2), "application/x-www-form-urlencoded", []byte(`a=1&b=x&b=y&o[x]=true`), []byte(object)) + f.Add(uint8(2), "application/x-www-form-urlencoded", []byte(`a=1`), []byte(`{"type":"object","additionalProperties":{"type":"integer"}}`)) + f.Add(uint8(3), "application/yaml", []byte("a: 1\nb: [x]\n"), []byte(object)) + f.Add(uint8(4), "multipart/form-data; boundary=b", []byte("--b\r\nContent-Disposition: form-data; name=\"a\"\r\n\r\n1\r\n--b\r\nContent-Disposition: form-data; name=\"b\"\r\n\r\nx\r\n--b\r\nContent-Disposition: form-data; name=\"o\"\r\nContent-Type: application/json\r\n\r\n{\"x\":true}\r\n--b\r\nContent-Disposition: form-data; name=\"f\"; filename=\"f\"\r\n\r\nraw\r\n--b--\r\n"), []byte(object)) + f.Add(uint8(5), "text/csv", []byte("a,b\n1,2\n"), []byte(`{"type":"string"}`)) + f.Add(uint8(6), "text/plain", []byte("hi"), []byte(`{"type":"string"}`)) + + f.Fuzz(func(t *testing.T, which uint8, contentType string, body, schemaJSON []byte) { + if len(body) > maxFuzzInput || len(schemaJSON) > maxFuzzInput || len(contentType) > 1<<10 { + t.Skip() + } + decoder := openapi3filter.RegisteredBodyDecoder(fuzzedBodyDecoders[int(which)%len(fuzzedBodyDecoders)]) + + var schema *openapi3.SchemaRef + if len(schemaJSON) != 0 { + schema = &openapi3.SchemaRef{} + if err := schema.UnmarshalJSON(schemaJSON); err != nil { + t.Skip() + } + if schema.Ref != "" { + t.Skip() // unresolved + } + } + encFn := func(string) *openapi3.Encoding { return nil } + + value, err := decoder(bytes.NewReader(body), http.Header{"Content-Type": {contentType}}, schema, encFn) + if err != nil { + return + } + requireBodyDecoderValue(t, value) + + if schema != nil && schema.Value != nil { + _ = schema.Value.VisitJSON(value) // no crash + } + }) +} + +func requireBodyDecoderValue(t *testing.T, value any) { + t.Helper() + switch v := value.(type) { + case nil, bool, string, json.Number, float64, int64, int, uint64, float32, int32: + case []any: + for _, e := range v { + requireBodyDecoderValue(t, e) + } + case map[string]any: + for _, e := range v { + requireBodyDecoderValue(t, e) + } + default: + require.Failf(t, "not a primitive, []any nor map[string]any", "%T: %#v", value, value) + } +} diff --git a/openapi3filter/fuzz_test.go b/openapi3filter/fuzz_test.go new file mode 100644 index 000000000..5f257dde8 --- /dev/null +++ b/openapi3filter/fuzz_test.go @@ -0,0 +1,147 @@ +package openapi3filter_test + +import ( + "bytes" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/internal/fuzzing" + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +// maxFuzzInput bounds fuzzer inputs so the fuzzer spends its budget on +// conversion logic rather than on allocating huge documents. +const maxFuzzInput = 32 << 10 + +func newFuzzLoader() *openapi3.Loader { + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(*openapi3.Loader, *url.URL) ([]byte, error) { return []byte(`{}`), nil } // deterministic + return loader +} + +func FuzzValidateRequest(f *testing.F) { + seeds := fuzzing.Testdata(f, "../openapi3/testdata", "testdata") + seeds = append(seeds, fuzzing.APIsGuru(f, "../openapi3/testdata", "openapi:")...) + for _, seed := range seeds { + f.Add(seed, "GET", "/", "application/json", []byte(`{}`)) + } + + const spec = ` +openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /items/{id}: + post: + parameters: + - {name: id, in: path, required: true, schema: {type: integer}} + - {name: q, in: query, schema: {type: array, items: {type: string}}} + requestBody: + content: + application/json: {schema: {type: object, required: [n], properties: {n: {type: number}}}} + multipart/form-data: {schema: {type: object, properties: {f: {type: string, format: binary}}}} + application/x-www-form-urlencoded: {schema: {type: object, properties: {a: {type: string}}}} + responses: {"200": {description: ok}} +` + f.Add([]byte(spec), "POST", "/items/1?q=a&q=b", "application/json", []byte(`{"n":1}`)) + f.Add([]byte(spec), "POST", "/items/x", "application/json", []byte(`{"n":"nope"}`)) + f.Add([]byte(spec), "POST", "/items/1", "application/x-www-form-urlencoded", []byte(`a=b&a=c`)) + f.Add([]byte(spec), "POST", "/items/1", "multipart/form-data; boundary=b", []byte("--b\r\nContent-Disposition: form-data; name=\"f\"\r\n\r\nx\r\n--b--\r\n")) + + f.Fuzz(func(t *testing.T, data []byte, method, target, contentType string, body []byte) { + if len(data) > maxFuzzInput || len(body) > maxFuzzInput { + t.Skip() + } + if len(method) > 64 || len(target) > 4<<10 || len(contentType) > 1<<10 { + t.Skip() + } + + loader := newFuzzLoader() + doc, err := loader.LoadFromData(data) + if err != nil { + t.Skip() + } + if err = doc.Validate(t.Context()); err != nil { + t.Skip() + } + + router, err := gorillamux.NewRouter(doc) + if err != nil { + return + } + + req, err := http.NewRequest(method, target, bytes.NewReader(body)) + if err != nil { + t.Skip() + } + req.Header.Set("Content-Type", contentType) + + route, pathParams, err := router.FindRoute(req) + if err != nil { + return + } + + // A matched route must be usable by callers: every field they dereference + // has to be populated, and every path parameter of the matched template + // has to be present. + require.NotNil(t, route) + require.NotNil(t, route.Spec) + require.NotNil(t, route.PathItem) + require.NotNil(t, route.Operation) + require.Equal(t, req.Method, route.Method) + requirePathParamsCoverTemplate(t, route, pathParams) + + // No crash in here + _ = openapi3filter.ValidateRequest(t.Context(), &openapi3filter.RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + Options: &openapi3filter.Options{ + MultiError: true, + AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, + }, + }) + }) +} + +func requirePathParamsCoverTemplate(t *testing.T, route *routers.Route, pathParams map[string]string) { + t.Helper() + for _, name := range muxVarNames(route.Path) { + require.Contains(t, pathParams, name, "path %q", route.Path) + } +} + +// muxVarNames extracts the variable names gorilla/mux reads out of a route +// template. It must stay in sync with mux's own parsing: braces nest, so +// "{{a}}" holds the single variable "{a}", and everything after the first +// colon is a match pattern rather than part of the name. +func muxVarNames(tpl string) (names []string) { + level, idx := 0, 0 + for i := 0; i < len(tpl); i++ { + switch tpl[i] { + case '{': + if level++; level == 1 { + idx = i + } + case '}': + if level--; level == 0 { + name, _, _ := strings.Cut(tpl[idx+1:i], ":") + names = append(names, name) + } else if level < 0 { + names = nil + return + } + } + } + if level != 0 { + names = nil + } + return +} diff --git a/openapi3filter/issue1367_test.go b/openapi3filter/issue1367_test.go new file mode 100644 index 000000000..14ad5d6c6 --- /dev/null +++ b/openapi3filter/issue1367_test.go @@ -0,0 +1,204 @@ +package openapi3filter_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +func setupTestRouter(t *testing.T, spec string) routers.Router { + t.Helper() + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(spec)) + require.NoError(t, err) + + err = doc.Validate(loader.Context) + require.NoError(t, err) + + router, err := gorillamux.NewRouter(doc) + require.NoError(t, err) + + return router +} + +func testIssue1367Request(t *testing.T, spec, url, errContains string) { + t.Helper() + + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + + router := setupTestRouter(t, spec) + route, pathParams, err := router.FindRoute(req) + require.NoError(t, err) + + err = openapi3filter.ValidateRequest(t.Context(), &openapi3filter.RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + }) + if errContains != "" { + require.ErrorContains(t, err, errContains) + } else { + require.NoError(t, err) + } +} + +func TestIssue1367TypelessConstAndEnumPrimitives(t *testing.T) { + t.Parallel() + + const spec = ` +openapi: 3.1.0 +info: + title: repro + version: 1.0.0 +paths: + /items: + get: + parameters: + - name: kind + in: query + required: true + schema: + const: widget + - name: withenum + in: query + schema: + enum: [widget] + responses: + '200': + description: ok +` + const rootURL = "http://example.com/items" + + testcases := []struct { + name string + url string + errContains string + }{{ + name: "const matches", + url: "?kind=widget", + }, { + name: "const and enum match", + url: "?kind=widget&withenum=widget", + }, { + name: "enum mismatch", + url: "?kind=widget&withenum=gadget", + errContains: "parameter \"withenum\" in query has an error: validation failed due to: at '': value must be 'widget'", + }, { + name: "const mismatch", + url: "?kind=gadget", + errContains: "parameter \"kind\" in query has an error: validation failed due to: at '': value must be 'widget'", + }, { + name: "empty enum value", + url: "?kind=widget&withenum=", + errContains: "parameter \"withenum\" in query has an error: validation failed due to: at '': value must be 'widget'", + }} + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + testIssue1367Request(t, spec, rootURL+tc.url, tc.errContains) + }) + } +} + +func TestIssue1367TypelessConstAndEnumNumberAndBoolean(t *testing.T) { + t.Parallel() + + const spec = ` +openapi: 3.1.0 +info: + title: repro + version: 1.0.0 +paths: + /picked: + get: + parameters: + - name: rank + in: query + required: true + schema: + enum: [1, 2, 3] + - name: flag + in: query + schema: + const: true + responses: + '200': + description: ok +` + const rootURL = "http://example.com/picked" + + testcases := []struct { + name string + url string + errContains string + }{{ + name: "number enum and boolean const match", + url: "?rank=2&flag=true", + }, { + name: "boolean const mismatch", + url: "?rank=2&flag=false", + errContains: "parameter \"flag\" in query has an error: validation failed due to: at '': value must be true", + }, { + name: "number enum mismatch", + url: "?rank=9&flag=true", + errContains: "parameter \"rank\" in query has an error: validation failed due to: at '': value must be one of 1, 2, 3", + }} + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + testIssue1367Request(t, spec, rootURL+tc.url, tc.errContains) + }) + } +} + +// A typeless enum may mix kinds; a numeric string still decodes to the number. +func TestIssue1367TypelessEnumMixedKinds(t *testing.T) { + t.Parallel() + + const spec = ` +openapi: 3.1.0 +info: + title: repro + version: 1.0.0 +paths: + /mid: + get: + parameters: + - name: mid + in: query + required: true + schema: + enum: [42, "42", [42]] + responses: + '200': + description: ok +` + const rootURL = "http://example.com/mid" + + testcases := []struct { + name string + url string + errContains string + }{{ + name: "numeric string decodes to number", + url: "?mid=42", + }, { + name: "no member matches", + url: "?mid=gadget", + errContains: "parameter \"mid\" in query has an error: validation failed due to: at '': 'enum' failed", + }, { + name: "array member never matches", + url: "?mid=%5B42%5D", + errContains: "parameter \"mid\" in query has an error: validation failed due to: at '': 'enum' failed", + }} + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + testIssue1367Request(t, spec, rootURL+tc.url, tc.errContains) + }) + } +} diff --git a/openapi3filter/issue1377_test.go b/openapi3filter/issue1377_test.go new file mode 100644 index 000000000..b1aebb183 --- /dev/null +++ b/openapi3filter/issue1377_test.go @@ -0,0 +1,53 @@ +package openapi3filter + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIssue1377(t *testing.T) { + t.Parallel() + + const spec = ` +openapi: 3.1.0 +info: {title: repro, version: "1"} +paths: + /config: + put: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + interval: + type: string + enum: [day, week] + responses: + "204": {description: ok} +` + + req, err := http.NewRequest(http.MethodPut, "/config", strings.NewReader(`{"interval":"hour"}`)) + require.NoError(t, err) + req.Header.Set(headerCT, "application/json") + + router := setupTestRouter(t, spec) + route, pathParams, err := router.FindRoute(req) + require.NoError(t, err) + + err = ValidateRequest(t.Context(), &RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + }) + require.Error(t, err) + + verr := ConvertErrors(err).(*ValidationError) + require.Equal(t, http.StatusUnprocessableEntity, verr.Status) + require.Equal(t, &ValidationErrorSource{Pointer: "/interval"}, verr.Source) + require.Equal(t, "value hour at /interval must be one of: day, week", verr.Detail) +} diff --git a/openapi3filter/middleware.go b/openapi3filter/middleware.go index d20889ed9..94a888a29 100644 --- a/openapi3filter/middleware.go +++ b/openapi3filter/middleware.go @@ -173,7 +173,8 @@ type responseWrapper interface { // not yet been written. flushBodyContents() error - // statusCode returns the response status code, 0 if not set yet. + // statusCode returns the response status code, 200 if not set, as + // net/http sends for a handler that writes neither a status nor a body. statusCode() int // bodyContents returns the buffered @@ -234,6 +235,9 @@ func (wr *warnResponseWrapper) flushBodyContents() error { } func (wr *warnResponseWrapper) statusCode() int { + if !wr.headerWritten { + return http.StatusOK + } return wr.status } @@ -270,12 +274,15 @@ func (wr *strictResponseWrapper) Header() http.Header { } func (wr *strictResponseWrapper) flushBodyContents() error { - wr.w.WriteHeader(wr.status) + wr.w.WriteHeader(wr.statusCode()) _, err := wr.w.Write(wr.body.Bytes()) return err } func (wr *strictResponseWrapper) statusCode() int { + if !wr.headerWritten { + return http.StatusOK + } return wr.status } diff --git a/openapi3filter/middleware_test.go b/openapi3filter/middleware_test.go index c9aa61788..a58101970 100644 --- a/openapi3filter/middleware_test.go +++ b/openapi3filter/middleware_test.go @@ -401,6 +401,53 @@ func TestValidator(t *testing.T) { } } +func TestValidatorHandlerWithoutStatus(t *testing.T) { + t.Parallel() + + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData([]byte(` +openapi: 3.0.0 +info: + title: 'Validator' + version: '0.0.0' +paths: + /ping: + get: + responses: + '200': + description: pong +`[1:])) + require.NoError(t, err) + require.NoError(t, doc.Validate(loader.Context)) + + router, err := gorillamux.NewRouter(doc) + require.NoError(t, err) + + // net/http sends a 200 for a handler that writes neither a status nor a body. + handler := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + + for _, strict := range []bool{true, false} { + for _, includeResponseStatus := range []bool{true, false} { + t.Run(fmt.Sprintf("strict=%v includeResponseStatus=%v", strict, includeResponseStatus), func(t *testing.T) { + var logged []string + v := openapi3filter.NewValidator(router, + openapi3filter.Strict(strict), + openapi3filter.ValidationOptions(openapi3filter.Options{IncludeResponseStatus: includeResponseStatus}), + openapi3filter.OnLog(func(_ context.Context, message string, err error) { + logged = append(logged, fmt.Sprintf("%s: %v", message, err)) + }), + ) + + w := httptest.NewRecorder() + v.Middleware(handler).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ping", nil)) + require.Empty(t, logged) + require.Equal(t, http.StatusOK, w.Code) + require.Empty(t, w.Body.String()) + }) + } + } +} + func ExampleValidator() { // OpenAPI specification for a simple service that squares integers, with // some limitations. diff --git a/openapi3filter/req_resp_decoder.go b/openapi3filter/req_resp_decoder.go index 3bcfd4931..572aa6cb3 100644 --- a/openapi3filter/req_resp_decoder.go +++ b/openapi3filter/req_resp_decoder.go @@ -20,7 +20,7 @@ import ( "strings" "sync" - yaml "github.com/oasdiff/yaml3" + yaml "go.yaml.in/yaml/v3" "github.com/getkin/kin-openapi/openapi3" ) @@ -338,6 +338,11 @@ func decodeValueGuarded(dec valueDecoder, param string, sm *openapi3.Serializati return nil, found, errors.New("not implemented: decoding 'not'") } + if schema.Value.Type == nil && (schema.Value.Const != nil || len(schema.Value.Enum) > 0) { + // Typeless const/enum schema (#1367): decode as a primitive. + return dec.DecodePrimitive(param, sm, schema) + } + if schema.Value.Type != nil { var decodeFn func(param string, sm *openapi3.SerializationMethod, schema *openapi3.SchemaRef) (any, bool, error) switch { @@ -1232,7 +1237,13 @@ func parseArray(raw []string, schemaRef *openapi3.SchemaRef) ([]any, error) { // that is specified by a schema. The function returns nil when the source string is empty and the type is not "string". // The function panics when a schema has a non-primitive type. func parsePrimitive(raw string, schema *openapi3.SchemaRef) (v any, err error) { - for _, typ := range schema.Value.Type.Slice() { + typs := schema.Value.Type.Slice() + constrainedTypeless := len(typs) == 0 && (schema.Value.Const != nil || len(schema.Value.Enum) > 0) + if constrainedTypeless { + // No type: derive the candidate types from the const/enum values (#1367). + typs = constraintTypes(schema) + } + for _, typ := range typs { if raw == "" && typ != openapi3.TypeString { return nil, nil } @@ -1240,9 +1251,57 @@ func parsePrimitive(raw string, schema *openapi3.SchemaRef) (v any, err error) { return } } + if constrainedTypeless { + return raw, nil + } return } +// constraintTypes returns the types of the const/enum values of a schema, +// with boolean and number before string so that "42" decodes to 42. +// It makes a single pass over the values, then keeps the seen types only, +// reducing the worst case from O(5 x values) hash lookups to O(values). +func constraintTypes(schema *openapi3.SchemaRef) []string { + seen := make(map[string]struct{}, 5) + consider := func(value any) { + switch value.(type) { + case bool: + seen[openapi3.TypeBoolean] = struct{}{} + case float32, float64, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number: + seen[openapi3.TypeNumber] = struct{}{} + case string: + seen[openapi3.TypeString] = struct{}{} + case []any: + seen[openapi3.TypeArray] = struct{}{} + case map[string]any: + seen[openapi3.TypeObject] = struct{}{} + } + } + for _, value := range schema.Value.Enum { + consider(value) + } + if schema.Value.Const != nil { + consider(schema.Value.Const) + } + typs := make([]string, 0, 5) + if _, ok := seen[openapi3.TypeBoolean]; ok { + typs = append(typs, openapi3.TypeBoolean) + } + if _, ok := seen[openapi3.TypeNumber]; ok { + typs = append(typs, openapi3.TypeNumber) + } + if _, ok := seen[openapi3.TypeString]; ok { + typs = append(typs, openapi3.TypeString) + } + if _, ok := seen[openapi3.TypeArray]; ok { + typs = append(typs, openapi3.TypeArray) + } + if _, ok := seen[openapi3.TypeObject]; ok { + typs = append(typs, openapi3.TypeObject) + } + return typs +} + func parsePrimitiveCase(raw string, schema *openapi3.SchemaRef, typ string) (any, error) { switch typ { case "integer": @@ -1259,6 +1318,12 @@ func parsePrimitiveCase(raw string, schema *openapi3.SchemaRef, typ string) (any } return v, nil case "number": + // strconv.ParseFloat also accepts Go float literals that are not decimal + // numbers: hexadecimal (0x1p4), digits separated by underscores (1_000), + // infinities and NaN. + if strings.ContainsFunc(raw, func(r rune) bool { return !strings.ContainsRune("+-.0123456789eE", r) }) { + return nil, &ParseError{Kind: KindInvalidFormat, Value: raw, Reason: "an invalid " + typ, Cause: strconv.ErrSyntax} + } v, err := strconv.ParseFloat(raw, 64) if err != nil { return nil, &ParseError{Kind: KindInvalidFormat, Value: raw, Reason: "an invalid " + typ, Cause: err.(*strconv.NumError).Err} @@ -1450,6 +1515,60 @@ func YamlBodyDecoder(body io.Reader, header http.Header, schema *openapi3.Schema if err := yaml.NewDecoder(body).Decode(&value); err != nil { return nil, &ParseError{Kind: KindInvalidFormat, Cause: err} } + value, err := yamlToJSONValue(value) + if err != nil { + return nil, &ParseError{Kind: KindInvalidFormat, Cause: err} + } + return value, nil +} + +// yamlToJSONValue turns the mappings YAML decodes with keys other than +// strings, such as "1: a" or "? : a", into the map[string]any a BodyDecoder +// returns. A scalar key is written the way JSON would write it, and a mapping +// or sequence as a key is refused: no JSON object can hold it. +func yamlToJSONValue(value any) (any, error) { + switch v := value.(type) { + case map[string]any: + for k, e := range v { + e, err := yamlToJSONValue(e) + if err != nil { + return nil, err + } + v[k] = e + } + case map[any]any: + m := make(map[string]any, len(v)) + for k, e := range v { + var key string + switch k := k.(type) { + case nil: + key = "null" + case string: + key = k + case bool, int, int64, uint64, float64: + key = fmt.Sprint(k) + default: + return nil, fmt.Errorf("unsupported mapping key of type %T", k) + } + if _, ok := m[key]; ok { + return nil, fmt.Errorf("duplicate mapping key %q", key) + } + e, err := yamlToJSONValue(e) + if err != nil { + return nil, err + } + m[key] = e + } + return m, nil + case []any: + for i, e := range v { + e, err := yamlToJSONValue(e) + if err != nil { + return nil, err + } + v[i] = e + } + } return value, nil } @@ -1470,7 +1589,7 @@ func UrlencodedBodyDecoder(body io.Reader, header http.Header, schema *openapi3. // Validate schema of request body. // By the OpenAPI 3 specification request body's schema must have type "object". // Properties of the schema describes individual parts of request body. - if !schema.Value.Type.Is("object") { + if schema == nil || schema.Value == nil || !schema.Value.Type.Is("object") { return nil, errors.New("unsupported schema of request body") } propNames := make([]string, 0, len(schema.Value.Properties)) @@ -1584,7 +1703,7 @@ func decodeProperty(dec valueDecoder, name string, prop *openapi3.SchemaRef, enc } func MultipartBodyDecoder(body io.Reader, header http.Header, schema *openapi3.SchemaRef, encFn EncodingFn) (any, error) { - if !schema.Value.Type.Is("object") { + if schema == nil || schema.Value == nil || !schema.Value.Type.Is("object") { return nil, errors.New("unsupported schema of request body") } diff --git a/openapi3filter/req_resp_decoder_test.go b/openapi3filter/req_resp_decoder_test.go index c62a9dfb3..96898a784 100644 --- a/openapi3filter/req_resp_decoder_test.go +++ b/openapi3filter/req_resp_decoder_test.go @@ -767,6 +767,41 @@ func TestDecodeParameter(t *testing.T) { found: true, err: &ParseError{Kind: KindInvalidFormat, Value: "foo"}, }, + { + name: "number exponent", + param: &openapi3.Parameter{Name: "param", In: "query", Schema: numberSchema}, + query: "param=-1.5E+3", + want: -1500.0, + found: true, + }, + { + name: "number hexadecimal", + param: &openapi3.Parameter{Name: "param", In: "query", Schema: numberSchema}, + query: "param=0x1p4", + found: true, + err: &ParseError{Kind: KindInvalidFormat, Value: "0x1p4"}, + }, + { + name: "number underscores", + param: &openapi3.Parameter{Name: "param", In: "query", Schema: numberSchema}, + query: "param=1_000.5", + found: true, + err: &ParseError{Kind: KindInvalidFormat, Value: "1_000.5"}, + }, + { + name: "number infinity", + param: &openapi3.Parameter{Name: "param", In: "query", Schema: numberSchema}, + query: "param=-Inf", + found: true, + err: &ParseError{Kind: KindInvalidFormat, Value: "-Inf"}, + }, + { + name: "number NaN", + param: &openapi3.Parameter{Name: "param", In: "query", Schema: numberSchema}, + query: "param=NaN", + found: true, + err: &ParseError{Kind: KindInvalidFormat, Value: "NaN"}, + }, { name: "boolean", param: &openapi3.Parameter{Name: "param", In: "query", Schema: booleanSchema}, @@ -1021,6 +1056,27 @@ func TestDecodeParameter(t *testing.T) { want: map[string]any{"items": []any{int64(123), int64(456)}}, found: true, }, + { + name: "deepObject explode array - negative index", + param: &openapi3.Parameter{Name: "param", In: "query", Style: "deepObject", Explode: explode, Schema: objectOf("items", integerArraySchema)}, + query: "param[items][-1]=123", + found: true, + err: &ParseError{path: []any{"items"}, Kind: KindInvalidFormat, Reason: "could not convert value map to array: array indexes must not be negative: -1"}, + }, + { + name: "deepObject explode array - sparse index within limit", + param: &openapi3.Parameter{Name: "param", In: "query", Style: "deepObject", Explode: explode, Schema: objectOf("items", integerArraySchema)}, + query: "param[items][2]=123", + want: map[string]any{"items": []any{nil, nil, int64(123)}}, + found: true, + }, + { + name: "deepObject explode array - too sparse index", + param: &openapi3.Parameter{Name: "param", In: "query", Style: "deepObject", Explode: explode, Schema: objectOf("items", integerArraySchema)}, + query: "param[items][99999999]=123", + found: true, + err: &ParseError{path: []any{"items"}, Kind: KindInvalidFormat, Reason: "could not convert value map to array: array index 99999999 is too sparse relative to the 1 supplied items"}, + }, { name: "deepObject explode nested object additionalProperties", param: &openapi3.Parameter{ diff --git a/openapi3filter/response_fuzz_test.go b/openapi3filter/response_fuzz_test.go new file mode 100644 index 000000000..d3c28d051 --- /dev/null +++ b/openapi3filter/response_fuzz_test.go @@ -0,0 +1,122 @@ +package openapi3filter_test + +import ( + "bytes" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/internal/fuzzing" + "github.com/getkin/kin-openapi/openapi3filter" + "github.com/getkin/kin-openapi/routers/gorillamux" +) + +// Validating a response never panics and reaches the same verdict every +// time, and turning checks off never rejects a response the full checks +// accept. +func FuzzValidateResponse(f *testing.F) { + seeds := fuzzing.Testdata(f, "../openapi3/testdata", "testdata") + for _, seed := range seeds { + f.Add(seed, "GET", "/", 200, "application/json", []byte(`{}`)) + } + + const spec = ` +openapi: 3.0.0 +info: {title: t, version: "1"} +paths: + /items/{id}: + get: + parameters: + - {name: id, in: path, required: true, schema: {type: integer}} + responses: + "200": + description: ok + headers: + X-Rate: {required: true, schema: {type: integer, minimum: 0}} + X-Tags: {schema: {type: array, items: {type: string}}, style: simple} + content: + application/json: + schema: + type: object + required: [n] + properties: + n: {type: number, readOnly: true} + w: {type: string, writeOnly: true} + d: {type: string, format: date-time} + text/plain: {schema: {type: string, maxLength: 3}} + application/x-www-form-urlencoded: {schema: {type: object, properties: {a: {type: string}}}} + 4XX: + description: client error + content: + application/problem+json: {schema: {type: object, properties: {title: {type: string}}}} + default: + description: other +` + f.Add([]byte(spec), "GET", "/items/1", 200, "application/json", []byte(`{"n":1,"d":"2020-01-01T00:00:00Z"}`)) + f.Add([]byte(spec), "GET", "/items/1", 200, "application/json", []byte(`{"w":"x"}`)) + f.Add([]byte(spec), "GET", "/items/1", 200, "text/plain; charset=utf-8", []byte(`abcd`)) + f.Add([]byte(spec), "GET", "/items/1", 200, "application/x-www-form-urlencoded", []byte(`a=b&a=c`)) + f.Add([]byte(spec), "GET", "/items/1", 404, "application/problem+json", []byte(`{"title":1}`)) + f.Add([]byte(spec), "GET", "/items/1", 503, "", []byte(``)) + + f.Fuzz(func(t *testing.T, data []byte, method, target string, status int, contentType string, body []byte) { + if len(data) > maxFuzzInput || len(body) > maxFuzzInput { + t.Skip() + } + if len(method) > 64 || len(target) > 4<<10 || len(contentType) > 1<<10 { + t.Skip() + } + + doc, err := newFuzzLoader().LoadFromData(data) + if err != nil { + t.Skip() + } + if err = doc.Validate(t.Context()); err != nil { + t.Skip() + } + router, err := gorillamux.NewRouter(doc) + if err != nil { + return + } + req, err := http.NewRequest(method, target, nil) + if err != nil { + t.Skip() + } + route, pathParams, err := router.FindRoute(req) + if err != nil { + return + } + + validate := func(options openapi3filter.Options) error { + options.MultiError = true + options.AuthenticationFunc = openapi3filter.NoopAuthenticationFunc + input := &openapi3filter.ResponseValidationInput{ + RequestValidationInput: &openapi3filter.RequestValidationInput{ + Request: req, + PathParams: pathParams, + Route: route, + }, + Status: status, + Header: http.Header{"Content-Type": {contentType}, "X-Rate": {"1"}}, + Options: &options, + } + input.SetBodyBytes(bytes.Clone(body)) + return openapi3filter.ValidateResponse(t.Context(), input) + } + + full := validate(openapi3filter.Options{IncludeResponseStatus: true}) + again := validate(openapi3filter.Options{IncludeResponseStatus: true}) + require.Equal(t, full == nil, again == nil, "first: %v\nagain: %v", full, again) + if full != nil { + return + } + for _, lax := range []openapi3filter.Options{ + {}, + {ExcludeResponseBody: true}, + {ExcludeReadOnlyValidations: true, ExcludeWriteOnlyValidations: true}, + } { + require.NoError(t, validate(lax), "%+v", lax) + } + }) +} diff --git a/openapi3filter/testdata/fuzz/FuzzBodyDecoder/998275c530f638c7 b/openapi3filter/testdata/fuzz/FuzzBodyDecoder/998275c530f638c7 new file mode 100644 index 000000000..f60d0441d --- /dev/null +++ b/openapi3filter/testdata/fuzz/FuzzBodyDecoder/998275c530f638c7 @@ -0,0 +1,5 @@ +go test fuzz v1 +byte('\x02') +string("applicatiw-formon/x-www-form-urlencoded") +[]byte("a=1") +[]byte("") diff --git a/openapi3filter/testdata/fuzz/FuzzBodyDecoder/e5ea51e705f070e5 b/openapi3filter/testdata/fuzz/FuzzBodyDecoder/e5ea51e705f070e5 new file mode 100644 index 000000000..20882a44e --- /dev/null +++ b/openapi3filter/testdata/fuzz/FuzzBodyDecoder/e5ea51e705f070e5 @@ -0,0 +1,5 @@ +go test fuzz v1 +byte('¹') +string("0") +[]byte("?") +[]byte("") diff --git a/openapi3filter/validate_response.go b/openapi3filter/validate_response.go index 221d74ae5..eb8a30267 100644 --- a/openapi3filter/validate_response.go +++ b/openapi3filter/validate_response.go @@ -244,7 +244,7 @@ func decodeContentResponseHeader(headerName string, header *openapi3.Header, inp } schema = mt.Schema.Value _, value, err = decodeBody(strings.NewReader(values[0]), http.Header{headerCT: {mediaType}}, mt.Schema, nil) - return + return // Only one pass } return } diff --git a/routers/fuzz_test.go b/routers/fuzz_test.go new file mode 100644 index 000000000..f4762a90b --- /dev/null +++ b/routers/fuzz_test.go @@ -0,0 +1,88 @@ +package routers_test + +import ( + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/getkin/kin-openapi/internal/fuzzing" + "github.com/getkin/kin-openapi/openapi3" + "github.com/getkin/kin-openapi/routers/gorillamux" + "github.com/getkin/kin-openapi/routers/legacy" +) + +// maxFuzzInput bounds fuzzer inputs so the fuzzer spends its budget on +// routing rather than on allocating huge documents. +const maxFuzzInput = 32 << 10 + +// Neither router panics, and when both find a route for a request they agree +// on which one it is and on the values of its path parameters. +func FuzzRoutersAgree(f *testing.F) { + for _, seed := range fuzzing.Testdata(f, "../openapi3/testdata") { + f.Add(seed, "GET", "/") + } + const spec = ` +openapi: 3.0.0 +info: {title: t, version: "1"} +servers: +- url: http://example.com/api/{v} + variables: {v: {default: v1, enum: [v1, v2]}} +- url: / +paths: + /items: {get: {responses: {"200": {description: ok}}}} + /items/{id}: {get: {parameters: [{name: id, in: path, required: true, schema: {type: string}}], responses: {"200": {description: ok}}}} + /items/{id}/tags/{tag}: {delete: {parameters: [{name: id, in: path, required: true, schema: {type: string}}, {name: tag, in: path, required: true, schema: {type: string}}], responses: {"200": {description: ok}}}} + /files/{name}.{ext}: {get: {parameters: [{name: name, in: path, required: true, schema: {type: string}}, {name: ext, in: path, required: true, schema: {type: string}}], responses: {"200": {description: ok}}}} +` + f.Add([]byte(spec), "GET", "http://example.com/api/v1/items/42") + f.Add([]byte(spec), "GET", "http://example.com/api/v3/items") + f.Add([]byte(spec), "DELETE", "/items/a%2Fb/tags/x") + f.Add([]byte(spec), "GET", "/files/report.tar.gz") + f.Add([]byte(spec), "GET", "/items/") + + f.Fuzz(func(t *testing.T, data []byte, method, target string) { + if len(data) > maxFuzzInput || len(method) > 64 || len(target) > 4<<10 { + t.Skip() + } + + loader := openapi3.NewLoader() + loader.IsExternalRefsAllowed = true + loader.ReadFromURIFunc = func(*openapi3.Loader, *url.URL) ([]byte, error) { return []byte(`{}`), nil } + doc, err := loader.LoadFromData(data) + if err != nil { + t.Skip() + } + if err := doc.Validate(t.Context()); err != nil { + t.Skip() + } + req, err := http.NewRequest(method, target, nil) + if err != nil { + t.Skip() + } + + mux, muxErr := gorillamux.NewRouter(doc) + leg, legErr := legacy.NewRouter(doc) + if muxErr != nil || legErr != nil { + return + } + + muxRoute, muxParams, muxErr := mux.FindRoute(req) + legRoute, legParams, legErr := leg.FindRoute(req) + if muxErr != nil || legErr != nil { + return + } + require.Equal(t, muxRoute.Method, legRoute.Method) + require.Equal(t, muxRoute.Path, legRoute.Path, "target: %s", target) + for name, value := range legParams { + // gorillamux leaves every value escaped, legacy only "%2F": compare + // them decoded. + muxValue, err := url.PathUnescape(muxParams[name]) + require.NoError(t, err) + legValue, err := url.PathUnescape(value) + require.NoError(t, err) + require.Equal(t, legValue, muxValue, "parameter %q of %s for %s", name, muxRoute.Path, target) + } + }) +} diff --git a/routers/testdata/fuzz/FuzzRoutersAgree/fa85e7cd2f151617 b/routers/testdata/fuzz/FuzzRoutersAgree/fa85e7cd2f151617 new file mode 100644 index 000000000..921d5dd61 --- /dev/null +++ b/routers/testdata/fuzz/FuzzRoutersAgree/fa85e7cd2f151617 @@ -0,0 +1,4 @@ +go test fuzz v1 +[]byte("{\"info\":{\"title\":A,\"version\":0A},\"openapi\":0A,\"paths\":{\"/{external1}\":{\"get\":{\"responses\":{0: {\"description\":A}}},\"parameters\":[{\"in\":\"path\",\"name\":\"external1\",\"required\":true,\"schema\":{}}]}}}") +string("") +string("/ ")