@contractkit/openapi-to-ck 0.10.1 → 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 +117 -0
- package/LICENSE +21 -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 +5 -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/ast-to-ck.ts
CHANGED
|
@@ -1,467 +1,43 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
ModelNode,
|
|
4
|
-
FieldNode,
|
|
5
|
-
ContractTypeNode,
|
|
6
|
-
OpRouteNode,
|
|
7
|
-
OpOperationNode,
|
|
8
|
-
OpRequestNode,
|
|
9
|
-
OpResponseNode,
|
|
10
|
-
ParamSource,
|
|
11
|
-
SecurityNode,
|
|
12
|
-
SecurityFields,
|
|
13
|
-
ObjectMode,
|
|
14
|
-
RouteModifier,
|
|
15
|
-
} from '@contractkit/core';
|
|
16
|
-
|
|
17
|
-
const INDENT = ' '; // 4 spaces
|
|
18
|
-
|
|
19
|
-
/** Matches a bare identifier that needs no quoting (mirrors serializeDefault). */
|
|
20
|
-
const IDENT_RE = /^[a-zA-Z_$][a-zA-Z0-9_$\-.]*$/;
|
|
1
|
+
import type { CkRootNode } from '@contractkit/core';
|
|
2
|
+
import { printCk, printType } from '@contractkit/core';
|
|
21
3
|
|
|
22
4
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
5
|
+
* `.ck` serialization for the OpenAPI importer.
|
|
6
|
+
*
|
|
7
|
+
* This module used to carry its own printer. `.ck` had two of them — this one and the prettier
|
|
8
|
+
* plugin's — and only the prettier copy was covered by the round-trip tests that the
|
|
9
|
+
* `ck-grammar-change` checklist points at, so this one silently fell behind the grammar: it
|
|
10
|
+
* ignored `hasBlock` and the `(documented)` response modifier, could not emit `mcp:`,
|
|
11
|
+
* `plugins:`, `name:`, `override`, `format(output=)` or options-level header globals, and
|
|
12
|
+
* emitted unparseable source for a regex containing `/` or an enum value containing both quote
|
|
13
|
+
* styles.
|
|
14
|
+
*
|
|
15
|
+
* The printer now lives in `@contractkit/core` next to `parseCk`, and this module is a thin
|
|
16
|
+
* adapter over it. A grammar change has one printer to update.
|
|
26
17
|
*/
|
|
27
|
-
function singleLineComment(text: string): string {
|
|
28
|
-
return text.replace(/\s+/g, ' ').trim();
|
|
29
|
-
}
|
|
30
18
|
|
|
31
19
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
20
|
+
* Options controlling how a {@link CkRootNode} is rendered to `.ck` source.
|
|
21
|
+
*
|
|
22
|
+
* @deprecated `includeComments` is a no-op and is kept only so existing callers still compile.
|
|
23
|
+
* Comments are controlled upstream: `ConvertOptions.includeComments` gates every `description`
|
|
24
|
+
* assignment in `schema-to-ast.ts` and `paths-to-ast.ts`, so when it is off the descriptions are
|
|
25
|
+
* absent from the AST and there is nothing left for the printer to suppress.
|
|
36
26
|
*/
|
|
37
|
-
function quoteEnumValue(value: string): string {
|
|
38
|
-
if (IDENT_RE.test(value)) return value;
|
|
39
|
-
if (!value.includes('"')) return `"${value}"`;
|
|
40
|
-
if (!value.includes("'")) return `'${value}'`;
|
|
41
|
-
// Value contains both quote styles; `.ck` cannot escape, so keep double
|
|
42
|
-
// quotes and preserve the inner ones as best we can.
|
|
43
|
-
return `"${value}"`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// ─── Public API ───────────────────────────────────────────────────────────
|
|
47
|
-
|
|
48
|
-
/** Options controlling how a {@link CkRootNode} is rendered to `.ck` source. */
|
|
49
27
|
export interface SerializeOptions {
|
|
50
|
-
/**
|
|
28
|
+
/** No-op. See the deprecation note on {@link SerializeOptions}. */
|
|
51
29
|
includeComments?: boolean;
|
|
52
30
|
}
|
|
53
31
|
|
|
54
32
|
/**
|
|
55
|
-
* Serialize a `.ck` AST back to formatted `.ck` source text.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* to re-parse cleanly via `parseCk` (see round-trip tests).
|
|
33
|
+
* Serialize a `.ck` AST back to formatted `.ck` source text.
|
|
34
|
+
*
|
|
35
|
+
* Delegates to `printCk`, which prints from a `CkRootNode` alone — no Ohm CST and no original
|
|
36
|
+
* source — so programmatically built nodes print correctly.
|
|
60
37
|
*/
|
|
61
|
-
export function astToCk(root: CkRootNode,
|
|
62
|
-
|
|
63
|
-
const ctx: Ctx = { includeComments };
|
|
64
|
-
const parts: string[] = [];
|
|
65
|
-
|
|
66
|
-
// Options block
|
|
67
|
-
const optionsBlock = serializeOptions(root);
|
|
68
|
-
if (optionsBlock) parts.push(optionsBlock);
|
|
69
|
-
|
|
70
|
-
// Models
|
|
71
|
-
for (const model of root.models) {
|
|
72
|
-
parts.push(serializeModel(model, ctx));
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Routes
|
|
76
|
-
for (const route of root.routes) {
|
|
77
|
-
parts.push(serializeRoute(route, ctx));
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return parts.join('\n\n') + '\n';
|
|
38
|
+
export function astToCk(root: CkRootNode, _options: SerializeOptions = {}): string {
|
|
39
|
+
return printCk(root);
|
|
81
40
|
}
|
|
82
41
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
interface Ctx {
|
|
86
|
-
includeComments: boolean;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// ─── Options block ────────────────────────────────────────────────────────
|
|
90
|
-
|
|
91
|
-
function serializeOptions(root: CkRootNode): string | null {
|
|
92
|
-
const hasKeys = Object.keys(root.meta).length > 0;
|
|
93
|
-
const hasServices = root.services && Object.keys(root.services).length > 0;
|
|
94
|
-
const hasSecurity = root.security !== undefined;
|
|
95
|
-
|
|
96
|
-
if (!hasKeys && !hasServices && !hasSecurity) return null;
|
|
97
|
-
|
|
98
|
-
const lines: string[] = ['options {'];
|
|
99
|
-
|
|
100
|
-
if (hasKeys) {
|
|
101
|
-
lines.push(`${INDENT}keys: {`);
|
|
102
|
-
for (const [key, value] of Object.entries(root.meta)) {
|
|
103
|
-
lines.push(`${INDENT}${INDENT}${key}: ${value}`);
|
|
104
|
-
}
|
|
105
|
-
lines.push(`${INDENT}}`);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (hasServices) {
|
|
109
|
-
lines.push(`${INDENT}services: {`);
|
|
110
|
-
for (const [name, path] of Object.entries(root.services)) {
|
|
111
|
-
lines.push(`${INDENT}${INDENT}${name}: "${path}"`);
|
|
112
|
-
}
|
|
113
|
-
lines.push(`${INDENT}}`);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (hasSecurity) {
|
|
117
|
-
lines.push(`${INDENT}security: {`);
|
|
118
|
-
if (root.security === 'none') {
|
|
119
|
-
lines.push(`${INDENT}${INDENT}none`);
|
|
120
|
-
} else {
|
|
121
|
-
const sec = root.security as SecurityFields;
|
|
122
|
-
if (sec.policy !== undefined) {
|
|
123
|
-
const value = sec.policy === false ? 'none' : sec.policy;
|
|
124
|
-
lines.push(`${INDENT}${INDENT}policy: ${value}`);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
lines.push(`${INDENT}}`);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
lines.push('}');
|
|
131
|
-
return lines.join('\n');
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ─── Models ───────────────────────────────────────────────────────────────
|
|
135
|
-
|
|
136
|
-
function serializeModel(model: ModelNode, ctx: Ctx): string {
|
|
137
|
-
const parts: string[] = [];
|
|
138
|
-
|
|
139
|
-
// Modifiers: format(input=snake) mode(loose) deprecated
|
|
140
|
-
const prefixes: string[] = [];
|
|
141
|
-
if (model.inputCase && model.inputCase !== 'camel') {
|
|
142
|
-
prefixes.push(`format(input=${model.inputCase})`);
|
|
143
|
-
}
|
|
144
|
-
if (model.mode && model.mode !== 'strict') {
|
|
145
|
-
prefixes.push(`mode(${model.mode})`);
|
|
146
|
-
}
|
|
147
|
-
if (model.deprecated) {
|
|
148
|
-
prefixes.push('deprecated');
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const prefix = prefixes.length > 0 ? prefixes.join(' ') + ' ' : '';
|
|
152
|
-
const comment = ctx.includeComments && model.description ? ` # ${singleLineComment(model.description)}` : '';
|
|
153
|
-
|
|
154
|
-
// Type alias: contract Name: typeExpression
|
|
155
|
-
if (model.type) {
|
|
156
|
-
parts.push(`contract ${prefix}${model.name}: ${serializeType(model.type)}${comment}`);
|
|
157
|
-
return parts.join('');
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Inheritance: contract Name: Base1 & Base2 & { ... }
|
|
161
|
-
if (model.bases && model.bases.length > 0) {
|
|
162
|
-
parts.push(`contract ${prefix}${model.name}: ${model.bases.join(' & ')} & {${comment}`);
|
|
163
|
-
} else {
|
|
164
|
-
parts.push(`contract ${prefix}${model.name}: {${comment}`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
for (const field of model.fields) {
|
|
168
|
-
parts.push(serializeField(field, 1, ctx));
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
parts.push('}');
|
|
172
|
-
return parts.join('\n');
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function serializeField(field: FieldNode, depth: number, ctx: Ctx): string {
|
|
176
|
-
const indent = INDENT.repeat(depth);
|
|
177
|
-
const optional = field.optional ? '?' : '';
|
|
178
|
-
const visibility = field.visibility !== 'normal' ? `${field.visibility} ` : '';
|
|
179
|
-
const deprecated = field.deprecated ? 'deprecated ' : '';
|
|
180
|
-
|
|
181
|
-
let typeStr = serializeType(field.type);
|
|
182
|
-
|
|
183
|
-
// Nullable fields: if the type doesn't already contain null, append `| null`
|
|
184
|
-
if (field.nullable && !typeContainsNull(field.type)) {
|
|
185
|
-
typeStr = `${typeStr} | null`;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const defaultVal = field.default !== undefined ? ` = ${serializeDefault(field.default)}` : '';
|
|
189
|
-
const comment = ctx.includeComments && field.description ? ` # ${singleLineComment(field.description)}` : '';
|
|
190
|
-
|
|
191
|
-
return `${indent}${field.name}${optional}: ${deprecated}${visibility}${typeStr}${defaultVal}${comment}`;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function typeContainsNull(type: ContractTypeNode): boolean {
|
|
195
|
-
if (type.kind === 'scalar' && type.name === 'null') return true;
|
|
196
|
-
if (type.kind === 'union') return type.members.some(typeContainsNull);
|
|
197
|
-
return false;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function serializeDefault(value: string | number | boolean): string {
|
|
201
|
-
if (typeof value === 'string') {
|
|
202
|
-
// If it looks like an identifier (e.g. enum value), don't quote it
|
|
203
|
-
if (/^[a-zA-Z_$][a-zA-Z0-9_$\-.]*$/.test(value)) return value;
|
|
204
|
-
return `"${value}"`;
|
|
205
|
-
}
|
|
206
|
-
return String(value);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// ─── Types ────────────────────────────────────────────────────────────────
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Serialize a single {@link ContractTypeNode} to its inline `.ck` type
|
|
213
|
-
* expression (e.g. `array(User, min=1)`, `string | int`, `enum(asc, desc)`).
|
|
214
|
-
* Recurses through composite types; enum values are quoted as needed via
|
|
215
|
-
* {@link quoteEnumValue}.
|
|
216
|
-
*/
|
|
217
|
-
export function serializeType(type: ContractTypeNode): string {
|
|
218
|
-
switch (type.kind) {
|
|
219
|
-
case 'scalar':
|
|
220
|
-
return serializeScalar(type);
|
|
221
|
-
case 'array':
|
|
222
|
-
return serializeArray(type);
|
|
223
|
-
case 'tuple':
|
|
224
|
-
return `tuple(${type.items.map(serializeType).join(', ')})`;
|
|
225
|
-
case 'record':
|
|
226
|
-
return `record(${serializeType(type.key)}, ${serializeType(type.value)})`;
|
|
227
|
-
case 'enum':
|
|
228
|
-
return `enum(${type.values.map(quoteEnumValue).join(', ')})`;
|
|
229
|
-
case 'literal':
|
|
230
|
-
return serializeLiteral(type);
|
|
231
|
-
case 'union':
|
|
232
|
-
return type.members.map(serializeType).join(' | ');
|
|
233
|
-
case 'discriminatedUnion':
|
|
234
|
-
return `discriminated(by=${type.discriminator}, ${type.members.map(serializeType).join(' | ')})`;
|
|
235
|
-
case 'intersection':
|
|
236
|
-
return type.members.map(serializeType).join(' & ');
|
|
237
|
-
case 'ref':
|
|
238
|
-
return type.name;
|
|
239
|
-
case 'inlineObject':
|
|
240
|
-
return serializeInlineObject(type);
|
|
241
|
-
case 'lazy':
|
|
242
|
-
return `lazy(${serializeType(type.inner)})`;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function serializeScalar(type: {
|
|
247
|
-
name: string;
|
|
248
|
-
min?: number | bigint | string;
|
|
249
|
-
max?: number | bigint | string;
|
|
250
|
-
len?: number;
|
|
251
|
-
regex?: string;
|
|
252
|
-
format?: string;
|
|
253
|
-
}): string {
|
|
254
|
-
const args: string[] = [];
|
|
255
|
-
if (type.len !== undefined) args.push(`length=${type.len}`);
|
|
256
|
-
if (type.min !== undefined) args.push(typeof type.min === 'string' ? `min="${type.min}"` : `min=${type.min}`);
|
|
257
|
-
if (type.max !== undefined) args.push(typeof type.max === 'string' ? `max="${type.max}"` : `max=${type.max}`);
|
|
258
|
-
if (type.regex !== undefined) args.push(`regex=${type.regex}`);
|
|
259
|
-
if (type.format !== undefined) args.push(`format=${type.format}`);
|
|
260
|
-
|
|
261
|
-
if (args.length === 0) return type.name;
|
|
262
|
-
return `${type.name}(${args.join(', ')})`;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function serializeArray(type: { item: ContractTypeNode; min?: number; max?: number }): string {
|
|
266
|
-
const args: string[] = [serializeType(type.item)];
|
|
267
|
-
if (type.min !== undefined) args.push(`min=${type.min}`);
|
|
268
|
-
if (type.max !== undefined) args.push(`max=${type.max}`);
|
|
269
|
-
return `array(${args.join(', ')})`;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function serializeLiteral(type: { value: string | number | boolean }): string {
|
|
273
|
-
if (typeof type.value === 'string') return `literal("${type.value}")`;
|
|
274
|
-
return `literal(${type.value})`;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function serializeInlineObject(type: { fields: FieldNode[]; mode?: ObjectMode }): string {
|
|
278
|
-
const modePrefix = type.mode ? `mode(${type.mode}) ` : '';
|
|
279
|
-
if (type.fields.length === 0) return `${modePrefix}{}`;
|
|
280
|
-
|
|
281
|
-
const lines: string[] = [`${modePrefix}{`];
|
|
282
|
-
for (const field of type.fields) {
|
|
283
|
-
lines.push(serializeField(field, 2, { includeComments: true }));
|
|
284
|
-
}
|
|
285
|
-
lines.push(`${INDENT}}`);
|
|
286
|
-
return lines.join('\n');
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// ─── Routes ───────────────────────────────────────────────────────────────
|
|
290
|
-
|
|
291
|
-
function serializeRoute(route: OpRouteNode, ctx: Ctx): string {
|
|
292
|
-
const lines: string[] = [];
|
|
293
|
-
|
|
294
|
-
const modStr = serializeModifiers(route.modifiers);
|
|
295
|
-
const comment = ctx.includeComments && route.description ? ` # ${singleLineComment(route.description)}` : '';
|
|
296
|
-
lines.push(`operation${modStr} ${route.path}: {${comment}`);
|
|
297
|
-
|
|
298
|
-
// Route-level params
|
|
299
|
-
if (route.params) {
|
|
300
|
-
serializeParamSource(lines, 'params', route.params, route.paramsMode, 1, ctx);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Route-level security
|
|
304
|
-
if (route.security !== undefined) {
|
|
305
|
-
serializeSecurityBlock(lines, route.security, 1, ctx);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// Operations
|
|
309
|
-
for (const op of route.operations) {
|
|
310
|
-
serializeOperation(lines, op, 1, ctx);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
lines.push('}');
|
|
314
|
-
return lines.join('\n');
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function serializeOperation(lines: string[], op: OpOperationNode, depth: number, ctx: Ctx): string[] {
|
|
318
|
-
const indent = INDENT.repeat(depth);
|
|
319
|
-
const modStr = serializeModifiers(op.modifiers);
|
|
320
|
-
const comment = ctx.includeComments && op.description ? ` # ${singleLineComment(op.description)}` : '';
|
|
321
|
-
lines.push(`${indent}${op.method}${modStr}: {${comment}`);
|
|
322
|
-
|
|
323
|
-
const inner = INDENT.repeat(depth + 1);
|
|
324
|
-
|
|
325
|
-
// Service
|
|
326
|
-
if (op.service) {
|
|
327
|
-
lines.push(`${inner}service: ${op.service}`);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// SDK
|
|
331
|
-
if (op.sdk) {
|
|
332
|
-
lines.push(`${inner}sdk: ${op.sdk}`);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Signature
|
|
336
|
-
if (op.signature) {
|
|
337
|
-
const sigComment = ctx.includeComments && op.signatureDescription ? ` # ${singleLineComment(op.signatureDescription)}` : '';
|
|
338
|
-
if (op.signaturePolicy) {
|
|
339
|
-
lines.push(`${inner}signature: {`);
|
|
340
|
-
lines.push(`${inner} options: ${op.signature}${sigComment}`);
|
|
341
|
-
lines.push(`${inner} policy: ${op.signaturePolicy}`);
|
|
342
|
-
lines.push(`${inner}}`);
|
|
343
|
-
} else {
|
|
344
|
-
lines.push(`${inner}signature: ${op.signature}${sigComment}`);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// Security
|
|
349
|
-
if (op.security !== undefined) {
|
|
350
|
-
serializeSecurityBlock(lines, op.security, depth + 1, ctx);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Query
|
|
354
|
-
if (op.query) {
|
|
355
|
-
serializeParamSource(lines, 'query', op.query, op.queryMode, depth + 1, ctx);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Headers
|
|
359
|
-
if (op.headers) {
|
|
360
|
-
serializeParamSource(lines, 'headers', op.headers, op.headersMode, depth + 1, ctx);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Request
|
|
364
|
-
if (op.request) {
|
|
365
|
-
serializeRequest(lines, op.request, depth + 1);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Responses
|
|
369
|
-
if (op.responses.length > 0) {
|
|
370
|
-
serializeResponses(lines, op.responses, depth + 1);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
lines.push(`${indent}}`);
|
|
374
|
-
return lines;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
function serializeModifiers(modifiers?: RouteModifier[]): string {
|
|
378
|
-
if (!modifiers || modifiers.length === 0) return '';
|
|
379
|
-
return `(${modifiers.join(', ')})`;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function serializeParamSource(lines: string[], keyword: string, source: ParamSource, mode: ObjectMode | undefined, depth: number, ctx: Ctx): void {
|
|
383
|
-
const indent = INDENT.repeat(depth);
|
|
384
|
-
|
|
385
|
-
// String reference: `query: TypeName`
|
|
386
|
-
if (source.kind === 'ref') {
|
|
387
|
-
lines.push(`${indent}${keyword}: ${source.name}`);
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// ContractTypeNode reference
|
|
392
|
-
if (source.kind === 'type') {
|
|
393
|
-
lines.push(`${indent}${keyword}: ${serializeType(source.node)}`);
|
|
394
|
-
return;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Inline param declarations: `params: { name: type }`
|
|
398
|
-
const modeStr = mode ? `mode(${mode}) ` : '';
|
|
399
|
-
lines.push(`${indent}${keyword}: ${modeStr}{`);
|
|
400
|
-
for (const param of source.nodes) {
|
|
401
|
-
const optional = param.optional ? '?' : '';
|
|
402
|
-
let typeStr = serializeType(param.type);
|
|
403
|
-
if (param.nullable && !typeContainsNull(param.type)) {
|
|
404
|
-
typeStr = `${typeStr} | null`;
|
|
405
|
-
}
|
|
406
|
-
const defaultVal = param.default !== undefined ? ` = ${serializeDefault(param.default)}` : '';
|
|
407
|
-
const comment = ctx.includeComments && param.description ? ` # ${singleLineComment(param.description)}` : '';
|
|
408
|
-
lines.push(`${INDENT.repeat(depth + 1)}${param.name}${optional}: ${typeStr}${defaultVal}${comment}`);
|
|
409
|
-
}
|
|
410
|
-
lines.push(`${indent}}`);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function serializeRequest(lines: string[], request: OpRequestNode, depth: number): void {
|
|
414
|
-
const indent = INDENT.repeat(depth);
|
|
415
|
-
lines.push(`${indent}request: {`);
|
|
416
|
-
for (const body of request.bodies) {
|
|
417
|
-
lines.push(`${INDENT.repeat(depth + 1)}${body.contentType}: ${serializeType(body.bodyType)}`);
|
|
418
|
-
}
|
|
419
|
-
lines.push(`${indent}}`);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
function serializeResponses(lines: string[], responses: OpResponseNode[], depth: number): void {
|
|
423
|
-
const indent = INDENT.repeat(depth);
|
|
424
|
-
lines.push(`${indent}response: {`);
|
|
425
|
-
for (const resp of responses) {
|
|
426
|
-
const bodies = resp.bodies;
|
|
427
|
-
const hasHeaders = resp.headers && resp.headers.length > 0;
|
|
428
|
-
if (bodies.length > 0 || hasHeaders) {
|
|
429
|
-
lines.push(`${INDENT.repeat(depth + 1)}${resp.statusCode}: {`);
|
|
430
|
-
for (const body of bodies) {
|
|
431
|
-
lines.push(`${INDENT.repeat(depth + 2)}${body.contentType}: ${serializeType(body.bodyType)}`);
|
|
432
|
-
}
|
|
433
|
-
if (hasHeaders) {
|
|
434
|
-
lines.push(`${INDENT.repeat(depth + 2)}headers: {`);
|
|
435
|
-
for (const h of resp.headers!) {
|
|
436
|
-
const opt = h.optional ? '?' : '';
|
|
437
|
-
const trail = h.description ? ` # ${singleLineComment(h.description)}` : '';
|
|
438
|
-
lines.push(`${INDENT.repeat(depth + 3)}${h.name}${opt}: ${serializeType(h.type)}${trail}`);
|
|
439
|
-
}
|
|
440
|
-
lines.push(`${INDENT.repeat(depth + 2)}}`);
|
|
441
|
-
}
|
|
442
|
-
lines.push(`${INDENT.repeat(depth + 1)}}`);
|
|
443
|
-
} else {
|
|
444
|
-
lines.push(`${INDENT.repeat(depth + 1)}${resp.statusCode}:`);
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
lines.push(`${indent}}`);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function serializeSecurityBlock(lines: string[], security: SecurityNode, depth: number, ctx: Ctx): void {
|
|
451
|
-
const indent = INDENT.repeat(depth);
|
|
452
|
-
if (security === 'none') {
|
|
453
|
-
lines.push(`${indent}security: none`);
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
const sec = security as SecurityFields;
|
|
458
|
-
if (sec.policy !== undefined) {
|
|
459
|
-
const comment = ctx.includeComments && sec.policyDescription ? ` # ${singleLineComment(sec.policyDescription)}` : '';
|
|
460
|
-
const value = sec.policy === false ? 'none' : sec.policy;
|
|
461
|
-
lines.push(`${indent}security: {`);
|
|
462
|
-
lines.push(`${INDENT.repeat(depth + 1)}policy: ${value}${comment}`);
|
|
463
|
-
lines.push(`${indent}}`);
|
|
464
|
-
} else {
|
|
465
|
-
lines.push(`${indent}security: {}`);
|
|
466
|
-
}
|
|
467
|
-
}
|
|
42
|
+
/** Render a `ContractTypeNode` to its `.ck` source string. Re-exported from core. */
|
|
43
|
+
export const serializeType = printType;
|
package/src/convert.ts
CHANGED
|
@@ -11,12 +11,13 @@ import { splitByTag, mergeIntoSingle } from './tag-splitter.js';
|
|
|
11
11
|
import { astToCk } from './ast-to-ck.js';
|
|
12
12
|
import type { NormalizedSchema } from './types.js';
|
|
13
13
|
import type { ModelNode } from '@contractkit/core';
|
|
14
|
+
import { parseCk, decomposeCk, validateRefs, DiagnosticCollector } from '@contractkit/core';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Convert an OpenAPI spec (2.0, 3.0, or 3.1) to Contract Kit .ck source files.
|
|
17
18
|
*/
|
|
18
19
|
export async function convertOpenApiToCk(options: ConvertOptions): Promise<ConvertResult> {
|
|
19
|
-
const { split = 'by-tag', includeComments = true } = options;
|
|
20
|
+
const { split = 'by-tag', includeComments = true, errorResponses = 'documented' } = options;
|
|
20
21
|
const warnings = new WarningCollector(options.onWarning);
|
|
21
22
|
|
|
22
23
|
// Step 1: Parse the input into a document object
|
|
@@ -41,6 +42,7 @@ export async function convertOpenApiToCk(options: ConvertOptions): Promise<Conve
|
|
|
41
42
|
namedSchemas: schemas,
|
|
42
43
|
extractedModels,
|
|
43
44
|
inlineCounter: 0,
|
|
45
|
+
insideModel: true,
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
const models = schemasToModels(schemas, schemaCtx);
|
|
@@ -53,24 +55,76 @@ export async function convertOpenApiToCk(options: ConvertOptions): Promise<Conve
|
|
|
53
55
|
namedSchemas: schemas,
|
|
54
56
|
extractedModels,
|
|
55
57
|
globalSecurity: doc.security,
|
|
58
|
+
errorResponses,
|
|
56
59
|
});
|
|
57
60
|
|
|
61
|
+
// Models extracted from inline request/response body schemas arrive during step 6, after
|
|
62
|
+
// `schemasToModels` has already read the array — without this they are referenced by the
|
|
63
|
+
// generated operations and never defined.
|
|
64
|
+
const known = new Set(models.map(m => m.name));
|
|
65
|
+
for (const extracted of extractedModels) {
|
|
66
|
+
if (known.has(extracted.name)) continue;
|
|
67
|
+
known.add(extracted.name);
|
|
68
|
+
models.push(extracted);
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
// Step 7: Split or merge
|
|
59
72
|
const files = new Map<string, string>();
|
|
60
73
|
|
|
61
74
|
if (split === 'by-tag') {
|
|
62
75
|
const ckRoots = splitByTag(models, routes, routeTags);
|
|
63
76
|
for (const [filename, root] of ckRoots) {
|
|
64
|
-
files.set(filename, astToCk(root
|
|
77
|
+
files.set(filename, astToCk(root));
|
|
65
78
|
}
|
|
66
79
|
} else {
|
|
67
80
|
const root = mergeIntoSingle(models, routes);
|
|
68
|
-
files.set('api.ck', astToCk(root
|
|
81
|
+
files.set('api.ck', astToCk(root));
|
|
69
82
|
}
|
|
70
83
|
|
|
84
|
+
// Step 8: Check what we are about to hand back actually compiles
|
|
85
|
+
checkGeneratedFiles(files, warnings);
|
|
86
|
+
|
|
71
87
|
return { files, warnings: warnings.warnings };
|
|
72
88
|
}
|
|
73
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Re-parse the generated files and report anything that does not survive.
|
|
92
|
+
*
|
|
93
|
+
* The converter builds core AST nodes and prints them, so a bug in either half produces `.ck`
|
|
94
|
+
* that will not compile — and, because the output is written straight to disk, the first sign of
|
|
95
|
+
* it is an error in the user's own build. Spec content is more adversarial than anything a
|
|
96
|
+
* hand-written contract contains (patterns full of punctuation, descriptions full of newlines,
|
|
97
|
+
* schema names that are not identifiers), so it is worth the round trip to find out here.
|
|
98
|
+
*
|
|
99
|
+
* Reference validation runs across all the files together, because `by-tag` deliberately splits
|
|
100
|
+
* a model into one file and its users into another. Parsing alone is not enough: a reference to
|
|
101
|
+
* a contract that was never emitted is perfectly good syntax, which is exactly how the importer
|
|
102
|
+
* shipped operations pointing at inline body models it had dropped.
|
|
103
|
+
*/
|
|
104
|
+
function checkGeneratedFiles(files: Map<string, string>, warnings: WarningCollector): void {
|
|
105
|
+
const diag = new DiagnosticCollector();
|
|
106
|
+
const roots = [];
|
|
107
|
+
for (const [filename, text] of files) {
|
|
108
|
+
const before = diag.getAll().length;
|
|
109
|
+
const root = parseCk(text, filename, diag);
|
|
110
|
+
if (diag.getAll().length === before) roots.push(root);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (roots.length === files.size) {
|
|
114
|
+
const decomposed = roots.map(decomposeCk);
|
|
115
|
+
validateRefs(
|
|
116
|
+
decomposed.map(d => d.contract),
|
|
117
|
+
decomposed.map(d => d.op),
|
|
118
|
+
diag,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const d of diag.getAll()) {
|
|
123
|
+
if (d.severity !== 'error') continue;
|
|
124
|
+
warnings.warn(d.file, `generated .ck is not valid (line ${d.line}): ${d.message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
74
128
|
// ─── Input Parsing ────────────────────────────────────────────────────────
|
|
75
129
|
|
|
76
130
|
async function parseInput(input: string | Record<string, unknown>): Promise<Record<string, unknown>> {
|