@ebarahona/loopback-openapi-v3 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +6 -0
- package/dist/keys.js +10 -0
- package/dist/keys.js.map +1 -0
- package/dist/openapi-version.component.d.ts +4 -0
- package/dist/openapi-version.component.js +18 -0
- package/dist/openapi-version.component.js.map +1 -0
- package/dist/openapi-version.enhancer.d.ts +8 -0
- package/dist/openapi-version.enhancer.js +46 -0
- package/dist/openapi-version.enhancer.js.map +1 -0
- package/dist/transform.d.ts +19 -0
- package/dist/transform.js +500 -0
- package/dist/transform.js.map +1 -0
- package/dist/types.d.ts +6 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +72 -0
- package/src/__tests__/openapi-version.enhancer.spec.ts +456 -0
- package/src/__tests__/transform.spec.ts +879 -0
- package/src/index.ts +10 -0
- package/src/keys.ts +9 -0
- package/src/openapi-version.component.ts +27 -0
- package/src/openapi-version.enhancer.ts +61 -0
- package/src/transform.ts +702 -0
- package/src/types.ts +20 -0
package/src/transform.ts
ADDED
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import {OpenApiVersionConfig, DEFAULT_CONFIG} from './types';
|
|
2
|
+
|
|
3
|
+
// Internal type for untyped spec traversal.
|
|
4
|
+
interface Obj {
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const SUPPORTED_MINORS = [0, 1, 2] as const;
|
|
9
|
+
|
|
10
|
+
function isSupportedMinor(minor: number): minor is typeof SUPPORTED_MINORS[number] {
|
|
11
|
+
return (SUPPORTED_MINORS as readonly number[]).includes(minor);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Diagnostic warning emitted when features are stripped during downgrade.
|
|
16
|
+
*/
|
|
17
|
+
export interface TransformWarning {
|
|
18
|
+
field: string;
|
|
19
|
+
message: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Result of a spec transformation, including the transformed spec
|
|
24
|
+
* and any diagnostic warnings about lossy operations.
|
|
25
|
+
*/
|
|
26
|
+
export interface TransformResult {
|
|
27
|
+
spec: Obj;
|
|
28
|
+
warnings: TransformWarning[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Emit a warning only once per unique field+message combination.
|
|
33
|
+
*/
|
|
34
|
+
function warnOnce(
|
|
35
|
+
warnings: TransformWarning[],
|
|
36
|
+
seen: Set<string>,
|
|
37
|
+
field: string,
|
|
38
|
+
message: string,
|
|
39
|
+
): void {
|
|
40
|
+
const key = `${field}:${message}`;
|
|
41
|
+
if (seen.has(key)) return;
|
|
42
|
+
seen.add(key);
|
|
43
|
+
warnings.push({field, message});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse and validate an OpenAPI version string.
|
|
48
|
+
* Throws if the version is not a supported format.
|
|
49
|
+
*/
|
|
50
|
+
export function parseVersion(version: string): {major: number; minor: number; patch: number} {
|
|
51
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
|
52
|
+
if (!match) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`Invalid OpenAPI version: "${version}". Expected format: "3.x.x"`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const major = Number(match[1]);
|
|
58
|
+
const minor = Number(match[2]);
|
|
59
|
+
const patch = Number(match[3]);
|
|
60
|
+
|
|
61
|
+
if (major !== 3) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Unsupported OpenAPI major version: ${major}. Only version 3.x is supported.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (!isSupportedMinor(minor)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`Unsupported OpenAPI minor version: 3.${minor}. Supported: 3.0.x, 3.1.x, 3.2.x`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return {major, minor, patch};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Transform an OpenAPI spec from its current version to the target version.
|
|
76
|
+
*
|
|
77
|
+
* Deep-clones the spec to prevent mutation of the original.
|
|
78
|
+
* Handles upgrades (3.0 -> 3.1/3.2) and compatibility downgrades
|
|
79
|
+
* (3.2 -> 3.0/3.1).
|
|
80
|
+
*
|
|
81
|
+
* Note: downgrades are lossy. Features that exist in higher versions
|
|
82
|
+
* but have no equivalent in lower versions are stripped. Warnings are
|
|
83
|
+
* emitted for each stripped feature.
|
|
84
|
+
*
|
|
85
|
+
* Requires Node.js 18+ for structuredClone.
|
|
86
|
+
*/
|
|
87
|
+
export function transformOpenApiSpec(
|
|
88
|
+
spec: Obj,
|
|
89
|
+
config: OpenApiVersionConfig = DEFAULT_CONFIG,
|
|
90
|
+
): TransformResult {
|
|
91
|
+
if (typeof spec.openapi !== 'string') {
|
|
92
|
+
throw new Error(
|
|
93
|
+
'Invalid OpenAPI spec: missing required string field "openapi".',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const sourceVersion = parseVersion(spec.openapi);
|
|
98
|
+
const targetVersion = parseVersion(config.version);
|
|
99
|
+
|
|
100
|
+
// Deep clone to prevent mutation of the original spec.
|
|
101
|
+
// Requires Node.js 18+. The package.json engines field enforces this.
|
|
102
|
+
const out = structuredClone(spec);
|
|
103
|
+
out.openapi = config.version;
|
|
104
|
+
|
|
105
|
+
const sourceMinor = sourceVersion.minor;
|
|
106
|
+
const targetMinor = targetVersion.minor;
|
|
107
|
+
const warnings: TransformWarning[] = [];
|
|
108
|
+
const warnedKeys = new Set<string>();
|
|
109
|
+
|
|
110
|
+
if (sourceMinor === targetMinor) {
|
|
111
|
+
return {spec: out, warnings};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 3.0 -> 3.1+: upgrade nullable
|
|
115
|
+
if (config.transformNullable !== false && targetMinor >= 1 && sourceMinor < 1) {
|
|
116
|
+
upgradeNullable(out);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Strip 3.2 features first (before strip31 removes containers like webhooks)
|
|
120
|
+
if (targetMinor < 2 && sourceMinor >= 2) {
|
|
121
|
+
strip32Features(out, targetMinor, warnings, warnedKeys);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// 3.1+ -> 3.0: downgrade nullable and strip 3.1 features
|
|
125
|
+
if (targetMinor < 1 && sourceMinor >= 1) {
|
|
126
|
+
downgradeNullable(out, warnings, warnedKeys);
|
|
127
|
+
strip31Features(out, warnings, warnedKeys);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {spec: out, warnings};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// -------------------------------------------------------------------
|
|
134
|
+
// Nullable transforms
|
|
135
|
+
// -------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 3.0 -> 3.1+: nullable upgrade
|
|
139
|
+
*
|
|
140
|
+
* Handles all nullable patterns:
|
|
141
|
+
* - { type: 'string', nullable: true } -> { type: ['string', 'null'] }
|
|
142
|
+
* - { nullable: true, oneOf: [...] } -> { oneOf: [..., { type: 'null' }] }
|
|
143
|
+
* - { nullable: true, anyOf: [...] } -> { anyOf: [..., { type: 'null' }] }
|
|
144
|
+
* - { nullable: true, allOf: [...] } -> { anyOf: [{ allOf: [...] }, { type: 'null' }] }
|
|
145
|
+
* - { nullable: true } (no type/composition) -> { type: 'null' }
|
|
146
|
+
*/
|
|
147
|
+
function upgradeNullable(spec: Obj): void {
|
|
148
|
+
walkAllSchemas(spec, (s: Obj) => {
|
|
149
|
+
if (s.nullable !== true) return;
|
|
150
|
+
|
|
151
|
+
delete s.nullable;
|
|
152
|
+
|
|
153
|
+
if (typeof s.type === 'string') {
|
|
154
|
+
s.type = [s.type, 'null'];
|
|
155
|
+
} else if (Array.isArray(s.oneOf)) {
|
|
156
|
+
(s.oneOf as Obj[]).push({type: 'null'});
|
|
157
|
+
} else if (Array.isArray(s.anyOf)) {
|
|
158
|
+
(s.anyOf as Obj[]).push({type: 'null'});
|
|
159
|
+
} else if (Array.isArray(s.allOf)) {
|
|
160
|
+
const allOf = s.allOf;
|
|
161
|
+
delete s.allOf;
|
|
162
|
+
s.anyOf = [{allOf}, {type: 'null'}];
|
|
163
|
+
} else {
|
|
164
|
+
s.type = 'null';
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 3.1+ -> 3.0: nullable downgrade
|
|
171
|
+
*
|
|
172
|
+
* Handles:
|
|
173
|
+
* - { type: ['string', 'null'] } -> { type: 'string', nullable: true }
|
|
174
|
+
* - { oneOf: [..., { type: 'null' }] } -> { nullable: true, oneOf: [...] }
|
|
175
|
+
* - { anyOf: [..., { type: 'null' }] } -> { nullable: true, anyOf: [...] }
|
|
176
|
+
*
|
|
177
|
+
* Does NOT unwrap single-element composition arrays to avoid
|
|
178
|
+
* metadata collision (description, title, default, etc.).
|
|
179
|
+
*/
|
|
180
|
+
function downgradeNullable(
|
|
181
|
+
spec: Obj,
|
|
182
|
+
warnings: TransformWarning[],
|
|
183
|
+
seen: Set<string>,
|
|
184
|
+
): void {
|
|
185
|
+
let converted = false;
|
|
186
|
+
walkAllSchemas(spec, (s: Obj) => {
|
|
187
|
+
// Type array with null
|
|
188
|
+
if (Array.isArray(s.type)) {
|
|
189
|
+
const types = s.type as string[];
|
|
190
|
+
if (types.includes('null')) {
|
|
191
|
+
const nonNull = types.filter(t => t !== 'null');
|
|
192
|
+
s.type = nonNull.length === 1 ? nonNull[0] : nonNull;
|
|
193
|
+
s.nullable = true;
|
|
194
|
+
converted = true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// oneOf/anyOf with { type: 'null' } member
|
|
199
|
+
for (const key of ['oneOf', 'anyOf'] as const) {
|
|
200
|
+
if (!Array.isArray(s[key])) continue;
|
|
201
|
+
const arr = s[key] as Obj[];
|
|
202
|
+
const nullIdx = arr.findIndex(
|
|
203
|
+
item => typeof item === 'object' && item !== null &&
|
|
204
|
+
Object.keys(item).length === 1 && item.type === 'null',
|
|
205
|
+
);
|
|
206
|
+
if (nullIdx !== -1) {
|
|
207
|
+
arr.splice(nullIdx, 1);
|
|
208
|
+
s.nullable = true;
|
|
209
|
+
converted = true;
|
|
210
|
+
// Do NOT unwrap single-element arrays to avoid metadata collision
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
if (converted) {
|
|
215
|
+
warnOnce(warnings, seen, 'nullable', 'Converted type arrays to nullable: true for 3.0 compatibility (lossy)');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// -------------------------------------------------------------------
|
|
220
|
+
// 3.1 feature stripping (when targeting 3.0)
|
|
221
|
+
// -------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
function strip31Features(
|
|
224
|
+
spec: Obj,
|
|
225
|
+
warnings: TransformWarning[],
|
|
226
|
+
seen: Set<string>,
|
|
227
|
+
): void {
|
|
228
|
+
if (spec.jsonSchemaDialect !== undefined) {
|
|
229
|
+
delete spec.jsonSchemaDialect;
|
|
230
|
+
warnOnce(warnings, seen, 'jsonSchemaDialect', 'Removed jsonSchemaDialect because target version is 3.0.x');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (spec.webhooks !== undefined) {
|
|
234
|
+
delete spec.webhooks;
|
|
235
|
+
warnOnce(warnings, seen, 'webhooks', 'Removed webhooks because target version is 3.0.x (webhooks require 3.1+)');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const components = spec.components as Obj | undefined;
|
|
239
|
+
if (components?.pathItems !== undefined) {
|
|
240
|
+
delete components.pathItems;
|
|
241
|
+
warnOnce(warnings, seen, 'components.pathItems', 'Removed components.pathItems because target version is 3.0.x');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const info = spec.info as Obj | undefined;
|
|
245
|
+
if (info) {
|
|
246
|
+
const license = info.license as Obj | undefined;
|
|
247
|
+
if (license?.identifier !== undefined) {
|
|
248
|
+
delete license.identifier;
|
|
249
|
+
warnOnce(warnings, seen, 'info.license.identifier', 'Removed license identifier because target version is 3.0.x (use url instead)');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// -------------------------------------------------------------------
|
|
255
|
+
// 3.2 feature stripping (when targeting 3.0 or 3.1)
|
|
256
|
+
// -------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
function strip32Features(
|
|
259
|
+
spec: Obj,
|
|
260
|
+
targetMinor: number,
|
|
261
|
+
warnings: TransformWarning[],
|
|
262
|
+
seen: Set<string>,
|
|
263
|
+
): void {
|
|
264
|
+
if (spec.$self !== undefined) {
|
|
265
|
+
delete spec.$self;
|
|
266
|
+
warnOnce(warnings, seen, '$self', `Removed $self because target version is 3.${targetMinor}.x`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// jsonSchemaDialect: only strip when targeting 3.0 (3.1 supports it)
|
|
270
|
+
// Already handled by strip31Features if targeting 3.0
|
|
271
|
+
|
|
272
|
+
// Server.name (3.2 only)
|
|
273
|
+
if (Array.isArray(spec.servers)) {
|
|
274
|
+
for (const server of spec.servers) {
|
|
275
|
+
if (server && typeof server === 'object') {
|
|
276
|
+
const s = server as Obj;
|
|
277
|
+
if (s.name !== undefined) {
|
|
278
|
+
delete s.name;
|
|
279
|
+
warnOnce(warnings, seen, 'servers', `Removed server name because target version is 3.${targetMinor}.x`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// PathItem: query method, additionalOperations
|
|
286
|
+
const paths = spec.paths as Obj | undefined;
|
|
287
|
+
if (paths) {
|
|
288
|
+
for (const path in paths) {
|
|
289
|
+
const item = paths[path];
|
|
290
|
+
if (!item || typeof item !== 'object') continue;
|
|
291
|
+
const pi = item as Obj;
|
|
292
|
+
if (pi.query !== undefined) {
|
|
293
|
+
delete pi.query;
|
|
294
|
+
warnOnce(warnings, seen, `paths.${path}.query`, `Removed QUERY method because target version is 3.${targetMinor}.x`);
|
|
295
|
+
}
|
|
296
|
+
if (pi.additionalOperations !== undefined) {
|
|
297
|
+
delete pi.additionalOperations;
|
|
298
|
+
warnOnce(warnings, seen, `paths.${path}.additionalOperations`, `Removed additionalOperations because target version is 3.${targetMinor}.x`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Webhooks: same treatment
|
|
304
|
+
const webhooks = spec.webhooks as Obj | undefined;
|
|
305
|
+
if (webhooks) {
|
|
306
|
+
for (const name in webhooks) {
|
|
307
|
+
const item = webhooks[name];
|
|
308
|
+
if (!item || typeof item !== 'object') continue;
|
|
309
|
+
const pi = item as Obj;
|
|
310
|
+
if (pi.query !== undefined) {
|
|
311
|
+
delete pi.query;
|
|
312
|
+
warnOnce(warnings, seen, `webhooks.${name}.query`, `Removed QUERY method because target version is 3.${targetMinor}.x`);
|
|
313
|
+
}
|
|
314
|
+
if (pi.additionalOperations !== undefined) {
|
|
315
|
+
delete pi.additionalOperations;
|
|
316
|
+
warnOnce(warnings, seen, `webhooks.${name}.additionalOperations`, `Removed additionalOperations because target version is 3.${targetMinor}.x`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Tags: summary, parent, kind
|
|
322
|
+
if (Array.isArray(spec.tags)) {
|
|
323
|
+
let tagWarned = false;
|
|
324
|
+
for (const tag of spec.tags) {
|
|
325
|
+
if (tag && typeof tag === 'object') {
|
|
326
|
+
const t = tag as Obj;
|
|
327
|
+
if (t.summary !== undefined || t.parent !== undefined || t.kind !== undefined) {
|
|
328
|
+
delete t.summary;
|
|
329
|
+
delete t.parent;
|
|
330
|
+
delete t.kind;
|
|
331
|
+
if (!tagWarned) {
|
|
332
|
+
warnOnce(warnings, seen, 'tags', `Removed tag fields (summary, parent, kind) because target version is 3.${targetMinor}.x`);
|
|
333
|
+
tagWarned = true;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Components
|
|
341
|
+
const components = spec.components as Obj | undefined;
|
|
342
|
+
if (components) {
|
|
343
|
+
// OAuth2 device flow
|
|
344
|
+
const schemes = components.securitySchemes as Obj | undefined;
|
|
345
|
+
if (schemes) {
|
|
346
|
+
for (const name in schemes) {
|
|
347
|
+
const scheme = schemes[name];
|
|
348
|
+
if (!scheme || typeof scheme !== 'object' || '$ref' in scheme) continue;
|
|
349
|
+
const flows = (scheme as Obj).flows as Obj | undefined;
|
|
350
|
+
if (flows?.device !== undefined) {
|
|
351
|
+
delete flows.device;
|
|
352
|
+
warnOnce(warnings, seen, `components.securitySchemes.${name}.flows.device`, `Removed OAuth2 device flow because target version is 3.${targetMinor}.x`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Example fields
|
|
358
|
+
const examples = components.examples as Obj | undefined;
|
|
359
|
+
if (examples) {
|
|
360
|
+
for (const name in examples) {
|
|
361
|
+
const ex = examples[name];
|
|
362
|
+
if (!ex || typeof ex !== 'object' || '$ref' in ex) continue;
|
|
363
|
+
const e = ex as Obj;
|
|
364
|
+
if (e.dataValue !== undefined || e.serializedValue !== undefined) {
|
|
365
|
+
delete e.dataValue;
|
|
366
|
+
delete e.serializedValue;
|
|
367
|
+
warnOnce(warnings, seen, `components.examples.${name}`, `Removed dataValue/serializedValue because target version is 3.${targetMinor}.x`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 3.2 reusable media types
|
|
373
|
+
if (components.mediaTypes !== undefined) {
|
|
374
|
+
delete components.mediaTypes;
|
|
375
|
+
warnOnce(warnings, seen, 'components.mediaTypes', `Removed components.mediaTypes because target version is 3.${targetMinor}.x`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// XML Object: text field (3.2 only)
|
|
380
|
+
// Walk all schemas and strip xml.text
|
|
381
|
+
walkAllSchemas(spec, (schema: Obj) => {
|
|
382
|
+
if (schema.xml && typeof schema.xml === 'object') {
|
|
383
|
+
const xml = schema.xml as Obj;
|
|
384
|
+
if (xml.text !== undefined) {
|
|
385
|
+
delete xml.text;
|
|
386
|
+
warnOnce(warnings, seen, 'xml.text', `Removed XML text field because target version is 3.${targetMinor}.x`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Per-operation: querystring param location, itemSchema, itemEncoding, prefixEncoding
|
|
392
|
+
forEachOperation(spec, (op: Obj, opPath: string) => {
|
|
393
|
+
// querystring -> query
|
|
394
|
+
if (Array.isArray(op.parameters)) {
|
|
395
|
+
for (const p of op.parameters) {
|
|
396
|
+
if (p && typeof p === 'object' && !('$ref' in p)) {
|
|
397
|
+
if ((p as Obj).in === 'querystring') {
|
|
398
|
+
(p as Obj).in = 'query';
|
|
399
|
+
warnOnce(warnings, seen, `${opPath}.parameters`, `Converted querystring parameter location to query because target version is 3.${targetMinor}.x`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Strip streaming media fields
|
|
406
|
+
stripMediaFields(op, opPath, warnings, seen, targetMinor);
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function stripMediaFields(
|
|
411
|
+
op: Obj,
|
|
412
|
+
opPath: string,
|
|
413
|
+
warnings: TransformWarning[],
|
|
414
|
+
seen: Set<string>,
|
|
415
|
+
targetMinor: number,
|
|
416
|
+
): void {
|
|
417
|
+
const fieldsToStrip = ['itemSchema', 'itemEncoding', 'prefixEncoding'];
|
|
418
|
+
|
|
419
|
+
function stripFromContent(content: Obj, location: string): void {
|
|
420
|
+
for (const mt in content) {
|
|
421
|
+
const media = content[mt];
|
|
422
|
+
if (!media || typeof media !== 'object') continue;
|
|
423
|
+
const m = media as Obj;
|
|
424
|
+
for (const field of fieldsToStrip) {
|
|
425
|
+
if (m[field] !== undefined) {
|
|
426
|
+
delete m[field];
|
|
427
|
+
warnOnce(warnings, seen, `${location}.${mt}.${field}`, `Removed ${field} because target version is 3.${targetMinor}.x`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Request body
|
|
434
|
+
const reqBody = op.requestBody;
|
|
435
|
+
if (reqBody && typeof reqBody === 'object' && !('$ref' in reqBody)) {
|
|
436
|
+
const content = (reqBody as Obj).content as Obj | undefined;
|
|
437
|
+
if (content) stripFromContent(content, `${opPath}.requestBody.content`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Responses
|
|
441
|
+
const responses = op.responses as Obj | undefined;
|
|
442
|
+
if (responses) {
|
|
443
|
+
for (const code in responses) {
|
|
444
|
+
const resp = responses[code];
|
|
445
|
+
if (!resp || typeof resp !== 'object' || '$ref' in resp) continue;
|
|
446
|
+
const content = (resp as Obj).content as Obj | undefined;
|
|
447
|
+
if (content) stripFromContent(content, `${opPath}.responses.${code}.content`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// -------------------------------------------------------------------
|
|
453
|
+
// Traversal helpers
|
|
454
|
+
// -------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Walk every schema location in the spec:
|
|
458
|
+
* - components.schemas
|
|
459
|
+
* - components.parameters (schema field)
|
|
460
|
+
* - components.requestBodies (content schemas)
|
|
461
|
+
* - components.responses (content schemas)
|
|
462
|
+
* - components.headers (schema field)
|
|
463
|
+
* - paths.*.operations.parameters
|
|
464
|
+
* - paths.*.operations.requestBody.content.*.schema
|
|
465
|
+
* - paths.*.operations.responses.*.content.*.schema
|
|
466
|
+
* - paths.*.parameters (path-level)
|
|
467
|
+
* - webhooks (same structure as paths)
|
|
468
|
+
* - callbacks (same structure as paths)
|
|
469
|
+
*
|
|
470
|
+
* Uses a WeakSet to prevent infinite loops on circular references.
|
|
471
|
+
*/
|
|
472
|
+
function walkAllSchemas(spec: Obj, visitor: (schema: Obj) => void): void {
|
|
473
|
+
const visited = new WeakSet<object>();
|
|
474
|
+
|
|
475
|
+
function visit(schema: Obj): void {
|
|
476
|
+
if (visited.has(schema)) return;
|
|
477
|
+
visited.add(schema);
|
|
478
|
+
|
|
479
|
+
visitor(schema);
|
|
480
|
+
|
|
481
|
+
// properties
|
|
482
|
+
if (schema.properties && typeof schema.properties === 'object') {
|
|
483
|
+
const props = schema.properties as Obj;
|
|
484
|
+
for (const key in props) {
|
|
485
|
+
const p = props[key];
|
|
486
|
+
if (p && typeof p === 'object' && !('$ref' in p)) visit(p as Obj);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// items
|
|
491
|
+
if (schema.items && typeof schema.items === 'object' && !('$ref' in schema.items)) {
|
|
492
|
+
visit(schema.items as Obj);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// allOf, oneOf, anyOf, prefixItems
|
|
496
|
+
for (const key of ['allOf', 'oneOf', 'anyOf', 'prefixItems']) {
|
|
497
|
+
if (Array.isArray(schema[key])) {
|
|
498
|
+
for (const item of schema[key] as Obj[]) {
|
|
499
|
+
if (item && typeof item === 'object' && !('$ref' in item)) visit(item);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// not
|
|
505
|
+
if (schema.not && typeof schema.not === 'object' && !('$ref' in schema.not)) {
|
|
506
|
+
visit(schema.not as Obj);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// additionalProperties
|
|
510
|
+
if (
|
|
511
|
+
schema.additionalProperties &&
|
|
512
|
+
typeof schema.additionalProperties === 'object' &&
|
|
513
|
+
!('$ref' in schema.additionalProperties)
|
|
514
|
+
) {
|
|
515
|
+
visit(schema.additionalProperties as Obj);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function visitMediaContent(content: Obj): void {
|
|
520
|
+
for (const mt in content) {
|
|
521
|
+
const media = content[mt] as Obj | undefined;
|
|
522
|
+
if (!media || typeof media !== 'object') continue;
|
|
523
|
+
if (media.schema && typeof media.schema === 'object' && !('$ref' in media.schema)) {
|
|
524
|
+
visit(media.schema as Obj);
|
|
525
|
+
}
|
|
526
|
+
if (media.itemSchema && typeof media.itemSchema === 'object' && !('$ref' in media.itemSchema)) {
|
|
527
|
+
visit(media.itemSchema as Obj);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function visitParams(params: unknown[]): void {
|
|
533
|
+
for (const p of params) {
|
|
534
|
+
if (!p || typeof p !== 'object' || '$ref' in p) continue;
|
|
535
|
+
const param = p as Obj;
|
|
536
|
+
if (param.schema && typeof param.schema === 'object' && !('$ref' in param.schema)) {
|
|
537
|
+
visit(param.schema as Obj);
|
|
538
|
+
}
|
|
539
|
+
if (param.content && typeof param.content === 'object') {
|
|
540
|
+
visitMediaContent(param.content as Obj);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Component-level schemas
|
|
546
|
+
const components = spec.components as Obj | undefined;
|
|
547
|
+
if (components) {
|
|
548
|
+
const schemas = components.schemas as Obj | undefined;
|
|
549
|
+
if (schemas) {
|
|
550
|
+
for (const name in schemas) {
|
|
551
|
+
const s = schemas[name];
|
|
552
|
+
if (s && typeof s === 'object' && !('$ref' in s)) visit(s as Obj);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const params = components.parameters as Obj | undefined;
|
|
557
|
+
if (params) {
|
|
558
|
+
for (const name in params) {
|
|
559
|
+
const p = params[name];
|
|
560
|
+
if (p && typeof p === 'object' && !('$ref' in p)) {
|
|
561
|
+
const param = p as Obj;
|
|
562
|
+
if (param.schema && typeof param.schema === 'object' && !('$ref' in param.schema)) {
|
|
563
|
+
visit(param.schema as Obj);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const reqBodies = components.requestBodies as Obj | undefined;
|
|
570
|
+
if (reqBodies) {
|
|
571
|
+
for (const name in reqBodies) {
|
|
572
|
+
const rb = reqBodies[name];
|
|
573
|
+
if (!rb || typeof rb !== 'object' || '$ref' in rb) continue;
|
|
574
|
+
const content = (rb as Obj).content as Obj | undefined;
|
|
575
|
+
if (content) visitMediaContent(content);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const responses = components.responses as Obj | undefined;
|
|
580
|
+
if (responses) {
|
|
581
|
+
for (const name in responses) {
|
|
582
|
+
const resp = responses[name];
|
|
583
|
+
if (!resp || typeof resp !== 'object' || '$ref' in resp) continue;
|
|
584
|
+
const content = (resp as Obj).content as Obj | undefined;
|
|
585
|
+
if (content) visitMediaContent(content);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const headers = components.headers as Obj | undefined;
|
|
590
|
+
if (headers) {
|
|
591
|
+
for (const name in headers) {
|
|
592
|
+
const h = headers[name];
|
|
593
|
+
if (!h || typeof h !== 'object' || '$ref' in h) continue;
|
|
594
|
+
const header = h as Obj;
|
|
595
|
+
if (header.schema && typeof header.schema === 'object' && !('$ref' in header.schema)) {
|
|
596
|
+
visit(header.schema as Obj);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Path-level and operation-level schemas
|
|
603
|
+
function visitPaths(pathsObj: Obj): void {
|
|
604
|
+
for (const path in pathsObj) {
|
|
605
|
+
const item = pathsObj[path];
|
|
606
|
+
if (!item || typeof item !== 'object') continue;
|
|
607
|
+
const pi = item as Obj;
|
|
608
|
+
|
|
609
|
+
if (Array.isArray(pi.parameters)) {
|
|
610
|
+
visitParams(pi.parameters);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const verbs = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace', 'query'];
|
|
614
|
+
for (const verb of verbs) {
|
|
615
|
+
const op = pi[verb];
|
|
616
|
+
if (!op || typeof op !== 'object') continue;
|
|
617
|
+
const operation = op as Obj;
|
|
618
|
+
|
|
619
|
+
if (Array.isArray(operation.parameters)) {
|
|
620
|
+
visitParams(operation.parameters);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const reqBody = operation.requestBody;
|
|
624
|
+
if (reqBody && typeof reqBody === 'object' && !('$ref' in reqBody)) {
|
|
625
|
+
const content = (reqBody as Obj).content as Obj | undefined;
|
|
626
|
+
if (content) visitMediaContent(content);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const responses = operation.responses as Obj | undefined;
|
|
630
|
+
if (responses) {
|
|
631
|
+
for (const code in responses) {
|
|
632
|
+
const resp = responses[code];
|
|
633
|
+
if (!resp || typeof resp !== 'object' || '$ref' in resp) continue;
|
|
634
|
+
const content = (resp as Obj).content as Obj | undefined;
|
|
635
|
+
if (content) visitMediaContent(content);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const callbacks = operation.callbacks as Obj | undefined;
|
|
640
|
+
if (callbacks) {
|
|
641
|
+
for (const cbName in callbacks) {
|
|
642
|
+
const cb = callbacks[cbName];
|
|
643
|
+
if (cb && typeof cb === 'object' && !('$ref' in cb)) {
|
|
644
|
+
visitPaths(cb as Obj);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
if (spec.paths && typeof spec.paths === 'object') {
|
|
653
|
+
visitPaths(spec.paths as Obj);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (spec.webhooks && typeof spec.webhooks === 'object') {
|
|
657
|
+
visitPaths(spec.webhooks as Obj);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Iterate every operation across paths, webhooks, and callbacks.
|
|
663
|
+
* Passes the operation path string for diagnostic context.
|
|
664
|
+
*/
|
|
665
|
+
function forEachOperation(
|
|
666
|
+
spec: Obj,
|
|
667
|
+
visitor: (op: Obj, path: string) => void,
|
|
668
|
+
): void {
|
|
669
|
+
const verbs = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace', 'query'];
|
|
670
|
+
|
|
671
|
+
function visitPathItems(pathsObj: Obj, prefix: string): void {
|
|
672
|
+
for (const path in pathsObj) {
|
|
673
|
+
const item = pathsObj[path];
|
|
674
|
+
if (!item || typeof item !== 'object') continue;
|
|
675
|
+
const pi = item as Obj;
|
|
676
|
+
|
|
677
|
+
for (const verb of verbs) {
|
|
678
|
+
const op = pi[verb];
|
|
679
|
+
if (!op || typeof op !== 'object') continue;
|
|
680
|
+
const opPath = `${prefix}.${path}.${verb}`;
|
|
681
|
+
visitor(op as Obj, opPath);
|
|
682
|
+
|
|
683
|
+
const callbacks = (op as Obj).callbacks as Obj | undefined;
|
|
684
|
+
if (callbacks) {
|
|
685
|
+
for (const cbName in callbacks) {
|
|
686
|
+
const cb = callbacks[cbName];
|
|
687
|
+
if (cb && typeof cb === 'object' && !('$ref' in cb)) {
|
|
688
|
+
visitPathItems(cb as Obj, `${opPath}.callbacks.${cbName}`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (spec.paths && typeof spec.paths === 'object') {
|
|
697
|
+
visitPathItems(spec.paths as Obj, 'paths');
|
|
698
|
+
}
|
|
699
|
+
if (spec.webhooks && typeof spec.webhooks === 'object') {
|
|
700
|
+
visitPathItems(spec.webhooks as Obj, 'webhooks');
|
|
701
|
+
}
|
|
702
|
+
}
|