Skip to content

Commit

Permalink
feat(reference): implement POC of external dereference
Browse files Browse the repository at this point in the history
Dereference is specific to OpenApi 3.1 and currently only
involves Reference Objects only.

Every Reference Object is replaces with either Object
from the same OpenApi 3.1 document or by semantically
transformed Object from external file.

Refs swagger-api/oss-planning#133
  • Loading branch information
char0n committed Feb 26, 2021
1 parent 5433197 commit 1f99a31
Show file tree
Hide file tree
Showing 8 changed files with 126 additions and 76 deletions.
2 changes: 2 additions & 0 deletions apidom/packages/@types/minim.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ declare module 'minim' {
register(name: string, elementClass: any): Namespace;

use(plugin: NamespacePlugin): Namespace;

getElementClass(element: string): typeof Element;
}

export interface NamespacePluginOptions {
Expand Down
18 changes: 15 additions & 3 deletions apidom/packages/apidom-ns-openapi-3-1/src/refractor/predicates.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { MemberElement, isStringElement, isObjectElement, Element } from 'apidom';
import { startsWith } from 'ramda';
import { startsWith, all } from 'ramda';

export const isOpenApi3_1LikeElement = <T extends Element>(element: T): boolean => {
// @ts-ignore
Expand All @@ -12,8 +12,20 @@ export const isParameterLikeElement = <T extends Element>(element: T): boolean =
};

export const isReferenceLikeElement = <T extends Element>(element: T): boolean => {
// @ts-ignore
return isObjectElement(element) && element.hasKey('$ref');
const isAllowedProperty = (property: string): boolean => {
// @ts-ignore
return ['$ref', 'description', 'summary'].includes(property);
};

return (
isObjectElement(element) &&
// @ts-ignore
element.hasKey('$ref') &&
// @ts-ignore
element.keys.length <= 3 &&
// @ts-ignore
all(isAllowedProperty)(element.keys)
);
};

export const isRequestBodyLikeElement = <T extends Element>(element: T): boolean => {
Expand Down
9 changes: 6 additions & 3 deletions apidom/packages/apidom-reference/src/ReferenceSet.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import stampit from 'stampit';
import { propEq } from 'ramda';
import { isNotUndefined } from 'ramda-adjunct';
import { isNotUndefined, isString } from 'ramda-adjunct';

import { Reference as IReference, ReferenceSet as IReferenceSet } from './types';

Expand All @@ -10,8 +10,9 @@ const ReferenceSet: stampit.Stamp<IReferenceSet> = stampit({
refs: [],
circular: false,
},
init() {
init({ refs = [] } = {}) {
this.refs = [];
refs.forEach((ref: IReference) => this.refs.add(ref));
},
methods: {
get size(): number {
Expand All @@ -23,6 +24,7 @@ const ReferenceSet: stampit.Stamp<IReferenceSet> = stampit({
if (!this.has(reference)) {
this.refs.push(reference);
this.rootRef = this.rootRef === null ? reference : this.rootRef;
reference.refSet = this; // eslint-disable-line no-param-reassign
}
return this;
},
Expand All @@ -34,7 +36,8 @@ const ReferenceSet: stampit.Stamp<IReferenceSet> = stampit({
return this;
},

has(uri: string): boolean {
has(thing: string | IReference): boolean {
const uri = isString(thing) ? thing : thing.uri;
return isNotUndefined(this.find(propEq('uri', uri)));
},

Expand Down
127 changes: 67 additions & 60 deletions apidom/packages/apidom-reference/test/dereference.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,79 @@
import util from 'util';
import path from 'path';
import stampit from 'stampit';
import { hasIn, pathSatisfies } from 'ramda';
import { hasIn, pathSatisfies, propEq } from 'ramda';
import { isNotUndefined } from 'ramda-adjunct';
import { transclude, toValue, visit } from 'apidom';
import { keyMap, ReferenceElement } from 'apidom-ns-openapi-3-1';
import { toValue, visit, createNamespace, isPrimitiveElement } from 'apidom';
import openApi3_1Namespace, {
keyMap,
getNodeType,
ReferenceElement,
isReferenceLikeElement,
} from 'apidom-ns-openapi-3-1';

import { parse } from '../src';
import ReferenceSet from '../src/ReferenceSet';
import Reference from '../src/Reference';
import * as url from '../src/util/url';
import { evaluate, uriToPointer } from '../src/selectors/json-pointer';
import { Reference as IReference } from '../src/types';

// @ts-ignore
const visitAsync = visit[Symbol.for('nodejs.util.promisify.custom')];

const DereferenceVisitor = stampit({
props: {
baseURI: '',
element: null,
indirections: [],
namespace: null,
reference: null,
},
init({ baseURI, element, indirections = [] }) {
this.baseURI = baseURI;
this.element = element;
init({ reference, namespace, indirections = [] }) {
this.indirections = indirections;
this.namespace = namespace;
this.reference = reference;
},
methods: {
async ReferenceElement(referenceElement: ReferenceElement) {
const uri = referenceElement.$ref.toValue();
async toReference(uri: string): Promise<IReference> {
const uriWithoutHash = url.stripHash(uri);
const sanitizedURI = url.isFileSystemPath(uriWithoutHash)
? url.fromFileSystemPath(uriWithoutHash)
: uriWithoutHash;
const baseURI = url.resolve(this.reference.uri, sanitizedURI);
const { refSet } = this.reference;

// if only hash is provided, reference is considered to be internal
if (url.getHash(uri) === uri) {
return this.ReferenceElementInternal(referenceElement);
// we've already processed this Reference in past
if (refSet.has(baseURI)) {
return refSet.find(propEq('uri', baseURI));
}
// everything else is treated as an external reference
return this.ReferenceElementExternal(referenceElement);

// register new Reference with ReferenceSet
const parseResult = await parse(baseURI);

return Reference({ uri: baseURI, value: parseResult.first, refSet });
},

async ReferenceElementInternal(referenceElement: ReferenceElement) {
async ReferenceElement(referenceElement: ReferenceElement) {
const reference = await this.toReference(referenceElement.$ref.toValue());

this.indirections.push(referenceElement);

const jsonPointer = uriToPointer(referenceElement.$ref.toValue());
let fragment = evaluate(jsonPointer, this.element);

// possibly non-semantic fragment
let fragment = evaluate(jsonPointer, reference.value);

// applying semantics to a fragment
if (referenceElement.meta.hasKey('referenced-element') && isPrimitiveElement(fragment)) {
if (isReferenceLikeElement(fragment)) {
// handling indirect references
fragment = ReferenceElement.refract(fragment);
} else {
// handling direct references
const elementType = referenceElement.meta.get('referenced-element').toValue();
const ElementClass = this.namespace.getElementClass(elementType);
fragment = ElementClass.refract(fragment);
}
}

// detect direct or circular reference
if (this.indirections.includes(fragment)) {
Expand All @@ -49,18 +82,11 @@ const DereferenceVisitor = stampit({

// dive deep into the fragment
const visitor = DereferenceVisitor({
baseURI: this.baseURI,
element: this.element,
reference,
namespace: this.namespace,
indirections: [...this.indirections],
});
await visitAsync(fragment, visitor, { keyMap });

/**
* Re-evaluate the JSON Pointer against the element as the fragment could
* have been another reference and the previous deep dive into fragment
* dereferenced it.
*/
fragment = evaluate(jsonPointer, this.element);
fragment = await visitAsync(fragment, visitor, { keyMap, nodeTypeGetter: getNodeType });

// override description and summary (outer has higher priority then inner)
const hasDescription = pathSatisfies(isNotUndefined, ['description'], referenceElement);
Expand All @@ -78,51 +104,32 @@ const DereferenceVisitor = stampit({
}
}

// transclude the element for a fragment
this.element = transclude(referenceElement, fragment, this.element);

this.indirections.pop();
},

async ReferenceElementExternal(referenceElement: ReferenceElement) {
this.indirections.push(referenceElement);

const uri = referenceElement.$ref.toValue();
const uriWithoutHash = url.stripHash(uri);
const sanitizedURI = url.isFileSystemPath(uriWithoutHash)
? url.fromFileSystemPath(uriWithoutHash)
: uriWithoutHash;
const baseURI = url.resolve(this.baseURI, sanitizedURI);
const parseResult = await parse(baseURI);
const { first: element } = parseResult;
const jsonPointer = uriToPointer(uri);
// @ts-ignore
const fragment = evaluate(jsonPointer, element);

// dive deep into the fragment
const visitor = DereferenceVisitor({
baseURI,
element,
indirections: [...this.indirections],
});
await visitAsync(fragment, visitor, { keyMap });

this.indirections.pop();
// transclude the element for a fragment
return fragment;
},
},
});

describe('dereference', function () {
specify('should dereference', async function () {
const fixturePath = path.join(__dirname, 'fixtures', 'dereference', 'reference-objects.json');
const parseResult = await parse(fixturePath, {
const { api } = await parse(fixturePath, {
parse: { mediaType: 'application/vnd.oai.openapi+json;version=3.1.0' },
});
const { api } = parseResult;
const visitor = DereferenceVisitor({ baseURI: fixturePath, element: api });
await visitAsync(api, visitor, { keyMap });
const namespace = createNamespace(openApi3_1Namespace);
const reference = Reference({ uri: fixturePath, value: api });
const visitor = DereferenceVisitor({ reference, namespace });
const refSet = ReferenceSet();
refSet.add(reference);

const dereferenced = await visitAsync(refSet.rootRef.value, visitor, {
keyMap,
nodeTypeGetter: getNodeType,
});

// @ts-ignore
console.log(util.inspect(toValue(api), true, null, true));
console.log(util.inspect(toValue(dereferenced), true, null, true));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@
"components": {
"parameters": {
"userId": {
"$ref": "#/components/parameters/indirection",
"$ref": "#/components/parameters/indirection1",
"description": "override"
},
"indirection": {
"$ref": "#/components/parameters/userIdRef",
"indirection1": {
"$ref": "#/components/parameters/indirection2",
"summary": "indirect summary"
},
"userIdRef": {
"indirection2": {
"$ref": "#/components/parameters/userIdRef",
"summary": "indirect summary"
},
"userIdRef": {
"name": "userId",
"in": "query",
"description": "ID of the user",
"required": true
},
"externalRef": {
"$ref": "./external-reference-objects.json",
"$ref": "./external-reference-objects.json#/externalParameter",
"description": "another ref"
}
}
Expand Down
1 change: 1 addition & 0 deletions apidom/packages/apidom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export {
isAnnotationElement,
isParseResultElement,
isSourceMapElement,
isPrimitiveElement,
hasElementSourceMap,
includesSymbols,
includesClasses,
Expand Down
21 changes: 19 additions & 2 deletions apidom/packages/apidom/src/predicates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import {
LinkElement,
RefElement,
} from 'minim';
import { all, isEmpty, either, curry, allPass, is, both } from 'ramda';
import { all, isEmpty, either, curry, allPass, is, both, anyPass } from 'ramda';
import { included } from 'ramda-adjunct';

import AnnotationElement from '../elements/Annotation';
import CommentElement from '../elements/Comment';
import ParserResultElement from '../elements/ParseResult';
import SourceMapElement from '../elements/SourceMap';
import createPredicate from './helpers';
import createPredicate, { isElementType as isElementTypeHelper } from './helpers';

export const isElement = createPredicate(({ hasBasicElementProps, primitiveEq }) => {
const primitiveEqUndefined = primitiveEq(undefined);
Expand Down Expand Up @@ -203,6 +203,23 @@ export const isSourceMapElement = createPredicate(
},
);

export const isPrimitiveElement = anyPass([
// @ts-ignore
isElementTypeHelper('object'),
// @ts-ignore
isElementTypeHelper('array'),
// @ts-ignore
isElementTypeHelper('member'),
// @ts-ignore
isElementTypeHelper('boolean'),
// @ts-ignore
isElementTypeHelper('number'),
// @ts-ignore
isElementTypeHelper('string'),
// @ts-ignore
isElementTypeHelper('null'),
]);

export const hasElementSourceMap = createPredicate(() => {
return (element) => isSourceMapElement(element.meta.get('sourceMap'));
});
Expand Down
10 changes: 7 additions & 3 deletions apidom/packages/apidom/src/traversal/visitor.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import stampit from 'stampit';
import { Element } from 'minim';
import { curryN, F as stubFalse, pipe, either } from 'ramda';
import { isString, isArray } from 'ramda-adjunct';
import { curryN, F as stubFalse, pipe } from 'ramda';
import { isString } from 'ramda-adjunct';
import { visit as astVisit, BREAK, mergeAllVisitors } from 'apidom-ast';

import {
Expand Down Expand Up @@ -51,7 +51,7 @@ export const getNodeType = <T extends Element>(element: T): string | undefined =
};

// isNode :: Node -> Boolean
const isNode = curryN(1, pipe(getNodeType, either(isString, isArray)));
const isNode = curryN(1, pipe(getNodeType, isString));

export const keyMapDefault = {
ObjectElement: ['content'],
Expand All @@ -63,6 +63,10 @@ export const keyMapDefault = {
NullElement: [],
RefElement: [],
LinkElement: [],
Annotation: [],
Comment: [],
ParseResultElement: ['content'],
SourceMap: ['content'],
};

export const PredicateVisitor = stampit({
Expand Down

0 comments on commit 1f99a31

Please sign in to comment.