@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.
Files changed (42) hide show
  1. package/.turbo/turbo-build$colon$ci.log +7 -7
  2. package/.turbo/turbo-test$colon$ci.log +30 -26
  3. package/CHANGELOG.md +117 -0
  4. package/LICENSE +21 -0
  5. package/README.md +30 -8
  6. package/dist/ast-to-ck.d.ts +32 -16
  7. package/dist/ast-to-ck.d.ts.map +1 -1
  8. package/dist/{chunk-JPI3AQ7V.js → chunk-Z53MK4FM.js} +196 -390
  9. package/dist/chunk-Z53MK4FM.js.map +1 -0
  10. package/dist/convert.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/normalize.d.ts +6 -2
  13. package/dist/normalize.d.ts.map +1 -1
  14. package/dist/paths-to-ast.d.ts +2 -0
  15. package/dist/paths-to-ast.d.ts.map +1 -1
  16. package/dist/plugin.d.ts.map +1 -1
  17. package/dist/plugin.js +18 -3
  18. package/dist/plugin.js.map +1 -1
  19. package/dist/schema-to-ast.d.ts +13 -1
  20. package/dist/schema-to-ast.d.ts.map +1 -1
  21. package/dist/tag-splitter.d.ts.map +1 -1
  22. package/dist/types.d.ts +28 -0
  23. package/dist/types.d.ts.map +1 -1
  24. package/package.json +5 -5
  25. package/src/ast-to-ck.ts +29 -453
  26. package/src/convert.ts +57 -3
  27. package/src/normalize.ts +87 -11
  28. package/src/paths-to-ast.ts +92 -11
  29. package/src/plugin.ts +17 -2
  30. package/src/schema-to-ast.ts +51 -7
  31. package/src/tag-splitter.ts +21 -16
  32. package/src/types.ts +28 -0
  33. package/tests/__snapshots__/kitchen-sink.ck +102 -0
  34. package/tests/ast-to-ck.test.ts +34 -17
  35. package/tests/component-refs.test.ts +114 -0
  36. package/tests/coverage.test.ts +246 -0
  37. package/tests/error-responses.test.ts +94 -0
  38. package/tests/fixtures/kitchen-sink-3.1.json +100 -0
  39. package/tests/helpers.ts +40 -0
  40. package/tests/kitchen-sink.test.ts +116 -0
  41. package/tests/schema-to-ast.test.ts +11 -2
  42. 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: '/^[a-z]+$/' });
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', () => {