Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<typeof SchemaElement>;

Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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, []);
});
});

Expand All @@ -35,15 +35,15 @@ describe('refractor', function () {
openapi: '3.1.0',
components: {
schemas: {
user: {
User: {
$anchor: '1',
type: 'object',
oneOf: [
{
$id: '$id1',
$anchor: '2',
type: 'number',
contains: { $anchor: '3', type: 'object' },
contains: { $id: '$id2', $anchor: '3', type: 'object' },
},
],
contains: {
Expand All @@ -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, []);
});
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -36,6 +36,24 @@ const refractToSchemaElement = <T extends Element>(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,
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand All @@ -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);
}
Expand Down