From 9704a76639c660fac64437dbe4f9fe885636252b Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Thu, 23 May 2024 12:53:59 +0200 Subject: [PATCH] feat(ns-openapi-3-1): add idempotence to header example plugin Refs #4134 --- package-lock.json | 1 + packages/apidom-ns-openapi-3-1/package.json | 1 + .../plugins/normalize-header-examples.ts | 69 ++++- .../src/refractor/toolbox.ts | 40 ++- .../test/refractor/index.ts | 7 +- .../components/__snapshots__/index.ts.snap | 43 --- .../options/scope.ts | 81 +++++ .../options/storage-field.ts | 78 +++++ .../paths/__snapshots__/index.ts.snap | 276 ------------------ 9 files changed, 268 insertions(+), 328 deletions(-) delete mode 100644 packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/components/__snapshots__/index.ts.snap create mode 100644 packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/options/scope.ts create mode 100644 packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/options/storage-field.ts delete mode 100644 packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/paths/__snapshots__/index.ts.snap diff --git a/package-lock.json b/package-lock.json index f5084df56..27319bcab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38561,6 +38561,7 @@ "@babel/runtime-corejs3": "^7.20.7", "@swagger-api/apidom-ast": "^1.0.0-alpha.3", "@swagger-api/apidom-core": "^1.0.0-alpha.3", + "@swagger-api/apidom-json-pointer": "^1.0.0-alpha.3", "@swagger-api/apidom-ns-openapi-3-0": "^1.0.0-alpha.3", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", diff --git a/packages/apidom-ns-openapi-3-1/package.json b/packages/apidom-ns-openapi-3-1/package.json index 157f758c0..a474d1288 100644 --- a/packages/apidom-ns-openapi-3-1/package.json +++ b/packages/apidom-ns-openapi-3-1/package.json @@ -47,6 +47,7 @@ "@babel/runtime-corejs3": "^7.20.7", "@swagger-api/apidom-ast": "^1.0.0-alpha.3", "@swagger-api/apidom-core": "^1.0.0-alpha.3", + "@swagger-api/apidom-json-pointer": "^1.0.0-alpha.3", "@swagger-api/apidom-ns-openapi-3-0": "^1.0.0-alpha.3", "@types/ramda": "~0.30.0", "ramda": "~0.30.0", diff --git a/packages/apidom-ns-openapi-3-1/src/refractor/plugins/normalize-header-examples.ts b/packages/apidom-ns-openapi-3-1/src/refractor/plugins/normalize-header-examples.ts index df043053c..cc9b7c9ed 100644 --- a/packages/apidom-ns-openapi-3-1/src/refractor/plugins/normalize-header-examples.ts +++ b/packages/apidom-ns-openapi-3-1/src/refractor/plugins/normalize-header-examples.ts @@ -1,8 +1,9 @@ -import { cloneDeep } from '@swagger-api/apidom-core'; +import { Element, ArrayElement, toValue, cloneDeep } from '@swagger-api/apidom-core'; import HeaderElement from '../../elements/Header'; import ExampleElement from '../../elements/Example'; -import { Predicates } from '../toolbox'; +import type { Toolbox } from '../toolbox'; +import OpenApi3_1Element from '../../elements/OpenApi3-1'; /** * Override of Schema.example and Schema.examples field inside the Header Objects. @@ -15,15 +16,51 @@ import { Predicates } from '../toolbox'; * * The example value SHALL override the example provided by the schema. * Furthermore, if referencing a schema that contains an example, the examples value SHALL override the example provided by the schema. + * + * NOTE: this plugin is idempotent */ +type JSONPointer = string; +type JSONPointerTokens = string[]; + +interface PluginOptions { + scope?: JSONPointer | JSONPointerTokens; + storageField?: string; +} + /* eslint-disable no-param-reassign */ const plugin = - () => - ({ predicates }: { predicates: Predicates }) => { + ({ scope = '/', storageField = 'x-normalized-header-examples' }: PluginOptions = {}) => + (toolbox: Toolbox) => { + const { predicates, ancestorLineageToJSONPointer, compileJSONPointerTokens } = toolbox; + const scopeJSONPointer = Array.isArray(scope) ? compileJSONPointerTokens(scope) : scope; + let storage: ArrayElement | undefined; + return { visitor: { + OpenApi3_1Element: { + enter(element: OpenApi3_1Element) { + // initialize the normalized storage + storage = element.get(storageField); + if (!predicates.isArrayElement(storage)) { + storage = new ArrayElement(); + element.set(storageField, storage); + } + }, + leave(element: OpenApi3_1Element) { + // make items in storage unique and release it + storage = new ArrayElement(Array.from(new Set(toValue(storage)))); + element.set(storageField, storage); + storage = undefined; + }, + }, HeaderElement: { - leave(headerElement: HeaderElement, key: any, parent: any, path: any, ancestors: any[]) { + leave( + headerElement: HeaderElement, + key: string | number, + parent: Element | undefined, + path: (string | number)[], + ancestors: [Element | Element[]], + ) { // skip visiting this Header Object if (ancestors.some(predicates.isComponentsElement)) { return; @@ -44,6 +81,22 @@ const plugin = return; } + const headerJSONPointer = ancestorLineageToJSONPointer([ + ...ancestors, + parent!, + headerElement, + ]); + + // skip visiting this Header Object if it's already normalized + if (storage!.includes(headerJSONPointer)) { + return; + } + + // skip visiting this Header Object if we're outside the assigned scope + if (!headerJSONPointer.startsWith(scopeJSONPointer)) { + return; + } + /** * Header.examples and Schema.examples have preferences over the older * and deprected `example` field. @@ -59,9 +112,11 @@ const plugin = if (typeof headerElement.schema.examples !== 'undefined') { headerElement.schema.set('examples', examples); + storage!.push(headerJSONPointer); } if (typeof headerElement.schema.example !== 'undefined') { - headerElement.schema.set('example', examples); + headerElement.schema.set('example', examples[0]); + storage!.push(headerJSONPointer); } return; } @@ -72,9 +127,11 @@ const plugin = if (typeof headerElement.example !== 'undefined') { if (typeof headerElement.schema.examples !== 'undefined') { headerElement.schema.set('examples', [cloneDeep(headerElement.example)]); + storage!.push(headerJSONPointer); } if (typeof headerElement.schema.example !== 'undefined') { headerElement.schema.set('example', cloneDeep(headerElement.example)); + storage!.push(headerJSONPointer); } } }, diff --git a/packages/apidom-ns-openapi-3-1/src/refractor/toolbox.ts b/packages/apidom-ns-openapi-3-1/src/refractor/toolbox.ts index 232cea880..1a4c8abba 100644 --- a/packages/apidom-ns-openapi-3-1/src/refractor/toolbox.ts +++ b/packages/apidom-ns-openapi-3-1/src/refractor/toolbox.ts @@ -1,13 +1,18 @@ import { + Element, + Namespace, + ArrayElement, isElement, isStringElement, isArrayElement, isObjectElement, isMemberElement, + toValue, createNamespace, includesClasses, hasElementSourceMap, } from '@swagger-api/apidom-core'; +import { compile as compileJSONPointerTokens } from '@swagger-api/apidom-json-pointer'; import { isServersElement } from '@swagger-api/apidom-ns-openapi-3-0'; import * as openApi3_1Predicates from '../predicates'; @@ -24,7 +29,38 @@ export type Predicates = typeof openApi3_1Predicates & { hasElementSourceMap: typeof hasElementSourceMap; }; -const createToolbox = () => { +export interface Toolbox { + predicates: Predicates; + compileJSONPointerTokens: typeof compileJSONPointerTokens; + ancestorLineageToJSONPointer: typeof ancestorLineageToJSONPointer; + namespace: Namespace; +} + +/** + * Translates visitor ancestor lineage to a JSON Pointer tokens. + * Ancestor lineage is constructed of following visitor method arguments: + * + * - ancestors + * - parent + * - element + */ +const ancestorLineageToJSONPointer = (elementPath: T) => { + const jsonPointerTokens = elementPath.reduce((path, element, index) => { + if (isMemberElement(element)) { + const token = String(toValue(element.key)); + path.push(token); + } else if (isArrayElement(elementPath[index - 2])) { + const token = String((elementPath[index - 2] as ArrayElement).content.indexOf(element)); + path.push(token); + } + + return path; + }, [] as string[]); + + return compileJSONPointerTokens(jsonPointerTokens); +}; + +const createToolbox = (): Toolbox => { const namespace = createNamespace(openApi3_1Namespace); const predicates: Predicates = { ...openApi3_1Predicates, @@ -38,7 +74,7 @@ const createToolbox = () => { hasElementSourceMap, }; - return { predicates, namespace }; + return { predicates, ancestorLineageToJSONPointer, compileJSONPointerTokens, namespace }; }; export default createToolbox; diff --git a/packages/apidom-ns-openapi-3-1/test/refractor/index.ts b/packages/apidom-ns-openapi-3-1/test/refractor/index.ts index 2347060ba..181ca8df9 100644 --- a/packages/apidom-ns-openapi-3-1/test/refractor/index.ts +++ b/packages/apidom-ns-openapi-3-1/test/refractor/index.ts @@ -71,7 +71,12 @@ describe('refractor', function () { plugins: [plugin1], }); - assert.hasAllKeys(plugin1.firstCall.args[0], ['predicates', 'namespace']); + assert.hasAllKeys(plugin1.firstCall.args[0], [ + 'predicates', + 'namespace', + 'ancestorLineageToJSONPointer', + 'compileJSONPointerTokens', + ]); }); specify('should have predicates in toolbox object', function () { diff --git a/packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/components/__snapshots__/index.ts.snap b/packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/components/__snapshots__/index.ts.snap deleted file mode 100644 index 7a5bd288d..000000000 --- a/packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/components/__snapshots__/index.ts.snap +++ /dev/null @@ -1,43 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`refractor plugins normalize-header-examples given Header Object is defined in Components.headers should skip the Header Object from normalization 1`] = ` -Object { - components: Object { - headers: Object { - header1: Object { - example: 2, - schema: Object { - example: 1, - type: number, - }, - }, - }, - }, - openapi: 3.1.0, -} -`; - -exports[`refractor plugins normalize-header-examples given Header Object is defined in Components.pathItems should skip the Header Object from normalization 1`] = ` -Object { - components: Object { - responses: Object { - response1: Object { - headers: Object { - content-type: Object { - examples: Object { - example1: Object { - value: 2, - }, - }, - schema: Object { - example: 1, - type: number, - }, - }, - }, - }, - }, - }, - openapi: 3.1.0, -} -`; diff --git a/packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/options/scope.ts b/packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/options/scope.ts new file mode 100644 index 000000000..56e167d11 --- /dev/null +++ b/packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/options/scope.ts @@ -0,0 +1,81 @@ +import { expect } from 'chai'; +import dedent from 'dedent'; +import { ObjectElement, toValue } from '@swagger-api/apidom-core'; +import { parse } from '@swagger-api/apidom-parser-adapter-yaml-1-2'; + +import { OpenApi3_1Element, refractorPluginNormalizeHeaderExamples } from '../../../../../src'; + +describe('refractor', function () { + context('plugins', function () { + context('normalize-header-examples', function () { + context('given scope to limit the normalization', function () { + specify('should limit the scope of normalization', async function () { + const yamlDefinition = dedent` + openapi: 3.1.0 + paths: + /: + get: + responses: + "200": + headers: + content-type: + schema: + type: number + example: 1 + examples: + example1: + value: 2 + "400": + headers: + content-type: + schema: + type: number + example: 1 + examples: + example1: + value: 2 + `; + const apiDOM = await parse(yamlDefinition); + const openApiElement = OpenApi3_1Element.refract(apiDOM.result, { + plugins: [ + refractorPluginNormalizeHeaderExamples({ scope: '/paths/~1/get/responses/200' }), + ], + }) as OpenApi3_1Element; + + expect(toValue(openApiElement)).toMatchSnapshot(); + }); + }); + + context('given scope and running normalization multiple times', function () { + specify('should avoid normalizing the same scope multiple times', async function () { + const yamlDefinition = dedent` + openapi: 3.1.0 + paths: + /: + get: + responses: + "200": + headers: + content-type: + schema: + type: number + example: 1 + examples: + example1: + value: 2 + `; + const apiDOM = await parse(yamlDefinition); + const result = apiDOM.result as ObjectElement; + result.set('x-normalized', ['/paths/~1/get/responses/200']); + const openApiElement = OpenApi3_1Element.refract(apiDOM.result, { + plugins: [ + refractorPluginNormalizeHeaderExamples({ scope: '/paths/~1/get/responses/200' }), + ], + }) as OpenApi3_1Element; + + expect(toValue(openApiElement)).toMatchSnapshot(); + }); + }); + }); + }); +}); diff --git a/packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/options/storage-field.ts b/packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/options/storage-field.ts new file mode 100644 index 000000000..521c44d74 --- /dev/null +++ b/packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/options/storage-field.ts @@ -0,0 +1,78 @@ +import { assert } from 'chai'; +import dedent from 'dedent'; +import { toValue } from '@swagger-api/apidom-core'; +import { parse } from '@swagger-api/apidom-parser-adapter-yaml-1-2'; + +import { OpenApi3_1Element, refractorPluginNormalizeHeaderExamples } from '../../../../../src'; + +describe('refractor', function () { + context('plugins', function () { + context('normalize-header-examples', function () { + specify( + 'should use x-normalized-header-examples top level field to store normalized scopes', + async function () { + const yamlDefinition = dedent` + openapi: 3.1.0 + paths: + /: + get: + responses: + "200": + headers: + content-type: + schema: + type: number + example: 1 + examples: + example1: + value: 2 + `; + const apiDOM = await parse(yamlDefinition); + const openApiElement = OpenApi3_1Element.refract(apiDOM.result, { + plugins: [ + refractorPluginNormalizeHeaderExamples({ scope: '/paths/~1/get/responses/200' }), + ], + }) as OpenApi3_1Element; + + assert.deepEqual(toValue(openApiElement.get('x-normalized')), [ + '/paths/~1/get/responses/200', + ]); + }, + ); + + context('given custom storage field', function () { + specify('should use custom top level field to store normalized scopes', async function () { + const yamlDefinition = dedent` + openapi: 3.1.0 + paths: + /: + get: + responses: + "200": + headers: + content-type: + schema: + type: number + example: 1 + examples: + example1: + value: 2 + `; + const apiDOM = await parse(yamlDefinition); + const openApiElement = OpenApi3_1Element.refract(apiDOM.result, { + plugins: [ + refractorPluginNormalizeHeaderExamples({ + scope: '/paths/~1/get/responses/200', + storageField: '$$normalized-header-examples', + }), + ], + }) as OpenApi3_1Element; + + assert.deepEqual(toValue(openApiElement.get('$$normalized-header-examples')), [ + '/paths/~1/get/responses/200', + ]); + }); + }); + }); + }); +}); diff --git a/packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/paths/__snapshots__/index.ts.snap b/packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/paths/__snapshots__/index.ts.snap deleted file mode 100644 index 46c4a2844..000000000 --- a/packages/apidom-ns-openapi-3-1/test/refractor/plugins/normalize-header-examples/paths/__snapshots__/index.ts.snap +++ /dev/null @@ -1,276 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`refractor plugins normalize-header-examples given Header Object defines both example and examples field and Schema Object defines both example and examples fields should override both Schema Object example and examples fields 1`] = ` -Object { - openapi: 3.1.0, - paths: Object { - /: Object { - get: Object { - responses: Object { - 200: Object { - headers: Object { - content-type: Object { - example: 4, - examples: Object { - example1: Object { - value: 3, - }, - }, - schema: Object { - example: Array [ - 3, - ], - examples: Array [ - 3, - ], - type: number, - }, - }, - }, - }, - }, - }, - }, - }, -} -`; - -exports[`refractor plugins normalize-header-examples given Header Object defines both example and examples field and Schema Object defines example field should override Schema Object example field 1`] = ` -Object { - openapi: 3.1.0, - paths: Object { - /: Object { - get: Object { - responses: Object { - 200: Object { - headers: Object { - content-type: Object { - example: 3, - examples: Object { - example1: Object { - value: 2, - }, - }, - schema: Object { - example: Array [ - 2, - ], - type: number, - }, - }, - }, - }, - }, - }, - }, - }, -} -`; - -exports[`refractor plugins normalize-header-examples given Header Object defines both example and examples field and Schema Object defines examples field should override Schema Object examples field 1`] = ` -Object { - openapi: 3.1.0, - paths: Object { - /: Object { - get: Object { - responses: Object { - 200: Object { - headers: Object { - content-type: Object { - example: 3, - examples: Object { - example1: Object { - value: 2, - }, - }, - schema: Object { - examples: Array [ - 2, - ], - type: number, - }, - }, - }, - }, - }, - }, - }, - }, -} -`; - -exports[`refractor plugins normalize-header-examples given Header Object defines example field and Schema Object defines both example and examples fields should override both Schema Object example and examples fields 1`] = ` -Object { - openapi: 3.1.0, - paths: Object { - /: Object { - get: Object { - responses: Object { - 200: Object { - headers: Object { - content-type: Object { - example: 3, - schema: Object { - example: 3, - examples: Array [ - 3, - ], - type: number, - }, - }, - }, - }, - }, - }, - }, - }, -} -`; - -exports[`refractor plugins normalize-header-examples given Header Object defines example field and Schema Object defines example field should override Schema Object example field 1`] = ` -Object { - openapi: 3.1.0, - paths: Object { - /: Object { - get: Object { - responses: Object { - 200: Object { - headers: Object { - content-type: Object { - example: 2, - schema: Object { - example: 2, - type: number, - }, - }, - }, - }, - }, - }, - }, - }, -} -`; - -exports[`refractor plugins normalize-header-examples given Header Object defines example field and Schema Object defines examples field should override Schema Object examples field 1`] = ` -Object { - openapi: 3.1.0, - paths: Object { - /: Object { - get: Object { - responses: Object { - 200: Object { - headers: Object { - content-type: Object { - example: 2, - schema: Object { - examples: Array [ - 2, - ], - type: number, - }, - }, - }, - }, - }, - }, - }, - }, -} -`; - -exports[`refractor plugins normalize-header-examples given Header Object defines examples field and Schema Object defines both example and examples fields should override both Schema Object example and examples fields 1`] = ` -Object { - openapi: 3.1.0, - paths: Object { - /: Object { - get: Object { - responses: Object { - 200: Object { - headers: Object { - content-type: Object { - examples: Object { - example1: Object { - value: 3, - }, - }, - schema: Object { - example: Array [ - 3, - ], - examples: Array [ - 3, - ], - type: number, - }, - }, - }, - }, - }, - }, - }, - }, -} -`; - -exports[`refractor plugins normalize-header-examples given Header Object defines examples field and Schema Object defines example field should override Schema Object example field 1`] = ` -Object { - openapi: 3.1.0, - paths: Object { - /: Object { - get: Object { - responses: Object { - 200: Object { - headers: Object { - content-type: Object { - examples: Object { - example1: Object { - value: 2, - }, - }, - schema: Object { - example: Array [ - 2, - ], - type: number, - }, - }, - }, - }, - }, - }, - }, - }, -} -`; - -exports[`refractor plugins normalize-header-examples given Header Object defines examples field and Schema Object defines examples field should override Schema Object examples field 1`] = ` -Object { - openapi: 3.1.0, - paths: Object { - /: Object { - get: Object { - responses: Object { - 200: Object { - headers: Object { - content-type: Object { - examples: Object { - example1: Object { - value: 2, - }, - }, - schema: Object { - examples: Array [ - 2, - ], - type: number, - }, - }, - }, - }, - }, - }, - }, - }, -} -`;