@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.
Files changed (97) hide show
  1. package/.turbo/turbo-build$colon$ci.log +40 -0
  2. package/.turbo/turbo-build.log +20 -0
  3. package/.turbo/turbo-format.log +36 -0
  4. package/.turbo/turbo-test$colon$ci.log +77 -0
  5. package/.turbo/turbo-test.log +19 -0
  6. package/CHANGELOG.md +108 -0
  7. package/coverage/base.css +224 -0
  8. package/coverage/block-navigation.js +87 -0
  9. package/coverage/clover.xml +878 -0
  10. package/coverage/coverage-final.json +10 -0
  11. package/coverage/favicon.png +0 -0
  12. package/coverage/index.html +131 -0
  13. package/coverage/prettify.css +1 -0
  14. package/coverage/prettify.js +2 -0
  15. package/coverage/sort-arrow-sprite.png +0 -0
  16. package/coverage/sorter.js +210 -0
  17. package/coverage/src/ast-to-ck.ts.html +1336 -0
  18. package/coverage/src/circular-refs.ts.html +298 -0
  19. package/coverage/src/convert.ts.html +535 -0
  20. package/coverage/src/index.html +221 -0
  21. package/coverage/src/normalize.ts.html +1162 -0
  22. package/coverage/src/paths-to-ast.ts.html +1165 -0
  23. package/coverage/src/schema-to-ast.ts.html +1378 -0
  24. package/coverage/src/tag-splitter.ts.html +685 -0
  25. package/coverage/src/warnings.ts.html +154 -0
  26. package/coverage/tests/helpers.ts.html +559 -0
  27. package/coverage/tests/index.html +116 -0
  28. package/dist/ast-to-ck.d.ts +8 -0
  29. package/dist/ast-to-ck.d.ts.map +1 -0
  30. package/dist/chunk-CQGFQKBJ.js +1681 -0
  31. package/dist/chunk-CQGFQKBJ.js.map +1 -0
  32. package/dist/chunk-FNUU2DWY.js +1779 -0
  33. package/dist/chunk-FNUU2DWY.js.map +1 -0
  34. package/dist/chunk-LD2HAFZG.js +1681 -0
  35. package/dist/chunk-LD2HAFZG.js.map +1 -0
  36. package/dist/chunk-LQ2B3EJG.js +1777 -0
  37. package/dist/chunk-LQ2B3EJG.js.map +1 -0
  38. package/dist/chunk-M6JA6WY2.js +1714 -0
  39. package/dist/chunk-M6JA6WY2.js.map +1 -0
  40. package/dist/chunk-MQTKLXTN.js +1681 -0
  41. package/dist/chunk-MQTKLXTN.js.map +1 -0
  42. package/dist/chunk-NV7RGUUS.js +1699 -0
  43. package/dist/chunk-NV7RGUUS.js.map +1 -0
  44. package/dist/chunk-REM25FDE.js +1779 -0
  45. package/dist/chunk-REM25FDE.js.map +1 -0
  46. package/dist/chunk-SNW7GJOM.js +1681 -0
  47. package/dist/chunk-SNW7GJOM.js.map +1 -0
  48. package/dist/chunk-UWOSCBRG.js +1681 -0
  49. package/dist/chunk-UWOSCBRG.js.map +1 -0
  50. package/dist/chunk-YP7FWA4H.js +1779 -0
  51. package/dist/chunk-YP7FWA4H.js.map +1 -0
  52. package/dist/circular-refs.d.ts +12 -0
  53. package/dist/circular-refs.d.ts.map +1 -0
  54. package/dist/convert.d.ts +6 -0
  55. package/dist/convert.d.ts.map +1 -0
  56. package/dist/index.d.ts +9 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +29 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/normalize.d.ts +12 -0
  61. package/dist/normalize.d.ts.map +1 -0
  62. package/dist/paths-to-ast.d.ts +22 -0
  63. package/dist/paths-to-ast.d.ts.map +1 -0
  64. package/dist/plugin.d.ts +4 -0
  65. package/dist/plugin.d.ts.map +1 -0
  66. package/dist/plugin.js +86 -0
  67. package/dist/plugin.js.map +1 -0
  68. package/dist/schema-to-ast.d.ts +39 -0
  69. package/dist/schema-to-ast.d.ts.map +1 -0
  70. package/dist/tag-splitter.d.ts +16 -0
  71. package/dist/tag-splitter.d.ts.map +1 -0
  72. package/dist/types.d.ts +137 -0
  73. package/dist/types.d.ts.map +1 -0
  74. package/dist/warnings.d.ts +10 -0
  75. package/dist/warnings.d.ts.map +1 -0
  76. package/eslint.config.js +6 -0
  77. package/package.json +46 -0
  78. package/src/ast-to-ck.ts +417 -0
  79. package/src/circular-refs.ts +71 -0
  80. package/src/convert.ts +150 -0
  81. package/src/index.ts +8 -0
  82. package/src/normalize.ts +359 -0
  83. package/src/paths-to-ast.ts +360 -0
  84. package/src/plugin.ts +89 -0
  85. package/src/schema-to-ast.ts +431 -0
  86. package/src/tag-splitter.ts +200 -0
  87. package/src/types.ts +133 -0
  88. package/src/warnings.ts +23 -0
  89. package/tests/ast-to-ck.test.ts +425 -0
  90. package/tests/circular-refs.test.ts +71 -0
  91. package/tests/convert.test.ts +330 -0
  92. package/tests/fixtures/petstore-3.1.json +187 -0
  93. package/tests/helpers.ts +158 -0
  94. package/tests/normalize.test.ts +114 -0
  95. package/tests/schema-to-ast.test.ts +341 -0
  96. package/tests/tag-splitter.test.ts +75 -0
  97. 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';
@@ -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
+ }