From 4f3ceda9997666b7f4064e5ab546ea946692d099 Mon Sep 17 00:00:00 2001 From: Vladimir Gorej Date: Tue, 17 Jan 2023 08:45:46 +0100 Subject: [PATCH] feat(resolver): add support for modelPropertyMacro option This change is specific to OpenAPI 3.1.0 strategy. Refs #2749 --- .../openapi-3-1-swagger-client/index.js | 4 + .../openapi-3-1-swagger-client/visitor.js | 331 +++++++++--------- src/resolver/strategies/openapi-3-1.js | 2 + .../__fixtures__/model-property-macro.json | 56 +++ .../openapi-3-1/__snapshots__/index.js.snap | 87 +++++ test/resolver/strategies/openapi-3-1/index.js | 12 + 6 files changed, 332 insertions(+), 160 deletions(-) create mode 100644 test/resolver/strategies/openapi-3-1/__fixtures__/model-property-macro.json diff --git a/src/helpers/apidom/reference/dereference/strategies/openapi-3-1-swagger-client/index.js b/src/helpers/apidom/reference/dereference/strategies/openapi-3-1-swagger-client/index.js index 6c6f73a08..acd911bf3 100644 --- a/src/helpers/apidom/reference/dereference/strategies/openapi-3-1-swagger-client/index.js +++ b/src/helpers/apidom/reference/dereference/strategies/openapi-3-1-swagger-client/index.js @@ -13,16 +13,19 @@ const OpenApi3_1SwaggerClientDereferenceStrategy = OpenApi3_1DereferenceStrategy useCircularStructures: true, allowMetaPatches: false, parameterMacro: null, + modelPropertyMacro: null, }, init({ useCircularStructures = this.useCircularStructures, allowMetaPatches = this.allowMetaPatches, parameterMacro = this.parameterMacro, + modelPropertyMacro = this.modelPropertyMacro, } = {}) { this.name = 'openapi-3-1-swagger-client'; this.useCircularStructures = useCircularStructures; this.allowMetaPatches = allowMetaPatches; this.parameterMacro = parameterMacro; + this.modelPropertyMacro = modelPropertyMacro; }, methods: { async dereference(file, options) { @@ -45,6 +48,7 @@ const OpenApi3_1SwaggerClientDereferenceStrategy = OpenApi3_1DereferenceStrategy useCircularStructures: this.useCircularStructures, allowMetaPatches: this.allowMetaPatches, parameterMacro: this.parameterMacro, + modelPropertyMacro: this.modelPropertyMacro, }); const dereferencedElement = await visitAsync(refSet.rootRef.value, visitor, { keyMap, diff --git a/src/helpers/apidom/reference/dereference/strategies/openapi-3-1-swagger-client/visitor.js b/src/helpers/apidom/reference/dereference/strategies/openapi-3-1-swagger-client/visitor.js index ec34a4169..1da7103af 100644 --- a/src/helpers/apidom/reference/dereference/strategies/openapi-3-1-swagger-client/visitor.js +++ b/src/helpers/apidom/reference/dereference/strategies/openapi-3-1-swagger-client/visitor.js @@ -46,14 +46,15 @@ const OpenApi3_1SwaggerClientDereferenceVisitor = OpenApi3_1DereferenceVisitor.i useCircularStructures = true, allowMetaPatches = false, parameterMacro = null, + modelPropertyMacro = null, }) { - const instance = this; let parameterMacroOperation = null; // props this.useCircularStructures = useCircularStructures; this.allowMetaPatches = allowMetaPatches; this.parameterMacro = parameterMacro; + this.modelPropertyMacro = modelPropertyMacro; // methods this.ReferenceElement = async function _ReferenceElement( @@ -132,6 +133,7 @@ const OpenApi3_1SwaggerClientDereferenceVisitor = OpenApi3_1DereferenceVisitor.i allowMetaPatches: this.allowMetaPatches, useCircularStructures: this.useCircularStructures, parameterMacro: this.parameterMacro, + modelPropertyMacro: this.modelPropertyMacro, }); fragment = await visitAsync(fragment, visitor, { keyMap, nodeTypeGetter: getNodeType }); @@ -262,6 +264,7 @@ const OpenApi3_1SwaggerClientDereferenceVisitor = OpenApi3_1DereferenceVisitor.i allowMetaPatches: this.allowMetaPatches, useCircularStructures: this.useCircularStructures, parameterMacro: this.parameterMacro, + modelPropertyMacro: this.modelPropertyMacro, }); referencedElement = await visitAsync(referencedElement, visitor, { keyMap, @@ -324,82 +327,53 @@ const OpenApi3_1SwaggerClientDereferenceVisitor = OpenApi3_1DereferenceVisitor.i return mergedPathItemElement; }; - this.SchemaElement = async function _SchemaElement( - referencingElement, - key, - parent, - path, - ancestors - ) { - const [ancestorsLineage, directAncestors] = this.toAncestorLineage(ancestors); + this.SchemaElement = { + enter: async (referencingElement, key, parent, path, ancestors) => { + const [ancestorsLineage, directAncestors] = this.toAncestorLineage(ancestors); - // skip current referencing schema as $ref keyword was not defined - if (!isStringElement(referencingElement.$ref)) { - // skip traversing this schema but traverse all it's child schemas - return undefined; - } + // skip current referencing schema as $ref keyword was not defined + if (!isStringElement(referencingElement.$ref)) { + // skip traversing this schema but traverse all it's child schemas + return undefined; + } - // skip already identified cycled schemas - if (includesClasses(['cycle'], referencingElement.$ref)) { - return false; - } + // skip already identified cycled schemas + if (includesClasses(['cycle'], referencingElement.$ref)) { + return false; + } - // detect possible cycle in traversal and avoid it - if (ancestorsLineage.some((ancs) => ancs.has(referencingElement))) { - // skip processing this schema and all it's child schemas - return false; - } + // detect possible cycle in traversal and avoid it + if (ancestorsLineage.some((ancs) => ancs.has(referencingElement))) { + // skip processing this schema and all it's child schemas + return false; + } - // compute baseURI using rules around $id and $ref keywords - let { reference } = this; - let { uri: retrievalURI } = reference; - const $refBaseURI = resolveSchema$refField(retrievalURI, referencingElement); - const $refBaseURIStrippedHash = url.stripHash($refBaseURI); - const file = File({ uri: $refBaseURIStrippedHash }); - const isUnknownURI = !this.options.resolve.resolvers.some((r) => r.canRead(file)); - const isURL = !isUnknownURI; - const isExternal = isURL && retrievalURI !== $refBaseURIStrippedHash; - - // ignore resolving external Schema Objects - if (!this.options.resolve.external && isExternal) { - // skip traversing this schema but traverse all it's child schemas - return undefined; - } + // compute baseURI using rules around $id and $ref keywords + let { reference } = this; + let { uri: retrievalURI } = reference; + const $refBaseURI = resolveSchema$refField(retrievalURI, referencingElement); + const $refBaseURIStrippedHash = url.stripHash($refBaseURI); + const file = File({ uri: $refBaseURIStrippedHash }); + const isUnknownURI = !this.options.resolve.resolvers.some((r) => r.canRead(file)); + const isURL = !isUnknownURI; + const isExternal = isURL && retrievalURI !== $refBaseURIStrippedHash; + + // ignore resolving external Schema Objects + if (!this.options.resolve.external && isExternal) { + // skip traversing this schema but traverse all it's child schemas + return undefined; + } - this.indirections.push(referencingElement); + this.indirections.push(referencingElement); - // determining reference, proper evaluation and selection mechanism - let referencedElement; + // determining reference, proper evaluation and selection mechanism + let referencedElement; - try { - if (isUnknownURI || isURL) { - // we're dealing with canonical URI or URL with possible fragment - const selector = $refBaseURI; - referencedElement = uriEvaluate( - selector, - maybeRefractToSchemaElement(reference.value.result) - ); - } else { - // we're assuming here that we're dealing with JSON Pointer here - reference = await this.toReference(url.unsanitize($refBaseURI)); - retrievalURI = reference.uri; - const selector = uriToPointer($refBaseURI); - referencedElement = maybeRefractToSchemaElement( - jsonPointerEvaluate(selector, reference.value.result) - ); - } - } catch (error) { - /** - * No SchemaElement($id=URL) was not found, so we're going to try to resolve - * the URL and assume the returned response is a JSON Schema. - */ - if (isURL && error instanceof EvaluationJsonSchemaUriError) { - if (isAnchor(uriToAnchor($refBaseURI))) { - // we're dealing with JSON Schema $anchor here - reference = await this.toReference(url.unsanitize($refBaseURI)); - retrievalURI = reference.uri; - const selector = uriToAnchor($refBaseURI); - referencedElement = $anchorEvaluate( + try { + if (isUnknownURI || isURL) { + // we're dealing with canonical URI or URL with possible fragment + const selector = $refBaseURI; + referencedElement = uriEvaluate( selector, maybeRefractToSchemaElement(reference.value.result) ); @@ -412,111 +386,148 @@ const OpenApi3_1SwaggerClientDereferenceVisitor = OpenApi3_1DereferenceVisitor.i jsonPointerEvaluate(selector, reference.value.result) ); } - } else { - throw error; + } catch (error) { + /** + * No SchemaElement($id=URL) was not found, so we're going to try to resolve + * the URL and assume the returned response is a JSON Schema. + */ + if (isURL && error instanceof EvaluationJsonSchemaUriError) { + if (isAnchor(uriToAnchor($refBaseURI))) { + // we're dealing with JSON Schema $anchor here + reference = await this.toReference(url.unsanitize($refBaseURI)); + retrievalURI = reference.uri; + const selector = uriToAnchor($refBaseURI); + referencedElement = $anchorEvaluate( + selector, + maybeRefractToSchemaElement(reference.value.result) + ); + } else { + // we're assuming here that we're dealing with JSON Pointer here + reference = await this.toReference(url.unsanitize($refBaseURI)); + retrievalURI = reference.uri; + const selector = uriToPointer($refBaseURI); + referencedElement = maybeRefractToSchemaElement( + jsonPointerEvaluate(selector, reference.value.result) + ); + } + } else { + throw error; + } } - } - // detect direct or indirect reference - if (this.indirections.includes(referencedElement)) { - throw new Error('Recursive JSON Pointer detected'); - } + // detect direct or indirect reference + if (this.indirections.includes(referencedElement)) { + throw new Error('Recursive JSON Pointer detected'); + } - // detect maximum depth of dereferencing - if (this.indirections.length > this.options.dereference.maxDepth) { - throw new MaximumDereferenceDepthError( - `Maximum dereference depth of "${this.options.dereference.maxDepth}" has been exceeded in file "${this.reference.uri}"` - ); - } + // detect maximum depth of dereferencing + if (this.indirections.length > this.options.dereference.maxDepth) { + throw new MaximumDereferenceDepthError( + `Maximum dereference depth of "${this.options.dereference.maxDepth}" has been exceeded in file "${this.reference.uri}"` + ); + } - // append referencing schema to ancestors lineage - directAncestors.add(referencingElement); + // append referencing schema to ancestors lineage + directAncestors.add(referencingElement); + + // dive deep into the fragment + const mergeVisitor = OpenApi3_1SwaggerClientDereferenceVisitor({ + reference, + namespace: this.namespace, + indirections: [...this.indirections], + options: this.options, + useCircularStructures: this.useCircularStructures, + allowMetaPatches: this.allowMetaPatches, + parameterMacro: this.parameterMacro, + modelPropertyMacro: this.modelPropertyMacro, + ancestors: ancestorsLineage, + }); + referencedElement = await visitAsync(referencedElement, mergeVisitor, { + keyMap, + nodeTypeGetter: getNodeType, + }); - // dive deep into the fragment - const mergeVisitor = OpenApi3_1SwaggerClientDereferenceVisitor({ - reference, - namespace: this.namespace, - indirections: [...this.indirections], - options: this.options, - useCircularStructures: this.useCircularStructures, - allowMetaPatches: this.allowMetaPatches, - parameterMacro: this.parameterMacro, - ancestors: ancestorsLineage, - }); - referencedElement = await visitAsync(referencedElement, mergeVisitor, { - keyMap, - nodeTypeGetter: getNodeType, - }); + // remove referencing schema from ancestors lineage + directAncestors.delete(referencingElement); - // remove referencing schema from ancestors lineage - directAncestors.delete(referencingElement); + this.indirections.pop(); - this.indirections.pop(); + if (isBooleanJsonSchemaElement(referencedElement)) { + // Boolean JSON Schema + const jsonSchemaBooleanElement = referencedElement.clone(); + // annotate referenced element with info about original referencing element + jsonSchemaBooleanElement.setMetaProperty('ref-fields', { + $ref: referencingElement.$ref?.toValue(), + }); + // annotate referenced element with info about origin + jsonSchemaBooleanElement.setMetaProperty('ref-origin', retrievalURI); + + return jsonSchemaBooleanElement; + } + + // useCircularStructures option processing + if (!this.useCircularStructures) { + const hasCycles = ancestorsLineage.some((ancs) => ancs.has(referencedElement)); + if (hasCycles) { + if (url.isHttpUrl(retrievalURI) || url.isFileSystemPath(retrievalURI)) { + // make the referencing URL or file system path absolute + const baseURI = url.resolve(retrievalURI, $refBaseURI); + const cycledSchemaElement = new SchemaElement( + { $ref: baseURI }, + referencingElement.meta.clone(), + referencingElement.attributes.clone() + ); + cycledSchemaElement.get('$ref').classes.push('cycle'); + return cycledSchemaElement; + } + // skip processing this schema but traverse all it's child schemas + return false; + } + } + + // Schema Object - merge keywords from referenced schema with referencing schema + const mergedSchemaElement = new SchemaElement( + [...referencedElement.content], + referencedElement.meta.clone(), + referencedElement.attributes.clone() + ); + // existing keywords from referencing schema overrides ones from referenced schema + referencingElement.forEach((memberValue, memberKey, member) => { + mergedSchemaElement.remove(memberKey.toValue()); + mergedSchemaElement.content.push(member); + }); + mergedSchemaElement.remove('$ref'); - if (isBooleanJsonSchemaElement(referencedElement)) { - // Boolean JSON Schema - const jsonSchemaBooleanElement = referencedElement.clone(); // annotate referenced element with info about original referencing element - jsonSchemaBooleanElement.setMetaProperty('ref-fields', { + mergedSchemaElement.setMetaProperty('ref-fields', { $ref: referencingElement.$ref?.toValue(), }); - // annotate referenced element with info about origin - jsonSchemaBooleanElement.setMetaProperty('ref-origin', retrievalURI); - - return jsonSchemaBooleanElement; - } + // annotate fragment with info about origin + mergedSchemaElement.setMetaProperty('ref-origin', retrievalURI); - // useCircularStructures option processing - if (!this.useCircularStructures) { - const hasCycles = ancestorsLineage.some((ancs) => ancs.has(referencedElement)); - if (hasCycles) { - if (url.isHttpUrl(retrievalURI) || url.isFileSystemPath(retrievalURI)) { - // make the referencing URL or file system path absolute + // allowMetaPatches option processing + if (this.allowMetaPatches) { + // apply meta patch only when not already applied + if (typeof mergedSchemaElement.get('$$ref') === 'undefined') { const baseURI = url.resolve(retrievalURI, $refBaseURI); - const cycledSchemaElement = new SchemaElement( - { $ref: baseURI }, - referencingElement.meta.clone(), - referencingElement.attributes.clone() - ); - cycledSchemaElement.get('$ref').classes.push('cycle'); - return cycledSchemaElement; + mergedSchemaElement.set('$$ref', baseURI); } - // skip processing this schema but traverse all it's child schemas - return false; } - } - - // Schema Object - merge keywords from referenced schema with referencing schema - const mergedSchemaElement = new SchemaElement( - [...referencedElement.content], - referencedElement.meta.clone(), - referencedElement.attributes.clone() - ); - // existing keywords from referencing schema overrides ones from referenced schema - referencingElement.forEach((memberValue, memberKey, member) => { - mergedSchemaElement.remove(memberKey.toValue()); - mergedSchemaElement.content.push(member); - }); - mergedSchemaElement.remove('$ref'); - // annotate referenced element with info about original referencing element - mergedSchemaElement.setMetaProperty('ref-fields', { - $ref: referencingElement.$ref?.toValue(), - }); - // annotate fragment with info about origin - mergedSchemaElement.setMetaProperty('ref-origin', retrievalURI); + // transclude referencing element with merged referenced element + return mergedSchemaElement; + }, + leave: (schemaElement) => { + if (typeof this.modelPropertyMacro !== 'function') return; + if (typeof schemaElement.properties === 'undefined') return; + if (!isObjectElement(schemaElement.properties)) return; - // allowMetaPatches option processing - if (this.allowMetaPatches) { - // apply meta patch only when not already applied - if (typeof mergedSchemaElement.get('$$ref') === 'undefined') { - const baseURI = url.resolve(retrievalURI, $refBaseURI); - mergedSchemaElement.set('$$ref', baseURI); - } - } + schemaElement.properties.forEach((property) => { + if (!isObjectElement(property)) return; - // transclude referencing element with merged referenced element - return mergedSchemaElement; + property.set('default', this.modelPropertyMacro(toValue(property))); + }); + }, }; this.OperationElement = { @@ -529,13 +540,13 @@ const OpenApi3_1SwaggerClientDereferenceVisitor = OpenApi3_1DereferenceVisitor.i }; this.ParameterElement = { - leave(parameterElement) { - if (typeof instance.parameterMacro !== 'function') return; + leave: (parameterElement) => { + if (typeof this.parameterMacro !== 'function') return; const pojoOperation = parameterMacroOperation === null ? null : toValue(parameterMacroOperation); const pojoParameter = toValue(parameterElement); - const defaultValue = instance.parameterMacro(pojoOperation, pojoParameter); + const defaultValue = this.parameterMacro(pojoOperation, pojoParameter); parameterElement.set('default', defaultValue); }, diff --git a/src/resolver/strategies/openapi-3-1.js b/src/resolver/strategies/openapi-3-1.js index 7f0acc556..ea1167d8b 100644 --- a/src/resolver/strategies/openapi-3-1.js +++ b/src/resolver/strategies/openapi-3-1.js @@ -35,6 +35,7 @@ const resolveOpenAPI31Strategy = async (options) => { useCircularStructures = false, skipNormalization = false, parameterMacro = null, + modelPropertyMacro = null, } = options; // determining BaseURI const defaultBaseURI = 'https://smartbear.com/'; @@ -96,6 +97,7 @@ const resolveOpenAPI31Strategy = async (options) => { allowMetaPatches, useCircularStructures, parameterMacro, + modelPropertyMacro, }), ], refSet, diff --git a/test/resolver/strategies/openapi-3-1/__fixtures__/model-property-macro.json b/test/resolver/strategies/openapi-3-1/__fixtures__/model-property-macro.json new file mode 100644 index 000000000..ef3cd6be9 --- /dev/null +++ b/test/resolver/strategies/openapi-3-1/__fixtures__/model-property-macro.json @@ -0,0 +1,56 @@ +{ + "openapi": "3.1.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore", + "license": { + "name": "MIT" + } + }, + "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/resolver/strategies/openapi-3-1/__snapshots__/index.js.snap b/test/resolver/strategies/openapi-3-1/__snapshots__/index.js.snap index b350636af..57b39c938 100644 --- a/test/resolver/strategies/openapi-3-1/__snapshots__/index.js.snap +++ b/test/resolver/strategies/openapi-3-1/__snapshots__/index.js.snap @@ -1292,6 +1292,93 @@ exports[`resolve OpenAPI 3.1.0 strategy given OpenAPI 3.1.0 definition via spec } `; +exports[`resolve OpenAPI 3.1.0 strategy given OpenAPI 3.1.0 definition via spec option and modelPropertyMacro is provided as a function should call modelPropertyMacro with Schema Object property 1`] = ` +{ + "errors": [], + "spec": { + "$$normalized": true, + "components": { + "schemas": { + "Error": { + "properties": { + "code": { + "default": "integer-3", + "format": "int32", + "type": "integer", + }, + "message": { + "default": "string-3", + "type": "string", + }, + }, + "required": [ + "code", + "message", + ], + "type": "object", + }, + "Pet": { + "properties": { + "id": { + "default": "integer-3", + "format": "int64", + "type": "integer", + }, + "name": { + "default": "string-3", + "type": "string", + }, + "tag": { + "default": "string-3", + "type": "string", + }, + }, + "required": [ + "id", + "name", + ], + "type": "object", + }, + "Pets": { + "items": { + "properties": { + "id": { + "default": "integer-3", + "format": "int64", + "type": "integer", + }, + "name": { + "default": "string-3", + "type": "string", + }, + "tag": { + "default": "string-3", + "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", + }, +} +`; + exports[`resolve OpenAPI 3.1.0 strategy given OpenAPI 3.1.0 definition via spec option and neither baseDoc nor url option is provided should resolve 1`] = ` { "errors": [], diff --git a/test/resolver/strategies/openapi-3-1/index.js b/test/resolver/strategies/openapi-3-1/index.js index 29e4a9aa5..f8ad1e549 100644 --- a/test/resolver/strategies/openapi-3-1/index.js +++ b/test/resolver/strategies/openapi-3-1/index.js @@ -218,6 +218,18 @@ describe('resolve', () => { expect(resolvedSpec).toMatchSnapshot(); }); }); + + describe('and modelPropertyMacro is provided as a function', () => { + test('should call modelPropertyMacro with Schema Object property', async () => { + const spec = globalThis.loadJsonFile(path.join(fixturePath, 'model-property-macro.json')); + const resolvedSpec = await SwaggerClient.resolve({ + spec, + modelPropertyMacro: (property) => `${property.type}-3`, + }); + + expect(resolvedSpec).toMatchSnapshot(); + }); + }); }); }); });