From 66458eb0090f111dbe73f91706a0d3267ecea2c7 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Tue, 17 Jan 2023 09:56:54 +0100 Subject: [PATCH] feat(subtree-resolver): adapt to support OpenAPI 3.1.0 Refs #2738 --- src/helpers/normalize/openapi-3-1.js | 19 +- src/resolver/strategies/openapi-3-1.js | 135 ++++----- src/subtree-resolver/index.js | 17 +- test/resolver/strategies/openapi-3-1/index.js | 14 +- .../strategies/openapi-2--3-0.js} | 2 +- .../openapi-3-1/__fixtures__/petstore.json | 179 ++++++++++++ .../openapi-3-1/__snapshots__/index.js.snap | 259 ++++++++++++++++++ .../strategies/openapi-3-1/index.js | 44 +++ 8 files changed, 590 insertions(+), 79 deletions(-) rename test/{subtree-resolver.js => subtree-resolver/strategies/openapi-2--3-0.js} (99%) create mode 100644 test/subtree-resolver/strategies/openapi-3-1/__fixtures__/petstore.json create mode 100644 test/subtree-resolver/strategies/openapi-3-1/__snapshots__/index.js.snap create mode 100644 test/subtree-resolver/strategies/openapi-3-1/index.js diff --git a/src/helpers/normalize/openapi-3-1.js b/src/helpers/normalize/openapi-3-1.js index dde9e01697..1f088ece0f 100644 --- a/src/helpers/normalize/openapi-3-1.js +++ b/src/helpers/normalize/openapi-3-1.js @@ -1,4 +1,5 @@ -import { dispatchRefractorPlugins, isObjectElement } from '@swagger-api/apidom-core'; +/* eslint-disable camelcase */ +import { dispatchRefractorPlugins, isObjectElement, toValue } from '@swagger-api/apidom-core'; import { refractorPluginNormalizeOperationIds, refractorPluginNormalizeParameters, @@ -9,6 +10,7 @@ import { createToolbox, keyMap, getNodeType, + OpenApi3_1Element, } from '@swagger-api/apidom-ns-openapi-3-1'; import opId from '../op-id.js'; @@ -38,4 +40,19 @@ const normalize = (element) => { return normalized; }; +/** + * This adapter allow to perform normalization on Plain Old JavaScript Objects. + * The function adapts the `normalize` function interface and is able to accept + * Plain Old JavaScript Objects and returns Plain Old JavaScript Objects. + */ +export const pojoAdapter = (normalizeFn) => (spec) => { + if (spec?.$$normalized) return spec; + + const openApiElement = OpenApi3_1Element.refract(spec); + const normalized = normalizeFn(openApiElement); + + return toValue(normalized); +}; + export default normalize; +/* eslint-enable camelcase */ diff --git a/src/resolver/strategies/openapi-3-1.js b/src/resolver/strategies/openapi-3-1.js index ea1167d8bc..5f95550ab5 100644 --- a/src/resolver/strategies/openapi-3-1.js +++ b/src/resolver/strategies/openapi-3-1.js @@ -3,6 +3,8 @@ import { toValue, transclude, ParseResultElement } from '@swagger-api/apidom-cor import { compile as jsonPointerCompile, evaluate as jsonPointerEvaluate, + EvaluationJsonPointerError, + InvalidJsonPointerError, } from '@swagger-api/apidom-json-pointer'; import { OpenApi3_1Element, mediaTypes } from '@swagger-api/apidom-ns-openapi-3-1'; import { @@ -37,77 +39,84 @@ const resolveOpenAPI31Strategy = async (options) => { parameterMacro = null, modelPropertyMacro = null, } = options; - // determining BaseURI - const defaultBaseURI = 'https://smartbear.com/'; - const retrievalURI = optionsUtil.retrievalURI(options) ?? url.cwd(); - const baseURI = url.isHttpUrl(retrievalURI) ? retrievalURI : defaultBaseURI; + try { + // determining BaseURI + const defaultBaseURI = 'https://smartbear.com/'; + const retrievalURI = optionsUtil.retrievalURI(options) ?? url.cwd(); + const baseURI = url.isHttpUrl(retrievalURI) ? retrievalURI : defaultBaseURI; - // prepare spec for dereferencing - const openApiElement = OpenApi3_1Element.refract(spec); - openApiElement.classes.push('result'); - const openApiParseResultElement = new ParseResultElement([openApiElement]); + // prepare spec for dereferencing + const openApiElement = OpenApi3_1Element.refract(spec); + openApiElement.classes.push('result'); + const openApiParseResultElement = new ParseResultElement([openApiElement]); - // prepare fragment for dereferencing - const jsonPointer = jsonPointerCompile(pathDiscriminator); - const jsonPointerURI = jsonPointer === '' ? '' : `#${jsonPointer}`; - const fragmentElement = jsonPointerEvaluate(jsonPointer, openApiElement); + // prepare fragment for dereferencing + const jsonPointer = jsonPointerCompile(pathDiscriminator); + const jsonPointerURI = jsonPointer === '' ? '' : `#${jsonPointer}`; + const fragmentElement = jsonPointerEvaluate(jsonPointer, openApiElement); - // prepare reference set for dereferencing - const openApiElementReference = Reference({ uri: baseURI, value: openApiParseResultElement }); - const refSet = ReferenceSet({ refs: [openApiElementReference] }); - if (jsonPointer !== '') refSet.rootRef = null; // reset root reference as we want fragment to become the root reference + // prepare reference set for dereferencing + const openApiElementReference = Reference({ uri: baseURI, value: openApiParseResultElement }); + const refSet = ReferenceSet({ refs: [openApiElementReference] }); + if (jsonPointer !== '') refSet.rootRef = null; // reset root reference as we want fragment to become the root reference - const dereferenced = await dereferenceApiDOM(fragmentElement, { - resolve: { - /** - * swagger-client only supports resolving HTTP(S) URLs or spec objects. - * If runtime env is detected as non-browser one, - * and baseURI was not provided as part of resolver options, - * then below baseURI check will make sure that constant HTTPS URL is used as baseURI. - */ - baseURI: `${baseURI}${jsonPointerURI}`, - resolvers: [ - HttpResolverSwaggerClient({ - timeout: timeout || 10000, - redirects: redirects || 10, - }), - ], - resolverOpts: { - swaggerHTTPClientConfig: { - requestInterceptor, - responseInterceptor, + const dereferenced = await dereferenceApiDOM(fragmentElement, { + resolve: { + /** + * swagger-client only supports resolving HTTP(S) URLs or spec objects. + * If runtime env is detected as non-browser one, + * and baseURI was not provided as part of resolver options, + * then below baseURI check will make sure that constant HTTPS URL is used as baseURI. + */ + baseURI: `${baseURI}${jsonPointerURI}`, + resolvers: [ + HttpResolverSwaggerClient({ + timeout: timeout || 10000, + redirects: redirects || 10, + }), + ], + resolverOpts: { + swaggerHTTPClientConfig: { + requestInterceptor, + responseInterceptor, + }, }, + strategies: [OpenApi3_1ResolveStrategy()], }, - strategies: [OpenApi3_1ResolveStrategy()], - }, - parse: { - mediaType: mediaTypes.latest(), - parsers: [ - OpenApiJson3_1Parser({ allowEmpty: false, sourceMap: false }), - OpenApiYaml3_1Parser({ allowEmpty: false, sourceMap: false }), - JsonParser({ allowEmpty: false, sourceMap: false }), - YamlParser({ allowEmpty: false, sourceMap: false }), - BinaryParser({ allowEmpty: false, sourceMap: false }), - ], - }, - dereference: { - maxDepth: 100, - strategies: [ - OpenApi3_1SwaggerClientDereferenceStrategy({ - allowMetaPatches, - useCircularStructures, - parameterMacro, - modelPropertyMacro, - }), - ], - refSet, - }, - }); + parse: { + mediaType: mediaTypes.latest(), + parsers: [ + OpenApiJson3_1Parser({ allowEmpty: false, sourceMap: false }), + OpenApiYaml3_1Parser({ allowEmpty: false, sourceMap: false }), + JsonParser({ allowEmpty: false, sourceMap: false }), + YamlParser({ allowEmpty: false, sourceMap: false }), + BinaryParser({ allowEmpty: false, sourceMap: false }), + ], + }, + dereference: { + maxDepth: 100, + strategies: [ + OpenApi3_1SwaggerClientDereferenceStrategy({ + allowMetaPatches, + useCircularStructures, + parameterMacro, + modelPropertyMacro, + }), + ], + refSet, + }, + }); - const transcluded = transclude(fragmentElement, dereferenced, openApiElement); - const normalized = skipNormalization ? transcluded : normalizeOpenAPI31(transcluded); + const transcluded = transclude(fragmentElement, dereferenced, openApiElement); + const normalized = skipNormalization ? transcluded : normalizeOpenAPI31(transcluded); - return { spec: toValue(normalized), errors: [] }; + return { spec: toValue(normalized), errors: [] }; + } catch (error) { + if (error instanceof InvalidJsonPointerError || error instanceof EvaluationJsonPointerError) { + return { spec: null, errors: [] }; + } + throw error; + } }; export default resolveOpenAPI31Strategy; diff --git a/src/subtree-resolver/index.js b/src/subtree-resolver/index.js index 205e544c95..c085fb9f7a 100644 --- a/src/subtree-resolver/index.js +++ b/src/subtree-resolver/index.js @@ -20,12 +20,12 @@ // future versions. // // TODO: move the remarks above into project documentation - import get from 'lodash/get'; +import { isOpenAPI31 } from '../helpers/openapi-predicates.js'; import resolve from '../resolver/index.js'; -// eslint-disable-next-line camelcase -import normalizeOpenAPI2__30 from '../helpers/normalize/openapi-2--3-0.js'; +import normalizeOpenAPI2__30 from '../helpers/normalize/openapi-2--3-0.js'; // eslint-disable-line camelcase +import normalizeOpenAPI31, { pojoAdapter } from '../helpers/normalize/openapi-3-1.js'; export default async function resolveSubtree(obj, path, opts = {}) { const { @@ -48,9 +48,14 @@ export default async function resolveSubtree(obj, path, opts = {}) { useCircularStructures, }; - const { spec: normalized } = normalizeOpenAPI2__30({ - spec: obj, - }); + let normalized; + if (isOpenAPI31(obj)) { + normalized = pojoAdapter(normalizeOpenAPI31)(obj); + } else { + ({ spec: normalized } = normalizeOpenAPI2__30({ + spec: obj, + })); + } const result = await resolve({ ...resolveOptions, diff --git a/test/resolver/strategies/openapi-3-1/index.js b/test/resolver/strategies/openapi-3-1/index.js index f8ad1e5492..01a8b6e509 100644 --- a/test/resolver/strategies/openapi-3-1/index.js +++ b/test/resolver/strategies/openapi-3-1/index.js @@ -1,6 +1,5 @@ import path from 'node:path'; import fetchMock from 'fetch-mock'; -import { EvaluationJsonPointerError } from '@swagger-api/apidom-json-pointer'; import SwaggerClient from '../../../../src/index.js'; @@ -183,15 +182,14 @@ describe('resolve', () => { }); describe('and pathDiscriminator compiles into invalid JSON Pointer', () => { - test('should throw error', async () => { + test('should return spec as null', async () => { const spec = globalThis.loadJsonFile(path.join(fixturePath, 'petstore.json')); - const resolveThunk = () => - SwaggerClient.resolve({ - spec, - pathDiscriminator: ['path', 'to', 'nothing'], - }); + const resolvedSpec = await SwaggerClient.resolve({ + spec, + pathDiscriminator: ['path', 'to', 'nothing'], + }); - await expect(resolveThunk()).rejects.toThrow(EvaluationJsonPointerError); + expect(resolvedSpec).toEqual({ spec: null, errors: [] }); }); }); diff --git a/test/subtree-resolver.js b/test/subtree-resolver/strategies/openapi-2--3-0.js similarity index 99% rename from test/subtree-resolver.js rename to test/subtree-resolver/strategies/openapi-2--3-0.js index 4d33923ebd..761db41283 100644 --- a/test/subtree-resolver.js +++ b/test/subtree-resolver/strategies/openapi-2--3-0.js @@ -1,6 +1,6 @@ import xmock from 'xmock'; -import resolve from '../src/subtree-resolver/index.js'; +import resolve from '../../../src/subtree-resolver/index.js'; describe('subtree $ref resolver', () => { let xapp; diff --git a/test/subtree-resolver/strategies/openapi-3-1/__fixtures__/petstore.json b/test/subtree-resolver/strategies/openapi-3-1/__fixtures__/petstore.json new file mode 100644 index 0000000000..5ce290715d --- /dev/null +++ b/test/subtree-resolver/strategies/openapi-3-1/__fixtures__/petstore.json @@ -0,0 +1,179 @@ +{ + "openapi": "3.1.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore", + "license": { + "name": "MIT" + } + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1" + } + ], + "paths": { + "/pets": { + "get": { + "summary": "List all pets", + "operationId": "listPets", + "tags": [ + "pets" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "How many items to return at one time (max 100)", + "required": false, + "schema": { + "type": "integer", + "maximum": 100, + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "A paged array of pets", + "headers": { + "x-next": { + "description": "A link to the next page of responses", + "schema": { + "type": "string" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pets" + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "summary": "Create a pet", + "operationId": "createPets", + "tags": [ + "pets" + ], + "responses": { + "201": { + "description": "Null response" + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/pets/{petId}": { + "get": { + "summary": "Info for a specific pet", + "operationId": "showPetById", + "tags": [ + "pets" + ], + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "description": "The id of the pet to retrieve", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Expected response to a valid request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "Pets": { + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/components/schemas/Pet" + } + }, + "Error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } + } + } + } +} diff --git a/test/subtree-resolver/strategies/openapi-3-1/__snapshots__/index.js.snap b/test/subtree-resolver/strategies/openapi-3-1/__snapshots__/index.js.snap new file mode 100644 index 0000000000..c9f8804c12 --- /dev/null +++ b/test/subtree-resolver/strategies/openapi-3-1/__snapshots__/index.js.snap @@ -0,0 +1,259 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`subtree-resolver OpenAPI 3.1.0 strategy should not resolve an untargeted subtree 1`] = ` +{ + "errors": [], + "spec": { + "$$normalized": true, + "components": { + "schemas": { + "Error": { + "properties": { + "code": { + "format": "int32", + "type": "integer", + }, + "message": { + "type": "string", + }, + }, + "required": [ + "code", + "message", + ], + "type": "object", + }, + "Pet": { + "properties": { + "id": { + "format": "int64", + "type": "integer", + }, + "name": { + "type": "string", + }, + "tag": { + "type": "string", + }, + }, + "required": [ + "id", + "name", + ], + "type": "object", + }, + "Pets": { + "items": { + "$$ref": "https://smartbear.com/#/components/schemas/Pet", + "properties": { + "id": { + "format": "int64", + "type": "integer", + }, + "name": { + "type": "string", + }, + "tag": { + "type": "string", + }, + }, + "required": [ + "id", + "name", + ], + "type": "object", + }, + "maxItems": 100, + "type": "array", + }, + }, + }, + "info": { + "license": { + "name": "MIT", + }, + "title": "Swagger Petstore", + "version": "1.0.0", + }, + "openapi": "3.1.0", + "paths": { + "/pets": { + "get": { + "operationId": "listPets", + "parameters": [ + { + "description": "How many items to return at one time (max 100)", + "in": "query", + "name": "limit", + "required": false, + "schema": { + "format": "int32", + "maximum": 100, + "type": "integer", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pets", + }, + }, + }, + "description": "A paged array of pets", + "headers": { + "x-next": { + "description": "A link to the next page of responses", + "schema": { + "type": "string", + }, + }, + }, + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error", + }, + }, + }, + "description": "unexpected error", + }, + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1", + }, + ], + "summary": "List all pets", + "tags": [ + "pets", + ], + }, + "post": { + "operationId": "createPets", + "responses": { + "201": { + "description": "Null response", + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error", + }, + }, + }, + "description": "unexpected error", + }, + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1", + }, + ], + "summary": "Create a pet", + "tags": [ + "pets", + ], + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1", + }, + ], + }, + "/pets/{petId}": { + "get": { + "operationId": "showPetById", + "parameters": [ + { + "description": "The id of the pet to retrieve", + "in": "path", + "name": "petId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet", + }, + }, + }, + "description": "Expected response to a valid request", + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error", + }, + }, + }, + "description": "unexpected error", + }, + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1", + }, + ], + "summary": "Info for a specific pet", + "tags": [ + "pets", + ], + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1", + }, + ], + }, + }, + "servers": [ + { + "url": "http://petstore.swagger.io/v1", + }, + ], + }, +} +`; + +exports[`subtree-resolver OpenAPI 3.1.0 strategy should resolve a subtree of an object, and return the targeted subtree 1`] = ` +{ + "errors": [], + "spec": { + "items": { + "$$ref": "https://smartbear.com/#/components/schemas/Pet", + "properties": { + "id": { + "format": "int64", + "type": "integer", + }, + "name": { + "type": "string", + }, + "tag": { + "type": "string", + }, + }, + "required": [ + "id", + "name", + ], + "type": "object", + }, + "maxItems": 100, + "type": "array", + }, +} +`; diff --git a/test/subtree-resolver/strategies/openapi-3-1/index.js b/test/subtree-resolver/strategies/openapi-3-1/index.js new file mode 100644 index 0000000000..7b5361a2f4 --- /dev/null +++ b/test/subtree-resolver/strategies/openapi-3-1/index.js @@ -0,0 +1,44 @@ +import path from 'node:path'; + +import SwaggerClient from '../../../../src/index.js'; + +const fixturePath = path.join(__dirname, '__fixtures__'); + +describe('subtree-resolver', () => { + describe('OpenAPI 3.1.0 strategy', () => { + test('should expose a resolver function', () => { + expect(SwaggerClient.resolveSubtree).toBeInstanceOf(Function); + }); + + test('should resolve a subtree of an object, and return the targeted subtree', async () => { + const spec = globalThis.loadJsonFile(path.join(fixturePath, 'petstore.json')); + const resolvedSpec = await SwaggerClient.resolveSubtree(spec, [ + 'components', + 'schemas', + 'Pets', + ]); + + expect(resolvedSpec).toMatchSnapshot(); + }); + + test('should return null when the path is invalid', async () => { + const spec = globalThis.loadJsonFile(path.join(fixturePath, 'petstore.json')); + const { spec: resolvedSpec } = await SwaggerClient.resolveSubtree(spec, ['asdfgh']); + + expect(resolvedSpec).toBeNull(); + }); + + test('should not resolve an untargeted subtree', async () => { + const spec = globalThis.loadJsonFile(path.join(fixturePath, 'petstore.json')); + const resolvedSpec = await SwaggerClient.resolveSubtree( + spec, + ['components', 'schemas', 'Pets'], + { + returnEntireTree: true, + } + ); + + expect(resolvedSpec).toMatchSnapshot(); + }); + }); +});