From 0f32aad4053e729228fc1eb8017d15a430426696 Mon Sep 17 00:00:00 2001 From: Myrotvorets Date: Fri, 29 Sep 2023 04:16:49 +0300 Subject: [PATCH] Support OpenAPI 3.1 specs --- .eslintignore | 2 + .eslintrc.json | 3 +- .mocharc.cjs | 3 +- lib/index.mts | 45 ++++++++++++++--- package-lock.json | 50 +++++++++++++++++++ package.json | 2 + test/index.test.mts | 113 ++++++++++++++++++++++++------------------ test/openapi-3.1.yaml | 106 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 264 insertions(+), 60 deletions(-) create mode 100644 test/openapi-3.1.yaml diff --git a/.eslintignore b/.eslintignore index 94375f7..0ce1b5b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,4 @@ dist/** node_modules/** +*.cjs +*.mjs diff --git a/.eslintrc.json b/.eslintrc.json index 36c98a1..d3f0e81 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,7 +4,8 @@ "sourceType": "module" }, "extends": [ - "@myrotvorets/myrotvorets-ts" + "@myrotvorets/myrotvorets-ts", + "plugin:mocha/recommended" ], "env": { "es2022": true, diff --git a/.mocharc.cjs b/.mocharc.cjs index 955542c..19ab9f2 100644 --- a/.mocharc.cjs +++ b/.mocharc.cjs @@ -3,11 +3,10 @@ module.exports = { recursive: true, spec: ['test/**/*.test.mts'], 'node-option': ['loader=ts-node/esm', 'no-warnings'], - // require: 'mocha.setup.mjs', reporter: 'mocha-multi', 'reporter-option': [ 'spec=-', process.env.GITHUB_ACTIONS === 'true' ? 'mocha-reporter-gha=-' : null, - 'mocha-reporter-sonarqube=test-report.xml' + process.env.SONARSCANNER === 'true' ? 'mocha-reporter-sonarqube=test-report.xml' : null, ].filter(Boolean), } diff --git a/lib/index.mts b/lib/index.mts index 359c93a..cc99fad 100644 --- a/lib/index.mts +++ b/lib/index.mts @@ -1,7 +1,20 @@ import { middleware } from 'express-openapi-validator'; -import { OpenApiSpecLoader } from 'express-openapi-validator/dist/framework/openapi.spec.loader.js'; +import { OpenApiSpecLoader, type Spec } from 'express-openapi-validator/dist/framework/openapi.spec.loader.js'; import type { Express } from 'express'; import type { OpenApiValidatorOpts } from 'express-openapi-validator/dist/openapi.validator.js'; +import type { OpenAPIV3 } from 'express-openapi-validator/dist/framework/types.d.ts'; + +function loadSpec(doc: OpenAPIV3.Document | string, validate: boolean): Promise { + const loader = new OpenApiSpecLoader({ + apiDoc: doc, + validateApiSpec: validate, + $refParser: { + mode: 'dereference', + }, + }); + + return loader.load(); +} export async function installOpenApiValidator( specFile: string, @@ -12,16 +25,32 @@ export async function installOpenApiValidator( 'apiSpec' | 'validateSecurity' | 'validateResponses' | 'validateFormats' | 'ajvFormats' | '$refParser' > = {}, ): Promise { - const loader = new OpenApiSpecLoader({ - apiDoc: specFile, - $refParser: { - mode: 'dereference', - }, - }); + let spec = await loadSpec(specFile, extraOptions.validateApiSpec ?? false); + if (spec.apiDoc.openapi.startsWith('3.1.') && extraOptions.validateApiSpec !== false) { + // express-openapi-validator does not support OpenAPI 3.1 yet - see https://github.com/cdimascio/express-openapi-validator/issues/755 + // We make use of `links` in our OpenAPI spec, which is only supported in OpenAPI 3.1 + spec.apiDoc.openapi = '3.0.3'; + + const { paths } = spec.apiDoc; + Object.keys(paths).forEach((name) => { + const path = paths[name]; + delete (path.delete as Record | undefined)?.links; + delete (path.get as Record | undefined)?.links; + delete (path.head as Record | undefined)?.links; + delete (path.options as Record | undefined)?.links; + delete (path.patch as Record | undefined)?.links; + delete (path.post as Record | undefined)?.links; + delete (path.put as Record | undefined)?.links; + delete (path.trace as Record | undefined)?.links; + }); + } - const spec = await loader.load(); spec.apiDoc.servers = [{ url: '/' }]; + if (extraOptions.validateApiSpec !== false) { + spec = await loadSpec(spec.apiDoc, true); + } + const validator = middleware({ ...extraOptions, apiSpec: spec.apiDoc, diff --git a/package-lock.json b/package-lock.json index a1649ee..ecea445 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@types/supertest": "^2.0.10", "c8": "^8.0.1", "eslint-formatter-gha": "^1.2.0", + "eslint-plugin-mocha": "^10.2.0", "express": "^4.17.1", "express-openapi-validator": "^5.0.0", "mocha": "^10.2.0", @@ -2009,6 +2010,22 @@ "semver": "bin/semver.js" } }, + "node_modules/eslint-plugin-mocha": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-mocha/-/eslint-plugin-mocha-10.2.0.tgz", + "integrity": "sha512-ZhdxzSZnd1P9LqDPF0DBcFLpRIGdh1zkF2JHnQklKQOvrQtT73kdP5K9V2mzvbLR+cCAO9OI48NXK/Ax9/ciCQ==", + "dev": true, + "dependencies": { + "eslint-utils": "^3.0.0", + "rambda": "^7.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-plugin-prettier": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.0.0.tgz", @@ -2082,6 +2099,33 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", + "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^2.0.0" + }, + "engines": { + "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=5" + } + }, + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/eslint-visitor-keys": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", @@ -4389,6 +4433,12 @@ } ] }, + "node_modules/rambda": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/rambda/-/rambda-7.5.0.tgz", + "integrity": "sha512-y/M9weqWAH4iopRd7EHDEQQvpFPHj1AA3oHozE9tfITHUtTR7Z9PSlIRRG2l1GuW7sefC1cXFfIcF+cgnShdBA==", + "dev": true + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", diff --git a/package.json b/package.json index 0042d7a..41a0153 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "prepare": "npm run build", "pretest": "npm run lint", "test": "mocha", + "pretest:coverage": "npm run lint", "test:coverage": "c8 mocha" }, "files": [ @@ -29,6 +30,7 @@ "@types/supertest": "^2.0.10", "c8": "^8.0.1", "eslint-formatter-gha": "^1.2.0", + "eslint-plugin-mocha": "^10.2.0", "express": "^4.17.1", "express-openapi-validator": "^5.0.0", "mocha": "^10.2.0", diff --git a/test/index.test.mts b/test/index.test.mts index 07d7255..cfbd5b6 100644 --- a/test/index.test.mts +++ b/test/index.test.mts @@ -1,6 +1,6 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { describe, it } from 'mocha'; +import { doesNotReject } from 'node:assert/strict'; import express, { type Application, type NextFunction, type Request, type Response } from 'express'; import request from 'supertest'; import { installOpenApiValidator } from '../lib/index.mjs'; @@ -32,65 +32,80 @@ async function buildServer(install: boolean, env: string): Promise return app; } -describe('Without installOpenApiValidator', () => { - it('should return 200 for bad request', async (): Promise => { - const server = await buildServer(false, ''); - return request(server).get('/test').expect(200); - }); -}); - -describe('With installOpenApiValidator', () => { - it('will not run security handlers', async (): Promise => { - const server = await buildServer(true, 'test'); - return request(server).get('/auth').expect(204); +describe('installOpenApiValidator', function () { + describe('Without installOpenApiValidator', function () { + it('should return 200 for bad request', async function (): Promise { + const server = await buildServer(false, ''); + return request(server).get('/test').expect(200); + }); }); - describe('in test mode', () => { - // This one checks that `servers` section gets overwritten - // If `servers` is not overwritten, the URL won't match the base URL constraint - it('will validate all requests', async (): Promise => { + describe('With installOpenApiValidator', function () { + it('will not run security handlers', async function (): Promise { const server = await buildServer(true, 'test'); - return request(server) - .get('/test') - .expect(400) - .expect(/\/query\/s/u); + return request(server).get('/auth').expect(204); }); - it('will thoroughly validate requests', async (): Promise => { - const server = await buildServer(true, 'test'); - return request(server) - .get('/test?s=2012-13-31') - .expect(400) - .expect(/\/query\/s/u) - .expect(/must match format/u); - }); + describe('in test mode', function () { + // This one checks that `servers` section gets overwritten + // If `servers` is not overwritten, the URL won't match the base URL constraint + it('will validate all requests', async function (): Promise { + const server = await buildServer(true, 'test'); + return request(server) + .get('/test') + .expect(400) + .expect(/\/query\/s/u); + }); - it('will validate responses', async (): Promise => { - const server = await buildServer(true, 'test'); - return request(server) - .get('/test?s=2012-12-31') - .expect(500) - .expect(/\/response\/debug/u); - }); - }); + it('will thoroughly validate requests', async function (): Promise { + const server = await buildServer(true, 'test'); + return request(server) + .get('/test?s=2012-13-31') + .expect(400) + .expect(/\/query\/s/u) + .expect(/must match format/u); + }); - describe('in production mode', () => { - it('will validate all requests', async (): Promise => { - const server = await buildServer(true, 'production'); - return request(server) - .get('/test') - .expect(400) - .expect(/\/query\/s/u); + it('will validate responses', async function (): Promise { + const server = await buildServer(true, 'test'); + return request(server) + .get('/test?s=2012-12-31') + .expect(500) + .expect(/\/response\/debug/u); + }); }); - it('will not thoroughly validate requests', async (): Promise => { - const server = await buildServer(true, 'production'); - return request(server).get('/test?s=2012-13-31').expect(200); + describe('in production mode', function () { + it('will validate all requests', async function (): Promise { + const server = await buildServer(true, 'production'); + return request(server) + .get('/test') + .expect(400) + .expect(/\/query\/s/u); + }); + + it('will not thoroughly validate requests', async function (): Promise { + const server = await buildServer(true, 'production'); + return request(server).get('/test?s=2012-13-31').expect(200); + }); + + it('will not validate responses', async function (): Promise { + const server = await buildServer(true, 'production'); + return request(server).get('/test?s=2012-12-31').expect(200); + }); }); + }); + + describe('OpenAPI 3.1', function () { + it('should load the spec', function () { + const app = express(); + const promise = installOpenApiValidator( + join(dirname(fileURLToPath(import.meta.url)), 'openapi-3.1.yaml'), + app, + 'development', + ); - it('will not validate responses', async (): Promise => { - const server = await buildServer(true, 'production'); - return request(server).get('/test?s=2012-12-31').expect(200); + return doesNotReject(promise); }); }); }); diff --git a/test/openapi-3.1.yaml b/test/openapi-3.1.yaml new file mode 100644 index 0000000..63f1954 --- /dev/null +++ b/test/openapi-3.1.yaml @@ -0,0 +1,106 @@ +openapi: '3.1.0' + +info: + title: Test API + version: 1.0.0 + +servers: + - url: / + +paths: + /test: + get: + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: object + properties: + id: + type: string + additionalProperties: false + required: + - id + links: + detailed-test: + operationId: detailed-test + parameters: + id: $response.body#/id + delete: + responses: + "204": + description: Sucecssful response + links: + detailed-test: + operationId: detailed-test + parameters: + id: 0 + + head: + responses: + "204": + description: Sucecssful response + links: + detailed-test: + operationId: detailed-test + parameters: + id: 0 + + options: + responses: + "204": + description: Sucecssful response + links: + detailed-test: + operationId: detailed-test + parameters: + id: 0 + + patch: + responses: + "204": + description: Sucecssful response + links: + detailed-test: + operationId: detailed-test + parameters: + id: 0 + + post: + responses: + "204": + description: Sucecssful response + links: + detailed-test: + operationId: detailed-test + parameters: + id: 0 + + put: + responses: + "204": + description: Sucecssful response + links: + detailed-test: + operationId: detailed-test + parameters: + id: 0 + + trace: + responses: + "204": + description: Sucecssful response + links: + detailed-test: + operationId: detailed-test + parameters: + id: 0 + + /test/{id}: + get: + operationId: detailed-test + responses: + "204": + description: Sucecssful response