From 4f2ceaa17b966c60a010e51c109ae544e3ab1f42 Mon Sep 17 00:00:00 2001 From: Sergio Ferreira Date: Sun, 31 Dec 2023 09:57:37 +0000 Subject: [PATCH 01/19] feat(openapi): support version 3.1 --- src/framework/openapi.schema.validator.ts | 32 +- .../openapi.v3_1.modified.schema.json | 1407 +++++++++++++++++ 2 files changed, 1431 insertions(+), 8 deletions(-) create mode 100644 src/framework/openapi.v3_1.modified.schema.json diff --git a/src/framework/openapi.schema.validator.ts b/src/framework/openapi.schema.validator.ts index 03da0129..b378bd62 100644 --- a/src/framework/openapi.schema.validator.ts +++ b/src/framework/openapi.schema.validator.ts @@ -6,8 +6,12 @@ import AjvDraft4, { import addFormats from 'ajv-formats'; // https://github.com/OAI/OpenAPI-Specification/blob/master/schemas/v3.0/schema.json import * as openapi3Schema from './openapi.v3.schema.json'; +// https://github.com/OAI/OpenAPI-Specification/blob/master/schemas/v3.1/schema.json with dynamic refs replaced due to AJV bug - https://github.com/ajv-validator/ajv/issues/1745 +import * as openapi31Schema from './openapi.v3_1.modified.schema.json'; import { OpenAPIV3 } from './types.js'; +import Ajv2020 from 'ajv/dist/2020'; + export interface OpenAPISchemaValidatorOpts { version: string; validateApiSpec: boolean; @@ -17,7 +21,6 @@ export class OpenAPISchemaValidator { private validator: ValidateFunction; constructor(opts: OpenAPISchemaValidatorOpts) { const options: Options = { - schemaId: 'id', allErrors: true, validateFormats: true, coerceTypes: false, @@ -29,15 +32,28 @@ export class OpenAPISchemaValidator { options.validateSchema = false; } - const v = new AjvDraft4(options); - addFormats(v, ['email', 'regex', 'uri', 'uri-reference']); - - const ver = opts.version && parseInt(String(opts.version), 10); + const ver = opts.version && parseFloat(String(opts.version)); if (!ver) throw Error('version missing from OpenAPI specification'); - if (ver != 3) throw Error('OpenAPI v3 specification version is required'); + if (parseInt(ver.toString()) != 3) throw Error('OpenAPI v3 specification version is required'); + + let ajvInstance; + let schema; + + if (ver === 3) { + schema = openapi3Schema; + ajvInstance = new AjvDraft4(options); + } else if (ver === 3.1) { + schema = openapi31Schema; + ajvInstance = new Ajv2020(options); + ajvInstance.addFormat('media-range', true); // TODO: Validate media-range format as defined in https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf + } else { + throw new Error('OpenAPI v3 specification 3.0 and 3.1 supported'); + } + + addFormats(ajvInstance, ['email', 'regex', 'uri', 'uri-reference']); - v.addSchema(openapi3Schema); - this.validator = v.compile(openapi3Schema); + ajvInstance.addSchema(schema); + this.validator = ajvInstance.compile(schema); } public validate(openapiDoc: OpenAPIV3.Document): { diff --git a/src/framework/openapi.v3_1.modified.schema.json b/src/framework/openapi.v3_1.modified.schema.json new file mode 100644 index 00000000..76e4df1d --- /dev/null +++ b/src/framework/openapi.v3_1.modified.schema.json @@ -0,0 +1,1407 @@ +{ + "$id": "https://spec.openapis.org/oas/3.1/schema/2022-10-07", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "The description of OpenAPI v3.1.x documents without schema validation, as defined by https://spec.openapis.org/oas/v3.1.0", + "type": "object", + "properties": { + "openapi": { + "type": "string", + "pattern": "^3\\.1\\.\\d+(-.+)?$" + }, + "info": { + "$ref": "#/$defs/info" + }, + "jsonSchemaDialect": { + "type": "string", + "format": "uri", + "default": "https://spec.openapis.org/oas/3.1/dialect/base" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + }, + "default": [{ + "url": "/" + }] + }, + "paths": { + "$ref": "#/$defs/paths" + }, + "webhooks": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/path-item-or-reference" + } + }, + "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.1.0#info-object", + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "termsOfService": { + "type": "string", + "format": "uri" + }, + "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.1.0#contact-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "license": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#license-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "identifier": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "name" + ], + "dependentSchemas": { + "identifier": { + "not": { + "required": [ + "url" + ] + } + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "server": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#server-object", + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri-reference" + }, + "description": { + "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.1.0#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.1.0#components-object", + "type": "object", + "properties": { + "schemas": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/schema" + } + }, + "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-or-reference" + } + } + }, + "patternProperties": { + "^(schemas|responses|parameters|examples|requestBodies|headers|securitySchemes|links|callbacks|pathItems)$": { + "$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.1.0#paths-object", + "type": "object", + "patternProperties": { + "^/": { + "$ref": "#/$defs/path-item" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "path-item": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#path-item-object", + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "servers": { + "type": "array", + "items": { + "$ref": "#/$defs/server" + } + }, + "parameters": { + "type": "array", + "items": { + "$ref": "#/$defs/parameter-or-reference" + } + }, + "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" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "path-item-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/path-item" + } + }, + "operation": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#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": { + "type": "array", + "items": { + "$ref": "#/$defs/parameter-or-reference" + } + }, + "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.1.0#external-documentation-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + } + }, + "required": [ + "url" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "parameter": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#parameter-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "in": { + "enum": [ + "query", + "header", + "path", + "cookie" + ] + }, + "description": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "deprecated": { + "default": false, + "type": "boolean" + }, + "schema": { + "$ref": "#/$defs/schema" + }, + "content": { + "$ref": "#/$defs/content", + "minProperties": 1, + "maxProperties": 1 + } + }, + "required": [ + "name", + "in" + ], + "oneOf": [{ + "required": [ + "schema" + ] + }, + { + "required": [ + "content" + ] + } + ], + "if": { + "properties": { + "in": { + "const": "query" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "allowEmptyValue": { + "default": false, + "type": "boolean" + } + } + }, + "dependentSchemas": { + "schema": { + "properties": { + "style": { + "type": "string" + }, + "explode": { + "type": "boolean" + } + }, + "allOf": [{ + "$ref": "#/$defs/examples" + }, + { + "$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" + }, + { + "$ref": "#/$defs/styles-for-form" + } + ], + "$defs": { + "styles-for-path": { + "if": { + "properties": { + "in": { + "const": "path" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "style": { + "default": "simple", + "enum": [ + "matrix", + "label", + "simple" + ] + }, + "required": { + "const": true + } + }, + "required": [ + "required" + ] + } + }, + "styles-for-header": { + "if": { + "properties": { + "in": { + "const": "header" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "style": { + "default": "simple", + "const": "simple" + } + } + } + }, + "styles-for-query": { + "if": { + "properties": { + "in": { + "const": "query" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "style": { + "default": "form", + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + }, + "allowReserved": { + "default": false, + "type": "boolean" + } + } + } + }, + "styles-for-cookie": { + "if": { + "properties": { + "in": { + "const": "cookie" + } + }, + "required": [ + "in" + ] + }, + "then": { + "properties": { + "style": { + "default": "form", + "const": "form" + } + } + } + } + } + } + }, + "$ref": "#/$defs/specification-extensions", + "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.1.0#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.1.0#fixed-fields-10", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/media-type" + }, + "propertyNames": { + "format": "media-range" + } + }, + "media-type": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#media-type-object", + "type": "object", + "properties": { + "schema": { + "$ref": "#/$defs/schema" + }, + "encoding": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/encoding" + } + } + }, + "allOf": [{ + "$ref": "#/$defs/specification-extensions" + }, + { + "$ref": "#/$defs/examples" + } + ], + "unevaluatedProperties": false + }, + "encoding": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#encoding-object", + "type": "object", + "properties": { + "contentType": { + "type": "string", + "format": "media-range" + }, + "headers": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/header-or-reference" + } + }, + "style": { + "default": "form", + "enum": [ + "form", + "spaceDelimited", + "pipeDelimited", + "deepObject" + ] + }, + "explode": { + "type": "boolean" + }, + "allowReserved": { + "default": false, + "type": "boolean" + } + }, + "allOf": [{ + "$ref": "#/$defs/specification-extensions" + }, + { + "$ref": "#/$defs/styles-for-form" + } + ], + "unevaluatedProperties": false + }, + "responses": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#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.1.0#response-object", + "type": "object", + "properties": { + "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" + } + } + }, + "required": [ + "description" + ], + "$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.1.0#callback-object", + "type": "object", + "$ref": "#/$defs/specification-extensions", + "additionalProperties": { + "$ref": "#/$defs/path-item-or-reference" + } + }, + "callbacks-or-reference": { + "if": { + "type": "object", + "required": [ + "$ref" + ] + }, + "then": { + "$ref": "#/$defs/reference" + }, + "else": { + "$ref": "#/$defs/callbacks" + } + }, + "example": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#example-object", + "type": "object", + "properties": { + "summary": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": true, + "externalValue": { + "type": "string", + "format": "uri" + } + }, + "not": { + "required": [ + "value", + "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.1.0#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" + }, + "body": { + "$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.1.0#header-object", + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "required": { + "default": false, + "type": "boolean" + }, + "deprecated": { + "default": false, + "type": "boolean" + }, + "schema": { + "$ref": "#/$defs/schema" + }, + "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" + } + }, + "$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.1.0#tag-object", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "externalDocs": { + "$ref": "#/$defs/external-documentation" + } + }, + "required": [ + "name" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "reference": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#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.1.0#schema-object", + "$dynamicAnchor": "meta", + "type": [ + "object", + "boolean" + ] + }, + "security-scheme": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#security-scheme-object", + "type": "object", + "properties": { + "type": { + "enum": [ + "apiKey", + "http", + "mutualTLS", + "oauth2", + "openIdConnect" + ] + }, + "description": { + "type": "string" + } + }, + "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" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "name": { + "type": "string" + }, + "in": { + "enum": [ + "query", + "header", + "cookie" + ] + } + }, + "required": [ + "name", + "in" + ] + } + }, + "type-http": { + "if": { + "properties": { + "type": { + "const": "http" + } + }, + "required": [ + "type" + ] + }, + "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" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "flows": { + "$ref": "#/$defs/oauth-flows" + } + }, + "required": [ + "flows" + ] + } + }, + "type-oidc": { + "if": { + "properties": { + "type": { + "const": "openIdConnect" + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "openIdConnectUrl": { + "type": "string", + "format": "uri" + } + }, + "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" + } + }, + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false, + "$defs": { + "implicit": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri" + }, + "refreshUrl": { + "type": "string", + "format": "uri" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "authorizationUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + }, + "password": { + "type": "object", + "properties": { + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "refreshUrl": { + "type": "string", + "format": "uri" + }, + "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" + }, + "refreshUrl": { + "type": "string", + "format": "uri" + }, + "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" + }, + "tokenUrl": { + "type": "string", + "format": "uri" + }, + "refreshUrl": { + "type": "string", + "format": "uri" + }, + "scopes": { + "$ref": "#/$defs/map-of-strings" + } + }, + "required": [ + "authorizationUrl", + "tokenUrl", + "scopes" + ], + "$ref": "#/$defs/specification-extensions", + "unevaluatedProperties": false + } + } + }, + "security-requirement": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#security-requirement-object", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "specification-extensions": { + "$comment": "https://spec.openapis.org/oas/v3.1.0#specification-extensions", + "patternProperties": { + "^x-": true + } + }, + "examples": { + "properties": { + "example": true, + "examples": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/example-or-reference" + } + } + } + }, + "map-of-strings": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "styles-for-form": { + "if": { + "properties": { + "style": { + "const": "form" + } + }, + "required": [ + "style" + ] + }, + "then": { + "properties": { + "explode": { + "default": true + } + } + }, + "else": { + "properties": { + "explode": { + "default": false + } + } + } + } + } +} \ No newline at end of file From 1894d84bcce0d2721f7fa491c290122240e9d771 Mon Sep 17 00:00:00 2001 From: sf97 Date: Mon, 5 Feb 2024 00:22:24 +0000 Subject: [PATCH 02/19] test(openapi_3.1): ensure that an API with webhooks and no routes is supported --- test/openapi_3.1/README.md | 3 +++ test/openapi_3.1/resources/webhook.yaml | 35 +++++++++++++++++++++++++ test/openapi_3.1/webhook.spec.ts | 31 ++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 test/openapi_3.1/README.md create mode 100644 test/openapi_3.1/resources/webhook.yaml create mode 100644 test/openapi_3.1/webhook.spec.ts diff --git a/test/openapi_3.1/README.md b/test/openapi_3.1/README.md new file mode 100644 index 00000000..defce399 --- /dev/null +++ b/test/openapi_3.1/README.md @@ -0,0 +1,3 @@ +# Open API 3.1 tests + +This folder, and its subfolders, contain tests for OpenAPI specification 3.1 diff --git a/test/openapi_3.1/resources/webhook.yaml b/test/openapi_3.1/resources/webhook.yaml new file mode 100644 index 00000000..27bbd8e2 --- /dev/null +++ b/test/openapi_3.1/resources/webhook.yaml @@ -0,0 +1,35 @@ +# From https://github.com/OAI/OpenAPI-Specification/blob/main/examples/v3.1/webhook-example.yaml +openapi: 3.1.0 +info: + title: Webhook Example + version: 1.0.0 +# Since OAS 3.1.0 the paths element isn't necessary. Now a valid OpenAPI Document can describe only paths, webhooks, or even only reusable components +webhooks: + # Each webhook needs a name + newPet: + # This is a Path Item Object, the only difference is that the request is initiated by the API provider + post: + requestBody: + description: Information about a new pet in the system + content: + application/json: + schema: + $ref: "#/components/schemas/Pet" + responses: + "200": + description: Return a 200 status to indicate that the data was received successfully + +components: + schemas: + Pet: + required: + - id + - name + properties: + id: + type: integer + format: int64 + name: + type: string + tag: + type: string \ No newline at end of file diff --git a/test/openapi_3.1/webhook.spec.ts b/test/openapi_3.1/webhook.spec.ts new file mode 100644 index 00000000..e088c12a --- /dev/null +++ b/test/openapi_3.1/webhook.spec.ts @@ -0,0 +1,31 @@ +import { expect } from "chai"; +import * as request from 'supertest'; +import { createApp } from "../common/app"; +import { join } from "path"; + +describe('webhook support - OpenAPI 3.1', () => { + let app; + + before(async () => { + const apiSpec = join('test', 'openapi_3.1', 'resources', 'webhook.yaml'); + app = await createApp( + { apiSpec, validateRequests: true }, + 3005, + undefined, + false, + ); + }); + + after(() => { + app.server.close(); + }); + + it('should support an API that only has webhooks defined, but provides no routes', () => { + // The webhook is not made available by the provider API, so the request will return 404 + // This test ensures that the request flow happens normally without any interruptions due to being a webhook + return request(app) + .get(`${app.basePath}/webhook`) + .expect(404); + }); + +}) \ No newline at end of file From bb53f78965eb3048a14ba9351393f7fb156ac8ef Mon Sep 17 00:00:00 2001 From: sf97 Date: Mon, 5 Feb 2024 00:25:12 +0000 Subject: [PATCH 03/19] feat(openapi_3.1): adds open api 3.1 type --- src/framework/ajv/index.ts | 6 +- src/framework/index.ts | 8 +-- src/framework/openapi.context.ts | 2 +- src/framework/openapi.schema.validator.ts | 2 +- src/framework/openapi.spec.loader.ts | 60 ++++++++++--------- src/framework/types.ts | 17 ++++-- src/middlewares/openapi.metadata.ts | 2 +- src/middlewares/openapi.multipart.ts | 2 +- src/middlewares/openapi.request.validator.ts | 11 ++-- src/middlewares/openapi.response.validator.ts | 4 +- src/middlewares/openapi.security.ts | 2 +- .../parsers/req.parameter.mutator.ts | 4 +- src/middlewares/parsers/schema.parse.ts | 4 +- .../parsers/schema.preprocessor.ts | 22 ++++--- src/middlewares/parsers/util.ts | 2 +- src/openapi.validator.ts | 10 ++-- src/resolvers.ts | 4 +- test/440.spec.ts | 2 +- test/478.spec.ts | 2 +- test/535.spec.ts | 4 +- test/577.spec.ts | 4 +- test/699.spec.ts | 1 - test/821.spec.ts | 2 +- test/allow.header.spec.ts | 2 +- test/invalid.apispec.spec.ts | 2 +- test/no.components.spec.ts | 2 +- test/petstore.spec.ts | 2 +- test/user-request-url.router.spec.ts | 4 +- 28 files changed, 102 insertions(+), 87 deletions(-) diff --git a/src/framework/ajv/index.ts b/src/framework/ajv/index.ts index 067467c4..edbc0404 100644 --- a/src/framework/ajv/index.ts +++ b/src/framework/ajv/index.ts @@ -11,21 +11,21 @@ interface SerDesSchema extends Partial { } export function createRequestAjv( - openApiSpec: OpenAPIV3.Document, + openApiSpec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, options: Options = {}, ): AjvDraft4 { return createAjv(openApiSpec, options); } export function createResponseAjv( - openApiSpec: OpenAPIV3.Document, + openApiSpec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, options: Options = {}, ): AjvDraft4 { return createAjv(openApiSpec, options, false); } function createAjv( - openApiSpec: OpenAPIV3.Document, + openApiSpec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, options: Options = {}, request = true, ): AjvDraft4 { diff --git a/src/framework/index.ts b/src/framework/index.ts index 471d6183..8fe2ceb9 100644 --- a/src/framework/index.ts +++ b/src/framework/index.ts @@ -75,7 +75,7 @@ export class OpenAPIFramework { private loadSpec( filePath: string | object, $refParser: { mode: 'bundle' | 'dereference' } = { mode: 'bundle' }, - ): Promise { + ): Promise { // Because of this issue ( https://github.com/APIDevTools/json-schema-ref-parser/issues/101#issuecomment-421755168 ) // We need this workaround ( use '$RefParser.dereference' instead of '$RefParser.bundle' ) if asked by user if (typeof filePath === 'string') { @@ -87,7 +87,7 @@ export class OpenAPIFramework { $refParser.mode === 'dereference' ? $RefParser.dereference(absolutePath) : $RefParser.bundle(absolutePath); - return doc as Promise; + return doc as Promise; } else { throw new Error( `${this.loggingPrefix}spec could not be read at ${filePath}`, @@ -98,10 +98,10 @@ export class OpenAPIFramework { $refParser.mode === 'dereference' ? $RefParser.dereference(filePath) : $RefParser.bundle(filePath); - return doc as Promise; + return doc as Promise; } - private sortApiDocTags(apiDoc: OpenAPIV3.Document): void { + private sortApiDocTags(apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1): void { if (apiDoc && Array.isArray(apiDoc.tags)) { apiDoc.tags.sort((a, b): number => { return a.name < b.name ? -1 : 1; diff --git a/src/framework/openapi.context.ts b/src/framework/openapi.context.ts index 2df421f0..3f8e654e 100644 --- a/src/framework/openapi.context.ts +++ b/src/framework/openapi.context.ts @@ -6,7 +6,7 @@ export interface RoutePair { openApiRoute: string; } export class OpenApiContext { - public readonly apiDoc: OpenAPIV3.Document; + public readonly apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; public readonly expressRouteMap = {}; public readonly openApiRouteMap = {}; public readonly routes: RouteMetadata[] = []; diff --git a/src/framework/openapi.schema.validator.ts b/src/framework/openapi.schema.validator.ts index b378bd62..81a20f7b 100644 --- a/src/framework/openapi.schema.validator.ts +++ b/src/framework/openapi.schema.validator.ts @@ -56,7 +56,7 @@ export class OpenAPISchemaValidator { this.validator = ajvInstance.compile(schema); } - public validate(openapiDoc: OpenAPIV3.Document): { + public validate(openapiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1): { errors: Array | null; } { const valid = this.validator(openapiDoc); diff --git a/src/framework/openapi.spec.loader.ts b/src/framework/openapi.spec.loader.ts index bc0d075f..07c9ce52 100644 --- a/src/framework/openapi.spec.loader.ts +++ b/src/framework/openapi.spec.loader.ts @@ -6,7 +6,7 @@ import { } from './types'; export interface Spec { - apiDoc: OpenAPIV3.Document; + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; basePaths: string[]; routes: RouteMetadata[]; } @@ -20,7 +20,7 @@ export interface RouteMetadata { } interface DiscoveredRoutes { - apiDoc: OpenAPIV3.Document; + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; basePaths: string[]; routes: RouteMetadata[]; } @@ -47,7 +47,7 @@ export class OpenApiSpecLoader { const routes: RouteMetadata[] = []; const toExpressParams = this.toExpressParams; // const basePaths = this.framework.basePaths; - // let apiDoc: OpenAPIV3.Document = null; + // let apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1 = null; // let basePaths: string[] = null; const { apiDoc, basePaths } = await this.framework.initialize({ visitApi(ctx: OpenAPIFrameworkAPIContext): void { @@ -55,34 +55,36 @@ export class OpenApiSpecLoader { const basePaths = ctx.basePaths; for (const bpa of basePaths) { const bp = bpa.replace(/\/$/, ''); - for (const [path, methods] of Object.entries(apiDoc.paths)) { - for (const [method, schema] of Object.entries(methods)) { - if ( - method.startsWith('x-') || - ['parameters', 'summary', 'description'].includes(method) - ) { - continue; - } - const pathParams = new Set(); - const parameters = [...schema.parameters ?? [], ...methods.parameters ?? []] - for (const param of parameters) { - if (param.in === 'path') { - pathParams.add(param.name); + if (apiDoc.paths) { + for (const [path, methods] of Object.entries(apiDoc.paths)) { + for (const [method, schema] of Object.entries(methods)) { + if ( + method.startsWith('x-') || + ['parameters', 'summary', 'description'].includes(method) + ) { + continue; + } + const pathParams = new Set(); + const parameters = [...schema.parameters ?? [], ...methods.parameters ?? []] + for (const param of parameters) { + if (param.in === 'path') { + pathParams.add(param.name); + } } + const openApiRoute = `${bp}${path}`; + const expressRoute = `${openApiRoute}` + .split(':') + .map(toExpressParams) + .join('\\:'); + + routes.push({ + basePath: bp, + expressRoute, + openApiRoute, + method: method.toUpperCase(), + pathParams: Array.from(pathParams), + }); } - const openApiRoute = `${bp}${path}`; - const expressRoute = `${openApiRoute}` - .split(':') - .map(toExpressParams) - .join('\\:'); - - routes.push({ - basePath: bp, - expressRoute, - openApiRoute, - method: method.toUpperCase(), - pathParams: Array.from(pathParams), - }); } } } diff --git a/src/framework/types.ts b/src/framework/types.ts index 201e6fbf..342a434b 100644 --- a/src/framework/types.ts +++ b/src/framework/types.ts @@ -21,7 +21,7 @@ export interface ValidationSchema extends ParametersSchema { } export interface OpenAPIFrameworkInit { - apiDoc: OpenAPIV3.Document; + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; basePaths: string[]; } export type SecurityHandlers = { @@ -109,7 +109,7 @@ export type SerDesMap = { }; export interface OpenApiValidatorOpts { - apiSpec: OpenAPIV3.Document | string; + apiSpec: OpenAPIV3.DocumentV3 | string; validateApiSpec?: boolean; validateResponses?: boolean | ValidateResponseOpts; validateRequests?: boolean | ValidateRequestOpts; @@ -152,7 +152,7 @@ export interface NormalizedOpenApiValidatorOpts extends OpenApiValidatorOpts { } export namespace OpenAPIV3 { - export interface Document { + export interface DocumentV3 { openapi: string; info: InfoObject; servers?: ServerObject[]; @@ -163,6 +163,13 @@ export namespace OpenAPIV3 { externalDocs?: ExternalDocumentationObject; } + export interface DocumentV3_1 extends Omit { + paths?: DocumentV3['paths'] + webhooks: { + [name: string]: PathItemObject | ReferenceObject + } + } + export interface InfoObject { title: string; description?: string; @@ -474,7 +481,7 @@ export interface OpenAPIFrameworkPathObject { } interface OpenAPIFrameworkArgs { - apiDoc: OpenAPIV3.Document | string; + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1 | string; validateApiSpec?: boolean; $refParser?: { mode: 'bundle' | 'dereference'; @@ -484,7 +491,7 @@ interface OpenAPIFrameworkArgs { export interface OpenAPIFrameworkAPIContext { // basePaths: BasePath[]; basePaths: string[]; - getApiDoc(): OpenAPIV3.Document; + getApiDoc(): OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; } export interface OpenAPIFrameworkVisitor { diff --git a/src/middlewares/openapi.metadata.ts b/src/middlewares/openapi.metadata.ts index 2dae08a2..c2852cbb 100644 --- a/src/middlewares/openapi.metadata.ts +++ b/src/middlewares/openapi.metadata.ts @@ -15,7 +15,7 @@ import { httpMethods } from './parsers/schema.preprocessor'; export function applyOpenApiMetadata( openApiContext: OpenApiContext, - responseApiDoc: OpenAPIV3.Document, + responseApiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, ): OpenApiRequestHandler { return (req: OpenApiRequest, res: Response, next: NextFunction): void => { // note base path is empty when path is fully qualified i.e. req.path.startsWith('') diff --git a/src/middlewares/openapi.multipart.ts b/src/middlewares/openapi.multipart.ts index 5871ccce..930fed08 100644 --- a/src/middlewares/openapi.multipart.ts +++ b/src/middlewares/openapi.multipart.ts @@ -15,7 +15,7 @@ import { MulterError } from 'multer'; const multer = require('multer'); export function multipart( - apiDoc: OpenAPIV3.Document, + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, options: MultipartOpts, ): OpenApiRequestHandler { const mult = multer(options.multerOpts); diff --git a/src/middlewares/openapi.request.validator.ts b/src/middlewares/openapi.request.validator.ts index 47748cb2..40efea19 100644 --- a/src/middlewares/openapi.request.validator.ts +++ b/src/middlewares/openapi.request.validator.ts @@ -21,6 +21,7 @@ import { import { BodySchemaParser } from './parsers/body.parse'; import { ParametersSchemaParser } from './parsers/schema.parse'; import { RequestParameterMutator } from './parsers/req.parameter.mutator'; +import { debug } from 'console'; type OperationObject = OpenAPIV3.OperationObject; type SchemaObject = OpenAPIV3.SchemaObject; @@ -31,13 +32,13 @@ type ApiKeySecurityScheme = OpenAPIV3.ApiKeySecurityScheme; export class RequestValidator { private middlewareCache: { [key: string]: RequestHandler } = {}; - private apiDoc: OpenAPIV3.Document; + private apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; private ajv: Ajv; private ajvBody: Ajv; private requestOpts: ValidateRequestOpts = {}; constructor( - apiDoc: OpenAPIV3.Document, + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, options: RequestValidatorOptions = {}, ) { this.middlewareCache = {}; @@ -267,7 +268,7 @@ export class RequestValidator { } class Validator { - private readonly apiDoc: OpenAPIV3.Document; + private readonly apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; readonly schemaGeneral: object; readonly schemaBody: object; readonly validatorGeneral: ValidateFunction; @@ -275,7 +276,7 @@ class Validator { readonly allSchemaProperties: ValidationSchema; constructor( - apiDoc: OpenAPIV3.Document, + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, parametersSchema: ParametersSchema, bodySchema: BodySchema, ajv: { @@ -329,7 +330,7 @@ class Validator { class Security { public static queryParam( - apiDocs: OpenAPIV3.Document, + apiDocs: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, schema: OperationObject, ): string[] { const hasPathSecurity = schema.security?.length > 0 ?? false; diff --git a/src/middlewares/openapi.response.validator.ts b/src/middlewares/openapi.response.validator.ts index 6166ca5a..6fdad5bf 100644 --- a/src/middlewares/openapi.response.validator.ts +++ b/src/middlewares/openapi.response.validator.ts @@ -27,14 +27,14 @@ interface ValidateResult { } export class ResponseValidator { private ajvBody: Ajv; - private spec: OpenAPIV3.Document; + private spec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; private validatorsCache: { [key: string]: { [key: string]: ValidateFunction }; } = {}; private eovOptions: ValidateResponseOpts; constructor( - openApiSpec: OpenAPIV3.Document, + openApiSpec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, options: Options = {}, eovOptions: ValidateResponseOpts = {}, ) { diff --git a/src/middlewares/openapi.security.ts b/src/middlewares/openapi.security.ts index b3dbfe98..ee163138 100644 --- a/src/middlewares/openapi.security.ts +++ b/src/middlewares/openapi.security.ts @@ -23,7 +23,7 @@ interface SecurityHandlerResult { error?: string; } export function security( - apiDoc: OpenAPIV3.Document, + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, securityHandlers: SecurityHandlers, ): OpenApiRequestHandler { return async (req, res, next) => { diff --git a/src/middlewares/parsers/req.parameter.mutator.ts b/src/middlewares/parsers/req.parameter.mutator.ts index 00ece3cd..cebe0b05 100644 --- a/src/middlewares/parsers/req.parameter.mutator.ts +++ b/src/middlewares/parsers/req.parameter.mutator.ts @@ -39,14 +39,14 @@ type Schema = ReferenceObject | SchemaObject; * the request is mutated to accomodate various styles and types e.g. form, explode, deepObject, etc */ export class RequestParameterMutator { - private _apiDocs: OpenAPIV3.Document; + private _apiDocs: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; private path: string; private ajv: Ajv; private parsedSchema: ValidationSchema; constructor( ajv: Ajv, - apiDocs: OpenAPIV3.Document, + apiDocs: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, path: string, parsedSchema: ValidationSchema, ) { diff --git a/src/middlewares/parsers/schema.parse.ts b/src/middlewares/parsers/schema.parse.ts index cbd58e47..fb5d380c 100644 --- a/src/middlewares/parsers/schema.parse.ts +++ b/src/middlewares/parsers/schema.parse.ts @@ -17,9 +17,9 @@ type Parameter = OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject; */ export class ParametersSchemaParser { private _ajv: Ajv; - private _apiDocs: OpenAPIV3.Document; + private _apiDocs: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; - constructor(ajv: Ajv, apiDocs: OpenAPIV3.Document) { + constructor(ajv: Ajv, apiDocs: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1) { this._ajv = ajv; this._apiDocs = apiDocs; } diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index 283e8d4b..40d0babb 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -91,12 +91,12 @@ export const httpMethods = new Set([ ]); export class SchemaPreprocessor { private ajv: Ajv; - private apiDoc: OpenAPIV3.Document; - private apiDocRes: OpenAPIV3.Document; + private apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; + private apiDocRes: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; private serDesMap: SerDesMap; private responseOpts: ValidateResponseOpts; constructor( - apiDoc: OpenAPIV3.Document, + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, ajvOptions: Options, validateResponsesOpts: ValidateResponseOpts, ) { @@ -108,22 +108,28 @@ export class SchemaPreprocessor { public preProcess() { const componentSchemas = this.gatherComponentSchemaNodes(); - const r = this.gatherSchemaNodesFromPaths(); + let r; + + if (this.apiDoc.paths) { + r = this.gatherSchemaNodesFromPaths(); + } // Now that we've processed paths, clone a response spec if we are validating responses this.apiDocRes = !!this.responseOpts ? cloneDeep(this.apiDoc) : null; const schemaNodes = { schemas: componentSchemas, - requestBodies: r.requestBodies, - responses: r.responses, - requestParameters: r.requestParameters, + requestBodies: r?.requestBodies, + responses: r?.responses, + requestParameters: r?.requestParameters, }; // Traverse the schemas - this.traverseSchemas(schemaNodes, (parent, schema, opts) => + if (r) { + this.traverseSchemas(schemaNodes, (parent, schema, opts) => this.schemaVisitor(parent, schema, opts), ); + } return { apiDoc: this.apiDoc, diff --git a/src/middlewares/parsers/util.ts b/src/middlewares/parsers/util.ts index aa0642e3..0c2119e3 100644 --- a/src/middlewares/parsers/util.ts +++ b/src/middlewares/parsers/util.ts @@ -4,7 +4,7 @@ import ajv = require('ajv'); import { OpenAPIFramework } from '../../framework'; export function dereferenceParameter( - apiDocs: OpenAPIV3.Document, + apiDocs: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, parameter: OpenAPIV3.ReferenceObject | OpenAPIV3.ParameterObject, ): OpenAPIV3.ParameterObject { // TODO this should recurse or use ajv.getSchema - if implemented as such, may want to cache the result diff --git a/src/openapi.validator.ts b/src/openapi.validator.ts index dd12fed2..4332521f 100644 --- a/src/openapi.validator.ts +++ b/src/openapi.validator.ts @@ -261,26 +261,26 @@ export class OpenApiValidator { private metadataMiddleware( context: OpenApiContext, - responseApiDoc: OpenAPIV3.Document, + responseApiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, ) { return middlewares.applyOpenApiMetadata(context, responseApiDoc); } - private multipartMiddleware(apiDoc: OpenAPIV3.Document) { + private multipartMiddleware(apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1) { return middlewares.multipart(apiDoc, { multerOpts: this.options.fileUploader, ajvOpts: this.ajvOpts.multipart, }); } - private securityMiddleware(apiDoc: OpenAPIV3.Document) { + private securityMiddleware(apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1) { const securityHandlers = (( this.options.validateSecurity ))?.handlers; return middlewares.security(apiDoc, securityHandlers); } - private requestValidationMiddleware(apiDoc: OpenAPIV3.Document) { + private requestValidationMiddleware(apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1) { const requestValidator = new middlewares.RequestValidator( apiDoc, this.ajvOpts.request, @@ -288,7 +288,7 @@ export class OpenApiValidator { return (req, res, next) => requestValidator.validate(req, res, next); } - private responseValidationMiddleware(apiDoc: OpenAPIV3.Document) { + private responseValidationMiddleware(apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1) { return new middlewares.ResponseValidator( apiDoc, this.ajvOpts.response, diff --git a/src/resolvers.ts b/src/resolvers.ts index 3092e3a3..18aba622 100644 --- a/src/resolvers.ts +++ b/src/resolvers.ts @@ -7,7 +7,7 @@ const cache = {}; export function defaultResolver( handlersPath: string, route: RouteMetadata, - apiDoc: OpenAPIV3.Document, + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, ): RequestHandler { const tmpModules = {}; const { basePath, expressRoute, openApiRoute, method } = route; @@ -51,7 +51,7 @@ export function defaultResolver( export function modulePathResolver( handlersPath: string, route: RouteMetadata, - apiDoc: OpenAPIV3.Document, + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, ): RequestHandler { const pathKey = route.openApiRoute.substring(route.basePath.length); const schema = apiDoc.paths[pathKey][route.method.toLowerCase()]; diff --git a/test/440.spec.ts b/test/440.spec.ts index e105f07e..50960245 100644 --- a/test/440.spec.ts +++ b/test/440.spec.ts @@ -9,7 +9,7 @@ describe(packageJson.name, () => { before(async () => { // Set up the express app - const apiSpec: OpenAPIV3.Document = { + const apiSpec: OpenAPIV3.DocumentV3 = { openapi: '3.0.0', info: { title: 'Api test', version: '1.0.0' }, servers: [{ url: '/api' }], diff --git a/test/478.spec.ts b/test/478.spec.ts index 5825bc22..232ae659 100644 --- a/test/478.spec.ts +++ b/test/478.spec.ts @@ -53,7 +53,7 @@ describe('issue #478', () => { .expect(200)); }); -function apiSpec(): OpenAPIV3.Document { +function apiSpec(): OpenAPIV3.DocumentV3 { return { openapi: '3.0.3', info: { diff --git a/test/535.spec.ts b/test/535.spec.ts index c07beee8..a7dd79bb 100644 --- a/test/535.spec.ts +++ b/test/535.spec.ts @@ -19,7 +19,7 @@ describe('#535 - calling `middleware()` multiple times', () => { }); async function createApp( - apiSpec: OpenAPIV3.Document, + apiSpec: OpenAPIV3.DocumentV3, ): Promise { const app = express(); @@ -39,7 +39,7 @@ async function createApp( return app; } -function createApiSpec(): OpenAPIV3.Document { +function createApiSpec(): OpenAPIV3.DocumentV3 { return { openapi: '3.0.3', info: { diff --git a/test/577.spec.ts b/test/577.spec.ts index 4c9b26fa..a142d45a 100644 --- a/test/577.spec.ts +++ b/test/577.spec.ts @@ -21,7 +21,7 @@ describe('#577 - Exclude response validation that is not in api spec', () => { }); async function createApp( - apiSpec: OpenAPIV3.Document, + apiSpec: OpenAPIV3.DocumentV3, ): Promise { const app = express(); @@ -51,7 +51,7 @@ async function createApp( return app; } -function createApiSpec(): OpenAPIV3.Document { +function createApiSpec(): OpenAPIV3.DocumentV3 { return { openapi: '3.0.3', info: { diff --git a/test/699.spec.ts b/test/699.spec.ts index 2997299b..4e3399e2 100644 --- a/test/699.spec.ts +++ b/test/699.spec.ts @@ -182,7 +182,6 @@ describe('699 serialize response components only', () => { 3005, (app) => { app.get([`${app.basePath}/users/:id?`], (req, res) => { - debugger; if (typeof req.params.id !== 'string') { throw new Error("Should be not be deserialized to ObjectId object"); } diff --git a/test/821.spec.ts b/test/821.spec.ts index 42eda867..5b9bc75d 100644 --- a/test/821.spec.ts +++ b/test/821.spec.ts @@ -30,7 +30,7 @@ describe('issue #821 - serialization inside addiotionalProperties', () => { }); async function createApp( - apiSpec: OpenAPIV3.Document | string, + apiSpec: OpenAPIV3.DocumentV3 | string, ): Promise { const app = express(); diff --git a/test/allow.header.spec.ts b/test/allow.header.spec.ts index 25620c81..04415919 100644 --- a/test/allow.header.spec.ts +++ b/test/allow.header.spec.ts @@ -50,7 +50,7 @@ async function createApp(): Promise { return app; } -function createApiSpec(): OpenAPIV3.Document { +function createApiSpec(): OpenAPIV3.DocumentV3 { return { openapi: '3.0.3', info: { diff --git a/test/invalid.apispec.spec.ts b/test/invalid.apispec.spec.ts index 2b7b02e8..0ded5b10 100644 --- a/test/invalid.apispec.spec.ts +++ b/test/invalid.apispec.spec.ts @@ -41,7 +41,7 @@ async function createApp( return app; } -function createApiSpec(): OpenAPIV3.Document { +function createApiSpec(): OpenAPIV3.DocumentV3 { return { openapi: '3.0.3', info: { diff --git a/test/no.components.spec.ts b/test/no.components.spec.ts index 0373bd3f..866f371b 100644 --- a/test/no.components.spec.ts +++ b/test/no.components.spec.ts @@ -33,7 +33,7 @@ describe('no components', () => { })); }); -function apiDoc(): OpenAPIV3.Document { +function apiDoc(): OpenAPIV3.DocumentV3 { return { openapi: '3.0.1', info: { diff --git a/test/petstore.spec.ts b/test/petstore.spec.ts index 1edf43e7..8588f5b5 100644 --- a/test/petstore.spec.ts +++ b/test/petstore.spec.ts @@ -36,7 +36,7 @@ describe('petstore', () => { request(app).get(`${app.basePath}/pets`).expect(200)); }); -function petstoreSpec(): OpenAPIV3.Document { +function petstoreSpec(): OpenAPIV3.DocumentV3 { return { openapi: '3.0.0', info: { diff --git a/test/user-request-url.router.spec.ts b/test/user-request-url.router.spec.ts index 4bba5dee..d4470ba4 100644 --- a/test/user-request-url.router.spec.ts +++ b/test/user-request-url.router.spec.ts @@ -112,7 +112,7 @@ function defaultResponse(): OpenAPIV3.ResponseObject { type of id in path and id in the response here defined as simple string with minLength */ -const gatewaySpec: OpenAPIV3.Document = { +const gatewaySpec: OpenAPIV3.DocumentV3 = { openapi: '3.0.0', info: { version: '1.0.0', title: 'test bug OpenApiValidator' }, servers: [{ url: 'http://localhost:3000/api' }], @@ -168,7 +168,7 @@ const gatewaySpec: OpenAPIV3.Document = { represents spec of the child router. We route request from main app (gateway) to this router. This router has its own schema, routes and validation formats. In particular, we force id in the path and id in the response to be uuid. */ -const childRouterSpec: OpenAPIV3.Document = { +const childRouterSpec: OpenAPIV3.DocumentV3 = { openapi: '3.0.0', info: { version: '1.0.0', title: 'test bug OpenApiValidator' }, servers: [{ url: 'http://localhost:3000/' }], From 0c4f24cad89a37efe1ae384387e3971e7383c48e Mon Sep 17 00:00:00 2001 From: sf97 Date: Mon, 5 Feb 2024 00:42:49 +0000 Subject: [PATCH 04/19] chore(test-scripts): run mocha with --extension instead of glob to pick up subdirectories Mocha was not picking up the tests in subdirectories with the provided glob. Adding --extension with the tests extension and setting the root test folder in tests fixed it --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index ba87f904..97455435 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,9 @@ "scripts": { "compile": "rimraf dist && tsc", "compile:release": "rimraf dist && tsc --sourceMap false", - "test": "mocha -r source-map-support/register -r ts-node/register --files --recursive -R spec test/**/*.spec.ts", - "test:debug": "mocha -r source-map-support/register -r ts-node/register --inspect-brk --files --recursive test/**/*.spec.ts", - "test:coverage": "nyc mocha -r source-map-support/register -r ts-node/register --recursive test/**/*.spec.ts", + "test": "mocha -r source-map-support/register -r ts-node/register --files --recursive -R spec --extension .spec.ts test", + "test:debug": "mocha -r source-map-support/register -r ts-node/register --inspect-brk --files --recursive --extension .spec.ts test", + "test:coverage": "nyc mocha -r source-map-support/register -r ts-node/register --recursive --extension .spec.ts test", "test:reset": "rimraf node_modules && npm i && npm run compile && npm t", "coveralls": "cat coverage/lcov.info | coveralls -v", "codacy": "bash <(curl -Ls https://coverage.codacy.com/get.sh) report -r coverage/lcov.info", From 289bd03333756c41cf916157f4c6af10a8dd2834 Mon Sep 17 00:00:00 2001 From: sf97 Date: Thu, 29 Feb 2024 23:28:45 +0000 Subject: [PATCH 05/19] test(openapi-3.1): adds test to ensure an API with only components is considered valid --- test/openapi_3.1/components.spec.ts | 30 ++++++++++++++++++++++ test/openapi_3.1/resources/components.yaml | 5 ++++ 2 files changed, 35 insertions(+) create mode 100644 test/openapi_3.1/components.spec.ts create mode 100644 test/openapi_3.1/resources/components.yaml diff --git a/test/openapi_3.1/components.spec.ts b/test/openapi_3.1/components.spec.ts new file mode 100644 index 00000000..958c81bb --- /dev/null +++ b/test/openapi_3.1/components.spec.ts @@ -0,0 +1,30 @@ +import * as request from 'supertest'; +import { createApp } from "../common/app"; +import { join } from "path"; + +describe('components support - OpenAPI 3.1', () => { + let app; + + before(async () => { + const apiSpec = join('test', 'openapi_3.1', 'resources', 'components.yaml'); + app = await createApp( + { apiSpec, validateRequests: true }, + 3005, + undefined, + false, + ); + }); + + after(() => { + app.server.close(); + }); + + it('should support an API that only has components defined, but provides no routes', () => { + // The component is not made available by the provider API, so the request will return 404 + // This test ensures that the request flow happens normally without any interruptions due to being a component + return request(app) + .get(`${app.basePath}/components`) + .expect(404); + }); + +}) \ No newline at end of file diff --git a/test/openapi_3.1/resources/components.yaml b/test/openapi_3.1/resources/components.yaml new file mode 100644 index 00000000..d9d51035 --- /dev/null +++ b/test/openapi_3.1/resources/components.yaml @@ -0,0 +1,5 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +components: {} \ No newline at end of file From 0e6ae31f290f2ce87f0140e84e7dbea51a5d52b0 Mon Sep 17 00:00:00 2001 From: sf97 Date: Thu, 29 Feb 2024 23:29:27 +0000 Subject: [PATCH 06/19] test(openapi-3.1): remove unnecessary import --- test/openapi_3.1/webhook.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/openapi_3.1/webhook.spec.ts b/test/openapi_3.1/webhook.spec.ts index e088c12a..58e81b1d 100644 --- a/test/openapi_3.1/webhook.spec.ts +++ b/test/openapi_3.1/webhook.spec.ts @@ -1,4 +1,3 @@ -import { expect } from "chai"; import * as request from 'supertest'; import { createApp } from "../common/app"; import { join } from "path"; From 31613122b10982d864788183cdccb94ad8338463 Mon Sep 17 00:00:00 2001 From: sf97 Date: Thu, 29 Feb 2024 23:42:36 +0000 Subject: [PATCH 07/19] test(openapi-3.1): add support for summary in info object --- src/framework/types.ts | 9 ++++-- test/openapi_3.1/info_summary.spec.ts | 30 ++++++++++++++++++++ test/openapi_3.1/resources/info_summary.yaml | 7 +++++ 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 test/openapi_3.1/info_summary.spec.ts create mode 100644 test/openapi_3.1/resources/info_summary.yaml diff --git a/src/framework/types.ts b/src/framework/types.ts index 342a434b..2c82ac53 100644 --- a/src/framework/types.ts +++ b/src/framework/types.ts @@ -163,8 +163,9 @@ export namespace OpenAPIV3 { externalDocs?: ExternalDocumentationObject; } - export interface DocumentV3_1 extends Omit { - paths?: DocumentV3['paths'] + export interface DocumentV3_1 extends Omit { + paths?: DocumentV3['paths']; + info: InfoObjectV3_1; webhooks: { [name: string]: PathItemObject | ReferenceObject } @@ -179,6 +180,10 @@ export namespace OpenAPIV3 { version: string; } + interface InfoObjectV3_1 extends InfoObject { + summary: string; + } + export interface ContactObject { name?: string; url?: string; diff --git a/test/openapi_3.1/info_summary.spec.ts b/test/openapi_3.1/info_summary.spec.ts new file mode 100644 index 00000000..20e8c5dc --- /dev/null +++ b/test/openapi_3.1/info_summary.spec.ts @@ -0,0 +1,30 @@ +import * as request from 'supertest'; +import { createApp } from "../common/app"; +import { join } from "path"; + +describe('summary support - OpenAPI 3.1', () => { + let app; + + before(async () => { + const apiSpec = join('test', 'openapi_3.1', 'resources', 'info_summary.yaml'); + app = await createApp( + { apiSpec, validateRequests: true }, + 3005, + undefined, + false, + ); + }); + + after(() => { + app.server.close(); + }); + + it('should support an API that has an info with a summary defined', () => { + // The endpoint is not made available by the provider API, so the request will return 404 + // This test ensures that the request flow happens normally without any interruptions + return request(app) + .get(`${app.basePath}/webhook`) + .expect(404); + }); + +}) \ No newline at end of file diff --git a/test/openapi_3.1/resources/info_summary.yaml b/test/openapi_3.1/resources/info_summary.yaml new file mode 100644 index 00000000..27e120cd --- /dev/null +++ b/test/openapi_3.1/resources/info_summary.yaml @@ -0,0 +1,7 @@ +# From https://github.com/OAI/OpenAPI-Specification/blob/6f386968654fd483720aba0177e618e87a5d612d/tests/v3.1/pass/info_summary.yaml +openapi: 3.1.0 +info: + title: API + summary: My lovely API + version: 1.0.0 +components: {} \ No newline at end of file From 7698f028bc139cd7cce119474d69efb3fe94408a Mon Sep 17 00:00:00 2001 From: sf97 Date: Thu, 29 Feb 2024 23:53:55 +0000 Subject: [PATCH 08/19] test(openapi-3.1): add support for identifier in license --- test/openapi_3.1/license_identifier.spec.ts | 30 +++++++++++++++++++ test/openapi_3.1/resources/components.yaml | 1 + .../resources/license_identifier.yaml | 10 +++++++ 3 files changed, 41 insertions(+) create mode 100644 test/openapi_3.1/license_identifier.spec.ts create mode 100644 test/openapi_3.1/resources/license_identifier.yaml diff --git a/test/openapi_3.1/license_identifier.spec.ts b/test/openapi_3.1/license_identifier.spec.ts new file mode 100644 index 00000000..7f615a93 --- /dev/null +++ b/test/openapi_3.1/license_identifier.spec.ts @@ -0,0 +1,30 @@ +import * as request from 'supertest'; +import { createApp } from "../common/app"; +import { join } from "path"; + +describe('identifier support - OpenAPI 3.1', () => { + let app; + + before(async () => { + const apiSpec = join('test', 'openapi_3.1', 'resources', 'license_identifier.yaml'); + app = await createApp( + { apiSpec, validateRequests: true }, + 3005, + undefined, + false, + ); + }); + + after(() => { + app.server.close(); + }); + + it('should support an API that has an info with a summary defined', () => { + // The endpoint is not made available by the provider API, so the request will return 404 + // This test ensures that the request flow happens normally without any interruptions + return request(app) + .get(`${app.basePath}/webhook`) + .expect(404); + }); + +}) \ No newline at end of file diff --git a/test/openapi_3.1/resources/components.yaml b/test/openapi_3.1/resources/components.yaml index d9d51035..b968adec 100644 --- a/test/openapi_3.1/resources/components.yaml +++ b/test/openapi_3.1/resources/components.yaml @@ -1,3 +1,4 @@ +# From https://github.com/OAI/OpenAPI-Specification/blob/6f386968654fd483720aba0177e618e87a5d612d/tests/v3.1/pass/minimal_comp.yaml openapi: 3.1.0 info: title: API diff --git a/test/openapi_3.1/resources/license_identifier.yaml b/test/openapi_3.1/resources/license_identifier.yaml new file mode 100644 index 00000000..85dd47a1 --- /dev/null +++ b/test/openapi_3.1/resources/license_identifier.yaml @@ -0,0 +1,10 @@ +# From https://github.com/OAI/OpenAPI-Specification/blob/6f386968654fd483720aba0177e618e87a5d612d/tests/v3.1/pass/license_identifier.yaml +openapi: 3.1.0 +info: + title: API + summary: My lovely API + version: 1.0.0 + license: + name: Apache + identifier: Apache-2.0 +components: {} \ No newline at end of file From 15d90498e699097e7e067c231f1c6ba85b656f6f Mon Sep 17 00:00:00 2001 From: sf97 Date: Sun, 31 Mar 2024 12:45:50 +0100 Subject: [PATCH 09/19] test(openapi_3.1): ensure API with type set to null works correctly --- test/openapi_3.1/resources/type_null.yaml | 23 ++++++++++++++ test/openapi_3.1/type_null.spec.ts | 37 +++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 test/openapi_3.1/resources/type_null.yaml create mode 100644 test/openapi_3.1/type_null.spec.ts diff --git a/test/openapi_3.1/resources/type_null.yaml b/test/openapi_3.1/resources/type_null.yaml new file mode 100644 index 00000000..54d885f6 --- /dev/null +++ b/test/openapi_3.1/resources/type_null.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +servers: + - url: /v1 +paths: + /entity: + get: + summary: test + description: GETS my entity + responses: + '200': + description: OK + content: + application/json: + schema: + title: Entity + type: object + properties: + property: + type: ['string', 'null'] + \ No newline at end of file diff --git a/test/openapi_3.1/type_null.spec.ts b/test/openapi_3.1/type_null.spec.ts new file mode 100644 index 00000000..452626c2 --- /dev/null +++ b/test/openapi_3.1/type_null.spec.ts @@ -0,0 +1,37 @@ +import * as request from 'supertest'; +import * as express from 'express'; +import { createApp } from "../common/app"; +import { join } from "path"; + +describe('type null support - OpenAPI 3.1', () => { + let app; + + before(async () => { + const apiSpec = join('test', 'openapi_3.1', 'resources', 'type_null.yaml'); + app = await createApp( + { apiSpec, validateRequests: true, validateResponses: true }, + 3005, + (app) => app.use( + express + .Router() + .get(`/v1/entity`, (req, res) => + res.status(200).json({ + property: null + }), + ), + ) + ); + }); + + after(() => { + app.server.close(); + }); + + // In OpenAPI 3.1, nullable = true was replaced by types = [..., null]. This test ensure that it works with Express OpenAPI Validator + it('should support an API with types set to null', async () => { + return request(app) + .get(`${app.basePath}/entity`) + .expect(200); + }); + +}) \ No newline at end of file From 05874154d0d6137b70c502a909ede1d0f8dfc816 Mon Sep 17 00:00:00 2001 From: sf97 Date: Sun, 31 Mar 2024 13:19:34 +0100 Subject: [PATCH 10/19] test(open_api3.1): ensure that methods with non-explicit semantics allow request body --- ...non_defined_semantics_request_body.spec.ts | 48 ++++++++++++++++ .../non_defined_semantics_request_body.yaml | 55 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 test/openapi_3.1/non_defined_semantics_request_body.spec.ts create mode 100644 test/openapi_3.1/resources/non_defined_semantics_request_body.yaml diff --git a/test/openapi_3.1/non_defined_semantics_request_body.spec.ts b/test/openapi_3.1/non_defined_semantics_request_body.spec.ts new file mode 100644 index 00000000..e843a587 --- /dev/null +++ b/test/openapi_3.1/non_defined_semantics_request_body.spec.ts @@ -0,0 +1,48 @@ +import * as request from 'supertest'; +import * as express from 'express'; +import { createApp } from "../common/app"; +import { join } from "path"; + +describe('Request body in operations without well defined semantics - OpenAPI 3.1', () => { + let app; + + before(async () => { + const apiSpec = join('test', 'openapi_3.1', 'resources', 'non_defined_semantics_request_body.yaml'); + app = await createApp( + { apiSpec, validateRequests: true, validateResponses: true }, + 3005, + (app) => app.use( + express + .Router() + .get(`/v1/entity`, (req, res) => + res.status(200).json({ + property: null + }), + ), + ) + ); + }); + + after(() => { + app.server.close(); + }); + + // In OpenAPI 3.0, methods that RFC7231 does not have explicitly defined semantics for request body (GET, HEAD, DELETE) do not allow request body + // In OpenAPI 3.1, request body is allowed for these methods. This test ensures that GET it is correctly handled + it('should validate a request body on GET', async () => { + return request(app) + .get(`${app.basePath}/entity`) + .set('Content-Type', 'application/json') + .send({request: 123}) + .expect(400); + }); + + // Ensures that DELETE it is correctly handled + it('should validate a request body on DELETE', async () => { + return request(app) + .delete(`${app.basePath}/entity`) + .set('Content-Type', 'application/json') + .send({request: 123}) + .expect(400); + }); +}) \ No newline at end of file diff --git a/test/openapi_3.1/resources/non_defined_semantics_request_body.yaml b/test/openapi_3.1/resources/non_defined_semantics_request_body.yaml new file mode 100644 index 00000000..5114a27a --- /dev/null +++ b/test/openapi_3.1/resources/non_defined_semantics_request_body.yaml @@ -0,0 +1,55 @@ +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +servers: + - url: /v1 +components: + schemas: + EntityRequest: + type: object + properties: + request: + type: string +paths: + /entity: + get: + description: GETS my entity + requestBody: + description: Request body for entity + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntityRequest' + responses: + '200': + description: OK + content: + application/json: + schema: + title: Entity + type: object + properties: + property: + type: ['string', 'null'] + delete: + description: DELETE my entity + requestBody: + description: Request body for entity + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EntityRequest' + responses: + '200': + description: OK + content: + application/json: + schema: + title: Entity + type: object + properties: + property: + type: ['string', 'null'] \ No newline at end of file From 415de44b8d2701742858177567a4a5bb6f1aa45f Mon Sep 17 00:00:00 2001 From: sf97 Date: Mon, 1 Apr 2024 00:14:36 +0100 Subject: [PATCH 11/19] test(open_api3.1): ensure 500 is returned when server variable has no default --- .../resources/server_variable_no_default.yaml | 13 ++++++++++++ test/openapi_3.1/server_variable.spec.ts | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 test/openapi_3.1/resources/server_variable_no_default.yaml create mode 100644 test/openapi_3.1/server_variable.spec.ts diff --git a/test/openapi_3.1/resources/server_variable_no_default.yaml b/test/openapi_3.1/resources/server_variable_no_default.yaml new file mode 100644 index 00000000..8408bf44 --- /dev/null +++ b/test/openapi_3.1/resources/server_variable_no_default.yaml @@ -0,0 +1,13 @@ +# Adapted from https://github.com/OAI/OpenAPI-Specification/blob/77c7b9a522ab6fb83a49e8088fa600e93da4f44e/tests/v3.1/fail/server_enum_empty.yaml + +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +servers: + - url: https://example.com/test + variables: + var: + enum: ['a', 'b'] +components: + {} \ No newline at end of file diff --git a/test/openapi_3.1/server_variable.spec.ts b/test/openapi_3.1/server_variable.spec.ts new file mode 100644 index 00000000..d790b9c6 --- /dev/null +++ b/test/openapi_3.1/server_variable.spec.ts @@ -0,0 +1,21 @@ +import * as request from 'supertest'; +import { createApp } from "../common/app"; +import { join } from "path"; + +describe('server variable - OpenAPI 3.1', () => { + it('returns 500 when server variable has no default property', async () => { + const apiSpec = join('test', 'openapi_3.1', 'resources', 'server_variable_no_default.yaml'); + const app = await createApp( + { apiSpec, validateRequests: true, validateResponses: true }, + 3005, + undefined, + false, + ) as any; + + await request(app) + .get(`${app.basePath}`) + .expect(500); + + app.server.close(); + }); +}) \ No newline at end of file From 7bcf518c6e87d6230075b883f45e2ea4ad78d66c Mon Sep 17 00:00:00 2001 From: sf97 Date: Mon, 1 Apr 2024 00:32:58 +0100 Subject: [PATCH 12/19] feat(openapi_3.1): ensure API supports an endpoint without response --- .../parsers/schema.preprocessor.ts | 2 +- test/openapi_3.1/path_no_response.spec.ts | 36 +++++++++++++++++++ .../resources/path_no_response.yaml | 11 ++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 test/openapi_3.1/path_no_response.spec.ts create mode 100644 test/openapi_3.1/resources/path_no_response.yaml diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index 40d0babb..fc82c582 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -504,7 +504,7 @@ export class SchemaPreprocessor { const op = node.schema; const responses = op.responses; - if (!responses) return; + if (!responses) return []; const schemas: Root[] = []; for (const [statusCode, response] of Object.entries(responses)) { diff --git a/test/openapi_3.1/path_no_response.spec.ts b/test/openapi_3.1/path_no_response.spec.ts new file mode 100644 index 00000000..b40619e3 --- /dev/null +++ b/test/openapi_3.1/path_no_response.spec.ts @@ -0,0 +1,36 @@ +import * as request from 'supertest'; +import * as express from 'express'; +import { createApp } from "../common/app"; +import { join } from "path"; + +describe('operation object without response - OpenAPI 3.1', () => { + let app; + + before(async () => { + const apiSpec = join('test', 'openapi_3.1', 'resources', 'path_no_response.yaml'); + app = await createApp( + { apiSpec, validateRequests: true, validateResponses: true }, + 3005, + (app) => app.use( + express + .Router() + .get(`/v1`, (req, res) => + res.status(200).end(), + ), + ) + ); + app + }); + + after(() => { + app.server.close(); + }); + + // In OpenAPI 3.1 it's possible to have a path without a response defined + it('should support endpoint with defined operation object without response', () => { + return request(app) + .get(`${app.basePath}`) + .expect(200); + }); + +}) \ No newline at end of file diff --git a/test/openapi_3.1/resources/path_no_response.yaml b/test/openapi_3.1/resources/path_no_response.yaml new file mode 100644 index 00000000..5426037b --- /dev/null +++ b/test/openapi_3.1/resources/path_no_response.yaml @@ -0,0 +1,11 @@ +# Adapted from https://github.com/OAI/OpenAPI-Specification/blob/77c7b9a522ab6fb83a49e8088fa600e93da4f44e/tests/v3.1/pass/path_no_response.yaml + +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +servers: + - url: /v1 +paths: + /: + get: {} \ No newline at end of file From 45842613f358dd840f848785006efd800b71c9cd Mon Sep 17 00:00:00 2001 From: sf97 Date: Sun, 28 Apr 2024 19:45:42 +0100 Subject: [PATCH 13/19] feat(openapi_3.1): add full type support for open api 3.1 --- src/framework/types.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/framework/types.ts b/src/framework/types.ts index fcc8f294..a294d52c 100644 --- a/src/framework/types.ts +++ b/src/framework/types.ts @@ -163,9 +163,14 @@ export namespace OpenAPIV3 { externalDocs?: ExternalDocumentationObject; } - export interface DocumentV3_1 extends Omit { + interface ComponentsV3_1 extends ComponentsObject { + pathItems?: { [path: string]: PathItemObject | ReferenceObject } + } + + export interface DocumentV3_1 extends Omit { paths?: DocumentV3['paths']; info: InfoObjectV3_1; + components: ComponentsV3_1; webhooks: { [name: string]: PathItemObject | ReferenceObject } From 9c581667ee601b36763bf64b75f1b2f5c7d1c1eb Mon Sep 17 00:00:00 2001 From: sf97 Date: Sun, 28 Apr 2024 20:08:56 +0100 Subject: [PATCH 14/19] test(openapi_3.1): adds test for path item support in components --- .../openapi_3.1/components_path_items.spec.ts | 34 +++++++++++++++++++ .../resources/components_path_items.yaml | 21 ++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 test/openapi_3.1/components_path_items.spec.ts create mode 100644 test/openapi_3.1/resources/components_path_items.yaml diff --git a/test/openapi_3.1/components_path_items.spec.ts b/test/openapi_3.1/components_path_items.spec.ts new file mode 100644 index 00000000..cbd282da --- /dev/null +++ b/test/openapi_3.1/components_path_items.spec.ts @@ -0,0 +1,34 @@ +import * as request from 'supertest'; +import * as express from 'express'; +import { createApp } from "../common/app"; +import { join } from "path"; + +describe('component path item support - OpenAPI 3.1', () => { + let app; + + before(async () => { + const apiSpec = join('test', 'openapi_3.1', 'resources', 'components_path_items.yaml'); + app = await createApp( + { apiSpec, validateRequests: true, validateResponses: true }, + 3005, + (app) => app.use( + express + .Router() + .get(`/v1/entity`, (req, res) => + res.status(200).json({}), + ), + ) + ); + }); + + after(() => { + app.server.close(); + }); + + it('should support path item on components', async () => { + return request(app) + .get(`${app.basePath}/entity`) + .expect(200); + }); + +}) \ No newline at end of file diff --git a/test/openapi_3.1/resources/components_path_items.yaml b/test/openapi_3.1/resources/components_path_items.yaml new file mode 100644 index 00000000..030e6757 --- /dev/null +++ b/test/openapi_3.1/resources/components_path_items.yaml @@ -0,0 +1,21 @@ +openapi: 3.1.0 +info: + title: Example specification + version: "1.0" +servers: + - url: /v1 +components: + pathItems: + entity: + get: + description: 'test' + responses: + 200: + description: GETS my entity + content: + application/json: + schema: + type: object +paths: + /entity: + $ref: '#/components/pathItems/entity' From 58975899747357f1d3ce511715b18950110f7178 Mon Sep 17 00:00:00 2001 From: sf97 Date: Mon, 13 May 2024 23:27:33 +0100 Subject: [PATCH 15/19] fix(openapi_3.1_schema): update schema to fix bug --- .../openapi.v3_1.modified.schema.json | 311 +++++------------- 1 file changed, 86 insertions(+), 225 deletions(-) diff --git a/src/framework/openapi.v3_1.modified.schema.json b/src/framework/openapi.v3_1.modified.schema.json index 76e4df1d..f34616fe 100644 --- a/src/framework/openapi.v3_1.modified.schema.json +++ b/src/framework/openapi.v3_1.modified.schema.json @@ -21,9 +21,11 @@ "items": { "$ref": "#/$defs/server" }, - "default": [{ - "url": "/" - }] + "default": [ + { + "url": "/" + } + ] }, "paths": { "$ref": "#/$defs/paths" @@ -31,7 +33,7 @@ "webhooks": { "type": "object", "additionalProperties": { - "$ref": "#/$defs/path-item-or-reference" + "$ref": "#/$defs/path-item" } }, "components": { @@ -53,24 +55,16 @@ "$ref": "#/$defs/external-documentation" } }, - "required": [ - "openapi", - "info" - ], - "anyOf": [{ - "required": [ - "paths" - ] + "required": ["openapi", "info"], + "anyOf": [ + { + "required": ["paths"] }, { - "required": [ - "components" - ] + "required": ["components"] }, { - "required": [ - "webhooks" - ] + "required": ["webhooks"] } ], "$ref": "#/$defs/specification-extensions", @@ -103,10 +97,7 @@ "type": "string" } }, - "required": [ - "title", - "version" - ], + "required": ["title", "version"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -144,15 +135,11 @@ "format": "uri" } }, - "required": [ - "name" - ], + "required": ["name"], "dependentSchemas": { "identifier": { "not": { - "required": [ - "url" - ] + "required": ["url"] } } }, @@ -164,8 +151,7 @@ "type": "object", "properties": { "url": { - "type": "string", - "format": "uri-reference" + "type": "string" }, "description": { "type": "string" @@ -177,9 +163,7 @@ } } }, - "required": [ - "url" - ], + "required": ["url"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -201,9 +185,7 @@ "type": "string" } }, - "required": [ - "default" - ], + "required": ["default"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -268,7 +250,7 @@ "pathItems": { "type": "object", "additionalProperties": { - "$ref": "#/$defs/path-item-or-reference" + "$ref": "#/$defs/path-item" } } }, @@ -298,6 +280,10 @@ "$comment": "https://spec.openapis.org/oas/v3.1.0#path-item-object", "type": "object", "properties": { + "$ref": { + "type": "string", + "format": "uri-reference" + }, "summary": { "type": "string" }, @@ -344,20 +330,6 @@ "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, - "path-item-or-reference": { - "if": { - "type": "object", - "required": [ - "$ref" - ] - }, - "then": { - "$ref": "#/$defs/reference" - }, - "else": { - "$ref": "#/$defs/path-item" - } - }, "operation": { "$comment": "https://spec.openapis.org/oas/v3.1.0#operation-object", "type": "object", @@ -430,9 +402,7 @@ "format": "uri" } }, - "required": [ - "url" - ], + "required": ["url"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -444,12 +414,7 @@ "type": "string" }, "in": { - "enum": [ - "query", - "header", - "path", - "cookie" - ] + "enum": ["query", "header", "path", "cookie"] }, "description": { "type": "string" @@ -471,19 +436,13 @@ "maxProperties": 1 } }, - "required": [ - "name", - "in" - ], - "oneOf": [{ - "required": [ - "schema" - ] + "required": ["name", "in"], + "oneOf": [ + { + "required": ["schema"] }, { - "required": [ - "content" - ] + "required": ["content"] } ], "if": { @@ -492,9 +451,7 @@ "const": "query" } }, - "required": [ - "in" - ] + "required": ["in"] }, "then": { "properties": { @@ -514,7 +471,8 @@ "type": "boolean" } }, - "allOf": [{ + "allOf": [ + { "$ref": "#/$defs/examples" }, { @@ -541,27 +499,19 @@ "const": "path" } }, - "required": [ - "in" - ] + "required": ["in"] }, "then": { "properties": { "style": { "default": "simple", - "enum": [ - "matrix", - "label", - "simple" - ] + "enum": ["matrix", "label", "simple"] }, "required": { "const": true } }, - "required": [ - "required" - ] + "required": ["required"] } }, "styles-for-header": { @@ -571,9 +521,7 @@ "const": "header" } }, - "required": [ - "in" - ] + "required": ["in"] }, "then": { "properties": { @@ -591,9 +539,7 @@ "const": "query" } }, - "required": [ - "in" - ] + "required": ["in"] }, "then": { "properties": { @@ -620,9 +566,7 @@ "const": "cookie" } }, - "required": [ - "in" - ] + "required": ["in"] }, "then": { "properties": { @@ -642,9 +586,7 @@ "parameter-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -668,18 +610,14 @@ "type": "boolean" } }, - "required": [ - "content" - ], + "required": ["content"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, "request-body-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -712,7 +650,8 @@ } } }, - "allOf": [{ + "allOf": [ + { "$ref": "#/$defs/specification-extensions" }, { @@ -737,12 +676,7 @@ }, "style": { "default": "form", - "enum": [ - "form", - "spaceDelimited", - "pipeDelimited", - "deepObject" - ] + "enum": ["form", "spaceDelimited", "pipeDelimited", "deepObject"] }, "explode": { "type": "boolean" @@ -752,7 +686,8 @@ "type": "boolean" } }, - "allOf": [{ + "allOf": [ + { "$ref": "#/$defs/specification-extensions" }, { @@ -810,18 +745,14 @@ } } }, - "required": [ - "description" - ], + "required": ["description"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, "response-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -835,15 +766,13 @@ "type": "object", "$ref": "#/$defs/specification-extensions", "additionalProperties": { - "$ref": "#/$defs/path-item-or-reference" + "$ref": "#/$defs/path-item" } }, "callbacks-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -869,10 +798,7 @@ } }, "not": { - "required": [ - "value", - "externalValue" - ] + "required": ["value", "externalValue"] }, "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false @@ -880,9 +806,7 @@ "example-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -913,15 +837,12 @@ "$ref": "#/$defs/server" } }, - "oneOf": [{ - "required": [ - "operationRef" - ] + "oneOf": [ + { + "required": ["operationRef"] }, { - "required": [ - "operationId" - ] + "required": ["operationId"] } ], "$ref": "#/$defs/specification-extensions", @@ -930,9 +851,7 @@ "link-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -965,15 +884,12 @@ "maxProperties": 1 } }, - "oneOf": [{ - "required": [ - "schema" - ] + "oneOf": [ + { + "required": ["schema"] }, { - "required": [ - "content" - ] + "required": ["content"] } ], "dependentSchemas": { @@ -997,9 +913,7 @@ "header-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -1022,9 +936,7 @@ "$ref": "#/$defs/external-documentation" } }, - "required": [ - "name" - ], + "required": ["name"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -1047,32 +959,22 @@ "schema": { "$comment": "https://spec.openapis.org/oas/v3.1.0#schema-object", "$dynamicAnchor": "meta", - "type": [ - "object", - "boolean" - ] + "type": ["object", "boolean"] }, "security-scheme": { "$comment": "https://spec.openapis.org/oas/v3.1.0#security-scheme-object", "type": "object", "properties": { "type": { - "enum": [ - "apiKey", - "http", - "mutualTLS", - "oauth2", - "openIdConnect" - ] + "enum": ["apiKey", "http", "mutualTLS", "oauth2", "openIdConnect"] }, "description": { "type": "string" } }, - "required": [ - "type" - ], - "allOf": [{ + "required": ["type"], + "allOf": [ + { "$ref": "#/$defs/specification-extensions" }, { @@ -1100,9 +1002,7 @@ "const": "apiKey" } }, - "required": [ - "type" - ] + "required": ["type"] }, "then": { "properties": { @@ -1110,17 +1010,10 @@ "type": "string" }, "in": { - "enum": [ - "query", - "header", - "cookie" - ] + "enum": ["query", "header", "cookie"] } }, - "required": [ - "name", - "in" - ] + "required": ["name", "in"] } }, "type-http": { @@ -1130,9 +1023,7 @@ "const": "http" } }, - "required": [ - "type" - ] + "required": ["type"] }, "then": { "properties": { @@ -1140,9 +1031,7 @@ "type": "string" } }, - "required": [ - "scheme" - ] + "required": ["scheme"] } }, "type-http-bearer": { @@ -1156,10 +1045,7 @@ "pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$" } }, - "required": [ - "type", - "scheme" - ] + "required": ["type", "scheme"] }, "then": { "properties": { @@ -1176,9 +1062,7 @@ "const": "oauth2" } }, - "required": [ - "type" - ] + "required": ["type"] }, "then": { "properties": { @@ -1186,9 +1070,7 @@ "$ref": "#/$defs/oauth-flows" } }, - "required": [ - "flows" - ] + "required": ["flows"] } }, "type-oidc": { @@ -1198,9 +1080,7 @@ "const": "openIdConnect" } }, - "required": [ - "type" - ] + "required": ["type"] }, "then": { "properties": { @@ -1209,9 +1089,7 @@ "format": "uri" } }, - "required": [ - "openIdConnectUrl" - ] + "required": ["openIdConnectUrl"] } } } @@ -1219,9 +1097,7 @@ "security-scheme-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -1264,10 +1140,7 @@ "$ref": "#/$defs/map-of-strings" } }, - "required": [ - "authorizationUrl", - "scopes" - ], + "required": ["authorizationUrl", "scopes"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -1286,10 +1159,7 @@ "$ref": "#/$defs/map-of-strings" } }, - "required": [ - "tokenUrl", - "scopes" - ], + "required": ["tokenUrl", "scopes"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -1308,10 +1178,7 @@ "$ref": "#/$defs/map-of-strings" } }, - "required": [ - "tokenUrl", - "scopes" - ], + "required": ["tokenUrl", "scopes"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -1334,11 +1201,7 @@ "$ref": "#/$defs/map-of-strings" } }, - "required": [ - "authorizationUrl", - "tokenUrl", - "scopes" - ], + "required": ["authorizationUrl", "tokenUrl", "scopes"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false } @@ -1384,9 +1247,7 @@ "const": "form" } }, - "required": [ - "style" - ] + "required": ["style"] }, "then": { "properties": { @@ -1404,4 +1265,4 @@ } } } -} \ No newline at end of file +} From 965441718baeec5fc75029dfbb0b147c318bb1e4 Mon Sep 17 00:00:00 2001 From: sf97 Date: Thu, 16 May 2024 23:19:17 +0100 Subject: [PATCH 16/19] feat(openapi_3.1): support reusable path items --- src/middlewares/parsers/schema.preprocessor.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index 6b18d7a2..5c8ca580 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -157,6 +157,11 @@ export class SchemaPreprocessor { for (const [p, pi] of Object.entries(this.apiDoc.paths)) { const pathItem = this.resolveSchema(pi); + + // Since OpenAPI 3.1, paths can be a #ref to reusable path items + // The following line mutates the paths item to dereference the reference, so that we can process as a POJO, as we would if it wasn't a reference + this.apiDoc.paths[p] = pathItem; + for (const method of Object.keys(pathItem)) { if (httpMethods.has(method)) { const operation = pathItem[method]; From ddc0ebc1c34c0399cf956ddb77c9d63454890964 Mon Sep 17 00:00:00 2001 From: sf97 Date: Mon, 20 May 2024 20:51:59 +0100 Subject: [PATCH 17/19] style(linting): fix linting issues --- src/middlewares/openapi.request.validator.ts | 1 - src/middlewares/parsers/schema.preprocessor.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/middlewares/openapi.request.validator.ts b/src/middlewares/openapi.request.validator.ts index 06e2527e..527254ef 100644 --- a/src/middlewares/openapi.request.validator.ts +++ b/src/middlewares/openapi.request.validator.ts @@ -21,7 +21,6 @@ import { import { BodySchemaParser } from './parsers/body.parse'; import { ParametersSchemaParser } from './parsers/schema.parse'; import { RequestParameterMutator } from './parsers/req.parameter.mutator'; -import { debug } from 'console'; type OperationObject = OpenAPIV3.OperationObject; type SchemaObject = OpenAPIV3.SchemaObject; diff --git a/src/middlewares/parsers/schema.preprocessor.ts b/src/middlewares/parsers/schema.preprocessor.ts index 5c8ca580..b6f756e8 100644 --- a/src/middlewares/parsers/schema.preprocessor.ts +++ b/src/middlewares/parsers/schema.preprocessor.ts @@ -127,8 +127,8 @@ export class SchemaPreprocessor { // Traverse the schemas if (r) { this.traverseSchemas(schemaNodes, (parent, schema, opts) => - this.schemaVisitor(parent, schema, opts), - ); + this.schemaVisitor(parent, schema, opts), + ); } return { From 2cae4b5592ed56b12d02643f3013655a9bd30174 Mon Sep 17 00:00:00 2001 From: sf97 Date: Mon, 20 May 2024 21:00:46 +0100 Subject: [PATCH 18/19] style(openapi): improve readability of version validation --- src/framework/openapi.schema.validator.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/framework/openapi.schema.validator.ts b/src/framework/openapi.schema.validator.ts index 81a20f7b..46d40cd6 100644 --- a/src/framework/openapi.schema.validator.ts +++ b/src/framework/openapi.schema.validator.ts @@ -32,22 +32,26 @@ export class OpenAPISchemaValidator { options.validateSchema = false; } - const ver = opts.version && parseFloat(String(opts.version)); - if (!ver) throw Error('version missing from OpenAPI specification'); - if (parseInt(ver.toString()) != 3) throw Error('OpenAPI v3 specification version is required'); + const [ok, major, minor] = /^(\d+)\.(\d+).(\d+)?$/.exec(opts.version); + + if (!ok) { + throw Error('Version missing from OpenAPI specification') + }; + + if (major !== '3' || minor !== '0' && minor !== '1') { + throw new Error('OpenAPI v3.0 or v3.1 specification version is required'); + } let ajvInstance; let schema; - if (ver === 3) { + if (minor === '0') { schema = openapi3Schema; ajvInstance = new AjvDraft4(options); - } else if (ver === 3.1) { + } else if (minor == '1') { schema = openapi31Schema; ajvInstance = new Ajv2020(options); ajvInstance.addFormat('media-range', true); // TODO: Validate media-range format as defined in https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf - } else { - throw new Error('OpenAPI v3 specification 3.0 and 3.1 supported'); } addFormats(ajvInstance, ['email', 'regex', 'uri', 'uri-reference']); From 81b45eef26526b47b454f04aac5ff24b08e3a925 Mon Sep 17 00:00:00 2001 From: sf97 Date: Mon, 20 May 2024 21:24:46 +0100 Subject: [PATCH 19/19] docs(schema-validator): clearly state why media-range attribute is not defined --- src/framework/openapi.schema.validator.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/framework/openapi.schema.validator.ts b/src/framework/openapi.schema.validator.ts index 46d40cd6..025f201f 100644 --- a/src/framework/openapi.schema.validator.ts +++ b/src/framework/openapi.schema.validator.ts @@ -51,7 +51,12 @@ export class OpenAPISchemaValidator { } else if (minor == '1') { schema = openapi31Schema; ajvInstance = new Ajv2020(options); - ajvInstance.addFormat('media-range', true); // TODO: Validate media-range format as defined in https://www.rfc-editor.org/rfc/rfc9110.html#name-collected-abnf + + // Open API 3.1 has a custom "media-range" attribute defined in its schema, but the spec does not define it. "It's not really intended to be validated" + // https://github.com/OAI/OpenAPI-Specification/issues/2714#issuecomment-923185689 + // Since the schema is non-normative (https://github.com/OAI/OpenAPI-Specification/pull/3355#issuecomment-1915695294) we will only validate that it's a string + // as the spec states + ajvInstance.addFormat('media-range', true); } addFormats(ajvInstance, ['email', 'regex', 'uri', 'uri-reference']);