diff --git a/apidom/packages/apidom-ns-openapi-3-1/src/refractor/plugins/embedded-resources-$id.ts b/apidom/packages/apidom-ns-openapi-3-1/src/refractor/plugins/embedded-resources-$id.ts index e53f469b8a..7175873306 100644 --- a/apidom/packages/apidom-ns-openapi-3-1/src/refractor/plugins/embedded-resources-$id.ts +++ b/apidom/packages/apidom-ns-openapi-3-1/src/refractor/plugins/embedded-resources-$id.ts @@ -1,10 +1,18 @@ -import { last, defaultTo } from 'ramda'; +import { last } from 'ramda'; import { isNonEmptyString } from 'ramda-adjunct'; +/** + * Instead of actually resolving the $id in this plugin, we're annotating every Schema + * with `inherited$id` meta property which contains ordered list of all $id values + * intercepted before the current Schema and including the current Schema. + * + * The `inherited$id` meta property can be folded by tooling into single URI + * from right to left using specialized URI resolution algorithm. + */ + // @ts-ignore -const plugin = ({ predicates, namespace }) => { - const { Schema: SchemaElement } = namespace.elements; - const { isStringElement, isSchemaElement } = predicates; +const plugin = ({ namespace }) => { + const { Schema: SchemaElement, Array: ArrayElement } = namespace.elements; let ancestors: Array; @@ -16,20 +24,24 @@ const plugin = ({ predicates, namespace }) => { visitor: { SchemaElement: { enter(schemaElement: typeof SchemaElement) { + // fetch this schema direct parent const parentSchema = last(ancestors); + // fetch parent's inherited$id + const inherited$id = + parentSchema !== undefined + ? parentSchema.getMetaProperty('inherited$id', []).clone() + : new ArrayElement(); - if (isSchemaElement(parentSchema) && !isStringElement(schemaElement.$id)) { - // parent is available and no $id is defined, set parent $id - const inherited$id = defaultTo( - parentSchema.meta.get('inherited$id')?.toValue(), - parentSchema.$id?.toValue(), - ); + // push current $id to inherited$id list + const $id = schemaElement.$id?.toValue(); - if (isNonEmptyString(inherited$id)) { - schemaElement.setMetaProperty('inherited$id', inherited$id); - } + // we're only interested in $ids that are non empty strings + if (isNonEmptyString($id)) { + inherited$id.push($id); } + schemaElement.setMetaProperty('inherited$id', inherited$id); + ancestors.push(schemaElement); }, leave() { diff --git a/apidom/packages/apidom-ns-openapi-3-1/test/refractor/plugins/embedded-resources-$id.ts b/apidom/packages/apidom-ns-openapi-3-1/test/refractor/plugins/embedded-resources-$id.ts index 113e32a101..ac2b6cb88e 100644 --- a/apidom/packages/apidom-ns-openapi-3-1/test/refractor/plugins/embedded-resources-$id.ts +++ b/apidom/packages/apidom-ns-openapi-3-1/test/refractor/plugins/embedded-resources-$id.ts @@ -7,7 +7,7 @@ describe('refractor', function () { context('plugins', function () { context('embedded-resources-$id', function () { context('given Schema Object without $id field', function () { - specify('should not inherited $id from parent schema', function () { + specify('should have empty inherited$id', function () { const genericObjectElement = new ObjectElement({ openapi: '3.1.0', components: { @@ -20,9 +20,9 @@ describe('refractor', function () { }); const openApiElement = OpenApi3_1Element.refract(genericObjectElement); const schemaElement = find((e) => isSchemaElement(e), openApiElement); - const actual = schemaElement?.meta.get('inherited$id'); + const actual = schemaElement?.meta.get('inherited$id').toValue(); - assert.isUndefined(actual); + assert.deepEqual(actual, []); }); }); @@ -35,7 +35,7 @@ describe('refractor', function () { openapi: '3.1.0', components: { schemas: { - user: { + User: { $anchor: '1', type: 'object', oneOf: [ @@ -43,7 +43,7 @@ describe('refractor', function () { $id: '$id1', $anchor: '2', type: 'number', - contains: { $anchor: '3', type: 'object' }, + contains: { $id: '$id2', $anchor: '3', type: 'object' }, }, ], contains: { @@ -57,51 +57,45 @@ describe('refractor', function () { openApiElement = OpenApi3_1Element.refract(genericObjectElement); }); - specify('should not annotate Schema Object($anchor=1) with inherited $id', function () { + specify('should annotate Schema Object($anchor=1) with inherited$id', function () { const schemaElement = find( (e) => isSchemaElement(e) && e.$anchor.equals('1'), openApiElement, ); - const actual = schemaElement?.meta.get('inherited$id'); + const actual = schemaElement?.meta.get('inherited$id').toValue(); - assert.isUndefined(actual); + assert.deepEqual(actual, []); }); - specify('should have Schema Object($anchor=2) $id set for appropriate value', function () { + specify('should annotate Schema Object($anchor=2) with inherited$id', function () { const schemaElement = find( (e) => isSchemaElement(e) && e.$anchor.equals('2'), openApiElement, ); // @ts-ignore - const actual = schemaElement?.$id.toValue(); - const expected = '$id1'; + const actual = schemaElement?.meta.get('inherited$id').toValue(); - assert.strictEqual(actual, expected); - assert.isFalse(schemaElement?.meta.hasKey('inherited$id')); + assert.deepEqual(actual, ['$id1']); }); - specify( - 'should annotate Schema Object($anchor=3) with appropriate inherited $id', - function () { - const schemaElement = find( - (e) => isSchemaElement(e) && e.$anchor.equals('3'), - openApiElement, - ); - const actual = schemaElement?.meta.get('inherited$id').toValue(); - const expected = '$id1'; + specify('should annotate Schema Object($anchor=3) with inherited$id', function () { + const schemaElement = find( + (e) => isSchemaElement(e) && e.$anchor.equals('3'), + openApiElement, + ); + const actual = schemaElement?.meta.get('inherited$id').toValue(); - assert.strictEqual(actual, expected); - }, - ); + assert.deepEqual(actual, ['$id1', '$id2']); + }); - specify('should not annotate Schema Object($anchor=4) with inherited $id', function () { + specify('should not annotate Schema Object($anchor=4) with inherited$id', function () { const schemaElement = find( (e) => isSchemaElement(e) && e.$anchor.equals('4'), openApiElement, ); - const actual = schemaElement?.meta.get('inherited$id'); + const actual = schemaElement?.meta.get('inherited$id').toValue(); - assert.isUndefined(actual); + assert.deepEqual(actual, []); }); }); }); diff --git a/apidom/packages/apidom-reference/src/dereference/strategies/openapi-3-1/visitor.ts b/apidom/packages/apidom-reference/src/dereference/strategies/openapi-3-1/visitor.ts index 5611c162dc..5c7798651a 100644 --- a/apidom/packages/apidom-reference/src/dereference/strategies/openapi-3-1/visitor.ts +++ b/apidom/packages/apidom-reference/src/dereference/strategies/openapi-3-1/visitor.ts @@ -1,6 +1,6 @@ import stampit from 'stampit'; -import { hasIn, pathSatisfies, propEq } from 'ramda'; -import { isNotUndefined, isNonEmptyString } from 'ramda-adjunct'; +import { hasIn, pathSatisfies, propEq, reduceRight } from 'ramda'; +import { isNotUndefined } from 'ramda-adjunct'; import { isPrimitiveElement, isStringElement, visit, Element } from 'apidom'; import { getNodeType, @@ -36,6 +36,24 @@ const refractToSchemaElement = (element: T) => { }; refractToSchemaElement.cache = new WeakMap(); +/** + * Folding of inherited$id list from right to left using + * URL resolving mechanism. + */ +const resolveInherited$id = (schemaElement: SchemaElement) => + reduceRight( + ($id: string, acc: string): string => { + const uriWithoutHash = url.stripHash($id); + const sanitizedURI = url.isFileSystemPath(uriWithoutHash) + ? url.fromFileSystemPath(uriWithoutHash) + : uriWithoutHash; + + return url.resolve(sanitizedURI, acc); + }, + schemaElement.$ref?.toValue(), + schemaElement.meta.get('inherited$id').toValue(), + ); + const OpenApi3_1DereferenceVisitor = stampit({ props: { indirections: null, @@ -187,21 +205,7 @@ const OpenApi3_1DereferenceVisitor = stampit({ } // compute Reference object using rules around $id and $ref keywords - const $refValue = referencingElement.$ref?.toValue(); - const $idValue = referencingElement.$id?.toValue(); - const $inheritedIdValue = referencingElement.meta.get('inherited$id')?.toValue(); - let uri; - if (isNonEmptyString($idValue)) { - uri = url.stripHash($idValue); - uri = url.isFileSystemPath(uri) ? url.fromFileSystemPath(uri) : uri; - uri = url.resolve($idValue, $refValue); - } else if (isNonEmptyString($inheritedIdValue)) { - uri = url.stripHash($inheritedIdValue); - uri = url.isFileSystemPath(uri) ? url.fromFileSystemPath(uri) : uri; - uri = url.resolve($inheritedIdValue, $refValue); - } else { - uri = $refValue; - } + const uri = resolveInherited$id(referencingElement); const reference = await this.toReference(uri); this.indirections.push(referencingElement); diff --git a/apidom/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$id-unresolvable/root.json b/apidom/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$id-unresolvable/root.json new file mode 100644 index 0000000000..a8a8381127 --- /dev/null +++ b/apidom/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$id-unresolvable/root.json @@ -0,0 +1,23 @@ +{ + "openapi": "3.1.0", + "components": { + "schemas": { + "User": { + "$id": "./schemas/", + "type": "object", + "properties": { + "login": { + "type": "string" + }, + "password": { + "type": "string" + }, + "profile": { + "$id": "./nested/", + "$ref": "./ex.json" + } + } + } + } + } +} diff --git a/apidom/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/index.ts b/apidom/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/index.ts index e8f6aa1eb6..cdf6c02a71 100644 --- a/apidom/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/index.ts +++ b/apidom/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/index.ts @@ -4,7 +4,11 @@ import { toValue } from 'apidom'; import { isSchemaElement } from 'apidom-ns-openapi-3-1'; import { dereference } from '../../../../../src'; -import { DereferenceError, MaximumDereferenceDepthError } from '../../../../../src/util/errors'; +import { + DereferenceError, + MaximumDereferenceDepthError, + ResolverError, +} from '../../../../../src/util/errors'; import { loadJsonFile } from '../../../../helpers'; import { evaluate } from '../../../../../src/selectors/json-pointer'; @@ -359,6 +363,24 @@ describe('dereference', function () { }); }); + context('given Schema Objects with unresolvable $id values', function () { + const fixturePath = path.join(rootFixturePath, '$id-unresolvable'); + + specify('should throw error', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + try { + await dereference(rootFilePath, { + parse: { mediaType: 'application/vnd.oai.openapi+json;version=3.1.0' }, + }); + assert.fail('should throw DereferenceError'); + } catch (error) { + assert.instanceOf(error, DereferenceError); + assert.instanceOf(error.cause.cause, ResolverError); + assert.match(error.cause.cause.message, /\/schemas\/nested\/ex\.json"$/); + } + }); + }); + context('given Schema Objects and maxDepth of dereference', function () { const fixturePath = path.join(rootFixturePath, 'max-depth'); @@ -388,6 +410,7 @@ describe('dereference', function () { await dereference(rootFilePath, { parse: { mediaType: 'application/vnd.oai.openapi+json;version=3.1.0' }, }); + assert.fail('should throw DereferenceError'); } catch (e) { assert.instanceOf(e, DereferenceError); } @@ -403,6 +426,7 @@ describe('dereference', function () { await dereference(rootFilePath, { parse: { mediaType: 'application/vnd.oai.openapi+json;version=3.1.0' }, }); + assert.fail('should throw DereferenceError'); } catch (e) { assert.instanceOf(e, DereferenceError); } @@ -418,6 +442,7 @@ describe('dereference', function () { await dereference(rootFilePath, { parse: { mediaType: 'application/vnd.oai.openapi+json;version=3.1.0' }, }); + assert.fail('should throw DereferenceError'); } catch (e) { assert.instanceOf(e, DereferenceError); } @@ -433,6 +458,7 @@ describe('dereference', function () { await dereference(rootFilePath, { parse: { mediaType: 'application/vnd.oai.openapi+json;version=3.1.0' }, }); + assert.fail('should throw DereferenceError'); } catch (e) { assert.instanceOf(e, DereferenceError); } @@ -448,6 +474,7 @@ describe('dereference', function () { await dereference(rootFilePath, { parse: { mediaType: 'application/vnd.oai.openapi+json;version=3.1.0' }, }); + assert.fail('should throw DereferenceError'); } catch (e) { assert.instanceOf(e, DereferenceError); } @@ -463,6 +490,7 @@ describe('dereference', function () { await dereference(rootFilePath, { parse: { mediaType: 'application/vnd.oai.openapi+json;version=3.1.0' }, }); + assert.fail('should throw DereferenceError'); } catch (e) { assert.instanceOf(e, DereferenceError); } @@ -478,6 +506,7 @@ describe('dereference', function () { await dereference(rootFilePath, { parse: { mediaType: 'application/vnd.oai.openapi+json;version=3.1.0' }, }); + assert.fail('should throw DereferenceError'); } catch (e) { assert.instanceOf(e, DereferenceError); }