diff --git a/package.json b/package.json index ecc98d40..1e59122c 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", diff --git a/src/framework/ajv/index.ts b/src/framework/ajv/index.ts index 5dda5ffa..38008ae5 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 8d6fc23f..eab583d7 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 03da0129..025f201f 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,18 +32,40 @@ export class OpenAPISchemaValidator { options.validateSchema = false; } - const v = new AjvDraft4(options); - addFormats(v, ['email', 'regex', 'uri', 'uri-reference']); + 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 (minor === '0') { + schema = openapi3Schema; + ajvInstance = new AjvDraft4(options); + } else if (minor == '1') { + schema = openapi31Schema; + ajvInstance = new Ajv2020(options); + + // 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); + } - const ver = opts.version && parseInt(String(opts.version), 10); - if (!ver) throw Error('version missing from OpenAPI specification'); - if (ver != 3) throw Error('OpenAPI v3 specification version is required'); + 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): { + 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 3ccb1486..e50d4346 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[]; serial: number; @@ -21,7 +21,7 @@ export interface RouteMetadata { } interface DiscoveredRoutes { - apiDoc: OpenAPIV3.Document; + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; basePaths: string[]; routes: RouteMetadata[]; serial: number; @@ -52,7 +52,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 { @@ -60,34 +60,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/openapi.v3_1.modified.schema.json b/src/framework/openapi.v3_1.modified.schema.json new file mode 100644 index 00000000..f34616fe --- /dev/null +++ b/src/framework/openapi.v3_1.modified.schema.json @@ -0,0 +1,1268 @@ +{ + "$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" + } + }, + "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" + }, + "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" + } + } + }, + "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": { + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "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 + }, + "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" + } + }, + "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 + } + } + } + } + } +} diff --git a/src/framework/types.ts b/src/framework/types.ts index 9d4d0c65..f1ae2015 100644 --- a/src/framework/types.ts +++ b/src/framework/types.ts @@ -22,7 +22,7 @@ export interface ValidationSchema extends ParametersSchema { } export interface OpenAPIFrameworkInit { - apiDoc: OpenAPIV3.Document; + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1; basePaths: string[]; } export type SecurityHandlers = { @@ -67,7 +67,7 @@ export type OperationHandlerOptions = { resolver: ( handlersPath: string, route: RouteMetadata, - apiDoc: OpenAPIV3.Document, + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, ) => RequestHandler | Promise; }; @@ -132,7 +132,7 @@ type DeepImmutableObject = { } export interface OpenApiValidatorOpts { - apiSpec: DeepImmutable | string; + apiSpec: DeepImmutable | string; validateApiSpec?: boolean; validateResponses?: boolean | ValidateResponseOpts; validateRequests?: boolean | ValidateRequestOpts; @@ -175,7 +175,7 @@ export interface NormalizedOpenApiValidatorOpts extends OpenApiValidatorOpts { } export namespace OpenAPIV3 { - export interface Document { + export interface DocumentV3 { openapi: string; info: InfoObject; servers?: ServerObject[]; @@ -186,6 +186,19 @@ export namespace OpenAPIV3 { externalDocs?: ExternalDocumentationObject; } + 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 + } + } + export interface InfoObject { title: string; description?: string; @@ -195,6 +208,10 @@ export namespace OpenAPIV3 { version: string; } + interface InfoObjectV3_1 extends InfoObject { + summary: string; + } + export interface ContactObject { name?: string; url?: string; @@ -498,7 +515,7 @@ export interface OpenAPIFrameworkPathObject { } interface OpenAPIFrameworkArgs { - apiDoc: OpenAPIV3.Document | string; + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1 | string; validateApiSpec?: boolean; $refParser?: { mode: 'bundle' | 'dereference'; @@ -508,7 +525,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 6d217be3..317273a3 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 e868b300..97a5fc47 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 96f8d9b0..3b795724 100644 --- a/src/middlewares/openapi.request.validator.ts +++ b/src/middlewares/openapi.request.validator.ts @@ -31,13 +31,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 = {}; @@ -278,7 +278,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; @@ -286,7 +286,7 @@ class Validator { readonly allSchemaProperties: ValidationSchema; constructor( - apiDoc: OpenAPIV3.Document, + apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, parametersSchema: ParametersSchema, bodySchema: BodySchema, ajv: { @@ -340,7 +340,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 a187ad0a..75b467ea 100644 --- a/src/middlewares/openapi.response.validator.ts +++ b/src/middlewares/openapi.response.validator.ts @@ -27,7 +27,7 @@ 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 }; } = {}; @@ -35,7 +35,7 @@ export class ResponseValidator { private serial: number; constructor( - openApiSpec: OpenAPIV3.Document, + openApiSpec: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, options: Options = {}, eovOptions: ValidateResponseOpts = {}, serial: number = -1, diff --git a/src/middlewares/openapi.security.ts b/src/middlewares/openapi.security.ts index 8f43af14..d4c62db4 100644 --- a/src/middlewares/openapi.security.ts +++ b/src/middlewares/openapi.security.ts @@ -42,7 +42,7 @@ function didOneSchemaPassValidation(results: (SecurityHandlerResult | SecurityHa } 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 5b92654c..43da1f96 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 8e14f4c0..b6f756e8 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) => - this.schemaVisitor(parent, schema, opts), - ); + if (r) { + this.traverseSchemas(schemaNodes, (parent, schema, opts) => + this.schemaVisitor(parent, schema, opts), + ); + } return { apiDoc: this.apiDoc, @@ -151,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]; @@ -535,7 +546,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/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 3265c187..047c9a97 100644 --- a/src/openapi.validator.ts +++ b/src/openapi.validator.ts @@ -37,7 +37,7 @@ export { interface MiddlewareContext { context: OpenApiContext, - responseApiDoc: OpenAPIV3.Document, + responseApiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, error: any, } @@ -264,26 +264,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, @@ -291,7 +291,7 @@ export class OpenApiValidator { return (req, res, next) => requestValidator.validate(req, res, next); } - private responseValidationMiddleware(apiDoc: OpenAPIV3.Document, serial: number) { + private responseValidationMiddleware(apiDoc: OpenAPIV3.DocumentV3 | OpenAPIV3.DocumentV3_1, serial: number) { 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/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/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/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/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/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/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/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/components.yaml b/test/openapi_3.1/resources/components.yaml new file mode 100644 index 00000000..b968adec --- /dev/null +++ b/test/openapi_3.1/resources/components.yaml @@ -0,0 +1,6 @@ +# From https://github.com/OAI/OpenAPI-Specification/blob/6f386968654fd483720aba0177e618e87a5d612d/tests/v3.1/pass/minimal_comp.yaml +openapi: 3.1.0 +info: + title: API + version: 1.0.0 +components: {} \ 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' 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 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 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 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 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/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/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/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 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 diff --git a/test/openapi_3.1/webhook.spec.ts b/test/openapi_3.1/webhook.spec.ts new file mode 100644 index 00000000..58e81b1d --- /dev/null +++ b/test/openapi_3.1/webhook.spec.ts @@ -0,0 +1,30 @@ +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 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/' }],