@contractkit/openapi-to-ck 0.7.1
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/.turbo/turbo-build$colon$ci.log +40 -0
- package/.turbo/turbo-build.log +20 -0
- package/.turbo/turbo-format.log +36 -0
- package/.turbo/turbo-test$colon$ci.log +77 -0
- package/.turbo/turbo-test.log +19 -0
- package/CHANGELOG.md +108 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +878 -0
- package/coverage/coverage-final.json +10 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +131 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/ast-to-ck.ts.html +1336 -0
- package/coverage/src/circular-refs.ts.html +298 -0
- package/coverage/src/convert.ts.html +535 -0
- package/coverage/src/index.html +221 -0
- package/coverage/src/normalize.ts.html +1162 -0
- package/coverage/src/paths-to-ast.ts.html +1165 -0
- package/coverage/src/schema-to-ast.ts.html +1378 -0
- package/coverage/src/tag-splitter.ts.html +685 -0
- package/coverage/src/warnings.ts.html +154 -0
- package/coverage/tests/helpers.ts.html +559 -0
- package/coverage/tests/index.html +116 -0
- package/dist/ast-to-ck.d.ts +8 -0
- package/dist/ast-to-ck.d.ts.map +1 -0
- package/dist/chunk-CQGFQKBJ.js +1681 -0
- package/dist/chunk-CQGFQKBJ.js.map +1 -0
- package/dist/chunk-FNUU2DWY.js +1779 -0
- package/dist/chunk-FNUU2DWY.js.map +1 -0
- package/dist/chunk-LD2HAFZG.js +1681 -0
- package/dist/chunk-LD2HAFZG.js.map +1 -0
- package/dist/chunk-LQ2B3EJG.js +1777 -0
- package/dist/chunk-LQ2B3EJG.js.map +1 -0
- package/dist/chunk-M6JA6WY2.js +1714 -0
- package/dist/chunk-M6JA6WY2.js.map +1 -0
- package/dist/chunk-MQTKLXTN.js +1681 -0
- package/dist/chunk-MQTKLXTN.js.map +1 -0
- package/dist/chunk-NV7RGUUS.js +1699 -0
- package/dist/chunk-NV7RGUUS.js.map +1 -0
- package/dist/chunk-REM25FDE.js +1779 -0
- package/dist/chunk-REM25FDE.js.map +1 -0
- package/dist/chunk-SNW7GJOM.js +1681 -0
- package/dist/chunk-SNW7GJOM.js.map +1 -0
- package/dist/chunk-UWOSCBRG.js +1681 -0
- package/dist/chunk-UWOSCBRG.js.map +1 -0
- package/dist/chunk-YP7FWA4H.js +1779 -0
- package/dist/chunk-YP7FWA4H.js.map +1 -0
- package/dist/circular-refs.d.ts +12 -0
- package/dist/circular-refs.d.ts.map +1 -0
- package/dist/convert.d.ts +6 -0
- package/dist/convert.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/normalize.d.ts +12 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/paths-to-ast.d.ts +22 -0
- package/dist/paths-to-ast.d.ts.map +1 -0
- package/dist/plugin.d.ts +4 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +86 -0
- package/dist/plugin.js.map +1 -0
- package/dist/schema-to-ast.d.ts +39 -0
- package/dist/schema-to-ast.d.ts.map +1 -0
- package/dist/tag-splitter.d.ts +16 -0
- package/dist/tag-splitter.d.ts.map +1 -0
- package/dist/types.d.ts +137 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/warnings.d.ts +10 -0
- package/dist/warnings.d.ts.map +1 -0
- package/eslint.config.js +6 -0
- package/package.json +46 -0
- package/src/ast-to-ck.ts +417 -0
- package/src/circular-refs.ts +71 -0
- package/src/convert.ts +150 -0
- package/src/index.ts +8 -0
- package/src/normalize.ts +359 -0
- package/src/paths-to-ast.ts +360 -0
- package/src/plugin.ts +89 -0
- package/src/schema-to-ast.ts +431 -0
- package/src/tag-splitter.ts +200 -0
- package/src/types.ts +133 -0
- package/src/warnings.ts +23 -0
- package/tests/ast-to-ck.test.ts +425 -0
- package/tests/circular-refs.test.ts +71 -0
- package/tests/convert.test.ts +330 -0
- package/tests/fixtures/petstore-3.1.json +187 -0
- package/tests/helpers.ts +158 -0
- package/tests/normalize.test.ts +114 -0
- package/tests/schema-to-ast.test.ts +341 -0
- package/tests/tag-splitter.test.ts +75 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detects circular $ref chains in OpenAPI schema definitions.
|
|
3
|
+
* Returns the set of schema names that participate in cycles.
|
|
4
|
+
* These should be wrapped in `lazy()` in the output .ck.
|
|
5
|
+
*/
|
|
6
|
+
export function detectCircularRefs(schemas: Record<string, unknown>): Set<string> {
|
|
7
|
+
const circular = new Set<string>();
|
|
8
|
+
const visiting = new Set<string>(); // current DFS path
|
|
9
|
+
const visited = new Set<string>(); // fully explored
|
|
10
|
+
|
|
11
|
+
function visit(name: string): void {
|
|
12
|
+
if (visited.has(name)) return;
|
|
13
|
+
if (visiting.has(name)) {
|
|
14
|
+
circular.add(name);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
visiting.add(name);
|
|
19
|
+
const schema = schemas[name];
|
|
20
|
+
if (schema && typeof schema === 'object') {
|
|
21
|
+
for (const ref of collectRefs(schema as Record<string, unknown>)) {
|
|
22
|
+
const refName = extractRefName(ref);
|
|
23
|
+
if (refName && schemas[refName]) {
|
|
24
|
+
visit(refName);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
visiting.delete(name);
|
|
29
|
+
visited.add(name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const name of Object.keys(schemas)) {
|
|
33
|
+
visit(name);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return circular;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Recursively collects all $ref strings from a schema object.
|
|
41
|
+
*/
|
|
42
|
+
function collectRefs(obj: Record<string, unknown>): string[] {
|
|
43
|
+
const refs: string[] = [];
|
|
44
|
+
|
|
45
|
+
function walk(val: unknown): void {
|
|
46
|
+
if (!val || typeof val !== 'object') return;
|
|
47
|
+
if (Array.isArray(val)) {
|
|
48
|
+
for (const item of val) walk(item);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const record = val as Record<string, unknown>;
|
|
52
|
+
if (typeof record.$ref === 'string') {
|
|
53
|
+
refs.push(record.$ref);
|
|
54
|
+
}
|
|
55
|
+
for (const v of Object.values(record)) {
|
|
56
|
+
walk(v);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
walk(obj);
|
|
61
|
+
return refs;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Extracts the schema name from a $ref like "#/components/schemas/Foo"
|
|
66
|
+
* or "#/definitions/Foo" (Swagger 2.0 after normalization still uses components).
|
|
67
|
+
*/
|
|
68
|
+
export function extractRefName(ref: string): string | undefined {
|
|
69
|
+
const match = ref.match(/^#\/(?:components\/schemas|definitions)\/(.+)$/);
|
|
70
|
+
return match?.[1];
|
|
71
|
+
}
|
package/src/convert.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { parse as parseYaml } from 'yaml';
|
|
3
|
+
import type { ConvertOptions, ConvertResult, NormalizedDocument } from './types.js';
|
|
4
|
+
import { WarningCollector } from './warnings.js';
|
|
5
|
+
import { normalize } from './normalize.js';
|
|
6
|
+
import { detectCircularRefs } from './circular-refs.js';
|
|
7
|
+
import { schemasToModels, sanitizeName } from './schema-to-ast.js';
|
|
8
|
+
import type { SchemaContext } from './schema-to-ast.js';
|
|
9
|
+
import { pathsToRoutes } from './paths-to-ast.js';
|
|
10
|
+
import { splitByTag, mergeIntoSingle } from './tag-splitter.js';
|
|
11
|
+
import { astToCk } from './ast-to-ck.js';
|
|
12
|
+
import type { NormalizedSchema } from './types.js';
|
|
13
|
+
import type { ModelNode } from '@contractkit/core';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Convert an OpenAPI spec (2.0, 3.0, or 3.1) to Contract Kit .ck source files.
|
|
17
|
+
*/
|
|
18
|
+
export async function convertOpenApiToCk(options: ConvertOptions): Promise<ConvertResult> {
|
|
19
|
+
const { split = 'by-tag', includeComments = true } = options;
|
|
20
|
+
const warnings = new WarningCollector(options.onWarning);
|
|
21
|
+
|
|
22
|
+
// Step 1: Parse the input into a document object
|
|
23
|
+
const rawDoc = await parseInput(options.input);
|
|
24
|
+
|
|
25
|
+
// Step 2: Normalize to 3.1 shape
|
|
26
|
+
const doc = normalize(rawDoc, warnings);
|
|
27
|
+
|
|
28
|
+
// Step 3: Sanitize schema names
|
|
29
|
+
const schemas = sanitizeSchemaNames(doc, warnings);
|
|
30
|
+
|
|
31
|
+
// Step 4: Detect circular references
|
|
32
|
+
const circularRefs = detectCircularRefs(schemas);
|
|
33
|
+
|
|
34
|
+
// Step 5: Convert schemas to model AST nodes
|
|
35
|
+
const extractedModels: ModelNode[] = [];
|
|
36
|
+
const schemaCtx: SchemaContext = {
|
|
37
|
+
circularRefs,
|
|
38
|
+
warnings,
|
|
39
|
+
path: '#/components/schemas',
|
|
40
|
+
includeComments,
|
|
41
|
+
namedSchemas: schemas,
|
|
42
|
+
extractedModels,
|
|
43
|
+
inlineCounter: 0,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const models = schemasToModels(schemas, schemaCtx);
|
|
47
|
+
|
|
48
|
+
// Step 6: Convert paths to route AST nodes
|
|
49
|
+
const { routes, routeTags } = pathsToRoutes(doc, {
|
|
50
|
+
circularRefs,
|
|
51
|
+
warnings,
|
|
52
|
+
includeComments,
|
|
53
|
+
namedSchemas: schemas,
|
|
54
|
+
extractedModels,
|
|
55
|
+
globalSecurity: doc.security,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Step 7: Split or merge
|
|
59
|
+
const files = new Map<string, string>();
|
|
60
|
+
|
|
61
|
+
if (split === 'by-tag') {
|
|
62
|
+
const ckRoots = splitByTag(models, routes, routeTags);
|
|
63
|
+
for (const [filename, root] of ckRoots) {
|
|
64
|
+
files.set(filename, astToCk(root, { includeComments }));
|
|
65
|
+
}
|
|
66
|
+
} else {
|
|
67
|
+
const root = mergeIntoSingle(models, routes);
|
|
68
|
+
files.set('api.ck', astToCk(root, { includeComments }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { files, warnings: warnings.warnings };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Input Parsing ────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
async function parseInput(input: string | Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
77
|
+
// Already a parsed object
|
|
78
|
+
if (typeof input === 'object') {
|
|
79
|
+
return input;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Try as a file path first
|
|
83
|
+
try {
|
|
84
|
+
const content = readFileSync(input, 'utf-8');
|
|
85
|
+
return parseJsonOrYaml(content);
|
|
86
|
+
} catch {
|
|
87
|
+
// Not a file path — try parsing as JSON/YAML string
|
|
88
|
+
return parseJsonOrYaml(input);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseJsonOrYaml(content: string): Record<string, unknown> {
|
|
93
|
+
// Try JSON first (faster)
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(content) as Record<string, unknown>;
|
|
96
|
+
} catch {
|
|
97
|
+
// Fall back to YAML
|
|
98
|
+
return parseYaml(content) as Record<string, unknown>;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Schema Name Sanitization ─────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
function sanitizeSchemaNames(doc: NormalizedDocument, warnings: WarningCollector): Record<string, NormalizedSchema> {
|
|
105
|
+
const original = doc.components?.schemas ?? {};
|
|
106
|
+
const sanitized: Record<string, NormalizedSchema> = {};
|
|
107
|
+
const nameMap = new Map<string, string>(); // original → sanitized
|
|
108
|
+
|
|
109
|
+
for (const name of Object.keys(original)) {
|
|
110
|
+
const clean = sanitizeName(name, warnings);
|
|
111
|
+
if (sanitized[clean]) {
|
|
112
|
+
warnings.warn(`#/components/schemas/${name}`, `Name collision after sanitization: "${name}" and another schema both map to "${clean}"`);
|
|
113
|
+
// Disambiguate with a suffix
|
|
114
|
+
let i = 2;
|
|
115
|
+
while (sanitized[`${clean}${i}`]) i++;
|
|
116
|
+
nameMap.set(name, `${clean}${i}`);
|
|
117
|
+
sanitized[`${clean}${i}`] = original[name] as NormalizedSchema;
|
|
118
|
+
} else {
|
|
119
|
+
nameMap.set(name, clean);
|
|
120
|
+
sanitized[clean] = original[name] as NormalizedSchema;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Update $refs in the document to use sanitized names
|
|
125
|
+
if (nameMap.size > 0) {
|
|
126
|
+
updateRefs(doc, nameMap);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return sanitized;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function updateRefs(obj: unknown, nameMap: Map<string, string>): void {
|
|
133
|
+
if (!obj || typeof obj !== 'object') return;
|
|
134
|
+
if (Array.isArray(obj)) {
|
|
135
|
+
for (const item of obj) updateRefs(item, nameMap);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const record = obj as Record<string, unknown>;
|
|
140
|
+
if (typeof record.$ref === 'string') {
|
|
141
|
+
const match = record.$ref.match(/^#\/components\/schemas\/(.+)$/);
|
|
142
|
+
if (match?.[1] && nameMap.has(match[1])) {
|
|
143
|
+
record.$ref = `#/components/schemas/${nameMap.get(match[1])}`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const value of Object.values(record)) {
|
|
148
|
+
updateRefs(value, nameMap);
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { convertOpenApiToCk } from './convert.js';
|
|
2
|
+
export type { ConvertOptions, ConvertResult, Warning } from './types.js';
|
|
3
|
+
export { astToCk, serializeType } from './ast-to-ck.js';
|
|
4
|
+
export { normalize } from './normalize.js';
|
|
5
|
+
export { detectCircularRefs, extractRefName } from './circular-refs.js';
|
|
6
|
+
export { schemasToModels, schemaToTypeNode, sanitizeName } from './schema-to-ast.js';
|
|
7
|
+
export { pathsToRoutes } from './paths-to-ast.js';
|
|
8
|
+
export { splitByTag, mergeIntoSingle } from './tag-splitter.js';
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { NormalizedDocument } from './types.js';
|
|
2
|
+
import type { WarningCollector } from './warnings.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Detects the OpenAPI version from a parsed document and normalizes it to a
|
|
6
|
+
* 3.1-like shape. Swagger 2.0 and OpenAPI 3.0 documents are transformed
|
|
7
|
+
* so that downstream code only needs to handle one schema dialect.
|
|
8
|
+
*
|
|
9
|
+
* Uses @scalar/openapi-parser's `upgrade()` for the heavy lifting when
|
|
10
|
+
* available, with manual fallbacks for edge cases.
|
|
11
|
+
*/
|
|
12
|
+
export function normalize(doc: Record<string, unknown>, warnings: WarningCollector): NormalizedDocument {
|
|
13
|
+
const version = detectVersion(doc);
|
|
14
|
+
|
|
15
|
+
if (version === '2.0') {
|
|
16
|
+
return normalizeSwagger2(doc, warnings);
|
|
17
|
+
}
|
|
18
|
+
if (version === '3.0') {
|
|
19
|
+
return normalizeOas30(doc as unknown as NormalizedDocument, warnings);
|
|
20
|
+
}
|
|
21
|
+
// 3.1+ — already in target shape
|
|
22
|
+
return doc as unknown as NormalizedDocument;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function detectVersion(doc: Record<string, unknown>): '2.0' | '3.0' | '3.1' {
|
|
26
|
+
if (typeof doc.swagger === 'string' && doc.swagger.startsWith('2')) return '2.0';
|
|
27
|
+
if (typeof doc.openapi === 'string') {
|
|
28
|
+
if (doc.openapi.startsWith('3.0')) return '3.0';
|
|
29
|
+
}
|
|
30
|
+
return '3.1';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Swagger 2.0 → 3.1 ───────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
function normalizeSwagger2(doc: Record<string, unknown>, warnings: WarningCollector): NormalizedDocument {
|
|
36
|
+
const info = (doc.info as Record<string, unknown>) ?? { title: 'Untitled', version: '0.0.0' };
|
|
37
|
+
const basePath = (doc.basePath as string) ?? '';
|
|
38
|
+
const schemes = (doc.schemes as string[]) ?? ['https'];
|
|
39
|
+
const host = (doc.host as string) ?? 'localhost';
|
|
40
|
+
const globalConsumes = (doc.consumes as string[]) ?? ['application/json'];
|
|
41
|
+
const globalProduces = (doc.produces as string[]) ?? ['application/json'];
|
|
42
|
+
|
|
43
|
+
const result: NormalizedDocument = {
|
|
44
|
+
openapi: '3.1.0',
|
|
45
|
+
info: {
|
|
46
|
+
title: (info.title as string) ?? 'Untitled',
|
|
47
|
+
version: (info.version as string) ?? '0.0.0',
|
|
48
|
+
description: info.description as string | undefined,
|
|
49
|
+
},
|
|
50
|
+
servers: [{ url: `${schemes[0]}://${host}${basePath}` }],
|
|
51
|
+
paths: {},
|
|
52
|
+
components: {
|
|
53
|
+
schemas: {},
|
|
54
|
+
securitySchemes: {},
|
|
55
|
+
},
|
|
56
|
+
tags: (doc.tags as NormalizedDocument['tags']) ?? [],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Convert definitions → components/schemas
|
|
60
|
+
const definitions = (doc.definitions as Record<string, unknown>) ?? {};
|
|
61
|
+
for (const [name, schema] of Object.entries(definitions)) {
|
|
62
|
+
result.components!.schemas![name] = normalizeNullable30(schema as Record<string, unknown>);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Convert securityDefinitions → components/securitySchemes
|
|
66
|
+
const secDefs = (doc.securityDefinitions as Record<string, unknown>) ?? {};
|
|
67
|
+
for (const [name, scheme] of Object.entries(secDefs)) {
|
|
68
|
+
result.components!.securitySchemes![name] = convertSecurityScheme2(scheme as Record<string, unknown>);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Convert paths
|
|
72
|
+
const paths = (doc.paths as Record<string, Record<string, unknown>>) ?? {};
|
|
73
|
+
for (const [path, pathItem] of Object.entries(paths)) {
|
|
74
|
+
result.paths![path] = normalizePathItem2(pathItem, globalConsumes, globalProduces, warnings);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Global security
|
|
78
|
+
if (doc.security) {
|
|
79
|
+
result.security = doc.security as Record<string, string[]>[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizePathItem2(
|
|
86
|
+
pathItem: Record<string, unknown>,
|
|
87
|
+
globalConsumes: string[],
|
|
88
|
+
globalProduces: string[],
|
|
89
|
+
warnings: WarningCollector,
|
|
90
|
+
): Record<string, unknown> {
|
|
91
|
+
const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
92
|
+
const normalized: Record<string, unknown> = {};
|
|
93
|
+
|
|
94
|
+
// Path-level parameters
|
|
95
|
+
const pathParams = (pathItem.parameters as unknown[]) ?? [];
|
|
96
|
+
|
|
97
|
+
for (const method of methods) {
|
|
98
|
+
const op = pathItem[method] as Record<string, unknown> | undefined;
|
|
99
|
+
if (!op) continue;
|
|
100
|
+
|
|
101
|
+
const opConsumes = (op.consumes as string[]) ?? globalConsumes;
|
|
102
|
+
const opProduces = (op.produces as string[]) ?? globalProduces;
|
|
103
|
+
const params = [...pathParams, ...((op.parameters as unknown[]) ?? [])];
|
|
104
|
+
|
|
105
|
+
// Separate body params from others
|
|
106
|
+
const nonBodyParams: unknown[] = [];
|
|
107
|
+
let requestBody: Record<string, unknown> | undefined;
|
|
108
|
+
|
|
109
|
+
for (const param of params as Record<string, unknown>[]) {
|
|
110
|
+
if (param.in === 'body') {
|
|
111
|
+
const contentType = opConsumes[0] ?? 'application/json';
|
|
112
|
+
requestBody = {
|
|
113
|
+
description: param.description,
|
|
114
|
+
required: param.required ?? true,
|
|
115
|
+
content: {
|
|
116
|
+
[contentType]: {
|
|
117
|
+
schema: normalizeNullable30((param.schema as Record<string, unknown>) ?? {}),
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
} else if (param.in === 'formData') {
|
|
122
|
+
warnings.info(`#/paths/${encodePathSegment(method)}`, 'formData parameters converted to multipart/form-data requestBody');
|
|
123
|
+
// Collect formData params into a schema
|
|
124
|
+
if (!requestBody) {
|
|
125
|
+
requestBody = {
|
|
126
|
+
content: {
|
|
127
|
+
'multipart/form-data': {
|
|
128
|
+
schema: { type: 'object', properties: {}, required: [] as string[] },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const formSchema = (requestBody.content as Record<string, Record<string, unknown>>)['multipart/form-data']!.schema as Record<
|
|
134
|
+
string,
|
|
135
|
+
unknown
|
|
136
|
+
>;
|
|
137
|
+
const props = formSchema.properties as Record<string, unknown>;
|
|
138
|
+
props[param.name as string] = normalizeNullable30(param as Record<string, unknown>);
|
|
139
|
+
if (param.required) {
|
|
140
|
+
(formSchema.required as string[]).push(param.name as string);
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
// Convert param schema
|
|
144
|
+
const normalizedParam = { ...param };
|
|
145
|
+
if (param.type) {
|
|
146
|
+
normalizedParam.schema = normalizeNullable30({
|
|
147
|
+
type: param.type,
|
|
148
|
+
format: param.format,
|
|
149
|
+
enum: param.enum,
|
|
150
|
+
items: param.items,
|
|
151
|
+
default: param.default,
|
|
152
|
+
minimum: param.minimum,
|
|
153
|
+
maximum: param.maximum,
|
|
154
|
+
minLength: param.minLength,
|
|
155
|
+
maxLength: param.maxLength,
|
|
156
|
+
pattern: param.pattern,
|
|
157
|
+
} as Record<string, unknown>);
|
|
158
|
+
delete normalizedParam.type;
|
|
159
|
+
delete normalizedParam.format;
|
|
160
|
+
delete normalizedParam.enum;
|
|
161
|
+
delete normalizedParam.items;
|
|
162
|
+
}
|
|
163
|
+
nonBodyParams.push(normalizedParam);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Convert responses
|
|
168
|
+
const responses: Record<string, unknown> = {};
|
|
169
|
+
const opResponses = (op.responses as Record<string, Record<string, unknown>>) ?? {};
|
|
170
|
+
for (const [code, resp] of Object.entries(opResponses)) {
|
|
171
|
+
const contentType = opProduces[0] ?? 'application/json';
|
|
172
|
+
const headers = convertResponseHeaders2(resp.headers as Record<string, Record<string, unknown>> | undefined);
|
|
173
|
+
const responseEntry: Record<string, unknown> = {
|
|
174
|
+
description: resp.description ?? '',
|
|
175
|
+
};
|
|
176
|
+
if (resp.schema) {
|
|
177
|
+
responseEntry.content = {
|
|
178
|
+
[contentType]: {
|
|
179
|
+
schema: normalizeNullable30(resp.schema as Record<string, unknown>),
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
if (headers) {
|
|
184
|
+
responseEntry.headers = headers;
|
|
185
|
+
}
|
|
186
|
+
responses[code] = responseEntry;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
normalized[method] = {
|
|
190
|
+
operationId: op.operationId,
|
|
191
|
+
summary: op.summary,
|
|
192
|
+
description: op.description,
|
|
193
|
+
tags: op.tags,
|
|
194
|
+
parameters: nonBodyParams.length > 0 ? nonBodyParams : undefined,
|
|
195
|
+
requestBody,
|
|
196
|
+
responses,
|
|
197
|
+
security: op.security,
|
|
198
|
+
deprecated: op.deprecated,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return normalized;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Convert Swagger 2.0 response headers to a 3.x-shaped Header Object map.
|
|
207
|
+
* 2.0 stores `type`/`format`/`items` inline on the header; 3.x wraps the same fields under `schema`.
|
|
208
|
+
*/
|
|
209
|
+
function convertResponseHeaders2(
|
|
210
|
+
headers: Record<string, Record<string, unknown>> | undefined,
|
|
211
|
+
): Record<string, Record<string, unknown>> | undefined {
|
|
212
|
+
if (!headers) return undefined;
|
|
213
|
+
const out: Record<string, Record<string, unknown>> = {};
|
|
214
|
+
for (const [name, header] of Object.entries(headers)) {
|
|
215
|
+
if (!header || typeof header !== 'object') continue;
|
|
216
|
+
const { description, type, format, items, ...rest } = header;
|
|
217
|
+
const schema: Record<string, unknown> = { ...rest };
|
|
218
|
+
if (type !== undefined) schema.type = type;
|
|
219
|
+
if (format !== undefined) schema.format = format;
|
|
220
|
+
if (items !== undefined) schema.items = items;
|
|
221
|
+
const normalized: Record<string, unknown> = {};
|
|
222
|
+
if (description !== undefined) normalized.description = description;
|
|
223
|
+
if (Object.keys(schema).length > 0) normalized.schema = normalizeNullable30(schema);
|
|
224
|
+
out[name] = normalized;
|
|
225
|
+
}
|
|
226
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function convertSecurityScheme2(scheme: Record<string, unknown>): unknown {
|
|
230
|
+
const type = scheme.type as string;
|
|
231
|
+
if (type === 'basic') {
|
|
232
|
+
return { type: 'http', scheme: 'basic' };
|
|
233
|
+
}
|
|
234
|
+
if (type === 'apiKey') {
|
|
235
|
+
return { type: 'apiKey', name: scheme.name, in: scheme.in };
|
|
236
|
+
}
|
|
237
|
+
if (type === 'oauth2') {
|
|
238
|
+
const flow = scheme.flow as string;
|
|
239
|
+
const flows: Record<string, unknown> = {};
|
|
240
|
+
if (flow === 'implicit') {
|
|
241
|
+
flows.implicit = { authorizationUrl: scheme.authorizationUrl, scopes: scheme.scopes ?? {} };
|
|
242
|
+
} else if (flow === 'password') {
|
|
243
|
+
flows.password = { tokenUrl: scheme.tokenUrl, scopes: scheme.scopes ?? {} };
|
|
244
|
+
} else if (flow === 'application') {
|
|
245
|
+
flows.clientCredentials = { tokenUrl: scheme.tokenUrl, scopes: scheme.scopes ?? {} };
|
|
246
|
+
} else if (flow === 'accessCode') {
|
|
247
|
+
flows.authorizationCode = {
|
|
248
|
+
authorizationUrl: scheme.authorizationUrl,
|
|
249
|
+
tokenUrl: scheme.tokenUrl,
|
|
250
|
+
scopes: scheme.scopes ?? {},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return { type: 'oauth2', flows };
|
|
254
|
+
}
|
|
255
|
+
return scheme;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── OpenAPI 3.0 → 3.1 ───────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
function normalizeOas30(doc: NormalizedDocument, _warnings: WarningCollector): NormalizedDocument {
|
|
261
|
+
// Walk all schemas and convert `nullable: true` to type arrays
|
|
262
|
+
if (doc.components?.schemas) {
|
|
263
|
+
for (const [name, schema] of Object.entries(doc.components.schemas)) {
|
|
264
|
+
doc.components.schemas[name] = normalizeNullable30(schema as Record<string, unknown>);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Walk paths and normalize inline schemas
|
|
269
|
+
if (doc.paths) {
|
|
270
|
+
for (const pathItem of Object.values(doc.paths)) {
|
|
271
|
+
normalizePathItemSchemas(pathItem as Record<string, unknown>);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
doc.openapi = '3.1.0';
|
|
276
|
+
return doc;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function normalizePathItemSchemas(pathItem: Record<string, unknown>): void {
|
|
280
|
+
const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
281
|
+
for (const method of methods) {
|
|
282
|
+
const op = pathItem[method] as Record<string, unknown> | undefined;
|
|
283
|
+
if (!op) continue;
|
|
284
|
+
|
|
285
|
+
// Normalize parameter schemas
|
|
286
|
+
const params = (op.parameters as Record<string, unknown>[]) ?? [];
|
|
287
|
+
for (const param of params) {
|
|
288
|
+
if (param.schema) {
|
|
289
|
+
param.schema = normalizeNullable30(param.schema as Record<string, unknown>);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Normalize requestBody schemas
|
|
294
|
+
const reqBody = op.requestBody as Record<string, unknown> | undefined;
|
|
295
|
+
if (reqBody?.content) {
|
|
296
|
+
for (const mediaType of Object.values(reqBody.content as Record<string, Record<string, unknown>>)) {
|
|
297
|
+
if (mediaType.schema) {
|
|
298
|
+
mediaType.schema = normalizeNullable30(mediaType.schema as Record<string, unknown>);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Normalize response schemas
|
|
304
|
+
const responses = (op.responses as Record<string, Record<string, unknown>>) ?? {};
|
|
305
|
+
for (const resp of Object.values(responses)) {
|
|
306
|
+
if (resp.content) {
|
|
307
|
+
for (const mediaType of Object.values(resp.content as Record<string, Record<string, unknown>>)) {
|
|
308
|
+
if (mediaType.schema) {
|
|
309
|
+
mediaType.schema = normalizeNullable30(mediaType.schema as Record<string, unknown>);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Recursively converts OAS 3.0 `nullable: true` to OAS 3.1 `type: [T, "null"]`.
|
|
319
|
+
* Also normalizes nested schemas (properties, items, allOf, etc.).
|
|
320
|
+
*/
|
|
321
|
+
function normalizeNullable30(schema: Record<string, unknown>): Record<string, unknown> {
|
|
322
|
+
if (!schema || typeof schema !== 'object') return schema;
|
|
323
|
+
|
|
324
|
+
const result = { ...schema };
|
|
325
|
+
|
|
326
|
+
// Convert nullable: true → type array with null
|
|
327
|
+
if (result.nullable === true && typeof result.type === 'string') {
|
|
328
|
+
result.type = [result.type, 'null'];
|
|
329
|
+
delete result.nullable;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Convert $ref alongside other properties (OAS 3.0 $ref with siblings was invalid,
|
|
333
|
+
// but OAS 3.1 allows it — no conversion needed, just keep walking)
|
|
334
|
+
|
|
335
|
+
// Recurse into nested schemas
|
|
336
|
+
if (result.properties && typeof result.properties === 'object') {
|
|
337
|
+
const props = result.properties as Record<string, Record<string, unknown>>;
|
|
338
|
+
for (const [key, val] of Object.entries(props)) {
|
|
339
|
+
props[key] = normalizeNullable30(val);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (result.items && typeof result.items === 'object' && !Array.isArray(result.items)) {
|
|
343
|
+
result.items = normalizeNullable30(result.items as Record<string, unknown>);
|
|
344
|
+
}
|
|
345
|
+
if (result.additionalProperties && typeof result.additionalProperties === 'object') {
|
|
346
|
+
result.additionalProperties = normalizeNullable30(result.additionalProperties as Record<string, unknown>);
|
|
347
|
+
}
|
|
348
|
+
for (const combiner of ['allOf', 'oneOf', 'anyOf'] as const) {
|
|
349
|
+
if (Array.isArray(result[combiner])) {
|
|
350
|
+
result[combiner] = (result[combiner] as Record<string, unknown>[]).map(normalizeNullable30);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return result;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function encodePathSegment(s: string): string {
|
|
358
|
+
return s.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
359
|
+
}
|