diff --git a/README.md b/README.md index bb4ff783..98fc260d 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,11 @@ Note: The canonical location of this README is on [RubyDoc](https://rubydoc.info ### OpenAPI specification and OpenAPI documents -To start with, you need an OpenAPI document (an OAD) describing a service you will be consuming. OpenAPI Specification v3.1, v3.0, and v2 (formerly known as Swagger) are supported. An OAD can be written by hand or sometimes generated from other existing sources. The creation of an OpenAPI document describing a given service is outside the scope of Scorpio. Here are several resources on OpenAPI: +To start with, you need an OpenAPI document (an OAD) describing a service you will be consuming. OpenAPI Specification v3.2, v3.1, v3.0, and v2 (formerly known as Swagger) are supported. An OAD can be written by hand or sometimes generated from other existing sources. The creation of an OpenAPI document describing a given service is outside the scope of Scorpio. Here are several resources on OpenAPI: - [Learn about OpenAPI](https://learn.openapis.org/) - [OpenAPI Specification at Wikipedia](https://en.wikipedia.org/wiki/OpenAPI_Specification) -- OpenAPI Specifications [v3.1](https://spec.openapis.org/oas/v3.1.html), [v3.0](https://spec.openapis.org/oas/v3.0.html), [v2.0](https://spec.openapis.org/oas/v2.0.html) +- OpenAPI Specifications [v3.2](https://spec.openapis.org/oas/v3.2.html), [v3.1](https://spec.openapis.org/oas/v3.1.html), [v3.0](https://spec.openapis.org/oas/v3.0.html), [v2.0](https://spec.openapis.org/oas/v2.0.html) - [OpenAPI Specification development on GitHub](https://github.com/OAI/OpenAPI-Specification) ### JSON Schema, JSI diff --git a/Rakefile b/Rakefile index ae49b853..8f3e4fc2 100644 --- a/Rakefile +++ b/Rakefile @@ -12,6 +12,7 @@ task 'test:each_format' do openapi2 openapi3_0 openapi3_1 + openapi3_2 ) require 'term/ansicolor' diff --git a/documents/spec.openapis.org/oas/3.2/dialect.yaml b/documents/spec.openapis.org/oas/3.2/dialect.yaml new file mode 100644 index 00000000..935a5d37 --- /dev/null +++ b/documents/spec.openapis.org/oas/3.2/dialect.yaml @@ -0,0 +1,22 @@ +# Scorpio::OpenAPI::V3_2::Ext::MetaSchema +$id: https://spec.openapis.org/oas/3.2/dialect/2025-09-17 +$schema: https://json-schema.org/draft/2020-12/schema + +title: OpenAPI 3.2 Schema Object Dialect +description: A JSON Schema dialect describing schemas found in OpenAPI v3.2.x Descriptions + +$dynamicAnchor: meta + +$vocabulary: + https://json-schema.org/draft/2020-12/vocab/applicator: true + https://json-schema.org/draft/2020-12/vocab/content: true + https://json-schema.org/draft/2020-12/vocab/core: true + https://json-schema.org/draft/2020-12/vocab/format-annotation: true + https://json-schema.org/draft/2020-12/vocab/meta-data: true + https://json-schema.org/draft/2020-12/vocab/unevaluated: true + https://json-schema.org/draft/2020-12/vocab/validation: true + https://spec.openapis.org/oas/3.2/vocab/base: false + +allOf: + - $ref: https://json-schema.org/draft/2020-12/schema + - $ref: https://spec.openapis.org/oas/3.2/meta/2025-09-17 diff --git a/documents/spec.openapis.org/oas/3.2/meta.yaml b/documents/spec.openapis.org/oas/3.2/meta.yaml new file mode 100644 index 00000000..06944d6c --- /dev/null +++ b/documents/spec.openapis.org/oas/3.2/meta.yaml @@ -0,0 +1,89 @@ +# Scorpio::OpenAPI::V3_2::Ext::VocabSchema +$id: https://spec.openapis.org/oas/3.2/meta/2025-09-17 +$schema: https://json-schema.org/draft/2020-12/schema + +title: OAS Base Vocabulary +description: A JSON Schema Vocabulary used in the OpenAPI JSON Schema Dialect + +$dynamicAnchor: meta + +$vocabulary: + https://spec.openapis.org/oas/3.2/vocab/base: true + +type: + - object + - boolean +properties: + discriminator: + $ref: '#/$defs/discriminator' + example: + deprecated: true + externalDocs: + $ref: '#/$defs/external-docs' + xml: + $ref: '#/$defs/xml' + +$defs: + discriminator: + $ref: '#/$defs/extensible' + properties: + mapping: + additionalProperties: + type: string + type: object + defaultMapping: + type: string + propertyName: + type: string + required: + - propertyName + type: object + unevaluatedProperties: false + + extensible: + patternProperties: + ^x-: true + external-docs: + $ref: '#/$defs/extensible' + properties: + description: + type: string + url: + format: uri-reference + type: string + required: + - url + type: object + unevaluatedProperties: false + + xml: + $ref: '#/$defs/extensible' + properties: + nodeType: + type: string + enum: + - element + - attribute + - text + - cdata + - none + name: + type: string + namespace: + format: iri + type: string + prefix: + type: string + attribute: + type: boolean + deprecated: true + wrapped: + type: boolean + deprecated: true + type: object + dependentSchemas: + nodeType: + properties: + attribute: false + wrapped: false + unevaluatedProperties: false diff --git a/documents/spec.openapis.org/oas/3.2/schema-base.yaml b/documents/spec.openapis.org/oas/3.2/schema-base.yaml new file mode 100644 index 00000000..1a214f1c --- /dev/null +++ b/documents/spec.openapis.org/oas/3.2/schema-base.yaml @@ -0,0 +1,21 @@ +# Scorpio::OpenAPI::V3_2::Ext::ExtDocument +$id: 'https://spec.openapis.org/oas/3.2/schema-base/2025-11-23' +$schema: 'https://json-schema.org/draft/2020-12/schema' + +description: The description of OpenAPI v3.2.x Documents using the OpenAPI JSON Schema dialect + +$ref: 'https://spec.openapis.org/oas/3.2/schema/2025-11-23' +properties: + jsonSchemaDialect: + $ref: '#/$defs/dialect' + +$defs: + dialect: + const: 'https://spec.openapis.org/oas/3.2/dialect/2025-09-17' + + schema: + $dynamicAnchor: meta + $ref: 'https://spec.openapis.org/oas/3.2/dialect/2025-09-17' + properties: + $schema: + $ref: '#/$defs/dialect' diff --git a/documents/spec.openapis.org/oas/3.2/schema.yaml b/documents/spec.openapis.org/oas/3.2/schema.yaml new file mode 100644 index 00000000..ab04bf72 --- /dev/null +++ b/documents/spec.openapis.org/oas/3.2/schema.yaml @@ -0,0 +1,1152 @@ +# Scorpio::OpenAPI::V3_2::Unscoped::Document +$id: 'https://spec.openapis.org/oas/3.2/schema/2025-11-23' +$schema: 'https://json-schema.org/draft/2020-12/schema' + +description: The description of OpenAPI v3.2.x Documents without Schema Object validation + +type: object +properties: + openapi: + type: string + pattern: '^3\.2\.\d+(-.+)?$' + $self: + type: string + format: uri-reference + $comment: MUST NOT contain a fragment + pattern: '^[^#]*$' + info: + $ref: '#/$defs/info' + jsonSchemaDialect: + type: string + format: uri-reference + default: 'https://spec.openapis.org/oas/3.2/dialect/2025-09-17' + servers: + type: array + items: + $ref: '#/$defs/server' + default: + - url: / + paths: + $ref: '#/$defs/paths' + webhooks: + type: object + additionalProperties: + $ref: '#/$defs/path-item' + components: + $ref: '#/$defs/components' + security: + type: array + items: + $ref: '#/$defs/security-requirement' + tags: + type: array + items: + $ref: '#/$defs/tag' + externalDocs: + $ref: '#/$defs/external-documentation' +required: + - openapi + - info +anyOf: + - required: + - paths + - required: + - components + - required: + - webhooks +$ref: '#/$defs/specification-extensions' +unevaluatedProperties: false + +$defs: + info: + $comment: https://spec.openapis.org/oas/v3.2#info-object + type: object + properties: + title: + type: string + summary: + type: string + description: + type: string + termsOfService: + type: string + format: uri-reference + contact: + $ref: '#/$defs/contact' + license: + $ref: '#/$defs/license' + version: + type: string + required: + - title + - version + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + contact: + $comment: https://spec.openapis.org/oas/v3.2#contact-object + type: object + properties: + name: + type: string + url: + type: string + format: uri-reference + email: + type: string + format: email + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + license: + $comment: https://spec.openapis.org/oas/v3.2#license-object + type: object + properties: + name: + type: string + identifier: + type: string + url: + type: string + format: uri-reference + required: + - name + dependentSchemas: + identifier: + not: + required: + - url + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + server: + $comment: https://spec.openapis.org/oas/v3.2#server-object + type: object + properties: + url: + type: string + description: + type: string + name: + type: string + variables: + type: object + additionalProperties: + $ref: '#/$defs/server-variable' + required: + - url + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + server-variable: + $comment: https://spec.openapis.org/oas/v3.2#server-variable-object + type: object + properties: + enum: + type: array + items: + type: string + minItems: 1 + default: + type: string + description: + type: string + required: + - default + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + components: + $comment: https://spec.openapis.org/oas/v3.2#components-object + type: object + properties: + schemas: + type: object + additionalProperties: + $dynamicRef: '#meta' + responses: + type: object + additionalProperties: + $ref: '#/$defs/response-or-reference' + parameters: + type: object + additionalProperties: + $ref: '#/$defs/parameter-or-reference' + examples: + type: object + additionalProperties: + $ref: '#/$defs/example-or-reference' + requestBodies: + type: object + additionalProperties: + $ref: '#/$defs/request-body-or-reference' + headers: + type: object + additionalProperties: + $ref: '#/$defs/header-or-reference' + securitySchemes: + type: object + additionalProperties: + $ref: '#/$defs/security-scheme-or-reference' + links: + type: object + additionalProperties: + $ref: '#/$defs/link-or-reference' + callbacks: + type: object + additionalProperties: + $ref: '#/$defs/callbacks-or-reference' + pathItems: + type: object + additionalProperties: + $ref: '#/$defs/path-item' + mediaTypes: + type: object + additionalProperties: + $ref: '#/$defs/media-type-or-reference' + patternProperties: + '^(?:schemas|responses|parameters|examples|requestBodies|headers|securitySchemes|links|callbacks|pathItems|mediaTypes)$': + $comment: Enumerating all of the property names in the regex above is necessary for unevaluatedProperties to work as expected + propertyNames: + pattern: '^[a-zA-Z0-9._-]+$' + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + paths: + $comment: https://spec.openapis.org/oas/v3.2#paths-object + type: object + patternProperties: + '^/': + $ref: '#/$defs/path-item' + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + path-item: + $comment: https://spec.openapis.org/oas/v3.2#path-item-object + type: object + properties: + $ref: + type: string + format: uri-reference + summary: + type: string + description: + type: string + servers: + type: array + items: + $ref: '#/$defs/server' + parameters: + $ref: '#/$defs/parameters' + additionalOperations: + type: object + additionalProperties: + $ref: '#/$defs/operation' + propertyNames: + $comment: RFC9110 restricts methods to "1*tchar" in ABNF + pattern: "^[a-zA-Z0-9!#$%&'*+.^_`|~-]+$" + not: + enum: + - GET + - PUT + - POST + - DELETE + - OPTIONS + - HEAD + - PATCH + - TRACE + - QUERY + get: + $ref: '#/$defs/operation' + put: + $ref: '#/$defs/operation' + post: + $ref: '#/$defs/operation' + delete: + $ref: '#/$defs/operation' + options: + $ref: '#/$defs/operation' + head: + $ref: '#/$defs/operation' + patch: + $ref: '#/$defs/operation' + trace: + $ref: '#/$defs/operation' + query: + $ref: '#/$defs/operation' + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + operation: + $comment: https://spec.openapis.org/oas/v3.2#operation-object + type: object + properties: + tags: + type: array + items: + type: string + summary: + type: string + description: + type: string + externalDocs: + $ref: '#/$defs/external-documentation' + operationId: + type: string + parameters: + $ref: '#/$defs/parameters' + requestBody: + $ref: '#/$defs/request-body-or-reference' + responses: + $ref: '#/$defs/responses' + callbacks: + type: object + additionalProperties: + $ref: '#/$defs/callbacks-or-reference' + deprecated: + default: false + type: boolean + security: + type: array + items: + $ref: '#/$defs/security-requirement' + servers: + type: array + items: + $ref: '#/$defs/server' + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + external-documentation: + $comment: https://spec.openapis.org/oas/v3.2#external-documentation-object + type: object + properties: + description: + type: string + url: + type: string + format: uri-reference + required: + - url + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + parameters: + type: array + items: + $ref: '#/$defs/parameter-or-reference' + not: + allOf: + - contains: + type: object + properties: + in: + const: query + required: + - in + - contains: + type: object + properties: + in: + const: querystring + required: + - in + contains: + type: object + properties: + in: + const: querystring + required: + - in + minContains: 0 + maxContains: 1 + + parameter: + $comment: https://spec.openapis.org/oas/v3.2#parameter-object + type: object + properties: + name: + type: string + in: + enum: + - query + - querystring + - header + - path + - cookie + description: + type: string + required: + default: false + type: boolean + deprecated: + default: false + type: boolean + schema: + $dynamicRef: '#meta' + content: + $ref: '#/$defs/content' + minProperties: 1 + maxProperties: 1 + required: + - name + - in + oneOf: + - required: + - schema + - required: + - content + allOf: + - $ref: '#/$defs/examples' + - $ref: '#/$defs/specification-extensions' + - if: + properties: + in: + const: query + then: + properties: + allowEmptyValue: + default: false + type: boolean + - if: + properties: + in: + const: querystring + then: + required: + - content + dependentSchemas: + schema: + properties: + style: + type: string + explode: + type: boolean + allOf: + - $ref: '#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-path' + - $ref: '#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-header' + - $ref: '#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-query' + - $ref: '#/$defs/parameter/dependentSchemas/schema/$defs/styles-for-cookie' + + $defs: + styles-for-path: + if: + properties: + in: + const: path + then: + properties: + name: + pattern: '^[^{}]+$' + style: + default: simple + enum: + - matrix + - label + - simple + required: + const: true + explode: + default: false + allowReserved: + type: boolean + default: false + required: + - required + + styles-for-header: + if: + properties: + in: + const: header + then: + properties: + style: + default: simple + const: simple + explode: + default: false + + styles-for-query: + if: + properties: + in: + const: query + then: + properties: + style: + default: form + enum: + - form + - spaceDelimited + - pipeDelimited + - deepObject + allowReserved: + type: boolean + default: false + $ref: '#/$defs/explode-for-form' + + styles-for-cookie: + if: + properties: + in: + const: cookie + then: + properties: + style: + default: form + enum: + - form + - cookie + explode: + default: true + if: + properties: + style: + const: form + then: + properties: + allowReserved: + type: boolean + default: false + + unevaluatedProperties: false + + parameter-or-reference: + if: + type: object + required: + - $ref + then: + $ref: '#/$defs/reference' + else: + $ref: '#/$defs/parameter' + + request-body: + $comment: https://spec.openapis.org/oas/v3.2#request-body-object + type: object + properties: + description: + type: string + content: + $ref: '#/$defs/content' + required: + default: false + type: boolean + required: + - content + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + request-body-or-reference: + if: + type: object + required: + - $ref + then: + $ref: '#/$defs/reference' + else: + $ref: '#/$defs/request-body' + + content: + $comment: https://spec.openapis.org/oas/v3.2#fixed-fields-10 + type: object + additionalProperties: + $ref: '#/$defs/media-type-or-reference' + propertyNames: + format: media-range + + media-type: + $comment: https://spec.openapis.org/oas/v3.2#media-type-object + type: object + properties: + description: + type: string + schema: + $dynamicRef: '#meta' + itemSchema: + $dynamicRef: '#meta' + encoding: + type: object + additionalProperties: + $ref: '#/$defs/encoding' + prefixEncoding: + type: array + items: + $ref: '#/$defs/encoding' + itemEncoding: + $ref: '#/$defs/encoding' + dependentSchemas: + encoding: + properties: + prefixEncoding: false + itemEncoding: false + allOf: + - $ref: '#/$defs/examples' + - $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + media-type-or-reference: + if: + type: object + required: + - $ref + then: + $ref: '#/$defs/reference' + else: + $ref: '#/$defs/media-type' + + encoding: + $comment: https://spec.openapis.org/oas/v3.2#encoding-object + type: object + properties: + contentType: + type: string + format: media-range + headers: + type: object + additionalProperties: + $ref: '#/$defs/header-or-reference' + style: + enum: + - form + - spaceDelimited + - pipeDelimited + - deepObject + explode: + type: boolean + allowReserved: + type: boolean + encoding: + type: object + additionalProperties: + $ref: '#/$defs/encoding' + prefixEncoding: + type: array + items: + $ref: '#/$defs/encoding' + itemEncoding: + $ref: '#/$defs/encoding' + dependentSchemas: + encoding: + properties: + prefixEncoding: false + itemEncoding: false + style: + properties: + allowReserved: + default: false + $ref: '#/$defs/explode-for-form' + explode: + properties: + style: + default: form + allowReserved: + default: false + allowReserved: + properties: + style: + default: form + $ref: '#/$defs/explode-for-form' + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + responses: + $comment: https://spec.openapis.org/oas/v3.2#responses-object + type: object + properties: + default: + $ref: '#/$defs/response-or-reference' + patternProperties: + '^[1-5](?:[0-9]{2}|XX)$': + $ref: '#/$defs/response-or-reference' + minProperties: 1 + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + if: + $comment: either default, or at least one response code property must exist + patternProperties: + '^[1-5](?:[0-9]{2}|XX)$': false + then: + required: [default] + + response: + $comment: https://spec.openapis.org/oas/v3.2#response-object + type: object + properties: + summary: + type: string + description: + type: string + headers: + type: object + additionalProperties: + $ref: '#/$defs/header-or-reference' + content: + $ref: '#/$defs/content' + links: + type: object + additionalProperties: + $ref: '#/$defs/link-or-reference' + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + response-or-reference: + if: + type: object + required: + - $ref + then: + $ref: '#/$defs/reference' + else: + $ref: '#/$defs/response' + + callbacks: + $comment: https://spec.openapis.org/oas/v3.2#callback-object + type: object + $ref: '#/$defs/specification-extensions' + additionalProperties: + $ref: '#/$defs/path-item' + + callbacks-or-reference: + if: + type: object + required: + - $ref + then: + $ref: '#/$defs/reference' + else: + $ref: '#/$defs/callbacks' + + example: + $comment: https://spec.openapis.org/oas/v3.2#example-object + type: object + properties: + summary: + type: string + description: + type: string + dataValue: true + serializedValue: + type: string + value: true + externalValue: + type: string + format: uri-reference + allOf: + - not: + required: + - value + - externalValue + - not: + required: + - value + - dataValue + - not: + required: + - value + - serializedValue + - not: + required: + - serializedValue + - externalValue + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + example-or-reference: + if: + type: object + required: + - $ref + then: + $ref: '#/$defs/reference' + else: + $ref: '#/$defs/example' + + link: + $comment: https://spec.openapis.org/oas/v3.2#link-object + type: object + properties: + operationRef: + type: string + format: uri-reference + operationId: + type: string + parameters: + $ref: '#/$defs/map-of-strings' + requestBody: true + description: + type: string + server: + $ref: '#/$defs/server' + oneOf: + - required: + - operationRef + - required: + - operationId + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + link-or-reference: + if: + type: object + required: + - $ref + then: + $ref: '#/$defs/reference' + else: + $ref: '#/$defs/link' + + header: + $comment: https://spec.openapis.org/oas/v3.2#header-object + type: object + properties: + description: + type: string + required: + default: false + type: boolean + deprecated: + default: false + type: boolean + schema: + $dynamicRef: '#meta' + content: + $ref: '#/$defs/content' + minProperties: 1 + maxProperties: 1 + oneOf: + - required: + - schema + - required: + - content + dependentSchemas: + schema: + properties: + style: + default: simple + const: simple + explode: + default: false + type: boolean + allOf: + - $ref: '#/$defs/examples' + - $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + header-or-reference: + if: + type: object + required: + - $ref + then: + $ref: '#/$defs/reference' + else: + $ref: '#/$defs/header' + + tag: + $comment: https://spec.openapis.org/oas/v3.2#tag-object + type: object + properties: + name: + type: string + summary: + type: string + description: + type: string + externalDocs: + $ref: '#/$defs/external-documentation' + parent: + type: string + kind: + type: string + required: + - name + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + reference: + $comment: https://spec.openapis.org/oas/v3.2#reference-object + type: object + properties: + $ref: + type: string + format: uri-reference + summary: + type: string + description: + type: string + + schema: + $comment: https://spec.openapis.org/oas/v3.2#schema-object + $dynamicAnchor: meta + type: + - object + - boolean + + security-scheme: + $comment: https://spec.openapis.org/oas/v3.2#security-scheme-object + type: object + properties: + type: + enum: + - apiKey + - http + - mutualTLS + - oauth2 + - openIdConnect + description: + type: string + deprecated: + default: false + type: boolean + required: + - type + allOf: + - $ref: '#/$defs/specification-extensions' + - $ref: '#/$defs/security-scheme/$defs/type-apikey' + - $ref: '#/$defs/security-scheme/$defs/type-http' + - $ref: '#/$defs/security-scheme/$defs/type-http-bearer' + - $ref: '#/$defs/security-scheme/$defs/type-oauth2' + - $ref: '#/$defs/security-scheme/$defs/type-oidc' + unevaluatedProperties: false + + $defs: + type-apikey: + if: + properties: + type: + const: apiKey + then: + properties: + name: + type: string + in: + enum: + - query + - header + - cookie + required: + - name + - in + + type-http: + if: + properties: + type: + const: http + then: + properties: + scheme: + type: string + required: + - scheme + + type-http-bearer: + if: + properties: + type: + const: http + scheme: + type: string + pattern: ^[Bb][Ee][Aa][Rr][Ee][Rr]$ + required: + - type + - scheme + then: + properties: + bearerFormat: + type: string + + type-oauth2: + if: + properties: + type: + const: oauth2 + then: + properties: + flows: + $ref: '#/$defs/oauth-flows' + oauth2MetadataUrl: + type: string + format: uri-reference + required: + - flows + + type-oidc: + if: + properties: + type: + const: openIdConnect + then: + properties: + openIdConnectUrl: + type: string + format: uri-reference + required: + - openIdConnectUrl + + security-scheme-or-reference: + if: + type: object + required: + - $ref + then: + $ref: '#/$defs/reference' + else: + $ref: '#/$defs/security-scheme' + + oauth-flows: + type: object + properties: + implicit: + $ref: '#/$defs/oauth-flows/$defs/implicit' + password: + $ref: '#/$defs/oauth-flows/$defs/password' + clientCredentials: + $ref: '#/$defs/oauth-flows/$defs/client-credentials' + authorizationCode: + $ref: '#/$defs/oauth-flows/$defs/authorization-code' + deviceAuthorization: + $ref: '#/$defs/oauth-flows/$defs/device-authorization' + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + $defs: + implicit: + type: object + properties: + authorizationUrl: + type: string + format: uri-reference + refreshUrl: + type: string + format: uri-reference + scopes: + $ref: '#/$defs/map-of-strings' + required: + - authorizationUrl + - scopes + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + password: + type: object + properties: + tokenUrl: + type: string + format: uri-reference + refreshUrl: + type: string + format: uri-reference + scopes: + $ref: '#/$defs/map-of-strings' + required: + - tokenUrl + - scopes + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + client-credentials: + type: object + properties: + tokenUrl: + type: string + format: uri-reference + refreshUrl: + type: string + format: uri-reference + scopes: + $ref: '#/$defs/map-of-strings' + required: + - tokenUrl + - scopes + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + authorization-code: + type: object + properties: + authorizationUrl: + type: string + format: uri-reference + tokenUrl: + type: string + format: uri-reference + refreshUrl: + type: string + format: uri-reference + scopes: + $ref: '#/$defs/map-of-strings' + required: + - authorizationUrl + - tokenUrl + - scopes + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + device-authorization: + type: object + properties: + deviceAuthorizationUrl: + type: string + format: uri-reference + tokenUrl: + type: string + format: uri-reference + refreshUrl: + type: string + format: uri-reference + scopes: + $ref: '#/$defs/map-of-strings' + required: + - deviceAuthorizationUrl + - tokenUrl + - scopes + $ref: '#/$defs/specification-extensions' + unevaluatedProperties: false + + security-requirement: + $comment: https://spec.openapis.org/oas/v3.2#security-requirement-object + type: object + additionalProperties: + type: array + items: + type: string + + specification-extensions: + $comment: https://spec.openapis.org/oas/v3.2#specification-extensions + patternProperties: + '^x-': true + + examples: + properties: + example: true + examples: + type: object + additionalProperties: + $ref: '#/$defs/example-or-reference' + not: + required: + - example + - examples + + map-of-strings: + type: object + additionalProperties: + type: string + + explode-for-form: + $comment: for encoding objects, and query and cookie parameters, style=form is the default + if: + properties: + style: + const: form + then: + properties: + explode: + default: true + else: + properties: + explode: + default: false diff --git a/lib/scorpio/openapi.rb b/lib/scorpio/openapi.rb index e6c3963e..34c2db80 100644 --- a/lib/scorpio/openapi.rb +++ b/lib/scorpio/openapi.rb @@ -50,6 +50,7 @@ def propertyName autoload(:V3, 'scorpio/openapi/v3_0') autoload(:V3_0, 'scorpio/openapi/v3_0') autoload(:V3_1, 'scorpio/openapi/v3_1') + autoload(:V3_2, 'scorpio/openapi/v3_2') autoload(:SchemaElements, 'scorpio/openapi/schema_elements') end diff --git a/lib/scorpio/openapi/document.rb b/lib/scorpio/openapi/document.rb index 4ecb7b06..69e1a7b6 100644 --- a/lib/scorpio/openapi/document.rb +++ b/lib/scorpio/openapi/document.rb @@ -25,6 +25,8 @@ def new_document(instance, **new_param) Scorpio::OpenAPI::V3_0::Document.new_jsi(instance, **new_param) elsif (instance['openapi'].is_a?(String) && instance['openapi'] =~ /\A3\.1(\.|\z)/) || instance['openapi'] == 3.1 Scorpio::OpenAPI::V3_1.new_document(instance, **new_param) + elsif (instance['openapi'].is_a?(String) && instance['openapi'] =~ /\A3\.2(\.|\z)/) || instance['openapi'] == 3.2 + Scorpio::OpenAPI::V3_2.new_document(instance, **new_param) elsif instance['kind'] == 'discovery#restDescription' Scorpio::Google::RestDescription.new_jsi(instance, register: true, **new_param) else @@ -39,6 +41,34 @@ def new_document(instance, **new_param) def from_instance(instance, **kw) Scorpio.new_document(instance, **kw) end + + # This is pretty much: `document_schema_module.with_dynamic_scope_from(JSI.registry.find(dialect_id))` + # + # However, this also supports a dialect whose meta-schema isn't aware of dynamic scope and doesn't + # have a `$dynamicAnchor: "meta"`, e.g. `jsonSchemaDialect: "http://json-schema.org/draft-07/schema"`. + # + # A schema like {OpenAPI::V3_1::Ext::ExtDocument} exists to `$ref` to + # {OpenAPI::V3_1::Unscoped::Document} with anchor `meta` in dynamic scope, with the + # `$dynamicAnchor: "meta"` schema `$ref`ing to {OpenAPI::V3_1::Ext::MetaSchema}. + # This method obviates the need for such a schema, directly applying dynamic scope. + # + # @api private + # @param document_schema_module [JSI::SchemaModule] + # @param dialect_id [#to_str] + # @return [JSI::SchemaModule] + def document_schema_module_with_meta(document_schema_module, dialect_id) + metaschema = JSI.registry.find(dialect_id) + dynamic_anchor_map = metaschema.jsi_next_schema_dynamic_anchor_map + unless dynamic_anchor_map.key?('meta') + # hax: pretend that the identified meta-schema has `$dynamicAnchor: "meta"`. + # this enables e.g. `jsonSchemaDialect: "http://json-schema.org/draft-07/schema"` to work. + # this is non-API JSI internals. + dynamic_anchor_map = dynamic_anchor_map.merge({ + 'meta' => [metaschema, [].freeze].freeze, + }).freeze + end + document_schema_module.schema.jsi_with_schema_dynamic_anchor_map(dynamic_anchor_map).jsi_schema_module + end end module Descendent @@ -48,44 +78,84 @@ def openapi_document end end + # Configurable attributes set on a document are inherited as configurable attributes + # of each operation of the document (via {OpenAPI::Operation::Configurables}) + # and each request from an operation of the document (via {Request::Configurables}). module Configurables + attr_writer(:scheme) + # see {Request::Configurables#scheme} + def scheme + nil # overridden for v2 + end + + attr_writer(:server) + # see {Request::Configurables#server} + def server + nil # overridden for v3 + end + + attr_writer(:server_variables) + # see {Request::Configurables#server_variables} + def server_variables + nil # overridden for v3 + end + + attr_writer(:base_url) + # see {Request::Configurables#base_url} + def base_url(scheme: self.scheme, server: self.server, server_variables: self.server_variables) + fail(NotImplementedError) # overridden + end + + attr_writer(:request_media_type) + # see {Request::Configurables#media_type} + def request_media_type + fail(NotImplementedError) # overridden + end + attr_writer :request_headers + # see {Request::Configurables#headers} def request_headers return @request_headers if instance_variable_defined?(:@request_headers) {}.freeze end attr_writer :user_agent + # see {Request::Configurables#user_agent} def user_agent return @user_agent if instance_variable_defined?(:@user_agent) Request::DEFAULT_USER_AGENT end attr_writer(:accept) + # see {Request::Configurables#accept} def accept return @accept if instance_variable_defined?(:@accept) nil end attr_writer(:authorization) + # see {Request::Configurables#authorization} def authorization return @authorization if instance_variable_defined?(:@authorization) nil end attr_writer :faraday_builder + # see {Request::Configurables#faraday_builder} def faraday_builder return @faraday_builder if instance_variable_defined?(:@faraday_builder) nil end attr_writer :faraday_adapter + # see {Request::Configurables#faraday_adapter} def faraday_adapter return @faraday_adapter if instance_variable_defined?(:@faraday_adapter) - [Faraday.default_adapter].freeze + Faraday.default_adapter end attr_writer :logger + # see {Request::Configurables#logger} def logger return @logger if instance_variable_defined?(:@logger) (Object.const_defined?(:Rails) && ::Rails.respond_to?(:logger) ? ::Rails.logger : nil) @@ -115,6 +185,7 @@ def each_operation(&block) yield(operation) end end + (path_item['additionalOperations'] || {}).each_value(&block) # only OAS v3.2+ end end @@ -123,45 +194,52 @@ def title end end + # an OAD with a `$self` property that indicates its resource URI + module Document::SelfURI + # overrides JSI::Base#jsi_each_resource_uri_compute. + # this is more into JSI internals than I prefer but currently this is the way to accomplish this. + private def jsi_each_resource_uri_compute + if respond_to?(:to_hash) && key?('$self') + yield jsi_base_uri ? jsi_base_uri.join(jsi_node_content['$self']) : JSI::URI[jsi_node_content['$self']] + end + super + end + end + module Document module V3Methods - module Configurables - def scheme - nil - end - attr_writer :server + # @private (doc on Configurables) def server return @server if instance_variable_defined?(:@server) if servers.respond_to?(:to_ary) && servers.size == 1 servers.first else - nil + raise(ConfigError, "configuration required: server (see https://rubydoc.info/gems/scorpio/Scorpio/Request/Configurables#server-instance_method )") end end - attr_writer :server_variables + + # @private (doc on Configurables) def server_variables return @server_variables if instance_variable_defined?(:@server_variables) {}.freeze end - attr_writer :base_url + + # @private (doc on Configurables) def base_url(scheme: nil, server: self.server, server_variables: self.server_variables) return @base_url if instance_variable_defined?(:@base_url) - if server - server.expanded_url(server_variables) - end + server.expanded_url(server_variables) end - attr_accessor(:request_media_type) - end - include Configurables + # @private (doc on Configurables) + attr_reader(:request_media_type) + include(OpenAPI::Document) end end module Document module V2Methods - module Configurables - attr_writer :scheme + # @private (doc on Configurables) def scheme return @scheme if instance_variable_defined?(:@scheme) if schemes.nil? @@ -172,17 +250,7 @@ def scheme end end - def server - nil - end - def server_variables - nil - end - - attr_writer :base_url - # the base url to which paths are appended. - # by default this looks at the openapi document's schemes, picking https or http first. - # it looks at the openapi_document's host and basePath. + # @private (doc on Configurables) def base_url(scheme: self.scheme, server: nil, server_variables: nil) return @base_url if instance_variable_defined?(:@base_url) if host && scheme @@ -191,10 +259,12 @@ def base_url(scheme: self.scheme, server: nil, server_variables: nil) host: host, path: basePath, ).freeze + else + raise(ConfigError, "configuration required: base_url (see https://rubydoc.info/gems/scorpio/Scorpio/Request/Configurables#base_url-instance_method )") end end - attr_writer :request_media_type + # @private (doc on Configurables) def request_media_type return @request_media_type if instance_variable_defined?(:@request_media_type) if consumes.respond_to?(:to_ary) @@ -203,8 +273,7 @@ def request_media_type nil end end - end - include Configurables + include(OpenAPI::Document) end end diff --git a/lib/scorpio/openapi/operation.rb b/lib/scorpio/openapi/operation.rb index c36bdaa7..52d937c1 100644 --- a/lib/scorpio/openapi/operation.rb +++ b/lib/scorpio/openapi/operation.rb @@ -6,50 +6,88 @@ module OpenAPI # # Scorpio::OpenAPI::Operation is a module common to V2 and V3 operations. module Operation + # Configurable attributes set on an operation override configurable attributes inherited from + # its OpenAPI document (via {OpenAPI::Document::Configurables}) and are inherited as + # configurable attributes of each request from the operation (via {Request::Configurables}). module Configurables + attr_writer(:scheme) + # see {Request::Configurables#scheme} + def scheme + return @scheme if instance_variable_defined?(:@scheme) + openapi_document.scheme + end + + attr_writer(:server) + # see {Request::Configurables#server} + def server + return @server if instance_variable_defined?(:@server) + openapi_document.server + end + + attr_writer(:server_variables) + # see {Request::Configurables#server_variables} + def server_variables + return @server_variables if instance_variable_defined?(:@server_variables) + openapi_document.server_variables + end + attr_writer :base_url + # see {Request::Configurables#base_url} def base_url(scheme: self.scheme, server: self.server, server_variables: self.server_variables) return @base_url if instance_variable_defined?(:@base_url) openapi_document.base_url(scheme: scheme, server: server, server_variables: server_variables) end + attr_writer(:request_media_type) + # see {Request::Configurables#media_type} + def request_media_type + fail(NotImplementedError) # overridden + end + attr_writer :request_headers + # see {Request::Configurables#headers} def request_headers return @request_headers if instance_variable_defined?(:@request_headers) openapi_document.request_headers end attr_writer :user_agent + # see {Request::Configurables#user_agent} def user_agent return @user_agent if instance_variable_defined?(:@user_agent) openapi_document.user_agent end attr_writer(:accept) + # see {Request::Configurables#accept} def accept return @accept if instance_variable_defined?(:@accept) openapi_document.accept end attr_writer(:authorization) + # see {Request::Configurables#authorization} def authorization return @authorization if instance_variable_defined?(:@authorization) openapi_document.authorization end attr_writer :faraday_builder + # see {Request::Configurables#faraday_builder} def faraday_builder return @faraday_builder if instance_variable_defined?(:@faraday_builder) openapi_document.faraday_builder end attr_writer :faraday_adapter + # see {Request::Configurables#faraday_adapter} def faraday_adapter return @faraday_adapter if instance_variable_defined?(:@faraday_adapter) openapi_document.faraday_adapter end attr_writer :logger + # see {Request::Configurables#logger} def logger return @logger if instance_variable_defined?(:@logger) openapi_document.logger @@ -73,15 +111,19 @@ def v2? # @return [String] def path_template_str return @path_template_str if instance_variable_defined?(:@path_template_str) + @path_template_str = path_template_str_find || raise(NotImplementedError, -"could not determine path template for operation: #{self}") + end + + # @return [String, nil] + private def path_template_str_find path_item = jsi_ancestor_nodes.detect { |n| n.is_a?(Scorpio::OpenAPI::PathItem) } - @path_template_str = path_item && path_item.jsi_ptr.tokens.last + path_item && path_item.jsi_ptr.tokens.last end # the path as an Addressable::Template # @return [Addressable::Template] def path_template return @path_template if instance_variable_defined?(:@path_template) - return(@path_template = nil) if !path_template_str @path_template = Addressable::Template.new(path_template_str) end @@ -102,7 +144,6 @@ def uri_template(base_url: self.base_url) # @return [String] def http_method return @http_method if instance_variable_defined?(:@http_method) - return(@http_method = nil) unless jsi_parent_node.is_a?(Scorpio::OpenAPI::PathItem) @http_method = jsi_ptr.tokens.last end @@ -147,7 +188,7 @@ def tagged?(tag_name) # a short identifier for this operation appropriate for an error message # @return [String] def human_id - operationId || -"path: #{path_template_str}, method: #{http_method}" + operationId || -"path: #{path_template_str_find}, method: #{http_method}" end # @param status [String, Integer] @@ -245,31 +286,13 @@ def each_link_page(**configuration, &block) private def jsi_object_group_text - [*super, http_method, path_template_str].compact.freeze + [*super, http_method, path_template_str_find].compact.freeze end end module Operation module V3Methods - module Configurables - def scheme - # not applicable; for OpenAPI v3, scheme is specified by servers. - nil - end - - attr_writer :server - def server - return @server if instance_variable_defined?(:@server) - openapi_document.server - end - - attr_writer :server_variables - def server_variables - return @server_variables if instance_variable_defined?(:@server_variables) - openapi_document.server_variables - end - - attr_writer :request_media_type + # @private (doc on Configurables) def request_media_type return @request_media_type if instance_variable_defined?(:@request_media_type) if requestBody && requestBody['content'] @@ -278,8 +301,7 @@ def request_media_type openapi_document.request_media_type end end - end - include Configurables + include(OpenAPI::Operation) # @return [JSI::Schema] @@ -310,9 +332,9 @@ def request_schemas def response_schema(status: , media_type: ) oa_response = self.oa_response(status: status) || return oa_media_types = oa_response['content'] || return # Scorpio::OpenAPI::V3_*::MediaTypes - oa_media_type = oa_media_types[media_type] # Scorpio::OpenAPI::V3_*::MediaType - oa_media_type ||= oa_media_types[-"#{::Ur::ContentType.new(media_type).type}/*"] - oa_media_type ||= oa_media_types['*/*'] || return + oa_media_type = oa_media_types[media_type] || + oa_media_types[-"#{::Ur::ContentType.new(media_type).type}/*"] || + oa_media_types['*/*'] || return # Scorpio::OpenAPI::V3_*::MediaType oa_schema = oa_media_type['schema'] || return # JSI::Schema, Scorpio::OpenAPI::V3_*::Schema JSI::Schema.ensure_schema(oa_schema) end @@ -338,20 +360,7 @@ def response_schemas module Operation module V2Methods - module Configurables - attr_writer :scheme - def scheme - return @scheme if instance_variable_defined?(:@scheme) - openapi_document.scheme - end - def server - nil - end - def server_variables - nil - end - - attr_writer :request_media_type + # @private (doc on Configurables) def request_media_type return @request_media_type if instance_variable_defined?(:@request_media_type) if key?('consumes') @@ -360,8 +369,7 @@ def request_media_type openapi_document.request_media_type end end - end - include Configurables + include(OpenAPI::Operation) # the body parameter diff --git a/lib/scorpio/openapi/server.rb b/lib/scorpio/openapi/server.rb index b16a809c..f234b846 100644 --- a/lib/scorpio/openapi/server.rb +++ b/lib/scorpio/openapi/server.rb @@ -27,7 +27,15 @@ def expanded_url(given_server_variables) server_variables = given_server_variables end template = Addressable::Template.new(url) - template.expand(server_variables).freeze + expanded_url = template.expand(server_variables).freeze + if expanded_url.relative? + raise(Error, -"server URL is relative with no base URL. server: #{inspect}") if !openapi_document.jsi_base_uri + # note: this uses the OAD jsi_base_uri, not this server object's, because OAS 3.2 + # excludes $self uri as the base for API urls including server url + # https://spec.openapis.org/oas/v3.2.0.html#relative-references-in-api-urls + expanded_url = openapi_document.jsi_base_uri.join(expanded_url) + end + expanded_url end end end diff --git a/lib/scorpio/openapi/tag.rb b/lib/scorpio/openapi/tag.rb index febf68f8..515b51e3 100644 --- a/lib/scorpio/openapi/tag.rb +++ b/lib/scorpio/openapi/tag.rb @@ -18,6 +18,25 @@ def each_operation(&block) yield(op) if op.tags.respond_to?(:to_ary) && op.tags.include?(name) end end + + # each operation tagged with this tag, a child tag of this, or any further descendent tag. + # @yield [OpenAPI::Operation] + def each_descendent_tag_operation(&block) + return(to_enum(__method__)) unless block + each_operation(&block) + child_tags.each { |tag| tag.each_descendent_operation(&block) } + nil + end + + # @return [OpenAPI::Tag, nil] + def parent_tag + self['parent'] ? openapi_document.tags.named(self['parent']) : nil + end + + # @return [Enumerable] + def child_tags + openapi_document.tags.select { |t| t['parent'] == name } + end end module Tags @@ -26,6 +45,11 @@ module Tags def named(name) detect { |tag| tag.name == name } end + + # @return [Enumerable] + def with_kind(kind) + select { |tag| tag['kind'] == kind } + end end end end diff --git a/lib/scorpio/openapi/v3_1.rb b/lib/scorpio/openapi/v3_1.rb index 2c847d61..ae4d920b 100644 --- a/lib/scorpio/openapi/v3_1.rb +++ b/lib/scorpio/openapi/v3_1.rb @@ -4,10 +4,13 @@ module Scorpio module OpenAPI module V3_1 class << self - attr_accessor(:document_schema_modules_by_dialect_id) + attr_reader(:document_schema_modules_by_dialect_id) end - self.document_schema_modules_by_dialect_id = {} + @document_schema_modules_by_dialect_id = Hash.new do |h, dialect_id| + document_schema_module = OpenAPI::Document.document_schema_module_with_meta(Unscoped::Document, dialect_id) + h[dialect_id] = set_up_document_schema_module(document_schema_module) + end def self.document_name_subschemas(document_schema_module, namespace) namespace.const_set(:Info, document_schema_module.defs['info']) @@ -58,40 +61,13 @@ def self.set_up_document_schema_module(document_schema_module) document_schema_module end - # This is pretty much: `Unscoped::Document.with_dynamic_scope_from(JSI.registry.find(dialect_id))` - # plus {.set_up_document_schema_module}. - # - # However, this also supports a dialect whose meta-schema isn't aware of dynamic scope and doesn't - # have a `$dynamicAnchor: "meta"`, e.g. `jsonSchemaDialect: "http://json-schema.org/draft-07/schema"`. - # - # A schema like {Ext::ExtDocument} exists to `$ref` to {Unscoped::Document} with anchor `meta` - # in dynamic scope, with the `$dynamicAnchor: "meta"` schema `$ref`ing to {Ext::MetaSchema}. - # This method obviates the need for such a schema, directly applying dynamic scope. - def self.document_schema_module_by_dialect_id(dialect_id) - dialect_uri = JSI::Util.uri(dialect_id) - document_schema_modules_by_dialect_id[dialect_uri] ||= begin - metaschema = JSI.registry.find(dialect_uri) - dynamic_anchor_map = metaschema.jsi_next_schema_dynamic_anchor_map - unless dynamic_anchor_map.key?('meta') - # hax: pretend that the identified meta-schema has `$dynamicAnchor: "meta"` - # this enables e.g. `jsonSchemaDialect: "http://json-schema.org/draft-07/schema"` to work - # this is non-API JSI internals. - dynamic_anchor_map = dynamic_anchor_map.merge({ - 'meta' => [metaschema, [].freeze].freeze, - }).freeze - end - document_schema = Unscoped::Document.schema.jsi_with_schema_dynamic_anchor_map(dynamic_anchor_map) - set_up_document_schema_module(document_schema.jsi_schema_module) - end - end - # Instantiates `instance` v3.1 OAD with schemas of the dialect indicated by `jsonSchemaDialect` # @param instance [#to_hash] # @return [JSI::Base + Scorpio::OpenAPI::V3_1::Document] def self.new_document(instance, **new_param) #jsonSchemaDialect = Scorpio::OpenAPI::V3_1::Unscoped::Document.new_jsi(instance, **new_param).jsonSchemaDialect(use_default: true) jsonSchemaDialect = instance.fetch('jsonSchemaDialect') { Unscoped::Document.properties['jsonSchemaDialect'].default } - document_schema_module = document_schema_module_by_dialect_id(jsonSchemaDialect) + document_schema_module = document_schema_modules_by_dialect_id[jsonSchemaDialect] document_schema_module.new_jsi(instance, **new_param) end @@ -215,7 +191,7 @@ module Ext::Document set_up_document_schema_module(Ext::Document) document_name_subschemas(Ext::Document, Ext) - # note: without this mapping set, document_schema_module_by_dialect_id(Ext::MetaSchema.schema_uri) + # note: without this mapping set, document_schema_modules_by_dialect_id[Ext::MetaSchema.schema_uri] # would be Unscoped::Document.with_dynamic_scope_from(Ext::Unscoped::MetaSchema) # instead of Unscoped::Document.with_dynamic_scope_from(Ext::ExtDocument) # schemas in OADs with this jsonSchemaDialect would have the right dialect, but diff --git a/lib/scorpio/openapi/v3_2.rb b/lib/scorpio/openapi/v3_2.rb new file mode 100644 index 00000000..50950e7b --- /dev/null +++ b/lib/scorpio/openapi/v3_2.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +module Scorpio + module OpenAPI + module V3_2 + class << self + attr_reader(:document_schema_modules_by_dialect_id) + end + + @document_schema_modules_by_dialect_id = Hash.new do |h, dialect_id| + document_schema_module = OpenAPI::Document.document_schema_module_with_meta(Unscoped::Document, dialect_id) + h[dialect_id] = set_up_document_schema_module(document_schema_module) + end + + def self.document_name_subschemas(document_schema_module, namespace) + namespace.const_set(:Info, document_schema_module.defs['info']) + namespace.const_set(:Contact, document_schema_module.defs['contact']) + namespace.const_set(:License, document_schema_module.defs['license']) + namespace.const_set(:Server, document_schema_module.defs['server']) + namespace.const_set(:ServerVariable, document_schema_module.defs['server-variable']) + namespace.const_set(:Components, document_schema_module.defs['components']) + namespace.const_set(:Paths, document_schema_module.defs['paths']) + namespace.const_set(:PathItem, document_schema_module.defs['path-item']) + namespace.const_set(:Operation, document_schema_module.defs['operation']) + namespace.const_set(:ExternalDocumentation, document_schema_module.defs['external-documentation']) + namespace.const_set(:Parameters, document_schema_module.defs['parameters']) + namespace.const_set(:Parameter, document_schema_module.defs['parameter']) + namespace.const_set(:RequestBody, document_schema_module.defs['request-body']) + namespace.const_set(:Content, document_schema_module.defs['content']) + namespace.const_set(:MediaType, document_schema_module.defs['media-type']) + namespace.const_set(:Encoding, document_schema_module.defs['encoding']) + namespace.const_set(:Responses, document_schema_module.defs['responses']) + namespace.const_set(:Response, document_schema_module.defs['response']) + namespace.const_set(:Callbacks, document_schema_module.defs['callbacks']) + namespace.const_set(:Example, document_schema_module.defs['example']) + namespace.const_set(:Link, document_schema_module.defs['link']) + namespace.const_set(:Header, document_schema_module.defs['header']) + namespace.const_set(:Tag, document_schema_module.defs['tag']) + namespace.const_set(:Reference, document_schema_module.defs['reference']) + namespace.const_set(:Schema, document_schema_module.defs['schema']) + namespace.const_set(:SecurityScheme, document_schema_module.defs['security-scheme']) + namespace.const_set(:OAuthFlows, document_schema_module.defs['oauth-flows']) + namespace.const_set(:SecurityRequirement, document_schema_module.defs['security-requirement']) + namespace.const_set(:Examples, document_schema_module.defs['examples']) + namespace.const_set(:MapOfStrings, document_schema_module.defs['map-of-strings']) + namespace.const_set(:ExplodeForForm, document_schema_module.defs['explode-for-form']) + namespace.const_set(:SpecificationExtension, document_schema_module.defs['specification-extensions'].patternProperties["^x-"]) + end + + def self.set_up_document_schema_module(document_schema_module) + document_schema_module.include(OpenAPI::V3_2::Document) + document_schema_module.defs['response'].include(OpenAPI::Response) + document_schema_module.defs['operation'].include(OpenAPI::Operation::V3Methods) + document_schema_module.defs['reference'].include(OpenAPI::Reference) + document_schema_module.defs['tag'].include(OpenAPI::Tag) + document_schema_module.defs['server'].include(OpenAPI::Server) + document_schema_module.defs['paths'].include(OpenAPI::Paths) + document_schema_module.defs['path-item'].include(OpenAPI::PathItem) + document_schema_module.defs['path-item'].include(OpenAPI::Reference) + document_schema_module.defs['security-scheme'].include(OpenAPI::SecurityScheme) + + document_schema_module + end + + # Instantiates `instance` v3.2 OAD with schemas of the dialect indicated by `jsonSchemaDialect` + # @param instance [#to_hash] + # @return [JSI::Base + Scorpio::OpenAPI::V3_2::Document] + def self.new_document(instance, **new_param) + #jsonSchemaDialect = Scorpio::OpenAPI::V3_2::Unscoped::Document.new_jsi(instance, **new_param).jsonSchemaDialect(use_default: true) + jsonSchemaDialect = instance.fetch('jsonSchemaDialect') { Unscoped::Document.properties['jsonSchemaDialect'].default } + document_schema_module = document_schema_modules_by_dialect_id[jsonSchemaDialect] + + document_schema_module.new_jsi(instance, **new_param) + end + + + module Document + include(OpenAPI::Document::V3Methods) + include(OpenAPI::Document::SelfURI) + end + + + # namespace + module Unscoped + end + + Unscoped::Document = JSI.new_schema_module( + YAML.safe_load(Scorpio.root.join('documents/spec.openapis.org/oas/3.2/schema.yaml').read), + ) + # Schema module: describes an OpenAPI document, but not normally instantiated. + # + # This document schema has no dynamic scope pointing `$dynamicAnchor: "meta"` to a real + # meta-schema. Schemas in the document described by this are just `type: [object, boolean]`, + # have no dialect, and are not usable schemas. + # + # - $id: `https://spec.openapis.org/oas/3.2/schema/2025-11-23` + module Unscoped::Document + end + + + # "Ext" is abbreviation for the "OpenAPI extension schema dialect" that extends JSON Schema draft 2020-12 + # and defines keywords: `discriminator`, `example`, `externalDocs`, `xml`. + # This module is a namespace for that. + module Ext + end + + # vocabulary for implementation of keywords: `discriminator`, `example`, `externalDocs`, `xml` + Ext::VOCAB = JSI::Schema::Vocabulary.new( + id: "https://spec.openapis.org/oas/3.2/vocab/base", + elements: [ + # TODO: + # - discriminator + # - example + # - externalDocs + # - xml + ], + ) + JSI.registry.register_vocabulary(Ext::VOCAB) + + + Ext::ExtDocument = JSI.new_schema_module( + YAML.safe_load(Scorpio.root.join('documents/spec.openapis.org/oas/3.2/schema-base.yaml').read), + ) + # Schema module: Describes an OAD with schemas of the OpenAPI extension schema dialect. + # This exists to dynamically scope the `meta` anchor + # for {Unscoped::Document} `` + # to {Ext::MetaSchema} `` + # via `<#/$defs/schema>` {Ext::ExtDocument::Schema}. + # + # - $id: `https://spec.openapis.org/oas/3.2/schema-base/2025-11-23` + # - $ref: {Ext::Document} `` + # - $dynamicAnchor: `meta` in `/$defs/schema` ({Ext::ExtDocument::Schema}) + # - properties: jsonSchemaDialect const {Ext::MetaSchema} `` + module Ext::ExtDocument + end + + Ext::ExtDocument::Schema = Ext::ExtDocument["$defs"]["schema"] + # Schema module: Describes schemas in an Ext::Document + # + # - $dynamicAnchor: `meta` + # - $ref: {Ext::MetaSchema} `` + # - properties: $schema const {Ext::MetaSchema} `` + module Ext::ExtDocument::Schema + end + + + # Some Ext schemas are used with dynamic scope from {Ext::ExtDocument}; Ext::Unscoped namespace + # contains those schemas without that dynamic scope. These are not normally instantiated. + module Ext::Unscoped + end + + Ext::Unscoped::VocabSchema = JSI.new_schema_module( + YAML.safe_load(Scorpio.root.join('documents/spec.openapis.org/oas/3.2/meta.yaml').read), + ) + module Ext::Unscoped::VocabSchema + end + + Ext::VocabSchema = Ext::Unscoped::VocabSchema.with_dynamic_scope_from(Ext::ExtDocument) + # Schema module: vocabulary schema for {Ext::VOCAB} + # + # - $id: `https://spec.openapis.org/oas/3.2/meta/2025-09-17` + # - $dynamicAnchor: `meta` (unused) + # - properties (schema keywords) discriminator, example, externalDocs, xml + module Ext::VocabSchema + end + + Ext::Unscoped::MetaSchema = JSI.new_schema_module( + YAML.safe_load(Scorpio.root.join('documents/spec.openapis.org/oas/3.2/dialect.yaml').read), + ) + module Ext::Unscoped::MetaSchema + end + + Ext::MetaSchema = Ext::Unscoped::MetaSchema.with_dynamic_scope_from(Ext::ExtDocument) + Ext::MetaSchema.describes_schema! + # Schema module: Meta-schema describing schemas within an OpenAPI document with the OpenAPI extension schema dialect + # + # - $id: `https://spec.openapis.org/oas/3.2/dialect/2025-09-17` + # - $dynamicAnchor: `meta` (overridden by dynamic scope with `meta` → {Ext::ExtDocument::Schema}) + # - $vocabulary: + # - The draft/2020-12 vocabularies - core, applicator, validation, etc (required: true) + # - {Ext::VOCAB} `` (required: false) + # - allOf: + # - $ref: {Ext::JSONSchemaDraft202012} `` + # - $ref: {Ext::VocabSchema} `` + module Ext::MetaSchema + end + + Ext::Document = Unscoped::Document.with_dynamic_scope_from(Ext::ExtDocument) + # Schema module: Describes an OpenAPI document containing schemas of the Ext dialect. + # This is {Unscoped::Document}, with dynamic scope pointing `$dynamicAnchor: "meta"` to {Ext::ExtDocument::Schema}. + module Ext::Document + end + + set_up_document_schema_module(Ext::Document) + document_name_subschemas(Ext::Document, Ext) + # note: without this mapping set, document_schema_modules_by_dialect_id[Ext::MetaSchema.schema_uri] + # would be Unscoped::Document.with_dynamic_scope_from(Ext::Unscoped::MetaSchema) + # instead of Unscoped::Document.with_dynamic_scope_from(Ext::ExtDocument) + # schemas in OADs with this jsonSchemaDialect would have the right dialect, but + # Ext::ExtDocument does also validate OAD jsonSchemaDialect and schema $schema properties. + document_schema_modules_by_dialect_id[Ext::MetaSchema.schema_uri] = Ext::ExtDocument + + Ext::JSONSchemaDraft202012 = JSI::JSONSchemaDraft202012.with_dynamic_scope_from(Ext::ExtDocument) + JSI::JSONSchemaDraft202012.name_vocab_schemas(Ext::JSONSchemaDraft202012) + # JSI::JSONSchemaDraft202012, with dynamic scope pointing `$dynamicAnchor: "meta"` to {Ext::ExtDocument::Schema}. + module Ext::JSONSchemaDraft202012 + end + + + module JSONSchemaDraft202012 + end + + JSONSchemaDraft202012::Document = Unscoped::Document.with_dynamic_scope_from(JSI::JSONSchemaDraft202012) + # Describes an OAD with `jsonSchemaDialect: "https://json-schema.org/draft/2020-12/schema"` + module JSONSchemaDraft202012::Document + end + + document_name_subschemas(JSONSchemaDraft202012::Document, JSONSchemaDraft202012) + end + end +end diff --git a/lib/scorpio/request.rb b/lib/scorpio/request.rb index 4963e7b4..40e9322a 100644 --- a/lib/scorpio/request.rb +++ b/lib/scorpio/request.rb @@ -25,49 +25,89 @@ def self.best_media_type(media_types) end end + # Configurable attributes set per request. + # + # Many of these inherit from configurable attributes of the request's + # {#operation} (via {OpenAPI::Operation::Configurables}) and from the operation's + # OpenAPI document (via {OpenAPI::Document::Configurables}), unless overridden. module Configurables attr_writer :path_params + # parameters interpolated into the {Request#path_template} + # @return [#to_hash] def path_params return @path_params if instance_variable_defined?(:@path_params) {}.freeze end attr_writer :query_params + # parameters that compose the query of the request URI + # @return [#to_hash, nil] def query_params return @query_params if instance_variable_defined?(:@query_params) nil end + attr_writer(:querystring) + def querystring + return @querystring if instance_variable_defined?(:@querystring) + nil + end + attr_writer :scheme + # HTTP scheme (OpenAPI v2 only) + # @return [#to_str, nil] def scheme return @scheme if instance_variable_defined?(:@scheme) operation.scheme end attr_writer :server + # API server (OpenAPI v3 only) + # + # If the OpenAPI document's `servers` define _one_ server, defaults to that server. + # @return [OpenAPI::Server, nil] def server return @server if instance_variable_defined?(:@server) operation.server end attr_writer :server_variables + # API server variables, interpolated into the `url` template of the {#server} object (OpenAPI v3 only) + # @return [#to_hash, nil] def server_variables return @server_variables if instance_variable_defined?(:@server_variables) operation.server_variables end attr_writer :base_url + # The base URL to which API operation paths are appended. + # + # For OpenAPI v3, constructed from {Request::Configurables#server} and {Request::Configurables#server_variables}. + # + # For OpenAPI v2, constructed from the document's `host`, `basePath`, and `schemes` or configurable {Request::Configurables#scheme}. + # @return [Addressable::URI] def base_url return @base_url if instance_variable_defined?(:@base_url) operation.base_url(scheme: scheme, server: server, server_variables: server_variables) end - # overriding url will cause all of path_params, query_params, querystring, scheme, server, server_variables, and base_url to be ignored - def url=(url) - @url = JSI::Util.uri(url) + attr_writer(:url) + # The full request URL. + # + # This is constructed using configured {#path_params}, {#query_params}, {#querystring}, {#scheme}, {#server}, {#server_variables}, and {#base_url}. + # Overriding `url` will cause those to be ignored. + # @return [Addressable::URI] + def url + return @url if instance_variable_defined?(:@url) + # we do not use Addressable::URI#join as the paths should just be concatenated, not resolved. + # we use File.join just to deal with consecutive slashes. + Addressable::URI.parse(File.join(base_url, path)).freeze end attr_writer :body + # The request body. This may be set directly as a string, or may be generated from the + # request {#body_object}. + # @return [#to_str, nil] def body return @body if instance_variable_defined?(:@body) if instance_variable_defined?(:@body_object) @@ -90,51 +130,84 @@ def body end end - attr_accessor :body_object + attr_writer(:body_object) + # An object from which the request {#body} is generated, according to the configured + # request {#media_type}. + def body_object + return @body_object if instance_variable_defined?(:@body_object) + nil + end attr_writer :headers + # Request headers + # @return [#to_hash<#to_str, #to_str>] def headers return @headers if instance_variable_defined?(:@headers) operation.request_headers end attr_writer :media_type + # Request media type informs the Content-Type request header and + # the generation of request {#body} from {#body_object}. + # @return [#to_str, nil] def media_type return @media_type if instance_variable_defined?(:@media_type) content_type_header ? content_type_header.media_type : operation.request_media_type end attr_writer :user_agent + # `User-Agent` request header + # + # Defaults to {Request::DEFAULT_USER_AGENT}. + # @return [#to_str, nil] def user_agent return @user_agent if instance_variable_defined?(:@user_agent) operation.user_agent end attr_writer(:accept) + # `Accept` request header + # @return [#to_str, nil] def accept return @accept if instance_variable_defined?(:@accept) operation.accept end attr_writer(:authorization) + # `Authorization` request header + # @return [#to_str, nil] def authorization return @authorization if instance_variable_defined?(:@authorization) operation.authorization end attr_writer :faraday_builder + # A proc/callable to set up the Faraday connection the request will use, + # in particular to configure middleware. This is called with the builder + # object Faraday passes to `Faraday.new` + # + # This should not set the adapter; instead set {#faraday_adapter}. + # @return [#call, nil] def faraday_builder return @faraday_builder if instance_variable_defined?(:@faraday_builder) operation.faraday_builder end attr_writer :faraday_adapter + # Faraday connection adapter. The adapter is specified as a Symbol (e.g. `:net_http`) or + # a class (e.g. `Faraday::Adapter::NetHttp`). `faraday_adapter`'s value is splatted as + # arguments to `Faraday::RackBuilder#adapter` so may be an array with additional arguments. + # + # By default uses `Faraday.default_adapter` which defaults to `:net_http`. def faraday_adapter return @faraday_adapter if instance_variable_defined?(:@faraday_adapter) operation.faraday_adapter end attr_writer :logger + # A logger, only used to set metadata of current logger tags if applicable (Scorpio does not do any logging itself). + # + # Defaults to `Rails.logger`, if that is defined. def logger return @logger if instance_variable_defined?(:@logger) operation.logger @@ -148,7 +221,6 @@ def logger # operation. def initialize(operation, **configuration, &b) @operation = operation - configuration = JSI::Util.stringify_symbol_keys(configuration) configuration.each do |name, value| if Configurables.public_method_defined?(:"#{name}=") public_send(:"#{name}=", value) @@ -203,24 +275,16 @@ def path end path = path_template.expand(path_params) + raise(AmbiguousParameter, "query_params + querystring both specified") if query_params && querystring if query_params path.query_values = query_params end + if querystring + path.query = querystring + end path.freeze end - # the full URL for this request - # @return [Addressable::URI] - def url - return @url if instance_variable_defined?(:@url) - unless base_url - raise(ArgumentError, "no base_url has been specified for request") - end - # we do not use Addressable::URI#join as the paths should just be concatenated, not resolved. - # we use File.join just to deal with consecutive slashes. - Addressable::URI.parse(File.join(base_url, path)).freeze - end - # the value of the request Content-Type header # @return [::Ur::ContentType] def content_type_header @@ -320,6 +384,8 @@ def set_param_from(param_in, name, value) self.path_params = self.path_params.merge(name => value) elsif param_in == 'query' self.query_params = (self.query_params || {}).merge(name => value) + elsif param_in == 'querystring' + self.querystring = value elsif param_in == 'header' self.headers = self.headers.merge(name => value.to_str) elsif param_in == 'cookie' @@ -342,6 +408,8 @@ def get_param_from(param_in, name) path_params[name] elsif param_in == 'query' query_params ? query_params[name] : nil + elsif param_in == 'querystring' + querystring elsif param_in == 'header' _, value = headers.detect { |headername, _| headername.casecmp?(name) } value diff --git a/lib/scorpio/resource_base.rb b/lib/scorpio/resource_base.rb index 1b236f00..93539eb0 100644 --- a/lib/scorpio/resource_base.rb +++ b/lib/scorpio/resource_base.rb @@ -513,6 +513,8 @@ class Container include(mod) end + define_singleton_method(:container_schemas) { schemas } + schemas.each do |schema| include(JSI::SchemaClasses.schema_property_reader_module(schema, conflicting_modules: modules + [Container])) include(JSI::SchemaClasses.schema_property_writer_module(schema, conflicting_modules: modules + [Container])) @@ -536,6 +538,22 @@ def new_container(object, openapi_document_class, options = {}) container_class.new(object, openapi_document_class, options) end + + # @return [String] + def inspect + return super unless respond_to?(:container_schemas) + schema_names = container_schemas.map do |schema| + mod_name = schema.jsi_schema_module_name_from_ancestor + next -"#{mod_name} <#{schema.jsi_resource_uri}>" if mod_name && schema.jsi_resource_uri + mod_name || -"<#{schema.schema_uri || schema.jsi_ptr.uri}>" + end + -"(#{[superclass, *schema_names].join(' + ')})" + end + + # @return [String] + def to_s + inspect + end end end diff --git a/pages/Request_Configuration.md b/pages/Request_Configuration.md index 397ad602..fa08ebe0 100644 --- a/pages/Request_Configuration.md +++ b/pages/Request_Configuration.md @@ -2,7 +2,7 @@ Scorpio aims to offer flexibility in how applications can configure requests. Requests are initiated from an Operation object (e.g. {Scorpio::OpenAPI::Operation#run}), utilizing the Operation and the OpenAPI document that contains it for configuration. Many configurable attributes can be set with varying granularity, on the document (applying to all requests from all operations, unless overridden), on the operation (applying to all requests from that operation), or on the request itself. -Configurable attributes are defined on several modules: for a request, {Scorpio::Request::Configurables}; for an operation, {Scorpio::OpenAPI::Operation::Configurables}; and for a document {Scorpio::OpenAPI::Document::Configurables} and {Scorpio::OpenAPI::Operation::V3Methods::Configurables} or {Scorpio::OpenAPI::Operation::V2Methods::Configurables}. +Configurable attributes are defined and documented on the module {Scorpio::Request::Configurables}. Modules {Scorpio::OpenAPI::Document::Configurables} and {Scorpio::OpenAPI::Operation::Configurables} define configurable attributes of a document and an operation, respectively, and document their relationship to Request configurables. {Scorpio::Request#initialize} and methods that instantiate a request (such as {Scorpio::OpenAPI::Operation#run}) take a keyword hash of configuration, which may include attributes defined on the Configurables module, or parameters defined by the operation (except where parameter names conflict with configurable attributes, or are ambiguous). diff --git a/pages/Security.md b/pages/Security.md index 3b1854b0..372e8586 100644 --- a/pages/Security.md +++ b/pages/Security.md @@ -1,6 +1,6 @@ # API Security -Scorpio does not currently implement an interface for any particular API security mechanism, which an OpenAPI description might specify using an operation's Security Requirement and corresponding Security Scheme. Scorpio offers flexibility in how applications may authenticate using common mechanisms - for more general guidance see {file:Request_Configuration Request Configuration}. +Scorpio does not currently implement an interface for any particular API security mechanism, which an OpenAPI description might specify using an operation's [Security Requirement](https://spec.openapis.org/oas/v3.2.0.html#security-requirement-object) and corresponding [Security Scheme](https://spec.openapis.org/oas/v3.2.0.html#security-scheme-object). Scorpio offers flexibility in how applications may authenticate using common mechanisms - for more general guidance see {file:Request_Configuration Request Configuration}. ### Authorization header diff --git a/test/blog.openapi3_2.yml b/test/blog.openapi3_2.yml new file mode 100644 index 00000000..6966ab76 --- /dev/null +++ b/test/blog.openapi3_2.yml @@ -0,0 +1,151 @@ +openapi: 3.2.0 +servers: +- url: "{scheme}://{host}:{port}/{basePath}" + variables: + scheme: + default: https + host: + default: blog.scorpio + port: + default: '443' + basePath: + enum: + - v1 + default: v1 +info: + title: Scorpio Blog + description: REST service for the Scorpio Blog + version: '' + contact: {} +tags: +- name: articles + description: articles +paths: + /articles: + get: + tags: + - articles + operationId: articles.index + responses: + default: + description: default response + content: + '*/*': + schema: + type: array + items: + $ref: '#/components/schemas/article' + post: + tags: + - articles + operationId: articles.post + responses: + 2XX: + description: ok + content: + 'application/*': + schema: + $ref: '#/components/schemas/article' + 4XX: + description: error + content: + application/json: + schema: + $ref: '#/components/schemas/error' + requestBody: + $ref: '#/components/requestBodies/article' + /articles_with_root: + get: + tags: + - articles + operationId: articles.index_with_root + parameters: + - name: note + in: header + responses: + default: + description: default response + content: + application/json: + schema: + type: object + properties: + articles: + type: array + items: + $ref: '#/components/schemas/article' + best_article: + $ref: '#/components/schemas/article' + version: + type: string + '/articles/{id}': + get: + tags: + - articles + operationId: articles.read + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + default: + description: default response + content: + application/json: + schema: + $ref: '#/components/schemas/article' + patch: + tags: + - articles + operationId: articles.patch + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + 2XX: + description: ok + content: + application/json: + schema: + $ref: '#/components/schemas/article' + 4XX: + description: error + content: + application/json: + schema: + $ref: '#/components/schemas/error' + requestBody: + $ref: '#/components/requestBodies/article' + /clean: + post: + tags: + - clean + operationId: clean + responses: + default: + description: default response +components: + requestBodies: + article: + content: + application/json: + schema: + $ref: '#/components/schemas/article' + required: true + schemas: + article: + type: object + properties: + id: + type: integer + title: + type: string + author_id: + type: integer + error: + title: error diff --git a/test/openapi_document_test.rb b/test/openapi_document_test.rb new file mode 100644 index 00000000..61e12c5c --- /dev/null +++ b/test/openapi_document_test.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true +require_relative 'test_helper' + +describe("OpenAPI::Document") do + describe("v3.2 base URI, $self") do + it("identifies") do + oad_self_abs = Scorpio::OpenAPI::Document.from_instance(YAML.safe_load(<<~YAML + openapi: 3.2.0 + $self: "tag:6a/self" + components: + schemas: + z: + $id: z + YAML + ), register: true) + assert_equal(oad_self_abs, JSI.registry.find('tag:6a/self')) + assert_equal(oad_self_abs.components.schemas['z'], JSI.registry.find('tag:6a/z')) + + oad_self_rel_base = Scorpio::OpenAPI::Document.from_instance(YAML.safe_load(<<~YAML + openapi: 3.2.0 + $self: "base/self" + components: + schemas: + z: + $id: z + YAML + ), register: true, base_uri: 'tag:6b/base') + assert_equal(oad_self_rel_base, JSI.registry.find('tag:6b/base/self')) + assert_equal(oad_self_rel_base.components.schemas['z'], JSI.registry.find('tag:6b/base/z')) + + oad_self_rel_root = Scorpio::OpenAPI::Document.from_instance(YAML.safe_load(<<~YAML + openapi: 3.2.0 + $self: "root/self" + components: + schemas: + z: + $id: z + YAML + ), register: true, root_uri: 'tag:6c/root') + assert_equal(oad_self_rel_root, JSI.registry.find('tag:6c/root')) + assert_equal(oad_self_rel_root, JSI.registry.find('tag:6c/root/self')) + assert_equal(oad_self_rel_root.components.schemas['z'], JSI.registry.find('tag:6c/root/z')) + end + + it("agrees with OAS v3.2 Appendix F.1 Base URI Within Content") do + # https://spec.openapis.org/oas/v3.2.0.html#base-uri-within-content + oad_f1 = Scorpio::OpenAPI::Document.from_instance(YAML.safe_load(<<~YAML + openapi: 3.2.0 + $self: https://example.com/api/openapi + info: + title: Example API + version: 1.0 + paths: + /foo: + get: + requestBody: + $ref: "shared/foo#/components/requestBodies/Foo" + YAML + ), register: true, root_uri: 'file://home/someone/src/api/openapi.yaml') + oad_f1foo = Scorpio::OpenAPI::Document.from_instance(YAML.safe_load(<<~YAML + openapi: 3.2.0 + $self: https://example.com/api/shared/foo + info: + title: Shared components for all APIs + version: 1.0 + components: + requestBodies: + Foo: + content: + application/json: + schema: + $ref: ../schemas/foo + schemas: + Foo: + $id: https://example.com/api/schemas/foo + properties: + bar: + $ref: bar + Bar: + $id: https://example.com/api/schemas/bar + type: string + YAML + ), register: true, root_uri: 'https://git.example.com/shared/blob/main/shared/foo.yaml') + assert_equal(oad_f1foo.components.requestBodies['Foo'], oad_f1.paths['/foo'].get.requestBody.resolve) + assert_equal(oad_f1foo.components.schemas['Foo'], oad_f1foo.components.requestBodies['Foo'].content['application/json'].schema.schema_ref.resolve) + assert_equal(oad_f1foo.components.schemas['Bar'], oad_f1foo.components.schemas['Foo'].properties['bar'].schema_ref.resolve) + end + + it("agrees with OAS v3.2 Appendix F.2 Base URI From Encapsulating Entity") do + # https://spec.openapis.org/oas/v3.2.0.html#base-uri-from-encapsulating-entity + oad_f2 = Scorpio::OpenAPI::Document.from_instance(YAML.safe_load(<<~YAML + openapi: 3.2.0 + info: + title: Example API + version: 1.0 + externalDocs: + url: docs.html + components: + requestBodies: + Foo: + content: + application/json: + schema: + $ref: "#/components/schemas/Foo" + schemas: + Foo: + properties: + bar: + $ref: schemas/bar + YAML + ), register: true, root_uri: 'https://example.com/api/openapi.yaml') + schema_f2bar = JSI::JSONSchemaDraft202012.new_schema({"type": "string"}, root_uri: 'https://example.com/api/schemas/bar') + assert_equal(oad_f2.components.schemas['Foo'], oad_f2.components.requestBodies['Foo'].content['application/json'].schema.schema_ref.resolve) + assert_equal(schema_f2bar, oad_f2.components.schemas['Foo'].properties['bar'].schema_ref.resolve) + end + + # F.3 seems redundant with F.2. F.4 is not relevant. + + it("agrees with OAS v3.2 Appendix F.5 Resolving Relative $self and $id") do + # https://spec.openapis.org/oas/v3.2.0.html#resolving-relative-self-and-id + oad_f5 = Scorpio::OpenAPI::Document.from_instance(YAML.safe_load(<<~YAML + openapi: 3.2.0 + $self: /api/openapi + info: + title: Example API + version: 1.0 + paths: + /foo: + get: + requestBody: + $ref: "shared/foo#/components/requestBodies/Foo" + YAML + ), register: true, root_uri: 'https://staging.example.com/api/openapi') + oad_f5foo = Scorpio::OpenAPI::Document.from_instance(YAML.safe_load(<<~YAML + openapi: 3.2.0 + $self: /api/shared/foo + info: + title: Shared components for all APIs + version: 1.0 + components: + requestBodies: + Foo: + content: + application/json: + schema: + $ref: ../schemas/foo + schemas: + Foo: + $id: /api/schemas/foo + properties: + bar: + $ref: bar + Bar: + $id: /api/schemas/bar + type: string + YAML + ), register: true, root_uri: 'https://staging.example.com/api/shared/foo') + assert_equal(oad_f5, JSI.registry.find('https://staging.example.com/api/openapi')) + assert_equal(oad_f5foo, JSI.registry.find('https://staging.example.com/api/shared/foo')) + assert_equal(oad_f5foo.components.schemas['Foo'], JSI.registry.find('https://staging.example.com/api/schemas/foo')) + assert_equal(oad_f5foo.components.schemas['Bar'], JSI.registry.find('https://staging.example.com/api/schemas/bar')) + assert_equal(oad_f5foo.components.requestBodies['Foo'], oad_f5.paths['/foo'].get.requestBody.resolve) + assert_equal(oad_f5foo.components.schemas['Foo'], oad_f5foo.components.requestBodies['Foo'].content['application/json'].schema.schema_ref.resolve) + assert_equal(oad_f5foo.components.schemas['Bar'], oad_f5foo.components.schemas['Foo'].properties['bar'].schema_ref.resolve) + end + end +end diff --git a/test/openapi_servers_test.rb b/test/openapi_servers_test.rb new file mode 100644 index 00000000..b2e5718f --- /dev/null +++ b/test/openapi_servers_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require_relative('test_helper') + +describe("OpenAPI::Server") do + describe("#expanded_url") do + let(:oad_content) do + YAML.load(<<~YAML + openapi: 3.0.0 + servers: + - url: "{basePath}" + variables: + basePath: + enum: + - v1 + default: v1 + YAML + ) + end + + it("expands url from document base uri") do + oad = Scorpio::OpenAPI::Document.from_instance(oad_content, base_uri: 'http://47z') + assert_equal('http://47z/v1', oad.base_url.to_s) + # relative server url with no base + assert_raises(Scorpio::OpenAPI::Error) { Scorpio::OpenAPI::Document.from_instance(oad_content).base_url } + end + end +end diff --git a/test/request_test.rb b/test/request_test.rb index cc333262..12f795ed 100644 --- a/test/request_test.rb +++ b/test/request_test.rb @@ -43,4 +43,48 @@ assert_raises(Scorpio::AmbiguousParameter) { oad.operations.first.build_request(a: 'A') } end end + + describe("querystring") do + it("sets") do + oad = Scorpio::OpenAPI::Document.from_instance(YAML.safe_load(<<~YAML + openapi: 3.2.0 + paths: + '/': + get: + parameters: + - name: param1 + in: querystring + YAML + )) + + request = oad.operations.first.build_request(param1: 'x') + assert_equal('x', request.get_param('param1')) + assert_equal('x', request.querystring) + assert_equal('/?x', request.path.to_s) + end + + it("with in: query param") do + oad = Scorpio::OpenAPI::Document.from_instance(YAML.safe_load(<<~YAML + openapi: 3.2.0 + paths: + '/': + get: + parameters: + - name: param1 + in: querystring + - name: param2 + in: query + YAML + )) + + # it builds the request and sets querystring and query_params, but errors when constructing Request#path. + # in future might change Request#query_params= and Request#querystring= to raise instead. + request = oad.operations.first.build_request(param1: 'x', param2: 'y') + assert_equal('x', request.get_param('param1')) + assert_equal('x', request.querystring) + assert_equal('y', request.get_param('param2')) + assert_equal({'param2' => 'y'}, request.query_params) + assert_raises(Scorpio::AmbiguousParameter) { request.path } + end + end end diff --git a/test/resource_base_test.rb b/test/resource_base_test.rb index ae07aef6..6207bdf4 100644 --- a/test/resource_base_test.rb +++ b/test/resource_base_test.rb @@ -191,3 +191,13 @@ def resource(represented_schemas: nil, tag_name: nil) end end end + +describe("ResourceBase::Container class") do + describe(".to_s") do + it("shows schemas") do + Article.post('title' => "!") + articles = Article.index_with_root + assert_match(%r(\A\(Scorpio::ResourceBase::Container \+ <[^>]*>\)\z), articles.articles.class.to_s) + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 1abd031e..b4c31bfa 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -55,6 +55,10 @@ class ScorpioSpec < Minitest::Spec # :nocov: end + before do + JSI.registry = JSI::DEFAULT_REGISTRY.dup + end + after do BlogClean.clean end