Skip to content

Commit

Permalink
feat(resolver): collect errors in ExampleElement visitor hook
Browse files Browse the repository at this point in the history
This change in specific to OpenAPI 3.1.0 resolution
strategy. Errors are now collected, instead of
thrown and visitor tranversal is not interrupted.

Refs #2798
  • Loading branch information
char0n committed Jan 30, 2023
1 parent f038e0e commit 09872a5
Show file tree
Hide file tree
Showing 6 changed files with 138 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Retrieves the root cause of ApiDOM error hierarchy.
* ApiDOM error hierarchies are modeled similar to Java.
* Every error can have cause attribute which references
* cause of this error.
*/
const getRootCause = (error) => {
if (error.cause == null) return error;

let { cause } = error;
while (cause.cause != null) {
cause = cause.cause;
}
return cause;
};

export default getRootCause;
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,27 @@ import {
EvaluationJsonSchemaUriError,
} from '@swagger-api/apidom-reference/dereference/strategies/openapi-3-1/selectors/uri';

import toPath from '../utils/to-path.js';
import getRootCause from '../utils/get-root-cause.js';
import specMapMod from '../../../../../../../specmap/lib/refs.js';

const { wrapError } = specMapMod;
const visitAsync = visit[Symbol.for('nodejs.util.promisify.custom')];

const OpenApi3_1SwaggerClientDereferenceVisitor = OpenApi3_1DereferenceVisitor.compose({
props: {
useCircularStructures: true,
allowMetaPatches: false,
basePath: null,
},
init({
allowMetaPatches = this.allowMetaPatches,
useCircularStructures = this.useCircularStructures,
basePath = this.basePath,
}) {
this.allowMetaPatches = allowMetaPatches;
this.useCircularStructures = useCircularStructures;
this.basePath = basePath;
},
methods: {
async ReferenceElement(referenceElement, key, parent, path, ancestors) {
Expand Down Expand Up @@ -122,6 +130,7 @@ const OpenApi3_1SwaggerClientDereferenceVisitor = OpenApi3_1DereferenceVisitor.c
ancestors: ancestorsLineage,
allowMetaPatches: this.allowMetaPatches,
useCircularStructures: this.useCircularStructures,
basePath: this.basePath ?? toPath([...ancestors, parent, referenceElement]),
});
fragment = await visitAsync(fragment, visitor, { keyMap, nodeTypeGetter: getNodeType });

Expand Down Expand Up @@ -180,6 +189,7 @@ const OpenApi3_1SwaggerClientDereferenceVisitor = OpenApi3_1DereferenceVisitor.c
// transclude the element for a fragment
return fragment;
},

async PathItemElement(pathItemElement, key, parent, path, ancestors) {
const [ancestorsLineage, directAncestors] = this.toAncestorLineage(ancestors);

Expand Down Expand Up @@ -244,6 +254,7 @@ const OpenApi3_1SwaggerClientDereferenceVisitor = OpenApi3_1DereferenceVisitor.c
ancestors: ancestorsLineage,
allowMetaPatches: this.allowMetaPatches,
useCircularStructures: this.useCircularStructures,
basePath: this.basePath ?? toPath([...ancestors, parent, pathItemElement]),
});
referencedElement = await visitAsync(referencedElement, visitor, {
keyMap,
Expand Down Expand Up @@ -305,6 +316,7 @@ const OpenApi3_1SwaggerClientDereferenceVisitor = OpenApi3_1DereferenceVisitor.c
// transclude referencing element with merged referenced element
return mergedPathItemElement;
},

async SchemaElement(referencingElement, key, parent, path, ancestors) {
const [ancestorsLineage, directAncestors] = this.toAncestorLineage(ancestors);

Expand Down Expand Up @@ -416,6 +428,7 @@ const OpenApi3_1SwaggerClientDereferenceVisitor = OpenApi3_1DereferenceVisitor.c
useCircularStructures: this.useCircularStructures,
allowMetaPatches: this.allowMetaPatches,
ancestors: ancestorsLineage,
basePath: this.basePath ?? toPath([...ancestors, parent, referencingElement]),
});
referencedElement = await visitAsync(referencedElement, mergeVisitor, {
keyMap,
Expand Down Expand Up @@ -492,6 +505,7 @@ const OpenApi3_1SwaggerClientDereferenceVisitor = OpenApi3_1DereferenceVisitor.c
// transclude referencing element with merged referenced element
return mergedSchemaElement;
},

async LinkElement() {
/**
* OpenApi3_1DereferenceVisitor is doing lookup of Operation Objects
Expand All @@ -500,6 +514,29 @@ const OpenApi3_1SwaggerClientDereferenceVisitor = OpenApi3_1DereferenceVisitor.c
*/
return undefined;
},

async ExampleElement(exampleElement, key, parent, path, ancestors) {
try {
return await OpenApi3_1DereferenceVisitor.compose.methods.ExampleElement.call(
this,
exampleElement,
key,
parent,
path,
ancestors
);
} catch (error) {
const rootCause = getRootCause(error);
const wrappedError = wrapError(rootCause, {
baseDoc: this.reference.uri,
externalValue: exampleElement.externalValue?.toValue(),
fullPath: this.basePath ?? toPath([...ancestors, parent, exampleElement]),
});
this.options.dereference.dereferenceOpts?.errors?.push?.(wrappedError);

return undefined;
}
},
},
});

Expand Down
5 changes: 3 additions & 2 deletions src/resolver/strategies/openapi-3-1.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const resolveOpenAPI31Strategy = async (options) => {
const refSet = ReferenceSet({ refs: [openApiElementReference] });
if (jsonPointer !== '') refSet.rootRef = null; // reset root reference as we want fragment to become the root reference

const errors = [];
const dereferenced = await dereferenceApiDOM(fragmentElement, {
resolve: {
/**
Expand Down Expand Up @@ -106,13 +107,13 @@ const resolveOpenAPI31Strategy = async (options) => {
}),
],
refSet,
dereferenceOpts: { errors },
},
});

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: [] };
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[
{
"openapi": "3.1.0",
"components": {
"examples": {
"example1": {
"description": "example1 description",
"externalValue": "./ex.json"
}
}
}
}
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[
{
"openapi": "3.1.0",
"components": {
"examples": {
"example1": {
"description": "example1 description",
"value": "sample value",
"externalValue": "./ex.json"
}
}
}
}
]
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import path from 'node:path';
import { toValue } from '@swagger-api/apidom-core';
import { mediaTypes } from '@swagger-api/apidom-ns-openapi-3-1';
import { dereference, DereferenceError } from '@swagger-api/apidom-reference/configuration/empty';
import { dereference } from '@swagger-api/apidom-reference/configuration/empty';

import * as jestSetup from '../__utils__/jest.local.setup.js';

Expand Down Expand Up @@ -135,14 +135,38 @@ describe('dereference', () => {
describe('and with unresolvable URI', () => {
const fixturePath = path.join(rootFixturePath, 'external-value-unresolvable');

test('should throw error', async () => {
const rootFilePath = path.join(fixturePath, 'root.json');
const dereferenceThunk = () =>
dereference(rootFilePath, {
test.only('should dereference', async () => {
try {
const rootFilePath = path.join(fixturePath, 'root.json');
const actual = await dereference(rootFilePath, {
parse: { mediaType: mediaTypes.latest('json') },
});
const expected = globalThis.loadJsonFile(
path.join(fixturePath, 'dereferenced.json')
);

expect(toValue(actual)).toEqual(expected);
} catch (e) {
console.dir(e);
}
});

test('should collect error', async () => {
const rootFilePath = path.join(fixturePath, 'root.json');
const errors = [];

await dereference(rootFilePath, {
parse: { mediaType: mediaTypes.latest('json') },
dereference: { dereferenceOpts: { errors } },
});

await expect(dereferenceThunk()).rejects.toThrow(DereferenceError);
expect(errors).toHaveLength(1);
expect(errors[0]).toMatchObject({
message: expect.stringMatching(/^Could not resolve reference: ENOENT/),
baseDoc: expect.stringMatching(/external-value-unresolvable\/root\.json$/),
externalValue: './ex.json',
fullPath: ['components', 'examples', 'example1'],
});
});
});

Expand All @@ -164,14 +188,32 @@ describe('dereference', () => {
describe('given both value and externalValue fields are defined', () => {
const fixturePath = path.join(rootFixturePath, 'external-value-value-both-defined');

test('should throw error', async () => {
test('should dereference', async () => {
const rootFilePath = path.join(fixturePath, 'root.json');
const dereferenceThunk = () =>
dereference(rootFilePath, {
parse: { mediaType: mediaTypes.latest('json') },
});
const actual = await dereference(rootFilePath, {
parse: { mediaType: mediaTypes.latest('json') },
});
const expected = globalThis.loadJsonFile(path.join(fixturePath, 'dereferenced.json'));

expect(toValue(actual)).toEqual(expected);
});

await expect(dereferenceThunk()).rejects.toThrow(DereferenceError);
test('should collect error', async () => {
const rootFilePath = path.join(fixturePath, 'root.json');
const errors = [];

await dereference(rootFilePath, {
parse: { mediaType: mediaTypes.latest('json') },
dereference: { dereferenceOpts: { errors } },
});

expect(errors).toHaveLength(1);
expect(errors[0]).toMatchObject({
message: expect.stringMatching(/^Could not resolve reference: ExampleElement/),
baseDoc: expect.stringMatching(/external-value-value-both-defined\/root\.json$/),
externalValue: './ex.json',
fullPath: ['components', 'examples', 'example1'],
});
});
});
});
Expand Down

0 comments on commit 09872a5

Please sign in to comment.