@contractkit/openapi-to-ck 0.10.2 → 0.11.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/.turbo/turbo-build$colon$ci.log +7 -7
- package/.turbo/turbo-test$colon$ci.log +30 -26
- package/CHANGELOG.md +109 -0
- package/README.md +30 -8
- package/dist/ast-to-ck.d.ts +32 -16
- package/dist/ast-to-ck.d.ts.map +1 -1
- package/dist/{chunk-JPI3AQ7V.js → chunk-Z53MK4FM.js} +196 -390
- package/dist/chunk-Z53MK4FM.js.map +1 -0
- package/dist/convert.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/normalize.d.ts +6 -2
- package/dist/normalize.d.ts.map +1 -1
- package/dist/paths-to-ast.d.ts +2 -0
- package/dist/paths-to-ast.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +18 -3
- package/dist/plugin.js.map +1 -1
- package/dist/schema-to-ast.d.ts +13 -1
- package/dist/schema-to-ast.d.ts.map +1 -1
- package/dist/tag-splitter.d.ts.map +1 -1
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -5
- package/src/ast-to-ck.ts +29 -453
- package/src/convert.ts +57 -3
- package/src/normalize.ts +87 -11
- package/src/paths-to-ast.ts +92 -11
- package/src/plugin.ts +17 -2
- package/src/schema-to-ast.ts +51 -7
- package/src/tag-splitter.ts +21 -16
- package/src/types.ts +28 -0
- package/tests/__snapshots__/kitchen-sink.ck +102 -0
- package/tests/ast-to-ck.test.ts +34 -17
- package/tests/component-refs.test.ts +114 -0
- package/tests/coverage.test.ts +246 -0
- package/tests/error-responses.test.ts +94 -0
- package/tests/fixtures/kitchen-sink-3.1.json +100 -0
- package/tests/helpers.ts +40 -0
- package/tests/kitchen-sink.test.ts +116 -0
- package/tests/schema-to-ast.test.ts +11 -2
- package/dist/chunk-JPI3AQ7V.js.map +0 -1
package/src/normalize.ts
CHANGED
|
@@ -6,20 +6,96 @@ import type { WarningCollector } from './warnings.js';
|
|
|
6
6
|
* 3.1-like shape. Swagger 2.0 and OpenAPI 3.0 documents are transformed
|
|
7
7
|
* so that downstream code only needs to handle one schema dialect.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* The transformation is hand-written. An earlier note here claimed `@scalar/openapi-parser`'s
|
|
10
|
+
* `upgrade()` did the heavy lifting; it never did, and that dependency has been dropped.
|
|
11
|
+
*
|
|
12
|
+
* After version normalization, `dereferenceComponents` inlines `$ref`s to reusable non-schema
|
|
13
|
+
* components, so the rest of the pipeline sees only inline parameter, response, request-body and
|
|
14
|
+
* header objects. Schema `$ref`s are deliberately left intact — they become `.ck` model refs.
|
|
11
15
|
*/
|
|
12
16
|
export function normalize(doc: Record<string, unknown>, warnings: WarningCollector): NormalizedDocument {
|
|
13
17
|
const version = detectVersion(doc);
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
const normalized =
|
|
20
|
+
version === '2.0'
|
|
21
|
+
? normalizeSwagger2(doc, warnings)
|
|
22
|
+
: version === '3.0'
|
|
23
|
+
? normalizeOas30(doc as unknown as NormalizedDocument, warnings)
|
|
24
|
+
: // 3.1+ — already in target shape
|
|
25
|
+
(doc as unknown as NormalizedDocument);
|
|
26
|
+
|
|
27
|
+
dereferenceComponents(normalized, warnings);
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Component $ref inlining ──────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** The component sections whose `$ref`s are inlined. `schemas` is deliberately absent. */
|
|
34
|
+
const DEREF_SECTIONS = ['parameters', 'requestBodies', 'responses', 'headers'] as const;
|
|
35
|
+
|
|
36
|
+
/** Guards against a `$ref` chain that loops back on itself. */
|
|
37
|
+
const MAX_REF_DEPTH = 10;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Inline `$ref`s to reusable non-schema components.
|
|
41
|
+
*
|
|
42
|
+
* Only `#/components/schemas/*` refs survive conversion — they become `.ck` model references.
|
|
43
|
+
* Everything else (`parameters`, `requestBodies`, `responses`, `headers`) has no `.ck`
|
|
44
|
+
* counterpart, and nothing downstream resolves it: a `$ref`'d parameter used to reach
|
|
45
|
+
* `parameterToNode` with no `name` and print as `undefined: string`, which *parses*, so the
|
|
46
|
+
* corruption was silent. Resolving here means the rest of the pipeline only ever sees inline
|
|
47
|
+
* objects.
|
|
48
|
+
*
|
|
49
|
+
* Sibling keys are kept and win over the target's, matching how OpenAPI 3.1 treats a `$ref`
|
|
50
|
+
* alongside other properties.
|
|
51
|
+
*/
|
|
52
|
+
function dereferenceComponents(doc: NormalizedDocument, warnings: WarningCollector): void {
|
|
53
|
+
const components = doc.components as Record<string, Record<string, unknown>> | undefined;
|
|
54
|
+
|
|
55
|
+
const resolve = (node: unknown, path: string, depth = 0): unknown => {
|
|
56
|
+
if (!node || typeof node !== 'object') return node;
|
|
57
|
+
if (Array.isArray(node)) return node.map(item => resolve(item, path, depth));
|
|
58
|
+
|
|
59
|
+
const obj = node as Record<string, unknown>;
|
|
60
|
+
const ref = obj.$ref;
|
|
61
|
+
if (typeof ref === 'string') {
|
|
62
|
+
const match = /^#\/components\/([^/]+)\/(.+)$/.exec(ref);
|
|
63
|
+
const section = match?.[1];
|
|
64
|
+
// Schema refs are the ones that survive into `.ck`; leave them for `extractRefName`.
|
|
65
|
+
if (section && section !== 'schemas' && (DEREF_SECTIONS as readonly string[]).includes(section)) {
|
|
66
|
+
if (depth >= MAX_REF_DEPTH) {
|
|
67
|
+
warnings.warn(path, `$ref chain too deep to resolve: ${ref}`);
|
|
68
|
+
return obj;
|
|
69
|
+
}
|
|
70
|
+
const target = components?.[section]?.[decodeRefToken(match[2]!)];
|
|
71
|
+
if (target === undefined) {
|
|
72
|
+
warnings.warn(path, `unresolved $ref '${ref}' — the component is not defined`);
|
|
73
|
+
return obj;
|
|
74
|
+
}
|
|
75
|
+
const siblings = { ...obj };
|
|
76
|
+
delete siblings.$ref;
|
|
77
|
+
const resolved = resolve(target, path, depth + 1);
|
|
78
|
+
return { ...(resolved as Record<string, unknown>), ...siblings };
|
|
79
|
+
}
|
|
80
|
+
return obj;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const out: Record<string, unknown> = {};
|
|
84
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
85
|
+
// A schema subtree can hold `$ref`s of its own, all of them schema refs.
|
|
86
|
+
out[key] = key === 'schema' || key === 'schemas' ? value : resolve(value, `${path}/${key}`, depth);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
for (const [path, pathItem] of Object.entries(doc.paths ?? {})) {
|
|
92
|
+
doc.paths![path] = resolve(pathItem, `#/paths/${path}`) as typeof pathItem;
|
|
20
93
|
}
|
|
21
|
-
|
|
22
|
-
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Undo the `~1`/`~0` escaping a JSON pointer uses for `/` and `~`. */
|
|
97
|
+
function decodeRefToken(token: string): string {
|
|
98
|
+
return decodeURIComponent(token).replace(/~1/g, '/').replace(/~0/g, '~');
|
|
23
99
|
}
|
|
24
100
|
|
|
25
101
|
function detectVersion(doc: Record<string, unknown>): '2.0' | '3.0' | '3.1' {
|
|
@@ -88,7 +164,7 @@ function normalizePathItem2(
|
|
|
88
164
|
globalProduces: string[],
|
|
89
165
|
warnings: WarningCollector,
|
|
90
166
|
): Record<string, unknown> {
|
|
91
|
-
const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
167
|
+
const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
|
|
92
168
|
const normalized: Record<string, unknown> = {};
|
|
93
169
|
|
|
94
170
|
// Path-level parameters
|
|
@@ -277,7 +353,7 @@ function normalizeOas30(doc: NormalizedDocument, _warnings: WarningCollector): N
|
|
|
277
353
|
}
|
|
278
354
|
|
|
279
355
|
function normalizePathItemSchemas(pathItem: Record<string, unknown>): void {
|
|
280
|
-
const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
356
|
+
const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
|
|
281
357
|
for (const method of methods) {
|
|
282
358
|
const op = pathItem[method] as Record<string, unknown> | undefined;
|
|
283
359
|
if (!op) continue;
|
package/src/paths-to-ast.ts
CHANGED
|
@@ -24,8 +24,28 @@ import type { SchemaContext } from './schema-to-ast.js';
|
|
|
24
24
|
import type { WarningCollector } from './warnings.js';
|
|
25
25
|
|
|
26
26
|
const LOC: SourceLocation = { file: '', line: 0 };
|
|
27
|
+
|
|
28
|
+
/** A plain `type/subtype`, which is all `mimeType` in the grammar accepts. */
|
|
29
|
+
const MIME_RE = /^[a-z0-9][a-z0-9.+_-]*\/[a-z0-9][a-z0-9.+_-]*$/i;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reduce a summary to something `nameText` can hold.
|
|
33
|
+
*
|
|
34
|
+
* `nameText = (~("\n" | "}" | " #" | "\t#") any)+` — the value runs to end of line and stops at
|
|
35
|
+
* a closing brace or a whitespace-preceded `#`. An OpenAPI summary respects none of that, and an
|
|
36
|
+
* unsanitized one would mis-parse the rest of the operation.
|
|
37
|
+
*/
|
|
38
|
+
function toNameText(summary: string): string {
|
|
39
|
+
return summary
|
|
40
|
+
.replace(/[}#]/g, ' ')
|
|
41
|
+
.replace(/\s+/g, ' ')
|
|
42
|
+
.trim();
|
|
43
|
+
}
|
|
27
44
|
const HTTP_METHODS: HttpMethod[] = ['get', 'post', 'put', 'patch', 'delete'];
|
|
28
45
|
|
|
46
|
+
/** Methods a spec may declare that `httpMethod` in the grammar has no keyword for. */
|
|
47
|
+
const UNSUPPORTED_METHODS = ['head', 'options', 'trace'] as const;
|
|
48
|
+
|
|
29
49
|
// ─── Public API ───────────────────────────────────────────────────────────
|
|
30
50
|
|
|
31
51
|
export interface PathsContext {
|
|
@@ -37,6 +57,8 @@ export interface PathsContext {
|
|
|
37
57
|
extractedModels: ModelNode[];
|
|
38
58
|
/** Global security from the spec (for detecting explicit overrides). */
|
|
39
59
|
globalSecurity?: Record<string, string[]>[];
|
|
60
|
+
/** How bodied 4xx/5xx responses are imported. See `ConvertOptions.errorResponses`. */
|
|
61
|
+
errorResponses: 'documented' | 'emitted';
|
|
40
62
|
}
|
|
41
63
|
|
|
42
64
|
/**
|
|
@@ -69,6 +91,13 @@ function pathItemToRoute(path: string, pathItem: NormalizedPathItem, ctx: PathsC
|
|
|
69
91
|
// Collect path-level parameters
|
|
70
92
|
const pathParams = (pathItem.parameters ?? []).filter(p => p.in === 'path');
|
|
71
93
|
|
|
94
|
+
for (const method of UNSUPPORTED_METHODS) {
|
|
95
|
+
// A grammar limitation, not a converter one — `.ck` has no keyword for these verbs.
|
|
96
|
+
if ((pathItem as Record<string, unknown>)[method]) {
|
|
97
|
+
ctx.warnings.warn(`#/paths/${encodePathSegment(path)}/${method}`, `\`${method}\` operations have no .ck equivalent; dropped`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
72
101
|
for (const method of HTTP_METHODS) {
|
|
73
102
|
const op = pathItem[method];
|
|
74
103
|
if (!op) continue;
|
|
@@ -121,6 +150,14 @@ function operationToNode(method: HttpMethod, op: NormalizedOperation, path: stri
|
|
|
121
150
|
node.sdk = op.operationId;
|
|
122
151
|
}
|
|
123
152
|
|
|
153
|
+
// summary → `name:`, the human-readable label the key exists for. `description` stays the
|
|
154
|
+
// doc comment; when only a summary is given it becomes the name and is not doubled as prose.
|
|
155
|
+
if (op.summary) {
|
|
156
|
+
const name = toNameText(op.summary);
|
|
157
|
+
if (name) node.name = name;
|
|
158
|
+
else ctx.warnings.warn(`${pathPrefix}/summary`, 'summary has no content `.ck` can carry as a name; dropped');
|
|
159
|
+
}
|
|
160
|
+
|
|
124
161
|
// Description
|
|
125
162
|
if (op.description && ctx.includeComments) {
|
|
126
163
|
node.description = op.description;
|
|
@@ -136,10 +173,19 @@ function operationToNode(method: HttpMethod, op: NormalizedOperation, path: stri
|
|
|
136
173
|
const headerParams: OpParamNode[] = [];
|
|
137
174
|
|
|
138
175
|
for (const param of op.parameters ?? []) {
|
|
176
|
+
// `dereferenceComponents` inlines `#/components/parameters/*` before this runs, so a
|
|
177
|
+
// parameter with no name is one nothing could resolve. Emitting it would print
|
|
178
|
+
// `undefined: string`, which parses — silent corruption is worse than a dropped param.
|
|
179
|
+
if (!param?.name) {
|
|
180
|
+
ctx.warnings.warn(`${pathPrefix}/parameters`, 'skipped a parameter with no name (an unresolved $ref?)');
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
139
183
|
if (param.in === 'query') {
|
|
140
184
|
queryParams.push(parameterToNode(param, schemaCtx));
|
|
141
185
|
} else if (param.in === 'header') {
|
|
142
186
|
headerParams.push(parameterToNode(param, schemaCtx));
|
|
187
|
+
} else if (param.in === 'cookie') {
|
|
188
|
+
ctx.warnings.warn(`${pathPrefix}/parameters/${param.name}`, 'cookie parameters have no `.ck` equivalent; dropped');
|
|
143
189
|
}
|
|
144
190
|
}
|
|
145
191
|
|
|
@@ -158,15 +204,22 @@ function operationToNode(method: HttpMethod, op: NormalizedOperation, path: stri
|
|
|
158
204
|
// Responses
|
|
159
205
|
const responses = op.responses ?? {};
|
|
160
206
|
for (const [code, resp] of Object.entries(responses)) {
|
|
207
|
+
// Strictly numeric: `parseInt('4XX')` is 4, which would silently invent a status code.
|
|
208
|
+
if (!/^\d{3}$/.test(code)) {
|
|
209
|
+
// `default`, `2XX`, `4XX` — the response block is keyed by a numeric status.
|
|
210
|
+
ctx.warnings.warn(`${pathPrefix}/responses/${code}`, `response key '${code}' is not a numeric status code; dropped`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
161
213
|
const statusCode = parseInt(code, 10);
|
|
162
|
-
if (isNaN(statusCode)) continue;
|
|
163
214
|
const respNode = responseToNode(statusCode, resp, op.operationId ?? `${method}${toPascalCase(path)}`, schemaCtx, ctx);
|
|
164
215
|
node.responses.push(respNode);
|
|
165
216
|
}
|
|
166
217
|
|
|
167
|
-
// Security
|
|
168
|
-
|
|
169
|
-
|
|
218
|
+
// Security. A spec-level `security` applies to every operation that does not override it;
|
|
219
|
+
// it used to be collected and never read, so a globally-secured spec imported as unsecured.
|
|
220
|
+
const security = op.security ?? ctx.globalSecurity;
|
|
221
|
+
if (security !== undefined) {
|
|
222
|
+
node.security = convertSecurity(security);
|
|
170
223
|
}
|
|
171
224
|
|
|
172
225
|
return node;
|
|
@@ -238,19 +291,22 @@ function requestBodyToNode(
|
|
|
238
291
|
const content = reqBody.content;
|
|
239
292
|
if (!content) return undefined;
|
|
240
293
|
|
|
241
|
-
const supported = new Set<string>(['application/json', 'application/x-www-form-urlencoded', 'multipart/form-data']);
|
|
242
294
|
const bodies: OpRequestNode['bodies'] = [];
|
|
243
295
|
|
|
244
296
|
for (const [contentType, mediaType] of Object.entries(content)) {
|
|
245
|
-
|
|
297
|
+
// `.ck` accepts any RFC 6838 `type/subtype`, so there is no reason to narrow a spec to
|
|
298
|
+
// the three content types this used to allow. What it cannot carry is a parameterised
|
|
299
|
+
// (`; charset=`) or wildcard mime, since `mimeType` is two `mimeChar+` runs.
|
|
300
|
+
if (!MIME_RE.test(contentType)) {
|
|
301
|
+
ctx.warnings.warn(`${schemaCtx.path}/requestBody/content`, `content type '${contentType}' is not a plain type/subtype; skipped`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (!mediaType?.schema) continue;
|
|
246
305
|
const { typeNode, model } = extractInlineModel(mediaType.schema, `${toPascalCase(operationName)}Request`, schemaCtx);
|
|
247
306
|
if (model) {
|
|
248
307
|
ctx.extractedModels.push(model);
|
|
249
308
|
}
|
|
250
|
-
bodies.push({
|
|
251
|
-
contentType: contentType as OpRequestNode['bodies'][number]['contentType'],
|
|
252
|
-
bodyType: typeNode,
|
|
253
|
-
});
|
|
309
|
+
bodies.push({ contentType, bodyType: typeNode });
|
|
254
310
|
}
|
|
255
311
|
|
|
256
312
|
if (bodies.length === 0) return undefined;
|
|
@@ -259,6 +315,23 @@ function requestBodyToNode(
|
|
|
259
315
|
|
|
260
316
|
// ─── Responses ────────────────────────────────────────────────────────────
|
|
261
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Whether an imported status should be marked `(documented)` rather than service-produced.
|
|
320
|
+
*
|
|
321
|
+
* Only applies to a status that carries a block, because that is the only case where the
|
|
322
|
+
* modifier does anything: `isEmitted` in core treats a block as "the service produces this", and
|
|
323
|
+
* `isRedundantDocumented` warns when `(documented)` is put on a bare bodyless non-2xx, where the
|
|
324
|
+
* status is already not emitted. 3xx is left alone — `observableResponses` already covers
|
|
325
|
+
* everything below 400, so the marker would change nothing a client sees, and a spec'd redirect
|
|
326
|
+
* body is plausibly service-produced.
|
|
327
|
+
*/
|
|
328
|
+
function shouldDocument(statusCode: number, braced: boolean, resp: NormalizedResponse, ctx: PathsContext): boolean {
|
|
329
|
+
if (!braced) return false;
|
|
330
|
+
// A spec this project emitted says so outright; prefer it over guessing from the status.
|
|
331
|
+
if (resp['x-contractkit-emit'] === 'documented') return true;
|
|
332
|
+
return statusCode >= 400 && ctx.errorResponses === 'documented';
|
|
333
|
+
}
|
|
334
|
+
|
|
262
335
|
function responseToNode(
|
|
263
336
|
statusCode: number,
|
|
264
337
|
resp: NormalizedResponse,
|
|
@@ -267,7 +340,12 @@ function responseToNode(
|
|
|
267
340
|
ctx: PathsContext,
|
|
268
341
|
): OpResponseNode {
|
|
269
342
|
const headers = convertResponseHeaders(resp.headers, schemaCtx);
|
|
270
|
-
const
|
|
343
|
+
const documented = (braced: boolean) => (shouldDocument(statusCode, braced, resp, ctx) ? { emit: 'documented' as const } : {});
|
|
344
|
+
const empty = (): OpResponseNode => ({
|
|
345
|
+
statusCode,
|
|
346
|
+
bodies: [],
|
|
347
|
+
...(headers ? { headers, hasBlock: true, ...documented(true) } : {}),
|
|
348
|
+
});
|
|
271
349
|
|
|
272
350
|
if (!resp.content) return empty();
|
|
273
351
|
|
|
@@ -291,6 +369,7 @@ function responseToNode(
|
|
|
291
369
|
bodies,
|
|
292
370
|
hasBlock: true,
|
|
293
371
|
...(headers ? { headers } : {}),
|
|
372
|
+
...documented(true),
|
|
294
373
|
};
|
|
295
374
|
}
|
|
296
375
|
|
|
@@ -334,6 +413,8 @@ function makeSchemaCtx(ctx: PathsContext, path: string): SchemaContext {
|
|
|
334
413
|
namedSchemas: ctx.namedSchemas as Record<string, never>,
|
|
335
414
|
extractedModels: ctx.extractedModels,
|
|
336
415
|
inlineCounter: 0,
|
|
416
|
+
// A response body, request body, param or header names an already-imported model.
|
|
417
|
+
insideModel: false,
|
|
337
418
|
};
|
|
338
419
|
}
|
|
339
420
|
|
package/src/plugin.ts
CHANGED
|
@@ -8,12 +8,16 @@ interface ImportArgs {
|
|
|
8
8
|
specPath: string;
|
|
9
9
|
output: string;
|
|
10
10
|
split: 'single' | 'by-tag';
|
|
11
|
+
includeComments: boolean;
|
|
12
|
+
errorResponses: 'documented' | 'emitted';
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
function parseImportArgs(argv: string[]): ImportArgs {
|
|
14
16
|
let specPath = '';
|
|
15
17
|
let output = '.';
|
|
16
18
|
let split: 'single' | 'by-tag' = 'by-tag';
|
|
19
|
+
let includeComments = true;
|
|
20
|
+
let errorResponses: 'documented' | 'emitted' = 'documented';
|
|
17
21
|
|
|
18
22
|
for (let i = 0; i < argv.length; i++) {
|
|
19
23
|
const arg = argv[i]!;
|
|
@@ -22,12 +26,17 @@ function parseImportArgs(argv: string[]): ImportArgs {
|
|
|
22
26
|
} else if (arg === '--split') {
|
|
23
27
|
const val = argv[++i];
|
|
24
28
|
if (val === 'single' || val === 'by-tag') split = val;
|
|
29
|
+
} else if (arg === '--no-comments') {
|
|
30
|
+
includeComments = false;
|
|
31
|
+
} else if (arg === '--error-responses') {
|
|
32
|
+
const val = argv[++i];
|
|
33
|
+
if (val === 'documented' || val === 'emitted') errorResponses = val;
|
|
25
34
|
} else if (!arg.startsWith('-')) {
|
|
26
35
|
specPath = arg;
|
|
27
36
|
}
|
|
28
37
|
}
|
|
29
38
|
|
|
30
|
-
return { specPath, output, split };
|
|
39
|
+
return { specPath, output, split, includeComments, errorResponses };
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
const USAGE = `Usage: contractkit import-openapi <spec-path> [options]
|
|
@@ -40,6 +49,11 @@ Arguments:
|
|
|
40
49
|
Options:
|
|
41
50
|
-o, --output <dir> Output directory for .ck files (default: current directory)
|
|
42
51
|
--split <mode> How to split output: "by-tag" (one file per tag) or "single" (default: by-tag)
|
|
52
|
+
--no-comments Skip OpenAPI descriptions instead of emitting them as # comments
|
|
53
|
+
--error-responses <mode>
|
|
54
|
+
How to import a 4xx/5xx that declares a body: "documented" (default)
|
|
55
|
+
marks it \`404(documented):\`, so the SDK throws it and the generated
|
|
56
|
+
router does not write it; "emitted" imports it as service-produced
|
|
43
57
|
-h, --help Show this help message`;
|
|
44
58
|
|
|
45
59
|
const plugin: ContractKitPlugin = {
|
|
@@ -61,7 +75,8 @@ const plugin: ContractKitPlugin = {
|
|
|
61
75
|
const result = await convertOpenApiToCk({
|
|
62
76
|
input: resolve(parsed.specPath),
|
|
63
77
|
split: parsed.split,
|
|
64
|
-
includeComments:
|
|
78
|
+
includeComments: parsed.includeComments,
|
|
79
|
+
errorResponses: parsed.errorResponses,
|
|
65
80
|
onWarning: (w: Warning) => {
|
|
66
81
|
const prefix = w.severity === 'warn' ? '⚠' : 'ℹ';
|
|
67
82
|
console.warn(` ${prefix} ${w.path}: ${w.message}`);
|
package/src/schema-to-ast.ts
CHANGED
|
@@ -6,8 +6,20 @@ import type { WarningCollector } from './warnings.js';
|
|
|
6
6
|
// ─── Conversion Context ───────────────────────────────────────────────────
|
|
7
7
|
|
|
8
8
|
export interface SchemaContext {
|
|
9
|
-
/** Schema names involved in circular references —
|
|
9
|
+
/** Schema names involved in circular references — see {@link insideModel}. */
|
|
10
10
|
circularRefs: Set<string>;
|
|
11
|
+
/**
|
|
12
|
+
* Whether the type being built sits inside a `contract` body. Default `true`.
|
|
13
|
+
*
|
|
14
|
+
* `lazy()` exists to break a definition cycle: `topoSortModels` in the TypeScript plugin
|
|
15
|
+
* emits dependencies before dependents and can only fall back to source order for a cycle,
|
|
16
|
+
* so a reference from one cycle member to another has to be deferred. A reference from an
|
|
17
|
+
* operation — a response body, request body, param, or response header — is not part of any
|
|
18
|
+
* such cycle: it names a model the generated module has already imported and fully
|
|
19
|
+
* evaluated. Wrapping it achieves nothing and makes every contract importing a
|
|
20
|
+
* self-referential schema noisier than it needs to be.
|
|
21
|
+
*/
|
|
22
|
+
insideModel: boolean;
|
|
11
23
|
/** Warning collector for unsupported features. */
|
|
12
24
|
warnings: WarningCollector;
|
|
13
25
|
/** Current JSON pointer path (for warnings). */
|
|
@@ -28,12 +40,17 @@ const LOC: SourceLocation = { file: '', line: 0 };
|
|
|
28
40
|
|
|
29
41
|
const FORMAT_TO_SCALAR: Record<string, ScalarTypeNode['name']> = {
|
|
30
42
|
email: 'email',
|
|
43
|
+
'idn-email': 'email',
|
|
31
44
|
uri: 'url',
|
|
45
|
+
'uri-reference': 'url',
|
|
46
|
+
iri: 'url',
|
|
47
|
+
'iri-reference': 'url',
|
|
32
48
|
url: 'url',
|
|
33
49
|
uuid: 'uuid',
|
|
34
50
|
date: 'date',
|
|
35
51
|
'date-time': 'datetime',
|
|
36
52
|
time: 'time',
|
|
53
|
+
duration: 'duration',
|
|
37
54
|
binary: 'binary',
|
|
38
55
|
int64: 'bigint',
|
|
39
56
|
};
|
|
@@ -95,6 +112,9 @@ function schemaToModel(name: string, schema: NormalizedSchema, ctx: SchemaContex
|
|
|
95
112
|
kind: 'model',
|
|
96
113
|
name,
|
|
97
114
|
fields,
|
|
115
|
+
// `additionalProperties: true` says unknown keys are allowed, which is `mode(loose)`.
|
|
116
|
+
// `false` and absent both match `.ck`'s `strict` default, so neither needs a mode.
|
|
117
|
+
...(schema.additionalProperties === true ? { mode: 'loose' as const } : {}),
|
|
98
118
|
description,
|
|
99
119
|
loc: LOC,
|
|
100
120
|
};
|
|
@@ -116,11 +136,13 @@ function schemaToModel(name: string, schema: NormalizedSchema, ctx: SchemaContex
|
|
|
116
136
|
* Convert an OpenAPI schema to a ContractTypeNode.
|
|
117
137
|
*/
|
|
118
138
|
export function schemaToTypeNode(schema: NormalizedSchema, ctx: SchemaContext): ContractTypeNode {
|
|
139
|
+
warnUnrepresentableConstraints(schema, ctx);
|
|
140
|
+
|
|
119
141
|
// $ref
|
|
120
142
|
if (schema.$ref) {
|
|
121
143
|
const refName = extractRefName(schema.$ref);
|
|
122
144
|
if (refName) {
|
|
123
|
-
if (ctx.circularRefs.has(refName)) {
|
|
145
|
+
if (ctx.insideModel && ctx.circularRefs.has(refName)) {
|
|
124
146
|
return { kind: 'lazy', inner: { kind: 'ref', name: refName } };
|
|
125
147
|
}
|
|
126
148
|
return { kind: 'ref', name: refName };
|
|
@@ -231,7 +253,7 @@ function stringSchemaToType(schema: NormalizedSchema): ContractTypeNode {
|
|
|
231
253
|
if (schema.minLength !== undefined) mods.min = schema.minLength;
|
|
232
254
|
if (schema.maxLength !== undefined) mods.max = schema.maxLength;
|
|
233
255
|
}
|
|
234
|
-
if (schema.pattern) mods.regex =
|
|
256
|
+
if (schema.pattern) mods.regex = schema.pattern;
|
|
235
257
|
if (schema.format && !FORMAT_TO_SCALAR[schema.format]) mods.format = schema.format;
|
|
236
258
|
|
|
237
259
|
return { kind: 'scalar', name: 'string', ...mods };
|
|
@@ -372,12 +394,28 @@ function toDiscriminatedUnion(schemas: NormalizedSchema[], discriminator: string
|
|
|
372
394
|
return { kind: 'discriminatedUnion', discriminator, members };
|
|
373
395
|
}
|
|
374
396
|
|
|
397
|
+
/** JSON Schema keywords with no `.ck` counterpart, warned about rather than silently dropped. */
|
|
398
|
+
const UNREPRESENTABLE_KEYWORDS = ['exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', 'uniqueItems'] as const;
|
|
399
|
+
|
|
375
400
|
function warnUnsupported(schema: NormalizedSchema, ctx: SchemaContext): void {
|
|
376
401
|
if (schema.xml) ctx.warnings.warn(ctx.path, 'xml metadata is not supported, skipping');
|
|
377
402
|
if (schema.externalDocs) ctx.warnings.info(ctx.path, 'externalDocs is not supported, skipping');
|
|
378
403
|
if (schema.not) ctx.warnings.warn(ctx.path, 'not keyword is not supported, skipping');
|
|
379
404
|
}
|
|
380
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Report constraints `.ck` has no vocabulary for.
|
|
408
|
+
*
|
|
409
|
+
* Its scalar and array arguments are `min`, `max`, `len`, `regex` and `format`. Folding
|
|
410
|
+
* `exclusiveMinimum` into `min=` would be an off-by-one lie and `multipleOf` has no analogue at
|
|
411
|
+
* all, so the constraint is reported as lost rather than approximated.
|
|
412
|
+
*/
|
|
413
|
+
function warnUnrepresentableConstraints(schema: NormalizedSchema, ctx: SchemaContext): void {
|
|
414
|
+
for (const keyword of UNREPRESENTABLE_KEYWORDS) {
|
|
415
|
+
if (schema[keyword] !== undefined) ctx.warnings.warn(ctx.path, `${keyword} has no .ck equivalent, dropping the constraint`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
381
419
|
/**
|
|
382
420
|
* Extract a named model from an inline object schema (used for request/response bodies).
|
|
383
421
|
*/
|
|
@@ -393,7 +431,9 @@ export function extractInlineModel(
|
|
|
393
431
|
|
|
394
432
|
// If it's an object with properties, extract as a named model
|
|
395
433
|
if (schema.properties || (schema.type === 'object' && schema.additionalProperties === undefined)) {
|
|
396
|
-
|
|
434
|
+
// The extracted model is a `contract` body like any other, so references inside it are
|
|
435
|
+
// subject to the same ordering problem an authored one would be.
|
|
436
|
+
const fields = schemaPropertiesToFields(schema, { ...ctx, insideModel: true });
|
|
397
437
|
const model: ModelNode = {
|
|
398
438
|
kind: 'model',
|
|
399
439
|
name: suggestedName,
|
|
@@ -423,9 +463,13 @@ export function sanitizeName(name: string, warnings: WarningCollector): string {
|
|
|
423
463
|
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
424
464
|
.join('');
|
|
425
465
|
|
|
426
|
-
|
|
427
|
-
|
|
466
|
+
// `identStart` excludes digits, so a schema named "3DModel" would sanitize to something the
|
|
467
|
+
// parser rejects. Prefix rather than drop the digit, which would collide names.
|
|
468
|
+
const safe = /^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned;
|
|
469
|
+
|
|
470
|
+
if (safe !== name) {
|
|
471
|
+
warnings.info(`#/components/schemas/${name}`, `Schema name sanitized: "${name}" → "${safe}"`);
|
|
428
472
|
}
|
|
429
473
|
|
|
430
|
-
return
|
|
474
|
+
return safe || 'UnnamedSchema';
|
|
431
475
|
}
|
package/src/tag-splitter.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CkRootNode, ModelNode, OpRouteNode, ContractTypeNode } from '@contractkit/core';
|
|
1
|
+
import type { CkRootNode, ModelNode, OpRouteNode, ContractTypeNode, ParamSource } from '@contractkit/core';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Split models and routes into per-tag CkRootNode instances.
|
|
@@ -132,21 +132,26 @@ function collectRouteRefs(route: OpRouteNode, refs: Set<string>): void {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
135
|
+
/**
|
|
136
|
+
* Collect the model names a `params`/`query`/`headers` source refers to.
|
|
137
|
+
*
|
|
138
|
+
* `ParamSource` is a tagged union, and this was written against the shape it had before that:
|
|
139
|
+
* a bare string, an array of params, or a type node. Only `kind: 'ref'` still worked, and only
|
|
140
|
+
* by coincidence — it happens to look like a `ModelRefTypeNode`. `'params'` and `'type'` fell
|
|
141
|
+
* through to `collectTypeRefs`, whose switch has neither case, so a model reached only from a
|
|
142
|
+
* query or header block collected no tags at all and was filed under `shared.ck`.
|
|
143
|
+
*/
|
|
144
|
+
function collectParamSourceRefs(source: ParamSource, refs: Set<string>): void {
|
|
145
|
+
switch (source.kind) {
|
|
146
|
+
case 'ref':
|
|
147
|
+
refs.add(source.name);
|
|
148
|
+
return;
|
|
149
|
+
case 'params':
|
|
150
|
+
for (const param of source.nodes) collectTypeRefs(param.type, refs);
|
|
151
|
+
return;
|
|
152
|
+
case 'type':
|
|
153
|
+
collectTypeRefs(source.node, refs);
|
|
154
|
+
return;
|
|
150
155
|
}
|
|
151
156
|
}
|
|
152
157
|
|
package/src/types.ts
CHANGED
|
@@ -7,6 +7,20 @@ export interface ConvertOptions {
|
|
|
7
7
|
split?: 'single' | 'by-tag';
|
|
8
8
|
/** Emit OpenAPI descriptions as # comments. Default: true. */
|
|
9
9
|
includeComments?: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* How a 4xx/5xx response that declares a body is imported. Default: `'documented'`.
|
|
12
|
+
*
|
|
13
|
+
* OpenAPI cannot say whether the handler *returns* a status or merely documents it, but
|
|
14
|
+
* `.ck` distinguishes the two and every generator downstream depends on the answer. Writing
|
|
15
|
+
* `404: { … }` means the service produces it: the generated router writes it and the SDKs
|
|
16
|
+
* hand it back as a value. `404(documented): { … }` means the body is the error contract —
|
|
17
|
+
* the SDK throws it as an `SdkError` and the service is not responsible for returning it,
|
|
18
|
+
* which is what an error response almost always is.
|
|
19
|
+
*
|
|
20
|
+
* `'emitted'` reproduces the pre-existing behaviour, where every declared status was imported
|
|
21
|
+
* as service-produced.
|
|
22
|
+
*/
|
|
23
|
+
errorResponses?: 'documented' | 'emitted';
|
|
10
24
|
/** Called for each warning during conversion. */
|
|
11
25
|
onWarning?: (warning: Warning) => void;
|
|
12
26
|
}
|
|
@@ -57,6 +71,10 @@ export interface NormalizedSchema {
|
|
|
57
71
|
maximum?: number;
|
|
58
72
|
minItems?: number;
|
|
59
73
|
maxItems?: number;
|
|
74
|
+
exclusiveMinimum?: number | boolean;
|
|
75
|
+
exclusiveMaximum?: number | boolean;
|
|
76
|
+
multipleOf?: number;
|
|
77
|
+
uniqueItems?: boolean;
|
|
60
78
|
discriminator?: { propertyName?: string; mapping?: Record<string, string> };
|
|
61
79
|
xml?: unknown;
|
|
62
80
|
externalDocs?: unknown;
|
|
@@ -71,6 +89,11 @@ export interface NormalizedDocument {
|
|
|
71
89
|
components?: {
|
|
72
90
|
schemas?: Record<string, NormalizedSchema>;
|
|
73
91
|
securitySchemes?: Record<string, unknown>;
|
|
92
|
+
/** Reusable component objects, inlined by `dereferenceComponents` before conversion. */
|
|
93
|
+
parameters?: Record<string, NormalizedParameter>;
|
|
94
|
+
requestBodies?: Record<string, NormalizedRequestBody>;
|
|
95
|
+
responses?: Record<string, NormalizedResponse>;
|
|
96
|
+
headers?: Record<string, NormalizedHeader>;
|
|
74
97
|
};
|
|
75
98
|
security?: Record<string, string[]>[];
|
|
76
99
|
servers?: { url: string; description?: string }[];
|
|
@@ -120,6 +143,11 @@ export interface NormalizedRequestBody {
|
|
|
120
143
|
|
|
121
144
|
export interface NormalizedResponse {
|
|
122
145
|
description?: string;
|
|
146
|
+
/**
|
|
147
|
+
* Set by `@contractkit/plugin-openapi` to carry the emitted-vs-documented distinction, which
|
|
148
|
+
* OpenAPI itself cannot express. Honoured ahead of the status-code heuristic on import.
|
|
149
|
+
*/
|
|
150
|
+
'x-contractkit-emit'?: 'documented';
|
|
123
151
|
content?: Record<string, { schema?: NormalizedSchema }>;
|
|
124
152
|
/** OpenAPI Header Objects keyed by header name (case-insensitive on the wire). */
|
|
125
153
|
headers?: Record<string, NormalizedHeader>;
|