@contractkit/openapi-to-ck 0.10.2 → 0.12.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 +24 -20
- package/CHANGELOG.md +172 -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-U7V7LFBV.js} +219 -391
- package/dist/chunk-U7V7LFBV.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 +36 -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 +71 -7
- package/src/tag-splitter.ts +21 -16
- package/src/types.ts +36 -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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
"openapi": "3.1.0",
|
|
3
|
+
"info": { "title": "Kitchen Sink", "version": "1.0.0" },
|
|
4
|
+
"security": [{ "apiKey": [] }],
|
|
5
|
+
"paths": {
|
|
6
|
+
"/widgets/{widgetId}": {
|
|
7
|
+
"parameters": [{ "$ref": "#/components/parameters/WidgetId" }],
|
|
8
|
+
"get": {
|
|
9
|
+
"operationId": "getWidget",
|
|
10
|
+
"summary": "Fetch a widget } with # awkward chars",
|
|
11
|
+
"description": "Long prose.\nAcross several lines.",
|
|
12
|
+
"tags": ["widgets"],
|
|
13
|
+
"parameters": [
|
|
14
|
+
{ "$ref": "#/components/parameters/Expand" },
|
|
15
|
+
{ "name": "sid", "in": "cookie", "schema": { "type": "string" } }
|
|
16
|
+
],
|
|
17
|
+
"responses": {
|
|
18
|
+
"200": {
|
|
19
|
+
"description": "ok",
|
|
20
|
+
"content": {
|
|
21
|
+
"application/json": { "schema": { "$ref": "#/components/schemas/Widget" } },
|
|
22
|
+
"text/csv": { "schema": { "type": "string" } }
|
|
23
|
+
},
|
|
24
|
+
"headers": { "X-Rate-Limit": { "$ref": "#/components/headers/RateLimit" } }
|
|
25
|
+
},
|
|
26
|
+
"204": { "description": "no content" },
|
|
27
|
+
"304": { "description": "not modified" },
|
|
28
|
+
"404": { "$ref": "#/components/responses/NotFound" },
|
|
29
|
+
"429": { "description": "slow down" },
|
|
30
|
+
"503": { "description": "unavailable", "headers": { "Retry-After": { "schema": { "type": "integer" } } } },
|
|
31
|
+
"default": { "description": "dropped" },
|
|
32
|
+
"4XX": { "description": "also dropped" }
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"head": { "responses": { "200": { "description": "ok" } } },
|
|
36
|
+
"trace": { "responses": { "200": { "description": "ok" } } }
|
|
37
|
+
},
|
|
38
|
+
"/widgets": {
|
|
39
|
+
"post": {
|
|
40
|
+
"operationId": "createWidget",
|
|
41
|
+
"tags": ["widgets"],
|
|
42
|
+
"security": [],
|
|
43
|
+
"requestBody": {
|
|
44
|
+
"content": {
|
|
45
|
+
"application/json": { "schema": { "$ref": "#/components/schemas/Widget" } },
|
|
46
|
+
"application/vnd.api+json": { "schema": { "type": "object", "properties": { "data": { "type": "string" } } } },
|
|
47
|
+
"text/plain; charset=utf-8": { "schema": { "type": "string" } }
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"responses": { "201": { "description": "created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Widget" } } } } }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"components": {
|
|
55
|
+
"securitySchemes": { "apiKey": { "type": "apiKey", "name": "X-Key", "in": "header" } },
|
|
56
|
+
"parameters": {
|
|
57
|
+
"WidgetId": { "name": "widgetId", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" } },
|
|
58
|
+
"Expand": { "name": "expand", "in": "query", "description": "expand nested", "schema": { "type": "boolean" } }
|
|
59
|
+
},
|
|
60
|
+
"headers": { "RateLimit": { "description": "requests left", "schema": { "type": "integer" } } },
|
|
61
|
+
"responses": {
|
|
62
|
+
"NotFound": { "description": "missing", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } } } }
|
|
63
|
+
},
|
|
64
|
+
"schemas": {
|
|
65
|
+
"Widget": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"description": "A widget.\nDocumented across lines.",
|
|
68
|
+
"required": ["id", "kind"],
|
|
69
|
+
"properties": {
|
|
70
|
+
"id": { "type": "string", "format": "uuid", "readOnly": true },
|
|
71
|
+
"secret": { "type": "string", "writeOnly": true },
|
|
72
|
+
"kind": { "type": "string", "enum": ["basic", "on hold", "a \"quoted\" kind"] },
|
|
73
|
+
"datePattern": { "type": "string", "pattern": "^\\d{2}/\\d{2}$" },
|
|
74
|
+
"ttl": { "type": "string", "format": "duration" },
|
|
75
|
+
"contact": { "type": "string", "format": "idn-email" },
|
|
76
|
+
"site": { "type": "string", "format": "uri-reference" },
|
|
77
|
+
"size": { "type": "integer", "exclusiveMinimum": 0, "multipleOf": 5 },
|
|
78
|
+
"tags": { "type": "array", "items": { "type": "string" }, "minItems": 1, "uniqueItems": true },
|
|
79
|
+
"legacy": { "type": "string", "deprecated": true },
|
|
80
|
+
"nickname": { "type": ["string", "null"] },
|
|
81
|
+
"parent": { "$ref": "#/components/schemas/Widget" },
|
|
82
|
+
"shape": { "$ref": "#/components/schemas/Shape" }
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"TimestampedWidget": {
|
|
86
|
+
"allOf": [{ "$ref": "#/components/schemas/Widget" }, { "type": "object", "properties": { "createdAt": { "type": "string", "format": "date-time" } } }]
|
|
87
|
+
},
|
|
88
|
+
"Shape": {
|
|
89
|
+
"oneOf": [{ "$ref": "#/components/schemas/Circle" }, { "$ref": "#/components/schemas/Square" }],
|
|
90
|
+
"discriminator": { "propertyName": "kind" }
|
|
91
|
+
},
|
|
92
|
+
"Circle": { "type": "object", "properties": { "kind": { "const": "circle" }, "r": { "type": "number" } } },
|
|
93
|
+
"Square": { "type": "object", "properties": { "kind": { "const": "square" }, "side": { "type": "number" } } },
|
|
94
|
+
"Bag": { "type": "object", "properties": { "id": { "type": "string" } }, "additionalProperties": true },
|
|
95
|
+
"Sealed": { "type": "object", "properties": { "id": { "type": "string" } }, "additionalProperties": false },
|
|
96
|
+
"3DModel": { "type": "object", "properties": { "mesh": { "type": "string" } } },
|
|
97
|
+
"ApiError": { "type": "object", "properties": { "message": { "type": "string" } } }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
package/tests/helpers.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { parseCk, DiagnosticCollector } from '@contractkit/core';
|
|
2
|
+
import { convertOpenApiToCk } from '../src/convert.js';
|
|
3
|
+
import type { ConvertOptions, Warning } from '../src/types.js';
|
|
1
4
|
import type {
|
|
2
5
|
CkRootNode,
|
|
3
6
|
ModelNode,
|
|
@@ -157,3 +160,40 @@ export function opRoute(path: string, operations: OpOperationNode[], overrides?:
|
|
|
157
160
|
if (overrides?.params !== undefined) normalized.params = normalizeParamSource(overrides.params);
|
|
158
161
|
return { path, operations, loc: loc(), ...normalized };
|
|
159
162
|
}
|
|
163
|
+
|
|
164
|
+
// ─── Conversion round-trip ────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Convert a spec and parse the result back, asserting it is clean `.ck`.
|
|
168
|
+
*
|
|
169
|
+
* Substring assertions over the emitted text cannot tell "the output parses but means the wrong
|
|
170
|
+
* thing" from "the output is correct" — which is exactly the class of bug that let every error
|
|
171
|
+
* response import as service-produced. Asserting on the parsed AST states the meaning instead of
|
|
172
|
+
* the formatting, and re-parsing catches source the printer cannot actually express.
|
|
173
|
+
*/
|
|
174
|
+
export async function convertAndParse(options: ConvertOptions & { file?: string }): Promise<{ root: CkRootNode; ck: string; warnings: Warning[] }> {
|
|
175
|
+
const file = options.file ?? 'api.ck';
|
|
176
|
+
const result = await convertOpenApiToCk({ split: 'single', ...options });
|
|
177
|
+
const ck = result.files.get(file);
|
|
178
|
+
if (ck === undefined) {
|
|
179
|
+
throw new Error(`no ${file} in output; got: ${[...result.files.keys()].join(', ')}`);
|
|
180
|
+
}
|
|
181
|
+
const diag = new DiagnosticCollector();
|
|
182
|
+
const root = parseCk(ck, file, diag);
|
|
183
|
+
if (diag.hasErrors()) {
|
|
184
|
+
throw new Error(`generated ${file} does not parse:\n${diag.getAll().map(d => ` ${d.line}: ${d.message}`).join('\n')}\n\n${ck}`);
|
|
185
|
+
}
|
|
186
|
+
return { root, ck, warnings: result.warnings };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The single operation of a single-route conversion. */
|
|
190
|
+
export function onlyOperation(root: CkRootNode): OpOperationNode {
|
|
191
|
+
return root.routes[0]!.operations[0]!;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Look up one response by status code. */
|
|
195
|
+
export function responseFor(op: OpOperationNode, statusCode: number): OpResponseNode {
|
|
196
|
+
const resp = op.responses.find(r => r.statusCode === statusCode);
|
|
197
|
+
if (!resp) throw new Error(`no ${statusCode} response; got ${op.responses.map(r => r.statusCode).join(', ')}`);
|
|
198
|
+
return resp;
|
|
199
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve, dirname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { emittedResponses, observableResponses, thrownResponses } from '@contractkit/core';
|
|
6
|
+
import { convertAndParse, onlyOperation, responseFor } from './helpers.js';
|
|
7
|
+
import { convertOpenApiToCk } from '../src/convert.js';
|
|
8
|
+
|
|
9
|
+
const fixturesDir = resolve(dirname(fileURLToPath(import.meta.url)), 'fixtures');
|
|
10
|
+
const spec = () => JSON.parse(readFileSync(resolve(fixturesDir, 'kitchen-sink-3.1.json'), 'utf-8'));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One spec exercising the constructs that have historically broken on import — patterns with a
|
|
14
|
+
* `/`, enum values with quotes, `$ref`'d components, descriptions with newlines, statuses that
|
|
15
|
+
* must not be service-produced, schema names that are not identifiers.
|
|
16
|
+
*
|
|
17
|
+
* The text snapshot is what makes the next grammar or printer change visible in review; the
|
|
18
|
+
* assertions below it state the meaning the generators will read.
|
|
19
|
+
*/
|
|
20
|
+
describe('kitchen sink', () => {
|
|
21
|
+
it('produces .ck that parses', async () => {
|
|
22
|
+
const { ck } = await convertAndParse({ input: spec() });
|
|
23
|
+
await expect(ck).toMatchFileSnapshot('./__snapshots__/kitchen-sink.ck');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('splits by tag without losing a file', async () => {
|
|
27
|
+
const result = await convertOpenApiToCk({ input: spec(), split: 'by-tag' });
|
|
28
|
+
expect(result.warnings.filter(w => w.message.includes('does not parse'))).toEqual([]);
|
|
29
|
+
expect([...result.files.keys()].sort()).toMatchInlineSnapshot(`
|
|
30
|
+
[
|
|
31
|
+
"shared.ck",
|
|
32
|
+
"widgets.ck",
|
|
33
|
+
]
|
|
34
|
+
`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('gets the response semantics right', async () => {
|
|
38
|
+
const { root } = await convertAndParse({ input: spec() });
|
|
39
|
+
const get = onlyOperation(root);
|
|
40
|
+
|
|
41
|
+
// The service writes 200 and 204. A bare `304:` is documented, not produced — something
|
|
42
|
+
// upstream emits it — but a client still observes it, which is why it is in neither the
|
|
43
|
+
// emitted nor the thrown set.
|
|
44
|
+
expect(emittedResponses(get).map(r => r.statusCode)).toEqual([200, 204]);
|
|
45
|
+
expect(observableResponses(get).map(r => r.statusCode)).toEqual([200, 204, 304]);
|
|
46
|
+
expect(thrownResponses(get).map(r => r.statusCode)).toEqual([404, 429, 503]);
|
|
47
|
+
|
|
48
|
+
expect(responseFor(get, 200).bodies.map(b => b.contentType)).toEqual(['application/json', 'text/csv']);
|
|
49
|
+
// `Widget` is self-referential, but a response body names an already-imported model —
|
|
50
|
+
// there is no definition cycle here for `lazy()` to break.
|
|
51
|
+
expect(responseFor(get, 200).bodies[0]!.bodyType).toEqual({ kind: 'ref', name: 'Widget' });
|
|
52
|
+
expect(responseFor(get, 200).headers!.map(h => h.name)).toEqual(['X-Rate-Limit']);
|
|
53
|
+
// A `$ref`'d response resolves to the schema behind it.
|
|
54
|
+
expect(responseFor(get, 404).bodies[0]!.bodyType).toEqual({ kind: 'ref', name: 'ApiError' });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('carries the values that used to emit unparseable source', async () => {
|
|
58
|
+
const { root } = await convertAndParse({ input: spec() });
|
|
59
|
+
const widget = root.models.find(m => m.name === 'Widget')!;
|
|
60
|
+
const typeOf = (name: string) => widget.fields.find(f => f.name === name)!.type;
|
|
61
|
+
|
|
62
|
+
expect(typeOf('datePattern')).toEqual({ kind: 'scalar', name: 'string', regex: '^\\d{2}/\\d{2}$' });
|
|
63
|
+
expect(typeOf('kind')).toEqual({ kind: 'enum', values: ['basic', 'on hold', 'a "quoted" kind'] });
|
|
64
|
+
expect(typeOf('ttl')).toEqual({ kind: 'scalar', name: 'duration' });
|
|
65
|
+
expect(typeOf('contact')).toEqual({ kind: 'scalar', name: 'email' });
|
|
66
|
+
expect(typeOf('site')).toEqual({ kind: 'scalar', name: 'url' });
|
|
67
|
+
// A self-reference is a cycle, so it must be lazy or the Zod schema cannot be built.
|
|
68
|
+
expect(typeOf('parent')).toEqual({ kind: 'lazy', inner: { kind: 'ref', name: 'Widget' } });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('reports everything it had to drop', async () => {
|
|
72
|
+
const { warnings } = await convertAndParse({ input: spec() });
|
|
73
|
+
const messages = warnings.map(w => w.message);
|
|
74
|
+
for (const expected of [
|
|
75
|
+
'`head` operations',
|
|
76
|
+
'`trace` operations',
|
|
77
|
+
"'default' is not a numeric status code",
|
|
78
|
+
"'4XX' is not a numeric status code",
|
|
79
|
+
'cookie parameters',
|
|
80
|
+
'not a plain type/subtype',
|
|
81
|
+
'exclusiveMinimum',
|
|
82
|
+
'multipleOf',
|
|
83
|
+
'uniqueItems',
|
|
84
|
+
]) {
|
|
85
|
+
expect(messages.some(m => m.includes(expected)), `expected a warning containing ${expected}`).toBe(true);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('handles the model shapes', async () => {
|
|
90
|
+
const { root } = await convertAndParse({ input: spec() });
|
|
91
|
+
const byName = (n: string) => root.models.find(m => m.name === n)!;
|
|
92
|
+
|
|
93
|
+
expect(byName('TimestampedWidget').bases).toEqual(['Widget']);
|
|
94
|
+
expect(byName('Shape').type).toEqual({ kind: 'discriminatedUnion', discriminator: 'kind', members: [{ kind: 'ref', name: 'Circle' }, { kind: 'ref', name: 'Square' }] });
|
|
95
|
+
expect(byName('Bag').mode).toBe('loose');
|
|
96
|
+
expect(byName('Sealed').mode).toBeUndefined();
|
|
97
|
+
expect(byName('_3DModel')).toBeDefined();
|
|
98
|
+
// Extracted from the inline `application/vnd.api+json` request schema. Models extracted
|
|
99
|
+
// during path conversion used to be referenced by the operations and never emitted.
|
|
100
|
+
expect(byName('CreateWidgetRequest').fields.map(f => f.name)).toEqual(['data']);
|
|
101
|
+
|
|
102
|
+
const widget = byName('Widget');
|
|
103
|
+
expect(widget.fields.find(f => f.name === 'id')!.visibility).toBe('readonly');
|
|
104
|
+
expect(widget.fields.find(f => f.name === 'secret')!.visibility).toBe('writeonly');
|
|
105
|
+
expect(widget.fields.find(f => f.name === 'legacy')!.deprecated).toBe(true);
|
|
106
|
+
expect(widget.fields.find(f => f.name === 'nickname')!.nullable).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('imports the operation label and the spec-level security override', async () => {
|
|
110
|
+
const { root } = await convertAndParse({ input: spec() });
|
|
111
|
+
// `}` and ` #` would have closed the operation block early.
|
|
112
|
+
expect(onlyOperation(root).name).toBe('Fetch a widget with awkward chars');
|
|
113
|
+
const post = root.routes.find(r => r.path === '/widgets')!.operations[0]!;
|
|
114
|
+
expect(post.security).toBe('none');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -13,6 +13,7 @@ function makeCtx(overrides?: Partial<SchemaContext>): SchemaContext {
|
|
|
13
13
|
namedSchemas: {},
|
|
14
14
|
extractedModels: [],
|
|
15
15
|
inlineCounter: 0,
|
|
16
|
+
insideModel: true,
|
|
16
17
|
...overrides,
|
|
17
18
|
};
|
|
18
19
|
}
|
|
@@ -66,7 +67,7 @@ describe('schemaToTypeNode', () => {
|
|
|
66
67
|
|
|
67
68
|
it('converts string with pattern', () => {
|
|
68
69
|
const result = schemaToTypeNode({ type: 'string', pattern: '^[a-z]+$' }, makeCtx());
|
|
69
|
-
expect(result).toEqual({ kind: 'scalar', name: 'string', regex: '
|
|
70
|
+
expect(result).toEqual({ kind: 'scalar', name: 'string', regex: '^[a-z]+$' });
|
|
70
71
|
});
|
|
71
72
|
|
|
72
73
|
it('converts integer → int', () => {
|
|
@@ -224,11 +225,19 @@ describe('schemaToTypeNode', () => {
|
|
|
224
225
|
expect(result).toEqual({ kind: 'ref', name: 'User' });
|
|
225
226
|
});
|
|
226
227
|
|
|
227
|
-
it('wraps circular $ref in lazy', () => {
|
|
228
|
+
it('wraps a circular $ref in lazy inside a contract body', () => {
|
|
228
229
|
const ctx = makeCtx({ circularRefs: new Set(['TreeNode']) });
|
|
229
230
|
const result = schemaToTypeNode({ $ref: '#/components/schemas/TreeNode' }, ctx);
|
|
230
231
|
expect(result).toEqual({ kind: 'lazy', inner: { kind: 'ref', name: 'TreeNode' } });
|
|
231
232
|
});
|
|
233
|
+
|
|
234
|
+
it('leaves a circular $ref bare outside a contract body', () => {
|
|
235
|
+
// `lazy()` breaks a definition cycle between contracts. An operation names a model
|
|
236
|
+
// the generated module has already imported, so there is no cycle to break.
|
|
237
|
+
const ctx = makeCtx({ circularRefs: new Set(['TreeNode']), insideModel: false });
|
|
238
|
+
const result = schemaToTypeNode({ $ref: '#/components/schemas/TreeNode' }, ctx);
|
|
239
|
+
expect(result).toEqual({ kind: 'ref', name: 'TreeNode' });
|
|
240
|
+
});
|
|
232
241
|
});
|
|
233
242
|
|
|
234
243
|
describe('nullable', () => {
|