diff --git a/packages/apidom-reference/package.json b/packages/apidom-reference/package.json index 841fbf119d..96c0fe00d4 100644 --- a/packages/apidom-reference/package.json +++ b/packages/apidom-reference/package.json @@ -200,6 +200,11 @@ "require": "./src/dereference/strategies/apidom/selectors/element-id.cjs", "types": "./types/dereference/strategies/apidom/selectors/element-id.d.ts" }, + "./dereference/DynamicScopeStack": { + "import": "./src/dereference/DynamicScopeStack.mjs", + "require": "./src/dereference/DynamicScopeStack.cjs", + "types": "./types/dereference/DynamicScopeStack.d.ts" + }, "./dereference/strategies/asyncapi-2": { "import": "./src/dereference/strategies/asyncapi-2/index.mjs", "require": "./src/dereference/strategies/asyncapi-2/index.cjs", @@ -225,6 +230,11 @@ "require": "./src/dereference/strategies/openapi-3-1/selectors/$anchor.cjs", "types": "./types/dereference/strategies/openapi-3-1/selectors/$anchor.d.ts" }, + "./dereference/strategies/openapi-3-1/selectors/$dynamicAnchor": { + "import": "./src/dereference/strategies/openapi-3-1/selectors/$dynamicAnchor.mjs", + "require": "./src/dereference/strategies/openapi-3-1/selectors/$dynamicAnchor.cjs", + "types": "./types/dereference/strategies/openapi-3-1/selectors/$dynamicAnchor.d.ts" + }, "./dereference/strategies/openapi-3-1/selectors/uri": { "import": "./src/dereference/strategies/openapi-3-1/selectors/uri.mjs", "require": "./src/dereference/strategies/openapi-3-1/selectors/uri.cjs", @@ -240,6 +250,11 @@ "require": "./src/dereference/strategies/openapi-3-2/selectors/$anchor.cjs", "types": "./types/dereference/strategies/openapi-3-2/selectors/$anchor.d.ts" }, + "./dereference/strategies/openapi-3-2/selectors/$dynamicAnchor": { + "import": "./src/dereference/strategies/openapi-3-2/selectors/$dynamicAnchor.mjs", + "require": "./src/dereference/strategies/openapi-3-2/selectors/$dynamicAnchor.cjs", + "types": "./types/dereference/strategies/openapi-3-2/selectors/$dynamicAnchor.d.ts" + }, "./dereference/strategies/openapi-3-2/selectors/uri": { "import": "./src/dereference/strategies/openapi-3-2/selectors/uri.mjs", "require": "./src/dereference/strategies/openapi-3-2/selectors/uri.cjs", diff --git a/packages/apidom-reference/src/dereference/DynamicScopeStack.ts b/packages/apidom-reference/src/dereference/DynamicScopeStack.ts new file mode 100644 index 0000000000..cf815b4840 --- /dev/null +++ b/packages/apidom-reference/src/dereference/DynamicScopeStack.ts @@ -0,0 +1,73 @@ +import { Element, find, isObjectElement, isStringElement, toValue } from '@swagger-api/apidom-core'; + +/** + * @public + */ +export type DynamicScopeEntryType = 'root' | '$ref' | '$dynamicRef' | 'applicator'; + +/** + * @public + */ +export interface DynamicScopeFrame { + readonly element: Element; + readonly documentURI: string; + readonly dynamicAnchors: Map; + readonly entryType: DynamicScopeEntryType; +} + +const collectDynamicAnchors = (element: Element): Map => { + const anchors = new Map(); + + // @ts-ignore + find((e: Element) => { + const elementWithDynamicAnchor = e as Element & { $dynamicAnchor?: Element }; + const dynamicAnchor = + elementWithDynamicAnchor.$dynamicAnchor ?? + (isObjectElement(e) ? e.get('$dynamicAnchor') : undefined); + + if (isStringElement(dynamicAnchor)) { + const anchor = toValue(dynamicAnchor); + if (typeof anchor === 'string' && !anchors.has(anchor)) { + anchors.set(anchor, e); + } + } + return false; + }, element); + + return anchors; +}; + +/** + * @public + */ +export class DynamicScopeStack extends Array { + pushFrame(element: Element, documentURI: string, entryType: DynamicScopeEntryType): void { + this.push({ + element, + documentURI, + entryType, + dynamicAnchors: collectDynamicAnchors(element), + }); + } + + popFrame(): DynamicScopeFrame | undefined { + return this.pop(); + } + + resolveDynamicRef( + anchorToken: string, + ): { referencedElement: Element; documentURI: string } | null { + for (let i = 0; i < this.length; i += 1) { + const frame = this[i]; + const match = frame.dynamicAnchors.get(anchorToken); + if (typeof match !== 'undefined') { + return { referencedElement: match, documentURI: frame.documentURI }; + } + } + return null; + } + + clone(): DynamicScopeStack { + return new DynamicScopeStack(...this); + } +} diff --git a/packages/apidom-reference/src/dereference/strategies/openapi-3-1/selectors/$dynamicAnchor.ts b/packages/apidom-reference/src/dereference/strategies/openapi-3-1/selectors/$dynamicAnchor.ts new file mode 100644 index 0000000000..52746d12d7 --- /dev/null +++ b/packages/apidom-reference/src/dereference/strategies/openapi-3-1/selectors/$dynamicAnchor.ts @@ -0,0 +1,60 @@ +import { trimCharsStart, isUndefined } from 'ramda-adjunct'; +import { Element, find, toValue } from '@swagger-api/apidom-core'; +import { isSchemaElement } from '@swagger-api/apidom-ns-openapi-3-1'; + +import { getHash } from '../../../../util/url.ts'; +import EvaluationJsonSchema$dynamicAnchorError from '../../../../errors/EvaluationJsonSchema$dynamicAnchorError.ts'; +import InvalidJsonSchema$dynamicAnchorError from '../../../../errors/InvalidJsonSchema$dynamicAnchorError.ts'; + +/** + * @public + */ +export const isDynamicAnchor = (uri: string) => { + /** + * MUST start with a letter ([A-Za-z]) or underscore ("_"), followed by any number of letters, + * digits ([0-9]), hyphens ("-"), underscores ("_"), and periods ("."). + * + * https://json-schema.org/draft/2020-12/json-schema-core.html#rfc.section.8.2.2 + */ + return /^[A-Za-z_][A-Za-z_0-9.-]*$/.test(uri); +}; + +/** + * @public + */ +export const uriToDynamicAnchor = (uri: string): string => { + const hash = getHash(uri); + return trimCharsStart('#', hash); +}; + +/** + * @public + */ +export const parse = (anchor: string): string => { + if (!isDynamicAnchor(anchor)) { + throw new InvalidJsonSchema$dynamicAnchorError(anchor); + } + + return anchor; +}; + +/** + * Evaluates JSON Schema $dynamicAnchor against ApiDOM fragment. + * @public + */ +export const evaluate = (anchor: string, element: T): Element | undefined => { + const token = parse(anchor); + + // @ts-ignore + const result = find((e) => isSchemaElement(e) && toValue(e.$dynamicAnchor) === token, element); + + if (isUndefined(result)) { + throw new EvaluationJsonSchema$dynamicAnchorError(`Evaluation failed on token: "${token}"`); + } + + // @ts-ignore + return result; +}; + +export { EvaluationJsonSchema$dynamicAnchorError, InvalidJsonSchema$dynamicAnchorError }; +export { default as JsonSchema$dynamicAnchorError } from '../../../../errors/JsonSchema$dynamicAnchorError.ts'; diff --git a/packages/apidom-reference/src/dereference/strategies/openapi-3-1/util.ts b/packages/apidom-reference/src/dereference/strategies/openapi-3-1/util.ts index 5cfea359de..242aadd667 100644 --- a/packages/apidom-reference/src/dereference/strategies/openapi-3-1/util.ts +++ b/packages/apidom-reference/src/dereference/strategies/openapi-3-1/util.ts @@ -44,6 +44,27 @@ export const resolveSchema$idField = (retrievalURI: string, schemaElement: Schem ); }; +export const resolveSchema$dynamicRefField = ( + retrievalURI: string, + schemaElement: SchemaElement, +) => { + if (typeof schemaElement.$dynamicRef === 'undefined') { + return undefined; + } + + const hash = url.getHash(toValue(schemaElement.$dynamicRef)); + const ancestorsSchemaIdentifiers = toValue(schemaElement.meta.get('ancestorsSchemaIdentifiers')); + const $dynamicRefBaseURI = reduce( + (acc: string, uri: string): string => { + return url.resolve(acc, url.sanitize(url.stripHash(uri))); + }, + retrievalURI, + [...ancestorsSchemaIdentifiers, toValue(schemaElement.$dynamicRef)], + ); + + return `${$dynamicRefBaseURI}${hash === '#' ? '' : hash}`; +}; + /** * Cached version of SchemaElement.refract. */ diff --git a/packages/apidom-reference/src/dereference/strategies/openapi-3-1/visitor.ts b/packages/apidom-reference/src/dereference/strategies/openapi-3-1/visitor.ts index 5120cc6283..ede15a5755 100644 --- a/packages/apidom-reference/src/dereference/strategies/openapi-3-1/visitor.ts +++ b/packages/apidom-reference/src/dereference/strategies/openapi-3-1/visitor.ts @@ -44,6 +44,11 @@ import { } from '@swagger-api/apidom-ns-openapi-3-1'; import { isAnchor, uriToAnchor, evaluate as $anchorEvaluate } from './selectors/$anchor.ts'; +import { + isDynamicAnchor, + uriToDynamicAnchor, + evaluate as $dynamicAnchorEvaluate, +} from './selectors/$dynamicAnchor.ts'; import { evaluate as uriEvaluate } from './selectors/uri.ts'; import MaximumDereferenceDepthError from '../../../errors/MaximumDereferenceDepthError.ts'; import MaximumResolveDepthError from '../../../errors/MaximumResolveDepthError.ts'; @@ -53,8 +58,13 @@ import Reference from '../../../Reference.ts'; import ReferenceSet from '../../../ReferenceSet.ts'; import File from '../../../File.ts'; import Resolver from '../../../resolve/resolvers/Resolver.ts'; -import { resolveSchema$refField, maybeRefractToSchemaElement } from './util.ts'; +import { + resolveSchema$refField, + resolveSchema$dynamicRefField, + maybeRefractToSchemaElement, +} from './util.ts'; import { AncestorLineage } from '../../util.ts'; +import { DynamicScopeStack } from '../../DynamicScopeStack.ts'; import EvaluationJsonSchemaUriError from '../../../errors/EvaluationJsonSchemaUriError.ts'; import type { ReferenceOptions } from '../../../options/index.ts'; @@ -95,6 +105,7 @@ export interface OpenAPI3_1DereferenceVisitorOptions { readonly errorContext?: Element[]; readonly errorPropagationCache?: Map; readonly pendingPropagations?: Array<{ referencingElement: Element; referencedElement: Element }>; + readonly dynamicScopeStack?: DynamicScopeStack; } /** @@ -124,6 +135,8 @@ class OpenAPI3_1DereferenceVisitor { referencedElement: Element; }> = []; + protected readonly dynamicScopeStack: DynamicScopeStack; + constructor({ reference, namespace, @@ -135,6 +148,7 @@ class OpenAPI3_1DereferenceVisitor { errorContext = [], errorPropagationCache = new Map(), pendingPropagations = [], + dynamicScopeStack = new DynamicScopeStack(), }: OpenAPI3_1DereferenceVisitorOptions) { this.indirections = indirections; this.namespace = namespace; @@ -146,6 +160,7 @@ class OpenAPI3_1DereferenceVisitor { this.errorContext = errorContext; this.errorPropagationCache = errorPropagationCache; this.pendingPropagations = pendingPropagations; + this.dynamicScopeStack = dynamicScopeStack; } protected popErrorContext(el: Element) { @@ -251,13 +266,17 @@ class OpenAPI3_1DereferenceVisitor { } protected getNestedVisitorOptions(referencingElement: ObjectElement): ReferenceOptions { + const referencingValueElement = + referencingElement.get('$ref') ?? referencingElement.get('$dynamicRef'); + const isInternalReference = + isStringElement(referencingValueElement) && toValue(referencingValueElement).startsWith('#'); + return { ...this.options, resolve: { ...this.options.resolve, external: - this.options.dereference?.dereferenceOpts?.skipNestedExternal && - toValue(referencingElement.get('$ref')).startsWith('#') + this.options.dereference?.dereferenceOpts?.skipNestedExternal && isInternalReference ? false : this.options.resolve.external, }, @@ -330,6 +349,33 @@ class OpenAPI3_1DereferenceVisitor { return [ancestorsLineage, directAncestors]; } + protected toDynamicScopeStack( + ancestorsLineage: AncestorLineage, + documentURI: string, + ): DynamicScopeStack { + const dynamicScopeStack = this.dynamicScopeStack.clone(); + + ancestorsLineage.forEach((ancestors) => { + ancestors.forEach((ancestor) => { + const isDynamicScopeCandidate = + isSchemaElement(ancestor) && + (typeof ancestor.$id !== 'undefined' || + typeof ancestor.$dynamicAnchor !== 'undefined' || + typeof ancestor.get('$defs') !== 'undefined' || + typeof ancestor.$ref !== 'undefined'); + + if ( + isDynamicScopeCandidate && + !dynamicScopeStack.some((frame) => frame.element === ancestor) + ) { + dynamicScopeStack.pushFrame(ancestor, documentURI, 'applicator'); + } + }); + }); + + return dynamicScopeStack; + } + public readonly OpenApi3_1Element = { leave: ( openApi3_1Element: OpenApi3_1Element, @@ -514,7 +560,12 @@ class OpenAPI3_1DereferenceVisitor { directAncestors.add(referencingElement); this.errorContext.push(referencingElement); - const visitor = new OpenAPI3_1DereferenceVisitor({ + const Visitor = this.constructor as new ( + options: OpenAPI3_1DereferenceVisitorOptions, + ) => OpenAPI3_1DereferenceVisitor; + const dynamicScopeStack = this.toDynamicScopeStack(ancestorsLineage, retrievalURI); + dynamicScopeStack.pushFrame(referencingElement, retrievalURI, '$ref'); + const visitor = new Visitor({ reference, namespace: this.namespace, indirections: [...this.indirections], @@ -525,6 +576,7 @@ class OpenAPI3_1DereferenceVisitor { errorContext: this.errorContext, errorPropagationCache: this.errorPropagationCache, pendingPropagations: this.pendingPropagations, + dynamicScopeStack, }); try { referencedElement = await visitAsync(referencedElement, visitor, { @@ -754,7 +806,12 @@ class OpenAPI3_1DereferenceVisitor { directAncestors.add(referencingElement); this.errorContext.push(referencingElement); - const visitor = new OpenAPI3_1DereferenceVisitor({ + const Visitor = this.constructor as new ( + options: OpenAPI3_1DereferenceVisitorOptions, + ) => OpenAPI3_1DereferenceVisitor; + const dynamicScopeStack = this.toDynamicScopeStack(ancestorsLineage, retrievalURI); + dynamicScopeStack.pushFrame(referencingElement, retrievalURI, '$dynamicRef'); + const visitor = new Visitor({ reference, namespace: this.namespace, indirections: [...this.indirections], @@ -765,6 +822,7 @@ class OpenAPI3_1DereferenceVisitor { errorContext: this.errorContext, errorPropagationCache: this.errorPropagationCache, pendingPropagations: this.pendingPropagations, + dynamicScopeStack: this.dynamicScopeStack.clone(), }); try { referencedElement = await visitAsync(referencedElement, visitor, { @@ -1079,7 +1137,12 @@ class OpenAPI3_1DereferenceVisitor { // append referencing reference to ancestors lineage directAncestors.add(schemaElement); - const visitor = new OpenAPI3_1DereferenceVisitor({ + const Visitor = this.constructor as new ( + options: OpenAPI3_1DereferenceVisitorOptions, + ) => OpenAPI3_1DereferenceVisitor; + const dynamicScopeStack = this.toDynamicScopeStack(ancestorsLineage, this.reference.uri); + dynamicScopeStack.pushFrame(schemaElement, this.reference.uri, '$ref'); + const visitor = new Visitor({ reference: this.reference, namespace: this.namespace, indirections: [...this.indirections], @@ -1087,6 +1150,9 @@ class OpenAPI3_1DereferenceVisitor { refractCache: this.refractCache, ancestors: ancestorsLineage, allOfDiscriminatorMapping: this.allOfDiscriminatorMapping, + errorContext: this.errorContext, + errorPropagationCache: this.errorPropagationCache, + dynamicScopeStack, }); let referencedElement: Element; @@ -1123,6 +1189,283 @@ class OpenAPI3_1DereferenceVisitor { return !parent ? memberElementCopy : undefined; } + protected async resolveSchema$dynamicRef( + referencingElement: SchemaElement, + key: string | number, + parent: Element | undefined, + path: (string | number)[], + ancestors: [Element | Element[]], + link: { replaceWith: (element: Element, replacer: typeof mutationReplacer) => void }, + ) { + if (this.indirections.includes(referencingElement)) { + return false; + } + + const [ancestorsLineage, directAncestors] = this.toAncestorLineage([...ancestors, parent]); + + let reference: Reference; + + try { + reference = await this.toReference(url.unsanitize(this.reference.uri)); + } catch (error) { + return this.handleDereferenceError(error, referencingElement, { directAncestors }); + } + + const { uri: retrievalURI } = reference; + const $dynamicRefBaseURI = resolveSchema$dynamicRefField(retrievalURI, referencingElement)!; + const anchorToken = uriToDynamicAnchor($dynamicRefBaseURI); + + this.indirections.push(referencingElement); + + let referencedElement: Element; + let isInternalReference = false; + let isDynamicAnchorTarget = false; + + try { + const staticSelector = isDynamicAnchor(anchorToken) + ? url.stripHash($dynamicRefBaseURI) + : $dynamicRefBaseURI; + const referenceAsSchema = maybeRefractToSchemaElement(reference.value.result as Element); + referencedElement = uriEvaluate(staticSelector, referenceAsSchema)!; + if (isDynamicAnchor(anchorToken)) { + try { + referencedElement = $dynamicAnchorEvaluate(anchorToken, referencedElement)!; + isDynamicAnchorTarget = true; + } catch { + referencedElement = $anchorEvaluate(anchorToken, referencedElement)!; + } + } + referencedElement = maybeRefractToSchemaElement(referencedElement); + referencedElement.id = identityManager.identify(referencedElement); + isInternalReference = true; + } catch (error) { + if (!(error instanceof EvaluationJsonSchemaUriError)) { + this.indirections.pop(); + return this.handleDereferenceError(error, referencingElement, { directAncestors }); + } + + const staticRetrievalURI = this.toBaseURI($dynamicRefBaseURI); + isInternalReference = url.stripHash(this.reference.uri) === staticRetrievalURI; + + if (!this.options.resolve.internal && isInternalReference) { + this.indirections.pop(); + return undefined; + } + if (!this.options.resolve.external && !isInternalReference) { + this.indirections.pop(); + return undefined; + } + + try { + reference = await this.toReference(url.unsanitize($dynamicRefBaseURI)); + const referenceAsSchema = maybeRefractToSchemaElement(reference.value.result as Element); + if (isDynamicAnchor(anchorToken)) { + try { + referencedElement = $dynamicAnchorEvaluate(anchorToken, referenceAsSchema)!; + isDynamicAnchorTarget = true; + } catch { + referencedElement = $anchorEvaluate(anchorToken, referenceAsSchema)!; + } + } else { + referencedElement = jsonPointerEvaluate( + referenceAsSchema, + URIFragmentIdentifier.fromURIReference($dynamicRefBaseURI), + ); + } + referencedElement = maybeRefractToSchemaElement(referencedElement); + referencedElement.id = identityManager.identify(referencedElement); + } catch (toReferenceError) { + this.indirections.pop(); + return this.handleDereferenceError(toReferenceError, referencingElement, { + directAncestors, + }); + } + } + + if (!this.options.resolve.internal && isInternalReference) { + this.indirections.pop(); + return undefined; + } + + if (isDynamicAnchorTarget) { + const dynamicScopeStack = this.toDynamicScopeStack(ancestorsLineage, retrievalURI); + const resolved = dynamicScopeStack.resolveDynamicRef(anchorToken); + if (resolved !== null) { + referencedElement = resolved.referencedElement; + if (resolved.documentURI !== this.reference.uri) { + try { + reference = await this.toReference(url.unsanitize(resolved.documentURI)); + } catch { + // fall back to static target if reference lookup fails + reference = this.reference; + } + } else { + reference = this.reference; + } + } + } + + if (referencingElement === referencedElement) { + const error = new ApiDOMError('Recursive Schema Object reference detected'); + this.indirections.pop(); + return this.handleDereferenceError(error, referencingElement, { directAncestors }); + } + + if (this.indirections.length > this.options.dereference.maxDepth) { + const error = new MaximumDereferenceDepthError( + `Maximum dereference depth of "${this.options.dereference.maxDepth}" has been exceeded in file "${this.reference.uri}"`, + ); + this.indirections.pop(); + return this.handleDereferenceError(error, referencingElement, { directAncestors }); + } + + const shouldResolveReferencedSchema = + isSchemaElement(referencedElement) && + (isStringElement(referencedElement.$ref) || isStringElement(referencedElement.$dynamicRef)); + + if (ancestorsLineage.includes(referencedElement) && !shouldResolveReferencedSchema) { + reference.refSet!.circular = true; + + if (this.options.dereference.circular === 'error') { + const error = new ApiDOMError('Circular reference detected'); + this.indirections.pop(); + return this.handleDereferenceError(error, referencingElement, { directAncestors }); + } + + if (this.options.dereference.circular === 'replace') { + const refElement = new RefElement(referencedElement.id, { + type: 'json-schema', + uri: reference.uri, + $dynamicRef: toValue(referencingElement.$dynamicRef), + baseURI: url.resolve(retrievalURI, $dynamicRefBaseURI), + referencingElement, + }); + const replacer = + this.options.dereference.strategyOpts['openapi-3-1']?.circularReplacer ?? + this.options.dereference.circularReplacer; + const replacement = replacer(refElement); + + link.replaceWith(replacement, mutationReplacer); + + return !parent ? replacement : false; + } + } + + const isNonRootDocument = url.stripHash(reference.refSet!.rootRef!.uri) !== reference.uri; + const shouldDetectCircular = ['error', 'replace'].includes(this.options.dereference.circular); + if ( + (!isInternalReference || + isNonRootDocument || + shouldResolveReferencedSchema || + shouldDetectCircular || + this.options.dereference.dereferenceOpts?.continueOnError) && + !ancestorsLineage.includesCycle(referencedElement) + ) { + directAncestors.add(referencingElement); + + const Visitor = this.constructor as new ( + options: OpenAPI3_1DereferenceVisitorOptions, + ) => OpenAPI3_1DereferenceVisitor; + const dynamicScopeStack = this.toDynamicScopeStack(ancestorsLineage, retrievalURI); + dynamicScopeStack.pushFrame(referencingElement, retrievalURI, '$ref'); + const visitor = new Visitor({ + reference, + namespace: this.namespace, + indirections: [...this.indirections], + options: this.getNestedVisitorOptions(referencingElement), + refractCache: this.refractCache, + ancestors: ancestorsLineage, + allOfDiscriminatorMapping: this.allOfDiscriminatorMapping, + errorContext: this.errorContext, + errorPropagationCache: this.errorPropagationCache, + dynamicScopeStack, + }); + try { + referencedElement = await visitAsync(referencedElement, visitor, { + keyMap, + nodeTypeGetter: getNodeType, + }); + } catch (error) { + this.indirections.pop(); + return this.handleDereferenceError(error, referencingElement, { directAncestors }); + } + + directAncestors.delete(referencingElement); + } + + this.indirections.pop(); + + if (isBooleanJsonSchemaElement(referencedElement as unknown)) { + const booleanJsonSchemaElement: BooleanElement = cloneDeep(referencedElement!); + booleanJsonSchemaElement.setMetaProperty('id', identityManager.generateId()); + booleanJsonSchemaElement.setMetaProperty('ref-fields', { + $dynamicRef: toValue(referencingElement.$dynamicRef), + $dynamicRefBaseURI, + }); + booleanJsonSchemaElement.setMetaProperty('ref-origin', reference.uri); + booleanJsonSchemaElement.setMetaProperty( + 'ref-referencing-element-id', + cloneDeep(identityManager.identify(referencingElement)), + ); + + link.replaceWith(booleanJsonSchemaElement, mutationReplacer); + + return !parent ? booleanJsonSchemaElement : false; + } + + if (isSchemaElement(referencedElement)) { + const mergedElement = new SchemaElement( + [...referencedElement.content] as any, + cloneDeep(referencedElement.meta), + cloneDeep(referencedElement.attributes), + ); + mergedElement.setMetaProperty('id', identityManager.generateId()); + referencingElement.forEach((value: Element, keyElement: Element, item: Element) => { + mergedElement.remove(toValue(keyElement)); + mergedElement.content.push(item); + }); + mergedElement.remove('$dynamicRef'); + mergedElement.setMetaProperty('ref-fields', { + $dynamicRef: toValue(referencingElement.$dynamicRef), + $dynamicRefBaseURI, + }); + mergedElement.setMetaProperty('ref-origin', reference.uri); + mergedElement.setMetaProperty( + 'ref-referencing-element-id', + cloneDeep(identityManager.identify(referencingElement)), + ); + + if (this.options.dereference.dereferenceOpts?.continueOnError) { + mergedElement.setMetaProperty('ref-referencing-element', referencingElement); + } + + // creating mapping for allOf discriminator + if (this.options.dereference.strategyOpts['openapi-3-1']?.dereferenceDiscriminatorMapping) { + const parentElement = ancestors[ancestors.length - 1]; + const parentSchemaElement = [...directAncestors].findLast(isSchemaElement); + const parentSchemaElementName = parentSchemaElement?.getMetaProperty('schemaName'); + const mergedElementName = toValue(mergedElement.getMetaProperty('schemaName')); + + if ( + mergedElementName && + parentSchemaElementName && + // @ts-ignore + parentElement?.classes?.contains('json-schema-allOf') + ) { + const currentMapping = this.allOfDiscriminatorMapping.get(mergedElementName) ?? []; + currentMapping.push(parentSchemaElement!); + this.allOfDiscriminatorMapping.set(mergedElementName, currentMapping); + } + } + + referencedElement = mergedElement; + } + + link.replaceWith(referencedElement!, mutationReplacer); + + return !parent ? referencedElement : undefined; + } + public async SchemaElement( referencingElement: SchemaElement, key: string | number, @@ -1131,11 +1474,17 @@ class OpenAPI3_1DereferenceVisitor { ancestors: [Element | Element[]], link: { replaceWith: (element: Element, replacer: typeof mutationReplacer) => void }, ) { - // skip current referencing schema as $ref keyword was not defined - if (!isStringElement(referencingElement.$ref)) { + const has$ref = isStringElement(referencingElement.$ref); + const has$dynamicRef = isStringElement(referencingElement.$dynamicRef); + + if (!has$ref && !has$dynamicRef) { return undefined; } + if (has$dynamicRef && !has$ref) { + return this.resolveSchema$dynamicRef(referencingElement, key, parent, path, ancestors, link); + } + // skip current referencing element as it's already been access if (this.indirections.includes(referencingElement)) { return false; @@ -1367,23 +1716,46 @@ class OpenAPI3_1DereferenceVisitor { * Cases to consider: * 1. We're crossing document boundary * 2. Fragment is from non-root document - * 3. Fragment is a Schema Object with $ref field. We need to follow it to get the eventual value + * 3. Fragment is a Schema Object with $ref or $dynamicRef field. We need to follow it to get the eventual value * 4. We are dereferencing the fragment lazily/eagerly depending on circular mode */ const isNonRootDocument = url.stripHash(reference.refSet!.rootRef!.uri) !== reference.uri; const shouldDetectCircular = ['error', 'replace'].includes(this.options.dereference.circular); + const containsNested$dynamicRef = + isSchemaElement(referencedElement) && + !isUndefined( + find( + (element) => + (isSchemaElement(element) && isStringElement(element.$dynamicRef)) || + (isObjectElement(element) && isStringElement(element.get('$dynamicRef'))), + referencedElement, + ), + ); + const hasDynamicScopeOverride = + typeof referencingElement.$dynamicAnchor !== 'undefined' || + typeof referencingElement.get('$defs') !== 'undefined'; if ( (isExternalReference || isNonRootDocument || - (isSchemaElement(referencedElement) && isStringElement(referencedElement.$ref)) || - shouldDetectCircular) && + (isSchemaElement(referencedElement) && + (isStringElement(referencedElement.$ref) || + isStringElement(referencedElement.$dynamicRef))) || + containsNested$dynamicRef || + hasDynamicScopeOverride || + shouldDetectCircular || + this.options.dereference.dereferenceOpts?.continueOnError) && !ancestorsLineage.includesCycle(referencedElement) ) { // append referencing reference to ancestors lineage directAncestors.add(referencingElement); this.errorContext.push(referencingElement); - const visitor = new OpenAPI3_1DereferenceVisitor({ + const Visitor = this.constructor as new ( + options: OpenAPI3_1DereferenceVisitorOptions, + ) => OpenAPI3_1DereferenceVisitor; + const dynamicScopeStack = this.toDynamicScopeStack(ancestorsLineage, retrievalURI); + dynamicScopeStack.pushFrame(referencingElement, retrievalURI, '$ref'); + const visitor = new Visitor({ reference, namespace: this.namespace, indirections: [...this.indirections], @@ -1394,6 +1766,7 @@ class OpenAPI3_1DereferenceVisitor { errorContext: this.errorContext, errorPropagationCache: this.errorPropagationCache, pendingPropagations: this.pendingPropagations, + dynamicScopeStack, }); try { referencedElement = await visitAsync(referencedElement, visitor, { diff --git a/packages/apidom-reference/src/dereference/strategies/openapi-3-2/selectors/$dynamicAnchor.ts b/packages/apidom-reference/src/dereference/strategies/openapi-3-2/selectors/$dynamicAnchor.ts new file mode 100644 index 0000000000..9333c25cd0 --- /dev/null +++ b/packages/apidom-reference/src/dereference/strategies/openapi-3-2/selectors/$dynamicAnchor.ts @@ -0,0 +1,60 @@ +import { trimCharsStart, isUndefined } from 'ramda-adjunct'; +import { Element, find, toValue } from '@swagger-api/apidom-core'; +import { isSchemaElement } from '@swagger-api/apidom-ns-openapi-3-2'; + +import { getHash } from '../../../../util/url.ts'; +import EvaluationJsonSchema$dynamicAnchorError from '../../../../errors/EvaluationJsonSchema$dynamicAnchorError.ts'; +import InvalidJsonSchema$dynamicAnchorError from '../../../../errors/InvalidJsonSchema$dynamicAnchorError.ts'; + +/** + * @public + */ +export const isDynamicAnchor = (uri: string) => { + /** + * MUST start with a letter ([A-Za-z]) or underscore ("_"), followed by any number of letters, + * digits ([0-9]), hyphens ("-"), underscores ("_"), and periods ("."). + * + * https://json-schema.org/draft/2020-12/json-schema-core.html#rfc.section.8.2.2 + */ + return /^[A-Za-z_][A-Za-z_0-9.-]*$/.test(uri); +}; + +/** + * @public + */ +export const uriToDynamicAnchor = (uri: string): string => { + const hash = getHash(uri); + return trimCharsStart('#', hash); +}; + +/** + * @public + */ +export const parse = (anchor: string): string => { + if (!isDynamicAnchor(anchor)) { + throw new InvalidJsonSchema$dynamicAnchorError(anchor); + } + + return anchor; +}; + +/** + * Evaluates JSON Schema $dynamicAnchor against ApiDOM fragment. + * @public + */ +export const evaluate = (anchor: string, element: T): Element | undefined => { + const token = parse(anchor); + + // @ts-ignore + const result = find((e) => isSchemaElement(e) && toValue(e.$dynamicAnchor) === token, element); + + if (isUndefined(result)) { + throw new EvaluationJsonSchema$dynamicAnchorError(`Evaluation failed on token: "${token}"`); + } + + // @ts-ignore + return result; +}; + +export { EvaluationJsonSchema$dynamicAnchorError, InvalidJsonSchema$dynamicAnchorError }; +export { default as JsonSchema$dynamicAnchorError } from '../../../../errors/JsonSchema$dynamicAnchorError.ts'; diff --git a/packages/apidom-reference/src/dereference/strategies/openapi-3-2/util.ts b/packages/apidom-reference/src/dereference/strategies/openapi-3-2/util.ts index a625928c9b..586f6a1c38 100644 --- a/packages/apidom-reference/src/dereference/strategies/openapi-3-2/util.ts +++ b/packages/apidom-reference/src/dereference/strategies/openapi-3-2/util.ts @@ -44,6 +44,27 @@ export const resolveSchema$idField = (retrievalURI: string, schemaElement: Schem ); }; +export const resolveSchema$dynamicRefField = ( + retrievalURI: string, + schemaElement: SchemaElement, +) => { + if (typeof schemaElement.$dynamicRef === 'undefined') { + return undefined; + } + + const hash = url.getHash(toValue(schemaElement.$dynamicRef)); + const ancestorsSchemaIdentifiers = toValue(schemaElement.meta.get('ancestorsSchemaIdentifiers')); + const $dynamicRefBaseURI = reduce( + (acc: string, uri: string): string => { + return url.resolve(acc, url.sanitize(url.stripHash(uri))); + }, + retrievalURI, + [...ancestorsSchemaIdentifiers, toValue(schemaElement.$dynamicRef)], + ); + + return `${$dynamicRefBaseURI}${hash === '#' ? '' : hash}`; +}; + /** * Cached version of SchemaElement.refract. */ diff --git a/packages/apidom-reference/src/dereference/strategies/openapi-3-2/visitor.ts b/packages/apidom-reference/src/dereference/strategies/openapi-3-2/visitor.ts index aea7e108a4..bc01b6e6d8 100644 --- a/packages/apidom-reference/src/dereference/strategies/openapi-3-2/visitor.ts +++ b/packages/apidom-reference/src/dereference/strategies/openapi-3-2/visitor.ts @@ -45,6 +45,11 @@ import { } from '@swagger-api/apidom-ns-openapi-3-2'; import { isAnchor, uriToAnchor, evaluate as $anchorEvaluate } from './selectors/$anchor.ts'; +import { + isDynamicAnchor, + uriToDynamicAnchor, + evaluate as $dynamicAnchorEvaluate, +} from './selectors/$dynamicAnchor.ts'; import { evaluate as uriEvaluate } from './selectors/uri.ts'; import MaximumDereferenceDepthError from '../../../errors/MaximumDereferenceDepthError.ts'; import MaximumResolveDepthError from '../../../errors/MaximumResolveDepthError.ts'; @@ -54,8 +59,13 @@ import Reference from '../../../Reference.ts'; import ReferenceSet from '../../../ReferenceSet.ts'; import File from '../../../File.ts'; import Resolver from '../../../resolve/resolvers/Resolver.ts'; -import { resolveSchema$refField, maybeRefractToSchemaElement } from './util.ts'; +import { + resolveSchema$refField, + resolveSchema$dynamicRefField, + maybeRefractToSchemaElement, +} from './util.ts'; import { AncestorLineage } from '../../util.ts'; +import { DynamicScopeStack } from '../../DynamicScopeStack.ts'; import EvaluationJsonSchemaUriError from '../../../errors/EvaluationJsonSchemaUriError.ts'; import type { ReferenceOptions } from '../../../options/index.ts'; @@ -113,6 +123,7 @@ export interface OpenAPI3_2DereferenceVisitorOptions { readonly errorContext?: Element[]; readonly errorPropagationCache?: Map; readonly pendingPropagations?: Array<{ referencingElement: Element; referencedElement: Element }>; + readonly dynamicScopeStack?: DynamicScopeStack; } /** @@ -144,6 +155,8 @@ class OpenAPI3_2DereferenceVisitor { referencedElement: Element; }> = []; + protected readonly dynamicScopeStack: DynamicScopeStack; + constructor({ reference, namespace, @@ -156,6 +169,7 @@ class OpenAPI3_2DereferenceVisitor { errorContext = [], errorPropagationCache = new Map(), pendingPropagations = [], + dynamicScopeStack = new DynamicScopeStack(), }: OpenAPI3_2DereferenceVisitorOptions) { this.indirections = indirections; this.namespace = namespace; @@ -168,6 +182,7 @@ class OpenAPI3_2DereferenceVisitor { this.errorContext = errorContext; this.errorPropagationCache = errorPropagationCache; this.pendingPropagations = pendingPropagations; + this.dynamicScopeStack = dynamicScopeStack; } protected popErrorContext(el: Element) { @@ -273,13 +288,17 @@ class OpenAPI3_2DereferenceVisitor { } protected getNestedVisitorOptions(referencingElement: ObjectElement): ReferenceOptions { + const referencingValueElement = + referencingElement.get('$ref') ?? referencingElement.get('$dynamicRef'); + const isInternalReference = + isStringElement(referencingValueElement) && toValue(referencingValueElement).startsWith('#'); + return { ...this.options, resolve: { ...this.options.resolve, external: - this.options.dereference?.dereferenceOpts?.skipNestedExternal && - toValue(referencingElement.get('$ref')).startsWith('#') + this.options.dereference?.dereferenceOpts?.skipNestedExternal && isInternalReference ? false : this.options.resolve.external, }, @@ -352,6 +371,33 @@ class OpenAPI3_2DereferenceVisitor { return [ancestorsLineage, directAncestors]; } + protected toDynamicScopeStack( + ancestorsLineage: AncestorLineage, + documentURI: string, + ): DynamicScopeStack { + const dynamicScopeStack = this.dynamicScopeStack.clone(); + + ancestorsLineage.forEach((ancestors) => { + ancestors.forEach((ancestor) => { + const isDynamicScopeCandidate = + isSchemaElement(ancestor) && + (typeof ancestor.$id !== 'undefined' || + typeof ancestor.$dynamicAnchor !== 'undefined' || + typeof ancestor.get('$defs') !== 'undefined' || + typeof ancestor.$ref !== 'undefined'); + + if ( + isDynamicScopeCandidate && + !dynamicScopeStack.some((frame) => frame.element === ancestor) + ) { + dynamicScopeStack.pushFrame(ancestor, documentURI, 'applicator'); + } + }); + }); + + return dynamicScopeStack; + } + /** * Extract $self value from a Reference's root OpenAPI 3.2 document. * Returns the $self value if present and valid, otherwise undefined. @@ -609,7 +655,12 @@ class OpenAPI3_2DereferenceVisitor { ? this.extractSelfFromReference(reference) : this.$selfValue; - const visitor = new OpenAPI3_2DereferenceVisitor({ + const Visitor = this.constructor as new ( + options: OpenAPI3_2DereferenceVisitorOptions, + ) => OpenAPI3_2DereferenceVisitor; + const dynamicScopeStack = this.toDynamicScopeStack(ancestorsLineage, retrievalURI); + dynamicScopeStack.pushFrame(referencingElement, retrievalURI, '$ref'); + const visitor = new Visitor({ reference, namespace: this.namespace, indirections: [...this.indirections], @@ -621,6 +672,7 @@ class OpenAPI3_2DereferenceVisitor { errorContext: this.errorContext, errorPropagationCache: this.errorPropagationCache, pendingPropagations: this.pendingPropagations, + dynamicScopeStack, }); try { referencedElement = await visitAsync(referencedElement, visitor, { @@ -861,7 +913,12 @@ class OpenAPI3_2DereferenceVisitor { ? this.extractSelfFromReference(reference) : this.$selfValue; - const visitor = new OpenAPI3_2DereferenceVisitor({ + const Visitor = this.constructor as new ( + options: OpenAPI3_2DereferenceVisitorOptions, + ) => OpenAPI3_2DereferenceVisitor; + const dynamicScopeStack = this.toDynamicScopeStack(ancestorsLineage, retrievalURI); + dynamicScopeStack.pushFrame(referencingElement, retrievalURI, '$dynamicRef'); + const visitor = new Visitor({ reference, namespace: this.namespace, indirections: [...this.indirections], @@ -873,6 +930,7 @@ class OpenAPI3_2DereferenceVisitor { errorContext: this.errorContext, errorPropagationCache: this.errorPropagationCache, pendingPropagations: this.pendingPropagations, + dynamicScopeStack: this.dynamicScopeStack.clone(), }); try { referencedElement = await visitAsync(referencedElement, visitor, { @@ -1208,7 +1266,12 @@ class OpenAPI3_2DereferenceVisitor { // For MemberElement (discriminator mapping), we stay in the same document context // so we keep using the current $selfValue - const visitor = new OpenAPI3_2DereferenceVisitor({ + const Visitor = this.constructor as new ( + options: OpenAPI3_2DereferenceVisitorOptions, + ) => OpenAPI3_2DereferenceVisitor; + const dynamicScopeStack = this.toDynamicScopeStack(ancestorsLineage, this.reference.uri); + dynamicScopeStack.pushFrame(schemaElement, this.reference.uri, '$ref'); + const visitor = new Visitor({ reference: this.reference, namespace: this.namespace, indirections: [...this.indirections], @@ -1217,6 +1280,9 @@ class OpenAPI3_2DereferenceVisitor { ancestors: ancestorsLineage, allOfDiscriminatorMapping: this.allOfDiscriminatorMapping, $selfValue: this.$selfValue, + errorContext: this.errorContext, + errorPropagationCache: this.errorPropagationCache, + dynamicScopeStack, }); let referencedElement: Element; @@ -1253,6 +1319,295 @@ class OpenAPI3_2DereferenceVisitor { return !parent ? memberElementCopy : undefined; } + protected async resolveSchema$dynamicRef( + referencingElement: SchemaElement, + key: string | number, + parent: Element | undefined, + path: (string | number)[], + ancestors: [Element | Element[]], + link: { replaceWith: (element: Element, replacer: typeof mutationReplacer) => void }, + ) { + if (this.indirections.includes(referencingElement)) { + return false; + } + + const [ancestorsLineage, directAncestors] = this.toAncestorLineage([...ancestors, parent]); + + let reference: Reference; + + try { + reference = await this.toReference(url.unsanitize(this.reference.uri)); + } catch (error) { + return this.handleDereferenceError(error, referencingElement, { directAncestors }); + } + + const { uri: retrievalURI } = reference; + const $dynamicRefBaseURI = resolveSchema$dynamicRefField(retrievalURI, referencingElement)!; + const anchorToken = uriToDynamicAnchor($dynamicRefBaseURI); + + this.indirections.push(referencingElement); + + let referencedElement: Element; + let isInternalReference = false; + let isDynamicAnchorTarget = false; + + try { + const staticSelector = isDynamicAnchor(anchorToken) + ? url.stripHash($dynamicRefBaseURI) + : $dynamicRefBaseURI; + const referenceAsSchema = maybeRefractToSchemaElement(reference.value.result as Element); + referencedElement = uriEvaluate(staticSelector, referenceAsSchema)!; + if (isDynamicAnchor(anchorToken)) { + try { + referencedElement = $dynamicAnchorEvaluate(anchorToken, referencedElement)!; + isDynamicAnchorTarget = true; + } catch { + referencedElement = $anchorEvaluate(anchorToken, referencedElement)!; + } + } + referencedElement = maybeRefractToSchemaElement(referencedElement); + referencedElement.id = identityManager.identify(referencedElement); + isInternalReference = true; + } catch (error) { + if (!(error instanceof EvaluationJsonSchemaUriError)) { + this.indirections.pop(); + return this.handleDereferenceError(error, referencingElement, { directAncestors }); + } + + const staticRetrievalURI = this.toBaseURI($dynamicRefBaseURI); + isInternalReference = url.stripHash(this.reference.uri) === staticRetrievalURI; + + if (!this.options.resolve.internal && isInternalReference) { + this.indirections.pop(); + return undefined; + } + if (!this.options.resolve.external && !isInternalReference) { + this.indirections.pop(); + return undefined; + } + + try { + reference = await this.toReference(url.unsanitize($dynamicRefBaseURI)); + const referenceAsSchema = maybeRefractToSchemaElement(reference.value.result as Element); + if (isDynamicAnchor(anchorToken)) { + try { + referencedElement = $dynamicAnchorEvaluate(anchorToken, referenceAsSchema)!; + isDynamicAnchorTarget = true; + } catch { + referencedElement = $anchorEvaluate(anchorToken, referenceAsSchema)!; + } + } else { + referencedElement = jsonPointerEvaluate( + referenceAsSchema, + URIFragmentIdentifier.fromURIReference($dynamicRefBaseURI), + ); + } + referencedElement = maybeRefractToSchemaElement(referencedElement); + referencedElement.id = identityManager.identify(referencedElement); + } catch (toReferenceError) { + this.indirections.pop(); + return this.handleDereferenceError(toReferenceError, referencingElement, { + directAncestors, + }); + } + } + + if (!this.options.resolve.internal && isInternalReference) { + this.indirections.pop(); + return undefined; + } + + if (isDynamicAnchorTarget) { + const dynamicScopeStack = this.toDynamicScopeStack(ancestorsLineage, retrievalURI); + const resolved = dynamicScopeStack.resolveDynamicRef(anchorToken); + if (resolved !== null) { + referencedElement = resolved.referencedElement; + if (resolved.documentURI !== this.reference.uri) { + try { + reference = await this.toReference(url.unsanitize(resolved.documentURI)); + } catch { + // fall back to static target if reference lookup fails + reference = this.reference; + } + } else { + reference = this.reference; + } + } + } + + if (referencingElement === referencedElement) { + const error = new ApiDOMError('Recursive Schema Object reference detected'); + this.indirections.pop(); + return this.handleDereferenceError(error, referencingElement, { directAncestors }); + } + + if (this.indirections.length > this.options.dereference.maxDepth) { + const error = new MaximumDereferenceDepthError( + `Maximum dereference depth of "${this.options.dereference.maxDepth}" has been exceeded in file "${this.reference.uri}"`, + ); + this.indirections.pop(); + return this.handleDereferenceError(error, referencingElement, { directAncestors }); + } + + const shouldResolveReferencedSchema = + isSchemaElement(referencedElement) && + (isStringElement(referencedElement.$ref) || isStringElement(referencedElement.$dynamicRef)); + + if (ancestorsLineage.includes(referencedElement) && !shouldResolveReferencedSchema) { + reference.refSet!.circular = true; + + if (this.options.dereference.circular === 'error') { + const error = new ApiDOMError('Circular reference detected'); + this.indirections.pop(); + return this.handleDereferenceError(error, referencingElement, { directAncestors }); + } + + if (this.options.dereference.circular === 'replace') { + const refElement = new RefElement(referencedElement.id, { + type: 'json-schema', + uri: reference.uri, + $dynamicRef: toValue(referencingElement.$dynamicRef), + baseURI: url.resolve(retrievalURI, $dynamicRefBaseURI), + referencingElement, + }); + const replacer = + this.options.dereference.strategyOpts['openapi-3-2']?.circularReplacer ?? + this.options.dereference.circularReplacer; + const replacement = replacer(refElement); + + link.replaceWith(replacement, mutationReplacer); + + return !parent ? replacement : false; + } + } + + const isNonRootDocument = url.stripHash(reference.refSet!.rootRef!.uri) !== reference.uri; + const shouldDetectCircular = ['error', 'replace'].includes(this.options.dereference.circular); + if ( + (!isInternalReference || + isNonRootDocument || + shouldResolveReferencedSchema || + shouldDetectCircular || + this.options.dereference.dereferenceOpts?.continueOnError) && + !ancestorsLineage.includesCycle(referencedElement) + ) { + directAncestors.add(referencingElement); + + const targetSelfValue = + !isInternalReference || isNonRootDocument + ? this.extractSelfFromReference(reference) + : this.$selfValue; + + const Visitor = this.constructor as new ( + options: OpenAPI3_2DereferenceVisitorOptions, + ) => OpenAPI3_2DereferenceVisitor; + const dynamicScopeStack = this.toDynamicScopeStack(ancestorsLineage, retrievalURI); + dynamicScopeStack.pushFrame(referencingElement, retrievalURI, '$ref'); + const visitor = new Visitor({ + reference, + namespace: this.namespace, + indirections: [...this.indirections], + options: this.getNestedVisitorOptions(referencingElement), + refractCache: this.refractCache, + ancestors: ancestorsLineage, + allOfDiscriminatorMapping: this.allOfDiscriminatorMapping, + $selfValue: targetSelfValue, + errorContext: this.errorContext, + errorPropagationCache: this.errorPropagationCache, + dynamicScopeStack, + }); + try { + referencedElement = await visitAsync(referencedElement, visitor, { + keyMap, + nodeTypeGetter: getNodeType, + }); + } catch (error) { + this.indirections.pop(); + return this.handleDereferenceError(error, referencingElement, { directAncestors }); + } + + directAncestors.delete(referencingElement); + } + + this.indirections.pop(); + + if (isBooleanJsonSchemaElement(referencedElement as unknown)) { + const booleanJsonSchemaElement: BooleanElement = cloneDeep(referencedElement!); + booleanJsonSchemaElement.setMetaProperty('id', identityManager.generateId()); + booleanJsonSchemaElement.setMetaProperty('ref-fields', { + $dynamicRef: toValue(referencingElement.$dynamicRef), + $dynamicRefBaseURI, + }); + const refOriginURI = !isInternalReference + ? this.extractSelfFromReference(reference) || reference.uri + : this.$selfValue || reference.uri; + booleanJsonSchemaElement.setMetaProperty('ref-origin', refOriginURI); + booleanJsonSchemaElement.setMetaProperty( + 'ref-referencing-element-id', + cloneDeep(identityManager.identify(referencingElement)), + ); + + link.replaceWith(booleanJsonSchemaElement, mutationReplacer); + + return !parent ? booleanJsonSchemaElement : false; + } + + if (isSchemaElement(referencedElement)) { + const mergedElement = new SchemaElement( + [...referencedElement.content] as any, + cloneDeep(referencedElement.meta), + cloneDeep(referencedElement.attributes), + ); + mergedElement.setMetaProperty('id', identityManager.generateId()); + referencingElement.forEach((value: Element, keyElement: Element, item: Element) => { + mergedElement.remove(toValue(keyElement)); + mergedElement.content.push(item); + }); + mergedElement.remove('$dynamicRef'); + mergedElement.setMetaProperty('ref-fields', { + $dynamicRef: toValue(referencingElement.$dynamicRef), + $dynamicRefBaseURI, + }); + const refOriginURI = !isInternalReference + ? this.extractSelfFromReference(reference) || reference.uri + : this.$selfValue || reference.uri; + mergedElement.setMetaProperty('ref-origin', refOriginURI); + mergedElement.setMetaProperty( + 'ref-referencing-element-id', + cloneDeep(identityManager.identify(referencingElement)), + ); + + if (this.options.dereference.dereferenceOpts?.continueOnError) { + mergedElement.setMetaProperty('ref-referencing-element', referencingElement); + } + + // creating mapping for allOf discriminator + if (this.options.dereference.strategyOpts['openapi-3-2']?.dereferenceDiscriminatorMapping) { + const parentElement = ancestors[ancestors.length - 1]; + const parentSchemaElement = [...directAncestors].findLast(isSchemaElement); + const parentSchemaElementName = parentSchemaElement?.getMetaProperty('schemaName'); + const mergedElementName = toValue(mergedElement.getMetaProperty('schemaName')); + + if ( + mergedElementName && + parentSchemaElementName && + // @ts-ignore + parentElement?.classes?.contains('json-schema-allOf') + ) { + const currentMapping = this.allOfDiscriminatorMapping.get(mergedElementName) ?? []; + currentMapping.push(parentSchemaElement!); + this.allOfDiscriminatorMapping.set(mergedElementName, currentMapping); + } + } + + referencedElement = mergedElement; + } + + link.replaceWith(referencedElement!, mutationReplacer); + + return !parent ? referencedElement : undefined; + } + public async SchemaElement( referencingElement: SchemaElement, key: string | number, @@ -1261,11 +1616,17 @@ class OpenAPI3_2DereferenceVisitor { ancestors: [Element | Element[]], link: { replaceWith: (element: Element, replacer: typeof mutationReplacer) => void }, ) { - // skip current referencing schema as $ref keyword was not defined - if (!isStringElement(referencingElement.$ref)) { + const has$ref = isStringElement(referencingElement.$ref); + const has$dynamicRef = isStringElement(referencingElement.$dynamicRef); + + if (!has$ref && !has$dynamicRef) { return undefined; } + if (has$dynamicRef && !has$ref) { + return this.resolveSchema$dynamicRef(referencingElement, key, parent, path, ancestors, link); + } + // skip current referencing element as it's already been access if (this.indirections.includes(referencingElement)) { return false; @@ -1497,16 +1858,34 @@ class OpenAPI3_2DereferenceVisitor { * Cases to consider: * 1. We're crossing document boundary * 2. Fragment is from non-root document - * 3. Fragment is a Schema Object with $ref field. We need to follow it to get the eventual value + * 3. Fragment is a Schema Object with $ref or $dynamicRef field. We need to follow it to get the eventual value * 4. We are dereferencing the fragment lazily/eagerly depending on circular mode */ const isNonRootDocument = url.stripHash(reference.refSet!.rootRef!.uri) !== reference.uri; const shouldDetectCircular = ['error', 'replace'].includes(this.options.dereference.circular); + const containsNested$dynamicRef = + isSchemaElement(referencedElement) && + !isUndefined( + find( + (element) => + (isSchemaElement(element) && isStringElement(element.$dynamicRef)) || + (isObjectElement(element) && isStringElement(element.get('$dynamicRef'))), + referencedElement, + ), + ); + const hasDynamicScopeOverride = + typeof referencingElement.$dynamicAnchor !== 'undefined' || + typeof referencingElement.get('$defs') !== 'undefined'; if ( (isExternalReference || isNonRootDocument || - (isSchemaElement(referencedElement) && isStringElement(referencedElement.$ref)) || - shouldDetectCircular) && + (isSchemaElement(referencedElement) && + (isStringElement(referencedElement.$ref) || + isStringElement(referencedElement.$dynamicRef))) || + containsNested$dynamicRef || + hasDynamicScopeOverride || + shouldDetectCircular || + this.options.dereference.dereferenceOpts?.continueOnError) && !ancestorsLineage.includesCycle(referencedElement) ) { // append referencing reference to ancestors lineage @@ -1519,7 +1898,12 @@ class OpenAPI3_2DereferenceVisitor { ? this.extractSelfFromReference(reference) : this.$selfValue; - const visitor = new OpenAPI3_2DereferenceVisitor({ + const Visitor = this.constructor as new ( + options: OpenAPI3_2DereferenceVisitorOptions, + ) => OpenAPI3_2DereferenceVisitor; + const dynamicScopeStack = this.toDynamicScopeStack(ancestorsLineage, retrievalURI); + dynamicScopeStack.pushFrame(referencingElement, retrievalURI, '$ref'); + const visitor = new Visitor({ reference, namespace: this.namespace, indirections: [...this.indirections], @@ -1531,6 +1915,7 @@ class OpenAPI3_2DereferenceVisitor { errorContext: this.errorContext, errorPropagationCache: this.errorPropagationCache, pendingPropagations: this.pendingPropagations, + dynamicScopeStack, }); try { referencedElement = await visitAsync(referencedElement, visitor, { diff --git a/packages/apidom-reference/src/errors/EvaluationJsonSchema$dynamicAnchorError.ts b/packages/apidom-reference/src/errors/EvaluationJsonSchema$dynamicAnchorError.ts new file mode 100644 index 0000000000..115489cc98 --- /dev/null +++ b/packages/apidom-reference/src/errors/EvaluationJsonSchema$dynamicAnchorError.ts @@ -0,0 +1,8 @@ +import JsonSchema$dynamicAnchorError from './JsonSchema$dynamicAnchorError.ts'; + +/** + * @public + */ +class EvaluationJsonSchema$dynamicAnchorError extends JsonSchema$dynamicAnchorError {} + +export default EvaluationJsonSchema$dynamicAnchorError; diff --git a/packages/apidom-reference/src/errors/InvalidJsonSchema$dynamicAnchorError.ts b/packages/apidom-reference/src/errors/InvalidJsonSchema$dynamicAnchorError.ts new file mode 100644 index 0000000000..14ec3054b7 --- /dev/null +++ b/packages/apidom-reference/src/errors/InvalidJsonSchema$dynamicAnchorError.ts @@ -0,0 +1,12 @@ +import JsonSchema$dynamicAnchorError from './JsonSchema$dynamicAnchorError.ts'; + +/** + * @public + */ +class InvalidJsonSchema$dynamicAnchorError extends JsonSchema$dynamicAnchorError { + constructor(anchor: string) { + super(`Invalid JSON Schema $dynamicAnchor "${anchor}".`); + } +} + +export default InvalidJsonSchema$dynamicAnchorError; diff --git a/packages/apidom-reference/src/errors/JsonSchema$dynamicAnchorError.ts b/packages/apidom-reference/src/errors/JsonSchema$dynamicAnchorError.ts new file mode 100644 index 0000000000..13c9fb2952 --- /dev/null +++ b/packages/apidom-reference/src/errors/JsonSchema$dynamicAnchorError.ts @@ -0,0 +1,8 @@ +import { ApiDOMError } from '@swagger-api/apidom-error'; + +/** + * @public + */ +class JsonSchema$dynamicAnchorError extends ApiDOMError {} + +export default JsonSchema$dynamicAnchorError; diff --git a/packages/apidom-reference/src/index.ts b/packages/apidom-reference/src/index.ts index e00c1747c7..f1a6671669 100644 --- a/packages/apidom-reference/src/index.ts +++ b/packages/apidom-reference/src/index.ts @@ -26,6 +26,8 @@ export type { ResolveStrategyOptions } from './resolve/strategies/ResolveStrateg export { default as DereferenceStrategy } from './dereference/strategies/DereferenceStrategy.ts'; export type { DereferenceStrategyOptions } from './dereference/strategies/DereferenceStrategy.ts'; export { AncestorLineage as DereferenceAncestorLineage } from './dereference/util.ts'; +export { DynamicScopeStack } from './dereference/DynamicScopeStack.ts'; +export type { DynamicScopeEntryType, DynamicScopeFrame } from './dereference/DynamicScopeStack.ts'; export { default as BundleStrategy } from './bundle/strategies/BundleStrategy.ts'; export type { BundleStrategyOptions } from './bundle/strategies/BundleStrategy.ts'; @@ -53,9 +55,12 @@ export { default as UnmatchedBundleStrategyError } from './errors/UnmatchedBundl export { default as DereferenceError } from './errors/DereferenceError.ts'; export { default as EvaluationElementIdError } from './errors/EvaluationElementIdError.ts'; export { default as EvaluationJsonSchema$anchorError } from './errors/EvaluationJsonSchema$anchorError.ts'; +export { default as EvaluationJsonSchema$dynamicAnchorError } from './errors/EvaluationJsonSchema$dynamicAnchorError.ts'; export { default as EvaluationJsonSchemaUriError } from './errors/EvaluationJsonSchemaUriError.ts'; export { default as InvalidJsonSchema$anchorError } from './errors/InvalidJsonSchema$anchorError.ts'; +export { default as InvalidJsonSchema$dynamicAnchorError } from './errors/InvalidJsonSchema$dynamicAnchorError.ts'; export { default as JsonSchema$anchorError } from './errors/JsonSchema$anchorError.ts'; +export { default as JsonSchema$dynamicAnchorError } from './errors/JsonSchema$dynamicAnchorError.ts'; export { default as JsonSchemaURIError } from './errors/JsonSchemaUriError.ts'; export { default as MaximumDereferenceDepthError } from './errors/MaximumDereferenceDepthError.ts'; export { default as MaximumResolveDepthError } from './errors/MaximumResolveDepthError.ts'; diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-allOf-discriminator/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-allOf-discriminator/dereferenced.json new file mode 100644 index 0000000000..986356131a --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-allOf-discriminator/dereferenced.json @@ -0,0 +1,95 @@ +[ + { + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "PetResponse": { + "type": "object", + "properties": { + "pet": { + "$dynamicAnchor": "petType", + "type": "object", + "properties": { + "petType": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + }, + "discriminator": { + "propertyName": "petType", + "mapping": { + "dog": "#/components/schemas/Dog", + "cat": "#/components/schemas/Cat" + } + } + }, + "PetTemplate": { + "$dynamicAnchor": "petType", + "type": "object", + "properties": { + "petType": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "Dog": { + "allOf": [ + { + "$dynamicAnchor": "petType", + "type": "object", + "properties": { + "petType": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "breed": { + "type": "string" + } + } + } + ] + }, + "Cat": { + "allOf": [ + { + "$dynamicAnchor": "petType", + "type": "object", + "properties": { + "petType": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "indoor": { + "type": "boolean" + } + } + } + ] + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-allOf-discriminator/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-allOf-discriminator/root.json new file mode 100644 index 0000000000..8551f1c4f2 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-allOf-discriminator/root.json @@ -0,0 +1,68 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "PetResponse": { + "type": "object", + "properties": { + "pet": { + "$dynamicRef": "#petType" + } + }, + "discriminator": { + "propertyName": "petType", + "mapping": { + "dog": "#/components/schemas/Dog", + "cat": "#/components/schemas/Cat" + } + } + }, + "PetTemplate": { + "$dynamicAnchor": "petType", + "type": "object", + "properties": { + "petType": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "Dog": { + "allOf": [ + { + "$dynamicAnchor": "petType", + "$ref": "#/components/schemas/PetTemplate" + }, + { + "type": "object", + "properties": { + "breed": { + "type": "string" + } + } + } + ] + }, + "Cat": { + "allOf": [ + { + "$dynamicAnchor": "petType", + "$ref": "#/components/schemas/PetTemplate" + }, + { + "type": "object", + "properties": { + "indoor": { + "type": "boolean" + } + } + } + ] + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ancestor-id/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ancestor-id/dereferenced.json new file mode 100644 index 0000000000..e0ff35c672 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ancestor-id/dereferenced.json @@ -0,0 +1,38 @@ +[ + { + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Target": { + "$id": "https://example.com/schemas/target", + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "target" + } + } + }, + "Consumer": { + "type": "object", + "properties": { + "item": { + "$dynamicAnchor": "node", + "$id": "https://example.com/schemas/target", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "target" + } + } + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ancestor-id/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ancestor-id/root.json new file mode 100644 index 0000000000..0b54e8ba6e --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ancestor-id/root.json @@ -0,0 +1,28 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Target": { + "$id": "https://example.com/schemas/target", + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "target" + } + } + }, + "Consumer": { + "type": "object", + "properties": { + "item": { + "$dynamicRef": "https://example.com/schemas/target#node" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-boolean-json-schema/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-boolean-json-schema/dereferenced.json new file mode 100644 index 0000000000..846963c1a1 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-boolean-json-schema/dereferenced.json @@ -0,0 +1,25 @@ +[ + { + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "active": { + "$dynamicAnchor": "alwaysTrue" + } + } + }, + "AlwaysTrue": { + "$dynamicAnchor": "alwaysTrue" + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-boolean-json-schema/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-boolean-json-schema/root.json new file mode 100644 index 0000000000..a24f15a421 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-boolean-json-schema/root.json @@ -0,0 +1,23 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "active": { + "$dynamicRef": "#alwaysTrue" + } + } + }, + "AlwaysTrue": { + "$dynamicAnchor": "alwaysTrue" + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-circular-error/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-circular-error/root.json new file mode 100644 index 0000000000..b829638e72 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-circular-error/root.json @@ -0,0 +1,24 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Category": { + "$dynamicAnchor": "CategoryNode", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$dynamicRef": "#CategoryNode" + } + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-circular-replace/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-circular-replace/root.json new file mode 100644 index 0000000000..b829638e72 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-circular-replace/root.json @@ -0,0 +1,24 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Category": { + "$dynamicAnchor": "CategoryNode", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$dynamicRef": "#CategoryNode" + } + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-continue-on-error-anchor/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-continue-on-error-anchor/root.json new file mode 100644 index 0000000000..14f0b76819 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-continue-on-error-anchor/root.json @@ -0,0 +1,17 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "profile": { + "$dynamicRef": "#nonExistent" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-continue-on-error-circular/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-continue-on-error-circular/root.json new file mode 100644 index 0000000000..b829638e72 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-continue-on-error-circular/root.json @@ -0,0 +1,24 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Category": { + "$dynamicAnchor": "CategoryNode", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$dynamicRef": "#CategoryNode" + } + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-continue-on-error-missing/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-continue-on-error-missing/root.json new file mode 100644 index 0000000000..521432eee0 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-continue-on-error-missing/root.json @@ -0,0 +1,17 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "profile": { + "$dynamicRef": "./missing.json#profileType" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-embedded-canonical-id/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-embedded-canonical-id/dereferenced.json new file mode 100644 index 0000000000..2fe99cb7f4 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-embedded-canonical-id/dereferenced.json @@ -0,0 +1,38 @@ +[ + { + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Base": { + "$id": "https://example.com/schemas/Base", + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "embedded" + } + } + }, + "Consumer": { + "type": "object", + "properties": { + "child": { + "$id": "https://example.com/schemas/Base", + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "embedded" + } + } + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-embedded-canonical-id/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-embedded-canonical-id/root.json new file mode 100644 index 0000000000..96536d24db --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-embedded-canonical-id/root.json @@ -0,0 +1,28 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Base": { + "$id": "https://example.com/schemas/Base", + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "embedded" + } + } + }, + "Consumer": { + "type": "object", + "properties": { + "child": { + "$dynamicRef": "https://example.com/schemas/Base#node" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external-scope-override/ex.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external-scope-override/ex.json new file mode 100644 index 0000000000..ee93643e2c --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external-scope-override/ex.json @@ -0,0 +1,13 @@ +{ + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "external" + }, + "child": { + "$dynamicRef": "#node" + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external-scope-override/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external-scope-override/root.json new file mode 100644 index 0000000000..eea78ac8c3 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external-scope-override/root.json @@ -0,0 +1,22 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Base": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "base" + }, + "child": { + "$dynamicRef": "./ex.json#node" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external/dereferenced.json new file mode 100644 index 0000000000..bf6bdd5c54 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external/dereferenced.json @@ -0,0 +1,34 @@ +[ + { + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "profile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "externalOnly": { + "type": "string" + } + } + } + } + }, + "LocalProfile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "localOnly": { + "type": "string" + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external/ex.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external/ex.json new file mode 100644 index 0000000000..b4559c087f --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external/ex.json @@ -0,0 +1,9 @@ +{ + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "externalOnly": { + "type": "string" + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external/root.json new file mode 100644 index 0000000000..243eb1a624 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-external/root.json @@ -0,0 +1,26 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "profile": { + "$dynamicRef": "./ex.json#profileType" + } + } + }, + "LocalProfile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "localOnly": { + "type": "string" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-fallback/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-fallback/dereferenced.json new file mode 100644 index 0000000000..5603a48b25 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-fallback/dereferenced.json @@ -0,0 +1,37 @@ +[ + { + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "friend": { + "$dynamicAnchor": "UserType", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + }, + "UserType": { + "$dynamicAnchor": "UserType", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-fallback/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-fallback/root.json new file mode 100644 index 0000000000..c0874ac5ce --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-fallback/root.json @@ -0,0 +1,29 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "friend": { + "$dynamicRef": "#UserType" + } + } + }, + "UserType": { + "$dynamicAnchor": "UserType", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-external/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-external/dereferenced.json new file mode 100644 index 0000000000..c5ebfb1181 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-external/dereferenced.json @@ -0,0 +1,19 @@ +[ + { + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "profile": { + "$dynamicRef": "./ex.json#profileType" + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-external/ex.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-external/ex.json new file mode 100644 index 0000000000..b4559c087f --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-external/ex.json @@ -0,0 +1,9 @@ +{ + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "externalOnly": { + "type": "string" + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-external/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-external/root.json new file mode 100644 index 0000000000..bc9302891b --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-external/root.json @@ -0,0 +1,17 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "profile": { + "$dynamicRef": "./ex.json#profileType" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-internal/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-internal/dereferenced.json new file mode 100644 index 0000000000..293df151a3 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-internal/dereferenced.json @@ -0,0 +1,31 @@ +[ + { + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "profile": { + "$dynamicRef": "#profileType" + } + } + }, + "Profile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "firstName": { + "type": "string" + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-internal/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-internal/root.json new file mode 100644 index 0000000000..07cc52b29d --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-ignore-internal/root.json @@ -0,0 +1,29 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "profile": { + "$dynamicRef": "#profileType" + } + } + }, + "Profile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "firstName": { + "type": "string" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-internal/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-internal/dereferenced.json new file mode 100644 index 0000000000..e99a09b4d3 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-internal/dereferenced.json @@ -0,0 +1,43 @@ +[ + { + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "profile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + } + } + } + } + }, + "Profile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-internal/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-internal/root.json new file mode 100644 index 0000000000..1af2fd5343 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-internal/root.json @@ -0,0 +1,32 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "profile": { + "$dynamicRef": "#profileType" + } + } + }, + "Profile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-max-depth/ex1.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-max-depth/ex1.json new file mode 100644 index 0000000000..04e50eb4e1 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-max-depth/ex1.json @@ -0,0 +1,4 @@ +{ + "$dynamicAnchor": "profileType", + "$ref": "./ex2.json#/$defs/Profile" +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-max-depth/ex2.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-max-depth/ex2.json new file mode 100644 index 0000000000..6313041148 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-max-depth/ex2.json @@ -0,0 +1,7 @@ +{ + "$defs": { + "Profile": { + "$ref": "./ex3.json" + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-max-depth/ex3.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-max-depth/ex3.json new file mode 100644 index 0000000000..7d6f2c6d15 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-max-depth/ex3.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-max-depth/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-max-depth/root.json new file mode 100644 index 0000000000..a599fe6d79 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-max-depth/root.json @@ -0,0 +1,17 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "profile": { + "$dynamicRef": "./ex1.json#profileType" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-missing-static-target/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-missing-static-target/root.json new file mode 100644 index 0000000000..27666269d6 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-missing-static-target/root.json @@ -0,0 +1,18 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Wrapper": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "child": { + "$dynamicRef": "./missing.json#node" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-nested-scope/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-nested-scope/dereferenced.json new file mode 100644 index 0000000000..66e8940e04 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-nested-scope/dereferenced.json @@ -0,0 +1,42 @@ +[ + { + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Container": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "outer" }, + "inner": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "inner" }, + "leaf": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "outer" }, + "inner": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "inner" }, + "leaf": { + "$ref": "Container" + } + } + } + } + } + } + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-nested-scope/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-nested-scope/root.json new file mode 100644 index 0000000000..81d259569a --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-nested-scope/root.json @@ -0,0 +1,26 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Container": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "outer" }, + "inner": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "inner" }, + "leaf": { + "$dynamicRef": "#node" + } + } + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-non-existent-anchor/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-non-existent-anchor/root.json new file mode 100644 index 0000000000..401f0a7138 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-non-existent-anchor/root.json @@ -0,0 +1,20 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "profile": { + "$dynamicRef": "#nonExistent" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-recursive/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-recursive/root.json new file mode 100644 index 0000000000..b829638e72 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-recursive/root.json @@ -0,0 +1,24 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Category": { + "$dynamicAnchor": "CategoryNode", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$dynamicRef": "#CategoryNode" + } + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-self-referencing/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-self-referencing/root.json new file mode 100644 index 0000000000..d9f5e48417 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-self-referencing/root.json @@ -0,0 +1,19 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "SelfRef": { + "$dynamicAnchor": "self", + "$dynamicRef": "#self", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-showcase/root.yaml b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-showcase/root.yaml new file mode 100644 index 0000000000..b7bafaccad --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-showcase/root.yaml @@ -0,0 +1,369 @@ +openapi: 3.1.0 +info: + title: DynamicRef Petstore Showcase API + description: > + A combined showcase fixture exercising all $dynamicRef patterns in a + realistic SDK-oriented API: generic pagination, generic response envelopes, + recursive category trees, nested resource graphs, non-identifier schema + keys, and typed request/response bodies. + version: 0.1.0 + license: + name: MIT + identifier: MIT +servers: + - url: https://api.petstore.example +security: [] + +paths: + /pets: + get: + summary: List pets + operationId: listPets + tags: [Pets] + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: Paginated list of pets + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/PaginatedPetItems' + $ref: '#/components/schemas/ApiEnvelopeTemplate' + '400': + description: Bad request + + post: + summary: Create a pet + operationId: createPet + tags: [Pets] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PetCreateRequest' + responses: + '201': + description: Created pet + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/pet' + $ref: '#/components/schemas/ApiEnvelopeTemplate' + '400': + description: Bad request + + /pets/{petId}: + get: + summary: Get a pet by ID + operationId: getPet + tags: [Pets] + parameters: + - name: petId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: A single pet + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/pet' + $ref: '#/components/schemas/ApiEnvelopeTemplate' + '404': + description: Not found + + /owners: + get: + summary: List owners + operationId: listOwners + tags: [Owners] + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: Paginated list of owners + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/PaginatedOwnerItems' + $ref: '#/components/schemas/ApiEnvelopeTemplate' + '400': + description: Bad request + + /species/tree: + get: + summary: Get species category tree + operationId: getSpeciesTree + tags: [Species] + responses: + '200': + description: Localized recursive species category tree + content: + application/json: + schema: + $ref: '#/components/schemas/LocalizedSpeciesCategory' + '400': + description: Bad request + + /shelters/{shelterId}/resources: + get: + summary: Get shelter resource tree + operationId: getShelterResources + tags: [Shelters] + parameters: + - name: shelterId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Shelter resource tree + content: + application/json: + schema: + $ref: '#/components/schemas/ShelterResource' + '404': + description: Not found + +components: + schemas: + Link: + type: object + properties: + href: + type: string + format: uri + + PetFields: + type: object + required: [name, species, status] + properties: + name: + type: string + maxLength: 100 + species: + type: string + status: + type: string + enum: [available, pending, adopted] + tag: + type: array + items: + type: string + + PetCreateRequest: + $ref: '#/components/schemas/PetFields' + + pet: + $id: https://example.com/schemas/pet + allOf: + - type: object + required: [id] + properties: + id: + type: string + format: uuid + - $ref: '#/components/schemas/PetFields' + + Owner: + type: object + required: [id, name, email] + properties: + id: + type: string + format: uuid + name: + type: string + email: + type: string + format: email + + ApiEnvelopeTemplate: + $id: https://example.com/schemas/ApiEnvelopeTemplate + $defs: + dataType: + $dynamicAnchor: dataType + not: {} + type: object + required: [data, requestId] + properties: + data: + $dynamicRef: '#dataType' + requestId: + type: string + links: + type: object + additionalProperties: + $ref: '#/components/schemas/Link' + + PaginatedTemplate: + $id: https://example.com/schemas/PaginatedTemplate + $defs: + itemType: + $dynamicAnchor: itemType + not: {} + type: object + required: [items, total, page, pageSize] + properties: + items: + type: array + items: + $dynamicRef: '#itemType' + total: + type: integer + minimum: 0 + page: + type: integer + minimum: 1 + pageSize: + type: integer + minimum: 1 + + PaginatedPetItems: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/pet' + $ref: '#/components/schemas/PaginatedTemplate' + + PaginatedOwnerItems: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Owner' + $ref: '#/components/schemas/PaginatedTemplate' + + BaseSpeciesCategory: + $id: https://example.com/schemas/BaseSpeciesCategory + $dynamicAnchor: speciesCategory + type: object + required: [id, label, children] + properties: + id: + type: string + label: + type: string + children: + type: array + items: + $dynamicRef: '#speciesCategory' + + LocalizedSpeciesCategory: + $id: https://example.com/schemas/LocalizedSpeciesCategory + $dynamicAnchor: speciesCategory + allOf: + - $ref: '#/components/schemas/BaseSpeciesCategory' + - type: object + required: [locale, displayName] + properties: + locale: + type: string + displayName: + type: string + + Document: + type: object + required: [kind, id, title] + properties: + kind: + const: document + id: + type: string + title: + type: string + + ShelterFolderTemplate: + $id: https://example.com/schemas/ShelterFolderTemplate + $defs: + folderType: + $dynamicAnchor: folderType + not: {} + resourceType: + $dynamicAnchor: resourceType + not: {} + type: object + required: [kind, id, name, children] + properties: + kind: + const: folder + id: + type: string + name: + type: string + children: + type: array + items: + oneOf: + - $ref: '#/components/schemas/Document' + - $dynamicRef: '#folderType' + shortcuts: + type: array + items: + $dynamicRef: '#resourceType' + + shelter-folder: + $id: https://example.com/schemas/shelter-folder + allOf: + - $defs: + folderType: + $dynamicAnchor: folderType + $ref: '#/components/schemas/shelter-folder' + resourceType: + $dynamicAnchor: resourceType + $ref: '#/components/schemas/ShelterResource' + $ref: '#/components/schemas/ShelterFolderTemplate' + - type: object + required: [accessLevel] + properties: + accessLevel: + type: string + enum: [public, staff, admin] + + ShelterResource: + type: object + oneOf: + - $ref: '#/components/schemas/Document' + - $ref: '#/components/schemas/shelter-folder' diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-static-anchor/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-static-anchor/dereferenced.json new file mode 100644 index 0000000000..ea29a4e2c2 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-static-anchor/dereferenced.json @@ -0,0 +1,36 @@ +[ + { + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Base": { + "$anchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "anchor" + } + } + }, + "Consumer": { + "type": "object", + "properties": { + "child": { + "$anchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "anchor" + } + } + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-static-anchor/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-static-anchor/root.json new file mode 100644 index 0000000000..b51e6eb628 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-static-anchor/root.json @@ -0,0 +1,27 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Base": { + "$anchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "anchor" + } + } + }, + "Consumer": { + "type": "object", + "properties": { + "child": { + "$dynamicRef": "#node" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-wrapper-defs/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-wrapper-defs/root.json new file mode 100644 index 0000000000..12347feeff --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-wrapper-defs/root.json @@ -0,0 +1,35 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Container": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "outer" } + }, + "$defs": { + "level1": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "level1" } + }, + "$defs": { + "level2": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "level2" }, + "leaf": { "$dynamicRef": "#node" } + } + } + } + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-wrapper-ref-template/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-wrapper-ref-template/root.json new file mode 100644 index 0000000000..cdb9046008 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/fixtures/$dynamicRef-wrapper-ref-template/root.json @@ -0,0 +1,33 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Concrete": { + "$defs": { + "payload": { + "$dynamicAnchor": "payloadType", + "type": "object", + "properties": { + "source": { "type": "string", "const": "concrete" } + } + } + }, + "$ref": "#/components/schemas/Template" + }, + "Template": { + "$defs": { + "payload": { + "$dynamicAnchor": "payloadType", + "not": {} + } + }, + "type": "object", + "properties": { + "payload": { "$dynamicRef": "#payloadType" } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/index.ts b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/index.ts index f7d016e29e..13d798a6f6 100644 --- a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/index.ts +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/schema-object/index.ts @@ -779,6 +779,477 @@ describe('dereference', function () { }); }); + context( + 'given Schema Objects with $dynamicRef and $dynamicAnchor keywords pointing internally', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-internal'); + + specify('should dereference', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef and $dynamicAnchor keywords pointing externally', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-external'); + + specify('should dereference external dynamic anchor', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { dereferenceOpts: { skipNestedExternal: true } }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef pointing to embedded canonical $id', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-embedded-canonical-id'); + + specify('should dereference embedded canonical dynamic anchor', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + resolve: { external: false }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef pointing to missing external static target', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-missing-static-target'); + + specify('should throw error before applying dynamic scope', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + try { + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + assert.fail('should throw DereferenceError'); + } catch (e) { + assert.instanceOf(e, DereferenceError); + } + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef pointing to $dynamicAnchor with no ancestor override', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-fallback'); + + specify( + 'should dereference by falling back to document-level search', + async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }, + ); + }, + ); + + context( + 'given Schema Objects with $dynamicRef creating recursive tree structure', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-recursive'); + + specify('should dereference and detect circularity', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const children = evaluate( + dereferenced, + '/0/components/schemas/Category/properties/children/items', + ); + const cyclicChildren = evaluate( + dereferenced, + '/0/components/schemas/Category/properties/children/items/properties/children/items', + ); + + assert.strictEqual(children, cyclicChildren); + }); + }, + ); + + context('given Schema Objects with nested $dynamicAnchor at multiple levels', function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-nested-scope'); + + specify('should resolve $dynamicRef to outermost $dynamicAnchor', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const dataPath = '/0/components/schemas/Container/properties/inner/properties/leaf'; + const data = evaluate(actual, dataPath); + + const dataEl = data as Element; + const dataVal = toValue(dataEl) as any; + assert.strictEqual(dataVal?.properties?.source?.const, 'outer'); + }); + }); + + context('given Schema Objects with deeply nested wrapper $defs bindings', function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-wrapper-defs'); + + specify( + 'should resolve $dynamicRef through the explicit dynamic scope stack', + async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const dataPath = + '/0/components/schemas/Container/$defs/level1/$defs/level2/properties/leaf'; + const data = evaluate(actual, dataPath); + const dataVal = toValue(data as Element) as any; + + assert.strictEqual(dataVal?.properties?.source?.const, 'outer'); + }, + ); + }); + + context('given Schema Objects with $ref-entered wrapper $defs bindings', function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-wrapper-ref-template'); + + specify('should resolve $dynamicRef to the caller binding', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const dataPath = '/0/components/schemas/Concrete/properties/payload'; + const data = evaluate(actual, dataPath); + const dataVal = toValue(data as Element) as any; + + assert.strictEqual(dataVal?.properties?.source?.const, 'concrete'); + }); + }); + + context('given Schema Objects with $dynamicRef targeting ordinary $anchor', function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-static-anchor'); + + specify('should behave like $ref and not apply dynamic scope', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }); + + context( + 'given Schema Objects with $dynamicRef creating recursive tree and circular=ignore', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-recursive'); + + specify('should dereference and create cycles', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { circular: 'ignore' }, + }); + + assert.throws(() => JSON.stringify(toValue(dereferenced))); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef and allOf with discriminator mapping', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-allOf-discriminator'); + + specify('should dereference with discriminator mapping enabled', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { + strategyOpts: { + 'openapi-3-1': { + dereferenceDiscriminatorMapping: true, + }, + }, + }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef pointing to non-existent anchor', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-non-existent-anchor'); + + specify('should throw error', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + try { + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + assert.fail('should throw DereferenceError'); + } catch (e) { + assert.instanceOf(e, DereferenceError); + } + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef resolving to boolean JSON Schema', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-boolean-json-schema'); + + specify('should dereference', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef creating recursive tree and circular=error', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-circular-error'); + + specify('should throw error on circular reference', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + try { + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { circular: 'error' }, + }); + assert.fail('should throw DereferenceError'); + } catch (e) { + assert.instanceOf(e, DereferenceError); + } + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef creating recursive tree and circular=replace', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-circular-replace'); + + specify('should eliminate cycles', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { circular: 'replace' }, + }); + + assert.doesNotThrow(() => JSON.stringify(toValue(dereferenced))); + }); + }, + ); + + context('given Schema Objects with $dynamicRef and maxDepth of dereference', function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-max-depth'); + + specify('should throw error', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + try { + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { maxDepth: 2 }, + }); + assert.fail('should throw MaximumDereferenceDepthError'); + } catch (error: any) { + assert.instanceOf(error, DereferenceError); + // @ts-ignore + assert.instanceOf(error.cause.cause, MaximumDereferenceDepthError); + } + }); + }); + + context( + 'given Schema Objects with $dynamicRef and internal resolution disabled', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-ignore-internal'); + + specify('should not dereference internal $dynamicRef', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + resolve: { internal: false }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef and external resolution disabled', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-ignore-external'); + + specify('should not dereference external $dynamicRef', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + resolve: { external: false }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef pointing to non-existent anchor and continueOnError', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-continue-on-error-anchor'); + + specify('should collect error and continue', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const errors: unknown[] = []; + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { dereferenceOpts: { continueOnError: true, errors } }, + }); + + assert.isAbove(errors.length, 0); + assert.isOk(dereferenced); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef pointing to missing external file and continueOnError', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-continue-on-error-missing'); + + specify('should collect error and continue', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const errors: unknown[] = []; + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { dereferenceOpts: { continueOnError: true, errors } }, + }); + + assert.isAbove(errors.length, 0); + assert.isOk(dereferenced); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef creating circular reference and continueOnError', + function () { + const fixturePath = path.join( + rootFixturePath, + '$dynamicRef-continue-on-error-circular', + ); + + specify('should collect error and continue', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const errors: unknown[] = []; + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { + circular: 'error', + dereferenceOpts: { continueOnError: true, errors }, + }, + }); + + assert.isAbove(errors.length, 0); + assert.isOk(dereferenced); + }); + }, + ); + + context( + 'given Schema Objects with external $dynamicRef and root document dynamic scope override', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-external-scope-override'); + + specify( + 'should resolve $dynamicRef to root document $dynamicAnchor', + async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { dereferenceOpts: { skipNestedExternal: true } }, + }); + const dataPath = '/0/components/schemas/Base/properties/child/properties/source'; + const data = evaluate(actual, dataPath); + const dataVal = toValue(data as Element) as any; + + assert.strictEqual(dataVal?.const, 'base'); + }, + ); + }, + ); + + context('given Schema Objects with direct self-referencing $dynamicRef', function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-self-referencing'); + + specify('should throw error for direct self-reference', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + try { + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + assert.fail('should throw DereferenceError'); + } catch (e) { + assert.instanceOf(e, DereferenceError); + } + }); + }); + + context( + 'given Schema Objects with $dynamicRef and ancestor $id changing base URI', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-ancestor-id'); + + specify('should dereference using ancestor schema identifiers', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + context('given Boolean JSON Schemas', function () { const fixturePath = path.join(rootFixturePath, 'boolean-json-schema'); diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-1/selectors/$dynamicAnchor/index.ts b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/selectors/$dynamicAnchor/index.ts new file mode 100644 index 0000000000..7f3f9af55a --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-1/selectors/$dynamicAnchor/index.ts @@ -0,0 +1,79 @@ +import { assert } from 'chai'; +import { toValue } from '@swagger-api/apidom-core'; +import { SchemaElement } from '@swagger-api/apidom-ns-openapi-3-1'; + +import { + isDynamicAnchor, + uriToDynamicAnchor, + parse, + evaluate, + InvalidJsonSchema$dynamicAnchorError, + EvaluationJsonSchema$dynamicAnchorError, +} from '../../../../../../src/dereference/strategies/openapi-3-1/selectors/$dynamicAnchor.ts'; + +describe('dereference', function () { + context('strategies', function () { + context('openapi-3-1', function () { + context('$dynamicAnchor selector', function () { + context('given isDynamicAnchor', function () { + specify('should return true for valid anchor names', function () { + assert.isTrue(isDynamicAnchor('foo')); + assert.isTrue(isDynamicAnchor('_bar')); + assert.isTrue(isDynamicAnchor('My-Anchor.1')); + assert.isTrue(isDynamicAnchor('A')); + }); + + specify('should return false for invalid anchor names', function () { + assert.isFalse(isDynamicAnchor('')); + assert.isFalse(isDynamicAnchor('1starts')); + assert.isFalse(isDynamicAnchor('has space')); + assert.isFalse(isDynamicAnchor('has/sash')); + assert.isFalse(isDynamicAnchor('#fragment')); + }); + }); + + context('given uriToDynamicAnchor', function () { + specify('should extract anchor token from URI fragment', function () { + assert.strictEqual(uriToDynamicAnchor('https://example.com/#myAnchor'), 'myAnchor'); + assert.strictEqual(uriToDynamicAnchor('#myAnchor'), 'myAnchor'); + }); + + specify('should return empty string for URI without fragment', function () { + assert.strictEqual(uriToDynamicAnchor('https://example.com/'), ''); + }); + }); + + context('given parse', function () { + specify('should return valid anchor', function () { + assert.strictEqual(parse('myAnchor'), 'myAnchor'); + }); + + specify('should throw for invalid anchor', function () { + assert.throws(() => parse('1invalid'), InvalidJsonSchema$dynamicAnchorError); + }); + }); + + context('given evaluate', function () { + specify('should find element with matching $dynamicAnchor', function () { + const element = new SchemaElement({ + $dynamicAnchor: 'target', + type: 'object', + }); + const result = evaluate('target', element)!; + + assert.strictEqual(toValue((result as SchemaElement).$dynamicAnchor), 'target'); + }); + + specify('should throw when no matching $dynamicAnchor found', function () { + const element = new SchemaElement({ type: 'object' }); + + assert.throws( + () => evaluate('missing', element), + EvaluationJsonSchema$dynamicAnchorError, + ); + }); + }); + }); + }); + }); +}); diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-allOf-discriminator/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-allOf-discriminator/dereferenced.json new file mode 100644 index 0000000000..d98bbdafbd --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-allOf-discriminator/dereferenced.json @@ -0,0 +1,95 @@ +[ + { + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "PetResponse": { + "type": "object", + "properties": { + "pet": { + "$dynamicAnchor": "petType", + "type": "object", + "properties": { + "petType": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + }, + "discriminator": { + "propertyName": "petType", + "mapping": { + "dog": "#/components/schemas/Dog", + "cat": "#/components/schemas/Cat" + } + } + }, + "PetTemplate": { + "$dynamicAnchor": "petType", + "type": "object", + "properties": { + "petType": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "Dog": { + "allOf": [ + { + "$dynamicAnchor": "petType", + "type": "object", + "properties": { + "petType": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "breed": { + "type": "string" + } + } + } + ] + }, + "Cat": { + "allOf": [ + { + "$dynamicAnchor": "petType", + "type": "object", + "properties": { + "petType": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + { + "type": "object", + "properties": { + "indoor": { + "type": "boolean" + } + } + } + ] + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-allOf-discriminator/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-allOf-discriminator/root.json new file mode 100644 index 0000000000..36486140ad --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-allOf-discriminator/root.json @@ -0,0 +1,68 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "PetResponse": { + "type": "object", + "properties": { + "pet": { + "$dynamicRef": "#petType" + } + }, + "discriminator": { + "propertyName": "petType", + "mapping": { + "dog": "#/components/schemas/Dog", + "cat": "#/components/schemas/Cat" + } + } + }, + "PetTemplate": { + "$dynamicAnchor": "petType", + "type": "object", + "properties": { + "petType": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "Dog": { + "allOf": [ + { + "$dynamicAnchor": "petType", + "$ref": "#/components/schemas/PetTemplate" + }, + { + "type": "object", + "properties": { + "breed": { + "type": "string" + } + } + } + ] + }, + "Cat": { + "allOf": [ + { + "$dynamicAnchor": "petType", + "$ref": "#/components/schemas/PetTemplate" + }, + { + "type": "object", + "properties": { + "indoor": { + "type": "boolean" + } + } + } + ] + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ancestor-id/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ancestor-id/dereferenced.json new file mode 100644 index 0000000000..878d1f8728 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ancestor-id/dereferenced.json @@ -0,0 +1,38 @@ +[ + { + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Target": { + "$id": "https://example.com/schemas/target", + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "target" + } + } + }, + "Consumer": { + "type": "object", + "properties": { + "item": { + "$dynamicAnchor": "node", + "$id": "https://example.com/schemas/target", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "target" + } + } + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ancestor-id/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ancestor-id/root.json new file mode 100644 index 0000000000..dfc1299733 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ancestor-id/root.json @@ -0,0 +1,28 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Target": { + "$id": "https://example.com/schemas/target", + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "target" + } + } + }, + "Consumer": { + "type": "object", + "properties": { + "item": { + "$dynamicRef": "https://example.com/schemas/target#node" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-boolean-json-schema/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-boolean-json-schema/dereferenced.json new file mode 100644 index 0000000000..e33dfceee7 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-boolean-json-schema/dereferenced.json @@ -0,0 +1,25 @@ +[ + { + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "active": { + "$dynamicAnchor": "alwaysTrue" + } + } + }, + "AlwaysTrue": { + "$dynamicAnchor": "alwaysTrue" + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-boolean-json-schema/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-boolean-json-schema/root.json new file mode 100644 index 0000000000..5fd4b8a293 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-boolean-json-schema/root.json @@ -0,0 +1,23 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "active": { + "$dynamicRef": "#alwaysTrue" + } + } + }, + "AlwaysTrue": { + "$dynamicAnchor": "alwaysTrue" + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-circular-error/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-circular-error/root.json new file mode 100644 index 0000000000..6b18d07640 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-circular-error/root.json @@ -0,0 +1,24 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Category": { + "$dynamicAnchor": "CategoryNode", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$dynamicRef": "#CategoryNode" + } + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-circular-replace/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-circular-replace/root.json new file mode 100644 index 0000000000..6b18d07640 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-circular-replace/root.json @@ -0,0 +1,24 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Category": { + "$dynamicAnchor": "CategoryNode", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$dynamicRef": "#CategoryNode" + } + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-continue-on-error-anchor/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-continue-on-error-anchor/root.json new file mode 100644 index 0000000000..ca9b470ffa --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-continue-on-error-anchor/root.json @@ -0,0 +1,17 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "profile": { + "$dynamicRef": "#nonExistent" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-continue-on-error-circular/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-continue-on-error-circular/root.json new file mode 100644 index 0000000000..6b18d07640 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-continue-on-error-circular/root.json @@ -0,0 +1,24 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Category": { + "$dynamicAnchor": "CategoryNode", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$dynamicRef": "#CategoryNode" + } + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-continue-on-error-missing/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-continue-on-error-missing/root.json new file mode 100644 index 0000000000..4a30540205 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-continue-on-error-missing/root.json @@ -0,0 +1,17 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "profile": { + "$dynamicRef": "./missing.json#profileType" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-embedded-canonical-id/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-embedded-canonical-id/dereferenced.json new file mode 100644 index 0000000000..6d711307ee --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-embedded-canonical-id/dereferenced.json @@ -0,0 +1,38 @@ +[ + { + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Base": { + "$id": "https://example.com/schemas/Base", + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "embedded" + } + } + }, + "Consumer": { + "type": "object", + "properties": { + "child": { + "$id": "https://example.com/schemas/Base", + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "embedded" + } + } + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-embedded-canonical-id/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-embedded-canonical-id/root.json new file mode 100644 index 0000000000..18e9876b2b --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-embedded-canonical-id/root.json @@ -0,0 +1,28 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Base": { + "$id": "https://example.com/schemas/Base", + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "embedded" + } + } + }, + "Consumer": { + "type": "object", + "properties": { + "child": { + "$dynamicRef": "https://example.com/schemas/Base#node" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external-scope-override/ex.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external-scope-override/ex.json new file mode 100644 index 0000000000..ee93643e2c --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external-scope-override/ex.json @@ -0,0 +1,13 @@ +{ + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "external" + }, + "child": { + "$dynamicRef": "#node" + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external-scope-override/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external-scope-override/root.json new file mode 100644 index 0000000000..90a290ce53 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external-scope-override/root.json @@ -0,0 +1,22 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Base": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "base" + }, + "child": { + "$dynamicRef": "./ex.json#node" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external/dereferenced.json new file mode 100644 index 0000000000..52de8185bb --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external/dereferenced.json @@ -0,0 +1,34 @@ +[ + { + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "profile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "externalOnly": { + "type": "string" + } + } + } + } + }, + "LocalProfile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "localOnly": { + "type": "string" + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external/ex.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external/ex.json new file mode 100644 index 0000000000..b4559c087f --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external/ex.json @@ -0,0 +1,9 @@ +{ + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "externalOnly": { + "type": "string" + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external/root.json new file mode 100644 index 0000000000..db6cf296dd --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-external/root.json @@ -0,0 +1,26 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "profile": { + "$dynamicRef": "./ex.json#profileType" + } + } + }, + "LocalProfile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "localOnly": { + "type": "string" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-fallback/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-fallback/dereferenced.json new file mode 100644 index 0000000000..e9215c4acf --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-fallback/dereferenced.json @@ -0,0 +1,37 @@ +[ + { + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "friend": { + "$dynamicAnchor": "UserType", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + }, + "UserType": { + "$dynamicAnchor": "UserType", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-fallback/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-fallback/root.json new file mode 100644 index 0000000000..cf7a59bdff --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-fallback/root.json @@ -0,0 +1,29 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "friend": { + "$dynamicRef": "#UserType" + } + } + }, + "UserType": { + "$dynamicAnchor": "UserType", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-external/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-external/dereferenced.json new file mode 100644 index 0000000000..6a3046d780 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-external/dereferenced.json @@ -0,0 +1,19 @@ +[ + { + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "profile": { + "$dynamicRef": "./ex.json#profileType" + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-external/ex.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-external/ex.json new file mode 100644 index 0000000000..b4559c087f --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-external/ex.json @@ -0,0 +1,9 @@ +{ + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "externalOnly": { + "type": "string" + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-external/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-external/root.json new file mode 100644 index 0000000000..865b4ef233 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-external/root.json @@ -0,0 +1,17 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "profile": { + "$dynamicRef": "./ex.json#profileType" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-internal/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-internal/dereferenced.json new file mode 100644 index 0000000000..f03ce1e701 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-internal/dereferenced.json @@ -0,0 +1,31 @@ +[ + { + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "profile": { + "$dynamicRef": "#profileType" + } + } + }, + "Profile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "firstName": { + "type": "string" + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-internal/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-internal/root.json new file mode 100644 index 0000000000..49db754351 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-ignore-internal/root.json @@ -0,0 +1,29 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "profile": { + "$dynamicRef": "#profileType" + } + } + }, + "Profile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "firstName": { + "type": "string" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-internal/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-internal/dereferenced.json new file mode 100644 index 0000000000..e0c4c9b4ea --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-internal/dereferenced.json @@ -0,0 +1,43 @@ +[ + { + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "profile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + } + } + } + } + }, + "Profile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-internal/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-internal/root.json new file mode 100644 index 0000000000..3b5d964dc5 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-internal/root.json @@ -0,0 +1,32 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "profile": { + "$dynamicRef": "#profileType" + } + } + }, + "Profile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-max-depth/ex1.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-max-depth/ex1.json new file mode 100644 index 0000000000..04e50eb4e1 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-max-depth/ex1.json @@ -0,0 +1,4 @@ +{ + "$dynamicAnchor": "profileType", + "$ref": "./ex2.json#/$defs/Profile" +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-max-depth/ex2.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-max-depth/ex2.json new file mode 100644 index 0000000000..6313041148 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-max-depth/ex2.json @@ -0,0 +1,7 @@ +{ + "$defs": { + "Profile": { + "$ref": "./ex3.json" + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-max-depth/ex3.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-max-depth/ex3.json new file mode 100644 index 0000000000..7d6f2c6d15 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-max-depth/ex3.json @@ -0,0 +1,8 @@ +{ + "type": "object", + "properties": { + "name": { + "type": "string" + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-max-depth/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-max-depth/root.json new file mode 100644 index 0000000000..b4ffbb5a9e --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-max-depth/root.json @@ -0,0 +1,17 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "profile": { + "$dynamicRef": "./ex1.json#profileType" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-missing-static-target/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-missing-static-target/root.json new file mode 100644 index 0000000000..18c0ce0566 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-missing-static-target/root.json @@ -0,0 +1,18 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Wrapper": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "child": { + "$dynamicRef": "./missing.json#node" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-nested-scope/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-nested-scope/dereferenced.json new file mode 100644 index 0000000000..107fbbaa68 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-nested-scope/dereferenced.json @@ -0,0 +1,42 @@ +[ + { + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Container": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "outer" }, + "inner": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "inner" }, + "leaf": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "outer" }, + "inner": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "inner" }, + "leaf": { + "$ref": "Container" + } + } + } + } + } + } + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-nested-scope/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-nested-scope/root.json new file mode 100644 index 0000000000..ccd01dcf7a --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-nested-scope/root.json @@ -0,0 +1,26 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Container": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "outer" }, + "inner": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "inner" }, + "leaf": { + "$dynamicRef": "#node" + } + } + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-non-existent-anchor/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-non-existent-anchor/root.json new file mode 100644 index 0000000000..e20e8399d4 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-non-existent-anchor/root.json @@ -0,0 +1,20 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "profile": { + "$dynamicRef": "#nonExistent" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-recursive/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-recursive/root.json new file mode 100644 index 0000000000..6b18d07640 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-recursive/root.json @@ -0,0 +1,24 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Category": { + "$dynamicAnchor": "CategoryNode", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$dynamicRef": "#CategoryNode" + } + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-self-deref/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-self-deref/root.json new file mode 100644 index 0000000000..0261be9f00 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-self-deref/root.json @@ -0,0 +1,30 @@ +{ + "openapi": "3.2.0", + "$self": "https://example.com/api.yaml", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "profile": { + "$dynamicRef": "#profileType" + } + } + }, + "Profile": { + "$dynamicAnchor": "profileType", + "type": "object", + "properties": { + "firstName": { + "type": "string" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-self-referencing/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-self-referencing/root.json new file mode 100644 index 0000000000..5c79d6817a --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-self-referencing/root.json @@ -0,0 +1,19 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "SelfRef": { + "$dynamicAnchor": "self", + "$dynamicRef": "#self", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-showcase/root.yaml b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-showcase/root.yaml new file mode 100644 index 0000000000..a5b2a2257d --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-showcase/root.yaml @@ -0,0 +1,369 @@ +openapi: 3.2.0 +info: + title: DynamicRef Petstore Showcase API + description: > + A combined showcase fixture exercising all $dynamicRef patterns in a + realistic SDK-oriented API: generic pagination, generic response envelopes, + recursive category trees, nested resource graphs, non-identifier schema + keys, and typed request/response bodies. + version: 0.1.0 + license: + name: MIT + identifier: MIT +servers: + - url: https://api.petstore.example +security: [] + +paths: + /pets: + get: + summary: List pets + operationId: listPets + tags: [Pets] + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: Paginated list of pets + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/PaginatedPetItems' + $ref: '#/components/schemas/ApiEnvelopeTemplate' + '400': + description: Bad request + + post: + summary: Create a pet + operationId: createPet + tags: [Pets] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PetCreateRequest' + responses: + '201': + description: Created pet + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/pet' + $ref: '#/components/schemas/ApiEnvelopeTemplate' + '400': + description: Bad request + + /pets/{petId}: + get: + summary: Get a pet by ID + operationId: getPet + tags: [Pets] + parameters: + - name: petId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: A single pet + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/pet' + $ref: '#/components/schemas/ApiEnvelopeTemplate' + '404': + description: Not found + + /owners: + get: + summary: List owners + operationId: listOwners + tags: [Owners] + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: pageSize + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: Paginated list of owners + content: + application/json: + schema: + $defs: + dataType: + $dynamicAnchor: dataType + $ref: '#/components/schemas/PaginatedOwnerItems' + $ref: '#/components/schemas/ApiEnvelopeTemplate' + '400': + description: Bad request + + /species/tree: + get: + summary: Get species category tree + operationId: getSpeciesTree + tags: [Species] + responses: + '200': + description: Localized recursive species category tree + content: + application/json: + schema: + $ref: '#/components/schemas/LocalizedSpeciesCategory' + '400': + description: Bad request + + /shelters/{shelterId}/resources: + get: + summary: Get shelter resource tree + operationId: getShelterResources + tags: [Shelters] + parameters: + - name: shelterId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Shelter resource tree + content: + application/json: + schema: + $ref: '#/components/schemas/ShelterResource' + '404': + description: Not found + +components: + schemas: + Link: + type: object + properties: + href: + type: string + format: uri + + PetFields: + type: object + required: [name, species, status] + properties: + name: + type: string + maxLength: 100 + species: + type: string + status: + type: string + enum: [available, pending, adopted] + tag: + type: array + items: + type: string + + PetCreateRequest: + $ref: '#/components/schemas/PetFields' + + pet: + $id: https://example.com/schemas/pet + allOf: + - type: object + required: [id] + properties: + id: + type: string + format: uuid + - $ref: '#/components/schemas/PetFields' + + Owner: + type: object + required: [id, name, email] + properties: + id: + type: string + format: uuid + name: + type: string + email: + type: string + format: email + + ApiEnvelopeTemplate: + $id: https://example.com/schemas/ApiEnvelopeTemplate + $defs: + dataType: + $dynamicAnchor: dataType + not: {} + type: object + required: [data, requestId] + properties: + data: + $dynamicRef: '#dataType' + requestId: + type: string + links: + type: object + additionalProperties: + $ref: '#/components/schemas/Link' + + PaginatedTemplate: + $id: https://example.com/schemas/PaginatedTemplate + $defs: + itemType: + $dynamicAnchor: itemType + not: {} + type: object + required: [items, total, page, pageSize] + properties: + items: + type: array + items: + $dynamicRef: '#itemType' + total: + type: integer + minimum: 0 + page: + type: integer + minimum: 1 + pageSize: + type: integer + minimum: 1 + + PaginatedPetItems: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/pet' + $ref: '#/components/schemas/PaginatedTemplate' + + PaginatedOwnerItems: + $defs: + itemType: + $dynamicAnchor: itemType + $ref: '#/components/schemas/Owner' + $ref: '#/components/schemas/PaginatedTemplate' + + BaseSpeciesCategory: + $id: https://example.com/schemas/BaseSpeciesCategory + $dynamicAnchor: speciesCategory + type: object + required: [id, label, children] + properties: + id: + type: string + label: + type: string + children: + type: array + items: + $dynamicRef: '#speciesCategory' + + LocalizedSpeciesCategory: + $id: https://example.com/schemas/LocalizedSpeciesCategory + $dynamicAnchor: speciesCategory + allOf: + - $ref: '#/components/schemas/BaseSpeciesCategory' + - type: object + required: [locale, displayName] + properties: + locale: + type: string + displayName: + type: string + + Document: + type: object + required: [kind, id, title] + properties: + kind: + const: document + id: + type: string + title: + type: string + + ShelterFolderTemplate: + $id: https://example.com/schemas/ShelterFolderTemplate + $defs: + folderType: + $dynamicAnchor: folderType + not: {} + resourceType: + $dynamicAnchor: resourceType + not: {} + type: object + required: [kind, id, name, children] + properties: + kind: + const: folder + id: + type: string + name: + type: string + children: + type: array + items: + oneOf: + - $ref: '#/components/schemas/Document' + - $dynamicRef: '#folderType' + shortcuts: + type: array + items: + $dynamicRef: '#resourceType' + + shelter-folder: + $id: https://example.com/schemas/shelter-folder + allOf: + - $defs: + folderType: + $dynamicAnchor: folderType + $ref: '#/components/schemas/shelter-folder' + resourceType: + $dynamicAnchor: resourceType + $ref: '#/components/schemas/ShelterResource' + $ref: '#/components/schemas/ShelterFolderTemplate' + - type: object + required: [accessLevel] + properties: + accessLevel: + type: string + enum: [public, staff, admin] + + ShelterResource: + type: object + oneOf: + - $ref: '#/components/schemas/Document' + - $ref: '#/components/schemas/shelter-folder' diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-static-anchor/dereferenced.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-static-anchor/dereferenced.json new file mode 100644 index 0000000000..9402059479 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-static-anchor/dereferenced.json @@ -0,0 +1,36 @@ +[ + { + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Base": { + "$anchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "anchor" + } + } + }, + "Consumer": { + "type": "object", + "properties": { + "child": { + "$anchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "anchor" + } + } + } + } + } + } + } + } +] diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-static-anchor/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-static-anchor/root.json new file mode 100644 index 0000000000..f9739aad16 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-static-anchor/root.json @@ -0,0 +1,27 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Base": { + "$anchor": "node", + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "anchor" + } + } + }, + "Consumer": { + "type": "object", + "properties": { + "child": { + "$dynamicRef": "#node" + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-wrapper-defs/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-wrapper-defs/root.json new file mode 100644 index 0000000000..379b1d137a --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-wrapper-defs/root.json @@ -0,0 +1,35 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Container": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "outer" } + }, + "$defs": { + "level1": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "level1" } + }, + "$defs": { + "level2": { + "$dynamicAnchor": "node", + "type": "object", + "properties": { + "source": { "type": "string", "const": "level2" }, + "leaf": { "$dynamicRef": "#node" } + } + } + } + } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-wrapper-ref-template/root.json b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-wrapper-ref-template/root.json new file mode 100644 index 0000000000..6f5a0476ca --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/fixtures/$dynamicRef-wrapper-ref-template/root.json @@ -0,0 +1,33 @@ +{ + "openapi": "3.2.0", + "info": { "title": "Test", "version": "1.0.0" }, + "paths": {}, + "components": { + "schemas": { + "Concrete": { + "$defs": { + "payload": { + "$dynamicAnchor": "payloadType", + "type": "object", + "properties": { + "source": { "type": "string", "const": "concrete" } + } + } + }, + "$ref": "#/components/schemas/Template" + }, + "Template": { + "$defs": { + "payload": { + "$dynamicAnchor": "payloadType", + "not": {} + } + }, + "type": "object", + "properties": { + "payload": { "$dynamicRef": "#payloadType" } + } + } + } + } +} diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/index.ts b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/index.ts index 1d13791357..1875c72b8f 100644 --- a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/index.ts +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/schema-object/index.ts @@ -779,6 +779,503 @@ describe('dereference', function () { }); }); + context( + 'given Schema Objects with $dynamicRef and $dynamicAnchor keywords pointing internally', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-internal'); + + specify('should dereference', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef and $dynamicAnchor keywords pointing externally', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-external'); + + specify('should dereference external dynamic anchor', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { dereferenceOpts: { skipNestedExternal: true } }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef pointing to embedded canonical $id', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-embedded-canonical-id'); + + specify('should dereference embedded canonical dynamic anchor', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + resolve: { external: false }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef pointing to missing external static target', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-missing-static-target'); + + specify('should throw error before applying dynamic scope', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + try { + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + assert.fail('should throw DereferenceError'); + } catch (e) { + assert.instanceOf(e, DereferenceError); + } + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef pointing to $dynamicAnchor with no ancestor override', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-fallback'); + + specify( + 'should dereference by falling back to document-level search', + async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }, + ); + }, + ); + + context( + 'given Schema Objects with $dynamicRef creating recursive tree structure', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-recursive'); + + specify('should dereference and detect circularity', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const children = evaluate( + dereferenced, + '/0/components/schemas/Category/properties/children/items', + ); + const cyclicChildren = evaluate( + dereferenced, + '/0/components/schemas/Category/properties/children/items/properties/children/items', + ); + + assert.strictEqual(children, cyclicChildren); + }); + }, + ); + + context('given Schema Objects with nested $dynamicAnchor at multiple levels', function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-nested-scope'); + + specify('should resolve $dynamicRef to outermost $dynamicAnchor', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const dataPath = '/0/components/schemas/Container/properties/inner/properties/leaf'; + const data = evaluate(actual, dataPath); + + const dataEl = data as Element; + const dataVal = toValue(dataEl) as any; + assert.strictEqual(dataVal?.properties?.source?.const, 'outer'); + }); + }); + + context('given Schema Objects with deeply nested wrapper $defs bindings', function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-wrapper-defs'); + + specify( + 'should resolve $dynamicRef through the explicit dynamic scope stack', + async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const dataPath = + '/0/components/schemas/Container/$defs/level1/$defs/level2/properties/leaf'; + const data = evaluate(actual, dataPath); + const dataVal = toValue(data as Element) as any; + + assert.strictEqual(dataVal?.properties?.source?.const, 'outer'); + }, + ); + }); + + context('given Schema Objects with $ref-entered wrapper $defs bindings', function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-wrapper-ref-template'); + + specify('should resolve $dynamicRef to the caller binding', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const dataPath = '/0/components/schemas/Concrete/properties/payload'; + const data = evaluate(actual, dataPath); + const dataVal = toValue(data as Element) as any; + + assert.strictEqual(dataVal?.properties?.source?.const, 'concrete'); + }); + }); + + context('given Schema Objects with $dynamicRef targeting ordinary $anchor', function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-static-anchor'); + + specify('should behave like $ref and not apply dynamic scope', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }); + + context( + 'given Schema Objects with $dynamicRef creating recursive tree and circular=ignore', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-recursive'); + + specify('should dereference and create cycles', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { circular: 'ignore' }, + }); + + assert.throws(() => JSON.stringify(toValue(dereferenced))); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef and allOf with discriminator mapping', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-allOf-discriminator'); + + specify('should dereference with discriminator mapping enabled', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { + strategyOpts: { + 'openapi-3-2': { + dereferenceDiscriminatorMapping: true, + }, + }, + }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef pointing to non-existent anchor', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-non-existent-anchor'); + + specify('should throw error', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + try { + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + assert.fail('should throw DereferenceError'); + } catch (e) { + assert.instanceOf(e, DereferenceError); + } + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef resolving to boolean JSON Schema', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-boolean-json-schema'); + + specify('should dereference', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef creating recursive tree and circular=error', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-circular-error'); + + specify('should throw error on circular reference', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + try { + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { circular: 'error' }, + }); + assert.fail('should throw DereferenceError'); + } catch (e) { + assert.instanceOf(e, DereferenceError); + } + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef creating recursive tree and circular=replace', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-circular-replace'); + + specify('should eliminate cycles', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { circular: 'replace' }, + }); + + assert.doesNotThrow(() => JSON.stringify(toValue(dereferenced))); + }); + }, + ); + + context('given Schema Objects with $dynamicRef and maxDepth of dereference', function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-max-depth'); + + specify('should throw error', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + try { + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { maxDepth: 2 }, + }); + assert.fail('should throw MaximumDereferenceDepthError'); + } catch (error: any) { + assert.instanceOf(error, DereferenceError); + // @ts-ignore + assert.instanceOf(error.cause.cause, MaximumDereferenceDepthError); + } + }); + }); + + context( + 'given Schema Objects with $dynamicRef and internal resolution disabled', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-ignore-internal'); + + specify('should not dereference internal $dynamicRef', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + resolve: { internal: false }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef and external resolution disabled', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-ignore-external'); + + specify('should not dereference external $dynamicRef', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + resolve: { external: false }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef pointing to non-existent anchor and continueOnError', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-continue-on-error-anchor'); + + specify('should collect error and continue', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const errors: unknown[] = []; + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { dereferenceOpts: { continueOnError: true, errors } }, + }); + + assert.isAbove(errors.length, 0); + assert.isOk(dereferenced); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef pointing to missing external file and continueOnError', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-continue-on-error-missing'); + + specify('should collect error and continue', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const errors: unknown[] = []; + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { dereferenceOpts: { continueOnError: true, errors } }, + }); + + assert.isAbove(errors.length, 0); + assert.isOk(dereferenced); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef creating circular reference and continueOnError', + function () { + const fixturePath = path.join( + rootFixturePath, + '$dynamicRef-continue-on-error-circular', + ); + + specify('should collect error and continue', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const errors: unknown[] = []; + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { + circular: 'error', + dereferenceOpts: { continueOnError: true, errors }, + }, + }); + + assert.isAbove(errors.length, 0); + assert.isOk(dereferenced); + }); + }, + ); + + context( + 'given Schema Objects with external $dynamicRef and root document dynamic scope override', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-external-scope-override'); + + specify( + 'should resolve $dynamicRef to root document $dynamicAnchor', + async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + dereference: { dereferenceOpts: { skipNestedExternal: true } }, + }); + const dataPath = '/0/components/schemas/Base/properties/child/properties/source'; + const data = evaluate(actual, dataPath); + const dataVal = toValue(data as Element) as any; + + assert.strictEqual(dataVal?.const, 'base'); + }, + ); + }, + ); + + context('given Schema Objects with direct self-referencing $dynamicRef', function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-self-referencing'); + + specify('should throw error for direct self-reference', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + try { + await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + assert.fail('should throw DereferenceError'); + } catch (e) { + assert.instanceOf(e, DereferenceError); + } + }); + }); + + context( + 'given Schema Objects with $dynamicRef and ancestor $id changing base URI', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-ancestor-id'); + + specify('should dereference using ancestor schema identifiers', async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const actual = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const expected = loadJsonFile(path.join(fixturePath, 'dereferenced.json')); + + assert.deepEqual(toValue(actual), expected); + }); + }, + ); + + context( + 'given Schema Objects with $dynamicRef and $self on root OpenAPI document', + function () { + const fixturePath = path.join(rootFixturePath, '$dynamicRef-self-deref'); + + specify( + 'should annotate transcluded element with $self-based ref-origin', + async function () { + const rootFilePath = path.join(fixturePath, 'root.json'); + const dereferenced = await dereference(rootFilePath, { + parse: { mediaType: mediaTypes.latest('json') }, + }); + const fragment = evaluate( + dereferenced, + '/0/components/schemas/User/properties/profile', + ); + + assert.strictEqual( + toValue(fragment.meta.get('ref-origin')), + 'https://example.com/api.yaml', + ); + }, + ); + }, + ); + context('given Boolean JSON Schemas', function () { const fixturePath = path.join(rootFixturePath, 'boolean-json-schema'); diff --git a/packages/apidom-reference/test/dereference/strategies/openapi-3-2/selectors/$dynamicAnchor/index.ts b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/selectors/$dynamicAnchor/index.ts new file mode 100644 index 0000000000..43c6df5c78 --- /dev/null +++ b/packages/apidom-reference/test/dereference/strategies/openapi-3-2/selectors/$dynamicAnchor/index.ts @@ -0,0 +1,79 @@ +import { assert } from 'chai'; +import { toValue } from '@swagger-api/apidom-core'; +import { SchemaElement } from '@swagger-api/apidom-ns-openapi-3-2'; + +import { + isDynamicAnchor, + uriToDynamicAnchor, + parse, + evaluate, + InvalidJsonSchema$dynamicAnchorError, + EvaluationJsonSchema$dynamicAnchorError, +} from '../../../../../../src/dereference/strategies/openapi-3-2/selectors/$dynamicAnchor.ts'; + +describe('dereference', function () { + context('strategies', function () { + context('openapi-3-2', function () { + context('$dynamicAnchor selector', function () { + context('given isDynamicAnchor', function () { + specify('should return true for valid anchor names', function () { + assert.isTrue(isDynamicAnchor('foo')); + assert.isTrue(isDynamicAnchor('_bar')); + assert.isTrue(isDynamicAnchor('My-Anchor.1')); + assert.isTrue(isDynamicAnchor('A')); + }); + + specify('should return false for invalid anchor names', function () { + assert.isFalse(isDynamicAnchor('')); + assert.isFalse(isDynamicAnchor('1starts')); + assert.isFalse(isDynamicAnchor('has space')); + assert.isFalse(isDynamicAnchor('has/sash')); + assert.isFalse(isDynamicAnchor('#fragment')); + }); + }); + + context('given uriToDynamicAnchor', function () { + specify('should extract anchor token from URI fragment', function () { + assert.strictEqual(uriToDynamicAnchor('https://example.com/#myAnchor'), 'myAnchor'); + assert.strictEqual(uriToDynamicAnchor('#myAnchor'), 'myAnchor'); + }); + + specify('should return empty string for URI without fragment', function () { + assert.strictEqual(uriToDynamicAnchor('https://example.com/'), ''); + }); + }); + + context('given parse', function () { + specify('should return valid anchor', function () { + assert.strictEqual(parse('myAnchor'), 'myAnchor'); + }); + + specify('should throw for invalid anchor', function () { + assert.throws(() => parse('1invalid'), InvalidJsonSchema$dynamicAnchorError); + }); + }); + + context('given evaluate', function () { + specify('should find element with matching $dynamicAnchor', function () { + const element = new SchemaElement({ + $dynamicAnchor: 'target', + type: 'object', + }); + const result = evaluate('target', element)!; + + assert.strictEqual(toValue((result as SchemaElement).$dynamicAnchor), 'target'); + }); + + specify('should throw when no matching $dynamicAnchor found', function () { + const element = new SchemaElement({ type: 'object' }); + + assert.throws( + () => evaluate('missing', element), + EvaluationJsonSchema$dynamicAnchorError, + ); + }); + }); + }); + }); + }); +});