Skip to content

Commit e8ddbb7

Browse files
authored
feat: add PoC of handling discriminator for oneOf/anyOf cases in OpenAPI 3.1 (#4952)
1 parent e73bf7a commit e8ddbb7

File tree

39 files changed

+1794
-0
lines changed

39 files changed

+1794
-0
lines changed

packages/apidom-ns-openapi-3-0/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export {
5151
isServerVariableElement,
5252
isMediaTypeElement,
5353
isServersElement,
54+
isDiscriminatorElement,
5455
} from './predicates.ts';
5556

5657
export {

packages/apidom-ns-openapi-3-0/src/predicates.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import SecuritySchemeElement from './elements/SecurityScheme.ts';
2626
import ServerElement from './elements/Server.ts';
2727
import ServerVariableElement from './elements/ServerVariable.ts';
2828
import MediaTypeElement from './elements/MediaType.ts';
29+
import DiscriminatorElement from './elements/Discriminator.ts';
2930
// NCE types
3031
import ServersElement from './elements/nces/Servers.ts';
3132

@@ -378,3 +379,16 @@ export const isServersElement = createPredicate(
378379
hasClass('servers', element));
379380
},
380381
);
382+
383+
/**
384+
* @public
385+
*/
386+
export const isDiscriminatorElement = createPredicate(
387+
({ hasBasicElementProps, isElementType, primitiveEq }) => {
388+
return (element: unknown): element is DiscriminatorElement =>
389+
element instanceof DiscriminatorElement ||
390+
(hasBasicElementProps(element) &&
391+
isElementType('discriminator', element) &&
392+
primitiveEq('object', element));
393+
},
394+
);

packages/apidom-ns-openapi-3-1/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export { default as refractorPluginNormalizeParameterExamples } from './refracto
3030
export type { PluginOptions as RefractorPluginNormalizeParameterExamplesOptions } from './refractor/plugins/normalize-parameter-examples.ts';
3131
export { default as refractorPluginNormalizeHeaderExamples } from './refractor/plugins/normalize-header-examples/index.ts';
3232
export type { PluginOptions as RefractorPluginNormalizeHeaderExamplesOptions } from './refractor/plugins/normalize-header-examples/index.ts';
33+
export { default as refractorPluginNormalizeDiscriminatorMapping } from './refractor/plugins/normalize-discriminator-mapping.ts';
34+
export type { PluginOptions as RefractorPluginNormalizeDiscriminatorMappingOptions } from './refractor/plugins/normalize-discriminator-mapping.ts';
3335
export { default as createToolbox } from './refractor/toolbox.ts';
3436
export type {
3537
Predicates as ToolboxPredicates,
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import {
2+
cloneDeep,
3+
Element,
4+
isArrayElement,
5+
ObjectElement,
6+
StringElement,
7+
toValue,
8+
} from '@swagger-api/apidom-core';
9+
import { isReferenceLikeElement, isDiscriminatorElement } from '@swagger-api/apidom-ns-openapi-3-0';
10+
11+
import type { Toolbox } from '../toolbox.ts';
12+
import OpenApi3_1Element from '../../elements/OpenApi3-1.ts';
13+
import NormalizeStorage from './normalize-header-examples/NormalizeStorage.ts';
14+
import { SchemaElement } from '../registration.ts';
15+
import { isSchemaElement } from '../../predicates.ts';
16+
17+
/**
18+
* Normalization of Discriminator.mapping field.
19+
*
20+
* Discriminator.mapping fields are normalized by adding missing mappings from oneOf/anyOf items
21+
* of the parent Schema Object and transforming existing mappings to Schema Objects.
22+
*
23+
* The normalized mapping is stored in the Schema.discriminator field as `x-normalized-mapping`.
24+
*
25+
* NOTE: this plugin is idempotent
26+
* @public
27+
*/
28+
29+
export interface PluginOptions {
30+
storageField?: string;
31+
baseURI?: string;
32+
}
33+
34+
/**
35+
* @public
36+
*/
37+
const plugin =
38+
({ storageField = 'x-normalized', baseURI = '' }: PluginOptions = {}) =>
39+
(toolbox: Toolbox) => {
40+
const { ancestorLineageToJSONPointer } = toolbox;
41+
let storage: NormalizeStorage | undefined;
42+
43+
return {
44+
visitor: {
45+
OpenApi3_1Element: {
46+
enter(element: OpenApi3_1Element) {
47+
storage = new NormalizeStorage(element, storageField, 'discriminator-mapping');
48+
},
49+
leave() {
50+
storage = undefined;
51+
},
52+
},
53+
54+
SchemaElement: {
55+
leave(
56+
schemaElement: SchemaElement,
57+
key: string | number,
58+
parent: Element | undefined,
59+
path: (string | number)[],
60+
ancestors: [Element | Element[]],
61+
) {
62+
// no Schema.discriminator field present
63+
if (!isDiscriminatorElement(schemaElement.discriminator)) {
64+
return;
65+
}
66+
67+
const schemaJSONPointer = ancestorLineageToJSONPointer([
68+
...ancestors,
69+
parent!,
70+
schemaElement,
71+
]);
72+
73+
// skip visiting this Schema Object if it's already normalized
74+
if (storage!.includes(schemaJSONPointer)) {
75+
return;
76+
}
77+
78+
// skip if both oneOf and anyOf are present
79+
if (isArrayElement(schemaElement.oneOf) && isArrayElement(schemaElement.anyOf)) {
80+
return;
81+
}
82+
83+
// skip if neither oneOf nor anyOf is present
84+
if (!isArrayElement(schemaElement.oneOf) && !isArrayElement(schemaElement.anyOf)) {
85+
return;
86+
}
87+
88+
const mapping = schemaElement.discriminator.get('mapping') ?? new ObjectElement();
89+
const normalizedMapping: ObjectElement = cloneDeep(mapping);
90+
let isNormalized = true;
91+
92+
const items = isArrayElement(schemaElement.oneOf)
93+
? schemaElement.oneOf
94+
: schemaElement.anyOf;
95+
96+
items!.forEach((item) => {
97+
if (!isSchemaElement(item)) {
98+
return;
99+
}
100+
101+
if (isReferenceLikeElement(item)) {
102+
isNormalized = false;
103+
return;
104+
}
105+
106+
const metaRefFields = toValue(item.getMetaProperty('ref-fields'));
107+
const metaRefOrigin = toValue(item.getMetaProperty('ref-origin'));
108+
const metaSchemaName = toValue(item.getMetaProperty('schemaName'));
109+
110+
/**
111+
* handle external references and internal references
112+
* that don't point to components/schemas/<SchemaName>
113+
*/
114+
if (metaRefOrigin !== baseURI || (!metaSchemaName && metaRefFields)) {
115+
let hasMatchingMapping = false;
116+
117+
mapping.forEach((mappingValue: StringElement, mappingKey: StringElement) => {
118+
const mappingValueSchema = mappingValue.getMetaProperty('ref-schema');
119+
const mappingValueSchemaRefBaseURI = mappingValueSchema
120+
?.getMetaProperty('ref-fields')
121+
?.get('$refBaseURI');
122+
123+
if (mappingValueSchemaRefBaseURI?.equals(metaRefFields?.$refBaseURI)) {
124+
normalizedMapping.set(toValue(mappingKey), cloneDeep(item));
125+
hasMatchingMapping = true;
126+
}
127+
});
128+
129+
if (!hasMatchingMapping) {
130+
isNormalized = false;
131+
}
132+
return;
133+
}
134+
135+
// handle internal references that point to components/schemas/<SchemaName>
136+
if (metaSchemaName) {
137+
let hasMatchingMapping = false;
138+
139+
mapping.forEach((mappingValue: StringElement, mappingKey: StringElement) => {
140+
const mappingValueSchema = mappingValue.getMetaProperty('ref-schema');
141+
const mappingValueSchemaName = mappingValueSchema?.getMetaProperty('schemaName');
142+
const mappingValueSchemaRefBaseURI = mappingValueSchema
143+
?.getMetaProperty('ref-fields')
144+
?.get('$refBaseURI');
145+
146+
if (
147+
mappingValueSchemaName?.equals(metaSchemaName) &&
148+
mappingValueSchemaRefBaseURI?.equals(metaRefFields?.$refBaseURI)
149+
) {
150+
normalizedMapping.set(toValue(mappingKey), cloneDeep(item));
151+
hasMatchingMapping = true;
152+
}
153+
});
154+
155+
// add a new mapping if no matching mapping was found
156+
if (!hasMatchingMapping) {
157+
normalizedMapping.set(metaSchemaName, cloneDeep(item));
158+
}
159+
}
160+
});
161+
162+
// check if any mapping is not a Schema Object
163+
isNormalized =
164+
isNormalized &&
165+
normalizedMapping.filter((mappingValue: Element) => !isSchemaElement(mappingValue))
166+
.length === 0;
167+
168+
if (isNormalized) {
169+
schemaElement.discriminator.set('x-normalized-mapping', normalizedMapping);
170+
storage!.append(schemaJSONPointer);
171+
}
172+
},
173+
},
174+
},
175+
};
176+
};
177+
178+
export default plugin;

packages/apidom-ns-openapi-3-1/src/refractor/visitors/open-api-3-1/components/SchemasVisitor.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Mixin } from 'ts-mixer';
22
import { always } from 'ramda';
3+
import { StringElement, ObjectElement, toValue } from '@swagger-api/apidom-core';
34
import {
45
ComponentsSchemasElement,
56
MapVisitor,
@@ -9,6 +10,9 @@ import {
910
FallbackVisitorOptions,
1011
} from '@swagger-api/apidom-ns-openapi-3-0';
1112

13+
import SchemaElement from '../../../../elements/Schema.ts';
14+
import { isSchemaElement } from '../../../../predicates.ts';
15+
1216
/**
1317
* @public
1418
*/
@@ -27,6 +31,20 @@ class SchemasVisitor extends Mixin(MapVisitor, FallbackVisitor) {
2731
this.element = new ComponentsSchemasElement();
2832
this.specPath = always(['document', 'objects', 'Schema']);
2933
}
34+
35+
ObjectElement(objectElement: ObjectElement) {
36+
const result = MapVisitor.prototype.ObjectElement.call(this, objectElement);
37+
38+
// decorate Schemas elements with Schema name
39+
this.element
40+
.filter(isSchemaElement)
41+
// @ts-ignore
42+
.forEach((schemaElement: SchemaElement, schemaName: StringElement) => {
43+
schemaElement.setMetaProperty('schemaName', toValue(schemaName));
44+
});
45+
46+
return result;
47+
}
3048
}
3149

3250
export default SchemasVisitor;

packages/apidom-ns-openapi-3-1/test/refractor/__snapshots__/index.mjs.snap

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4995,6 +4995,10 @@ exports[`refractor given generic ApiDOM object in OpenApi 3.1 shape should refra
49954995
},
49964996
"ancestorsSchemaIdentifiers": {
49974997
"element": "array"
4998+
},
4999+
"schemaName": {
5000+
"element": "string",
5001+
"content": "schema1"
49985002
}
49995003
},
50005004
"content": [
@@ -5077,6 +5081,10 @@ exports[`refractor given generic ApiDOM object in OpenApi 3.1 shape should refra
50775081
"referenced-element": {
50785082
"element": "string",
50795083
"content": "schema"
5084+
},
5085+
"schemaName": {
5086+
"element": "string",
5087+
"content": "schema2"
50805088
}
50815089
},
50825090
"content": [

0 commit comments

Comments
 (0)