@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.
Files changed (41) 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 +109 -0
  4. package/README.md +30 -8
  5. package/dist/ast-to-ck.d.ts +32 -16
  6. package/dist/ast-to-ck.d.ts.map +1 -1
  7. package/dist/{chunk-JPI3AQ7V.js → chunk-Z53MK4FM.js} +196 -390
  8. package/dist/chunk-Z53MK4FM.js.map +1 -0
  9. package/dist/convert.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/normalize.d.ts +6 -2
  12. package/dist/normalize.d.ts.map +1 -1
  13. package/dist/paths-to-ast.d.ts +2 -0
  14. package/dist/paths-to-ast.d.ts.map +1 -1
  15. package/dist/plugin.d.ts.map +1 -1
  16. package/dist/plugin.js +18 -3
  17. package/dist/plugin.js.map +1 -1
  18. package/dist/schema-to-ast.d.ts +13 -1
  19. package/dist/schema-to-ast.d.ts.map +1 -1
  20. package/dist/tag-splitter.d.ts.map +1 -1
  21. package/dist/types.d.ts +28 -0
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +4 -5
  24. package/src/ast-to-ck.ts +29 -453
  25. package/src/convert.ts +57 -3
  26. package/src/normalize.ts +87 -11
  27. package/src/paths-to-ast.ts +92 -11
  28. package/src/plugin.ts +17 -2
  29. package/src/schema-to-ast.ts +51 -7
  30. package/src/tag-splitter.ts +21 -16
  31. package/src/types.ts +28 -0
  32. package/tests/__snapshots__/kitchen-sink.ck +102 -0
  33. package/tests/ast-to-ck.test.ts +34 -17
  34. package/tests/component-refs.test.ts +114 -0
  35. package/tests/coverage.test.ts +246 -0
  36. package/tests/error-responses.test.ts +94 -0
  37. package/tests/fixtures/kitchen-sink-3.1.json +100 -0
  38. package/tests/helpers.ts +40 -0
  39. package/tests/kitchen-sink.test.ts +116 -0
  40. package/tests/schema-to-ast.test.ts +11 -2
  41. package/dist/chunk-JPI3AQ7V.js.map +0 -1
@@ -0,0 +1,102 @@
1
+ # A widget.
2
+ # Documented across lines.
3
+ contract Widget: {
4
+ id: readonly uuid
5
+ secret?: writeonly string
6
+ kind: enum(basic, "on hold", 'a "quoted" kind')
7
+ datePattern?: string(regex="^\d{2}/\d{2}$")
8
+ ttl?: duration
9
+ contact?: email
10
+ site?: url
11
+ size?: int
12
+ tags?: array(string, min=1)
13
+ legacy?: deprecated string
14
+ nickname?: string | null
15
+ parent?: lazy(Widget)
16
+ shape?: Shape
17
+ }
18
+
19
+ contract TimestampedWidget: Widget & {
20
+ createdAt?: datetime
21
+ }
22
+
23
+ contract Shape: discriminated(by=kind, Circle | Square)
24
+
25
+ contract Circle: {
26
+ kind?: literal("circle")
27
+ r?: number
28
+ }
29
+
30
+ contract Square: {
31
+ kind?: literal("square")
32
+ side?: number
33
+ }
34
+
35
+ contract mode(loose) Bag: {
36
+ id?: string
37
+ }
38
+
39
+ contract Sealed: {
40
+ id?: string
41
+ }
42
+
43
+ contract _3DModel: {
44
+ mesh?: string
45
+ }
46
+
47
+ contract ApiError: {
48
+ message?: string
49
+ }
50
+
51
+ contract CreateWidgetRequest: {
52
+ data?: string
53
+ }
54
+
55
+ operation /widgets/{widgetId}: {
56
+ params: {
57
+ widgetId: uuid
58
+ }
59
+ get: { # Long prose. Across several lines.
60
+ name: Fetch a widget with awkward chars
61
+ sdk: getWidget
62
+ query: {
63
+ expand?: boolean # expand nested
64
+ }
65
+ response: {
66
+ 200: {
67
+ application/json: Widget
68
+ text/csv: string
69
+ headers: {
70
+ X-Rate-Limit?: int # requests left
71
+ }
72
+ }
73
+ 204:
74
+ 304:
75
+ 404(documented): {
76
+ application/json: ApiError
77
+ }
78
+ 429:
79
+ 503(documented): {
80
+ headers: {
81
+ Retry-After?: int
82
+ }
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ operation /widgets: {
89
+ post: {
90
+ sdk: createWidget
91
+ security: none
92
+ request: {
93
+ application/json: Widget
94
+ application/vnd.api+json: CreateWidgetRequest
95
+ }
96
+ response: {
97
+ 201: {
98
+ application/json: Widget
99
+ }
100
+ }
101
+ }
102
+ }
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { parseCk, DiagnosticCollector } from '@contractkit/core';
3
3
  import { astToCk, serializeType } from '../src/ast-to-ck.js';
4
+ import { isUnquotable } from '@contractkit/core';
4
5
  import {
5
6
  ckRoot,
6
7
  model,
@@ -38,8 +39,10 @@ describe('serializeType', () => {
38
39
 
39
40
  it('serializes scalar types with constraints', () => {
40
41
  expect(serializeType(scalarType('string', { min: 1, max: 100 }))).toBe('string(min=1, max=100)');
41
- expect(serializeType(scalarType('string', { len: 3 }))).toBe('string(length=3)');
42
- expect(serializeType(scalarType('string', { regex: '/^[a-z]+$/' }))).toBe('string(regex=/^[a-z]+$/)');
42
+ expect(serializeType(scalarType('string', { len: 3 }))).toBe('string(len=3)');
43
+ expect(serializeType(scalarType('string', { regex: '^[a-z]+$' }))).toBe('string(regex=/^[a-z]+$/)');
44
+ // A pattern containing the `/` delimiter cannot be a regex literal, so it prints quoted.
45
+ expect(serializeType(scalarType('string', { regex: '^\\d{2}/\\d{2}$' }))).toBe('string(regex="^\\d{2}/\\d{2}$")');
43
46
  expect(serializeType(scalarType('int', { min: 0 }))).toBe('int(min=0)');
44
47
  expect(serializeType(scalarType('int', { min: 0, max: 100 }))).toBe('int(min=0, max=100)');
45
48
  });
@@ -122,16 +125,21 @@ describe('model serialization', () => {
122
125
  models: [model('User', [field('id', scalarType('uuid'), { description: 'The user ID' })], { description: 'A user' })],
123
126
  });
124
127
  const result = astToCk(root);
125
- expect(result).toContain('contract User: { # A user');
128
+ // A model description is a doc-comment block above the declaration; a field description
129
+ // is a trailing comment. That asymmetry is the printer's, and matches how `.ck` sources
130
+ // are written by hand.
131
+ expect(result).toContain('# A user\ncontract User: {');
126
132
  expect(result).toContain(' id: uuid # The user ID');
127
133
  });
128
134
 
129
- it('omits comments when includeComments is false', () => {
135
+ it('prints no comments when the AST carries no descriptions', () => {
136
+ // `includeComments: false` is honoured upstream — `schema-to-ast.ts` and `paths-to-ast.ts`
137
+ // never set `description` when it is off — so by the time the printer runs there is
138
+ // nothing to suppress. `convert.test.ts` covers the flag end to end.
130
139
  const root = ckRoot({
131
- models: [model('User', [field('id', scalarType('uuid'), { description: 'The user ID' })], { description: 'A user' })],
140
+ models: [model('User', [field('id', scalarType('uuid'))])],
132
141
  });
133
- const result = astToCk(root, { includeComments: false });
134
- expect(result).not.toContain('#');
142
+ expect(astToCk(root)).not.toContain('#');
135
143
  });
136
144
 
137
145
  it('serializes model with default values', () => {
@@ -442,7 +450,7 @@ describe('full document', () => {
442
450
  expect(result).toContain('LedgerService: "#src/modules/ledger/ledger.service.js"');
443
451
 
444
452
  // Model
445
- expect(result).toContain('contract LedgerAccount: { # A ledger account');
453
+ expect(result).toContain('# A ledger account\ncontract LedgerAccount: {');
446
454
  expect(result).toContain(' id: readonly uuid # The account ID');
447
455
 
448
456
  // Route
@@ -461,15 +469,18 @@ function parsesCleanly(source: string): boolean {
461
469
  }
462
470
 
463
471
  describe('round-trip: multi-line descriptions', () => {
464
- it('flattens a multi-line model description into a single-line trailing comment', () => {
472
+ it('splits a multi-line model description across doc-comment lines', () => {
465
473
  const root = ckRoot({
466
474
  models: [model('User', [field('id', scalarType('uuid'))], { description: 'A user.\nSpans multiple\nlines.' })],
467
475
  });
468
476
  const result = astToCk(root);
469
- expect(result).toContain('contract User: { # A user. Spans multiple lines.');
470
- // The comment line must not contain an embedded newline that leaks source.
471
- const commentLine = result.split('\n').find(l => l.includes('contract User'))!;
472
- expect(commentLine).toBe('contract User: { # A user. Spans multiple lines.');
477
+ expect(result).toContain('# A user.\n# Spans multiple\n# lines.\ncontract User: {');
478
+ // Every emitted comment must stay on one line — an embedded newline would terminate the
479
+ // comment and leak the remainder as raw source.
480
+ for (const line of result.split('\n')) {
481
+ if (line.trimStart().startsWith('#')) expect(line).not.toContain('\n');
482
+ }
483
+ expect(parsesCleanly(result)).toBe(true);
473
484
  });
474
485
 
475
486
  it('flattens a multi-line field description', () => {
@@ -511,10 +522,16 @@ describe('round-trip: enum values needing quotes', () => {
511
522
  expect(serializeType(enumType('a "quoted" value'))).toBe(`enum('a "quoted" value')`);
512
523
  });
513
524
 
514
- it('keeps double quotes when the value contains both quote styles', () => {
515
- // `.ck` string literals have no escape sequences, so a value with both
516
- // quote styles cannot round-trip; the serializer keeps double quotes.
517
- expect(serializeType(enumType(`a "b" 'c'`))).toBe(`enum("a "b" 'c'")`);
525
+ it('still emits parseable source when the value contains both quote styles', () => {
526
+ // `.ck` string literals have no escape sequences, so a value carrying both quote styles
527
+ // is unrepresentable. It cannot come from `parseCk` for that same reason, so this only
528
+ // arises from a programmatically built AST — the printer degrades the value rather than
529
+ // emit source that will not parse. `isUnquotable` lets a producer warn before that.
530
+ expect(isUnquotable(`a "b" 'c'`)).toBe(true);
531
+ const root = ckRoot({
532
+ models: [model('Task', [field('status', enumType(`a "b" 'c'`))])],
533
+ });
534
+ expect(parsesCleanly(astToCk(root))).toBe(true);
518
535
  });
519
536
 
520
537
  it('re-parses cleanly with space-containing enum values', () => {
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { convertAndParse, onlyOperation, responseFor } from './helpers.js';
3
+
4
+ /**
5
+ * Only `#/components/schemas/*` refs survive into `.ck` as model references. Every other
6
+ * component section has no `.ck` counterpart and nothing downstream resolved it, so a `$ref`'d
7
+ * parameter used to reach the printer with no name and emit `undefined: string` — which parses,
8
+ * making the corruption invisible.
9
+ */
10
+ describe('component $ref inlining', () => {
11
+ const base = {
12
+ openapi: '3.1.0',
13
+ info: { title: 'T', version: '1.0' },
14
+ components: {
15
+ schemas: { Pet: { type: 'object', properties: { id: { type: 'string' } } } },
16
+ parameters: {
17
+ Limit: { name: 'limit', in: 'query', description: 'page size', schema: { type: 'integer' } },
18
+ TraceId: { name: 'x-trace-id', in: 'header', required: true, schema: { type: 'string' } },
19
+ },
20
+ headers: { RateLimit: { description: 'requests left', schema: { type: 'integer' } } },
21
+ responses: {
22
+ NotFound: { description: 'missing', content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } } },
23
+ },
24
+ requestBodies: {
25
+ PetBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Pet' } } } },
26
+ },
27
+ },
28
+ };
29
+
30
+ it('inlines a $ref parameter instead of emitting a nameless one', async () => {
31
+ const { root, warnings } = await convertAndParse({
32
+ input: {
33
+ ...base,
34
+ paths: {
35
+ '/pets': {
36
+ get: {
37
+ operationId: 'listPets',
38
+ parameters: [{ $ref: '#/components/parameters/Limit' }, { $ref: '#/components/parameters/TraceId' }],
39
+ responses: { '200': { description: 'ok' } },
40
+ },
41
+ },
42
+ },
43
+ },
44
+ });
45
+ const op = onlyOperation(root);
46
+ expect(op.query).toEqual(expect.objectContaining({ kind: 'params' }));
47
+ const query = op.query as { kind: 'params'; nodes: { name: string; optional: boolean }[] };
48
+ expect(query.nodes.map(n => n.name)).toEqual(['limit']);
49
+ expect(query.nodes[0]!.optional).toBe(true);
50
+
51
+ const headers = op.headers as { kind: 'params'; nodes: { name: string; optional: boolean }[] };
52
+ expect(headers.nodes.map(n => n.name)).toEqual(['x-trace-id']);
53
+ expect(headers.nodes[0]!.optional).toBe(false);
54
+
55
+ expect(warnings.filter(w => w.message.includes('no name'))).toHaveLength(0);
56
+ });
57
+
58
+ it('inlines a $ref response and request body, keeping the schema ref a model ref', async () => {
59
+ const { root, ck } = await convertAndParse({
60
+ input: {
61
+ ...base,
62
+ paths: {
63
+ '/pets': {
64
+ post: {
65
+ operationId: 'createPet',
66
+ requestBody: { $ref: '#/components/requestBodies/PetBody' },
67
+ responses: { '201': { description: 'made' }, '404': { $ref: '#/components/responses/NotFound' } },
68
+ },
69
+ },
70
+ },
71
+ },
72
+ });
73
+ const op = onlyOperation(root);
74
+ expect(op.request!.bodies[0]!.bodyType).toEqual({ kind: 'ref', name: 'Pet' });
75
+ expect(responseFor(op, 404).bodies[0]!.bodyType).toEqual({ kind: 'ref', name: 'Pet' });
76
+ expect(ck).not.toContain('$ref');
77
+ });
78
+
79
+ it('inlines a $ref response header', async () => {
80
+ const { root } = await convertAndParse({
81
+ input: {
82
+ ...base,
83
+ paths: {
84
+ '/pets': {
85
+ get: {
86
+ operationId: 'listPets',
87
+ responses: { '200': { description: 'ok', headers: { 'X-Rate-Limit': { $ref: '#/components/headers/RateLimit' } } } },
88
+ },
89
+ },
90
+ },
91
+ },
92
+ });
93
+ expect(responseFor(onlyOperation(root), 200).headers!.map(h => h.name)).toEqual(['X-Rate-Limit']);
94
+ });
95
+
96
+ it('warns and drops rather than emitting a nameless parameter for an unresolvable $ref', async () => {
97
+ const { root, warnings } = await convertAndParse({
98
+ input: {
99
+ ...base,
100
+ paths: {
101
+ '/pets': {
102
+ get: {
103
+ operationId: 'listPets',
104
+ parameters: [{ $ref: '#/components/parameters/Nope' }],
105
+ responses: { '200': { description: 'ok' } },
106
+ },
107
+ },
108
+ },
109
+ },
110
+ });
111
+ expect(warnings.some(w => w.message.includes("unresolved $ref '#/components/parameters/Nope'"))).toBe(true);
112
+ expect(onlyOperation(root).query).toBeUndefined();
113
+ });
114
+ });
@@ -0,0 +1,246 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { convertAndParse, onlyOperation, responseFor } from './helpers.js';
3
+ import { schemaToTypeNode } from '../src/schema-to-ast.js';
4
+ import { WarningCollector } from '../src/warnings.js';
5
+ import type { SchemaContext } from '../src/schema-to-ast.js';
6
+
7
+ function schemaCtx(warnings = new WarningCollector()): SchemaContext {
8
+ return { circularRefs: new Set(), warnings, path: '#/x', includeComments: true, namedSchemas: {}, extractedModels: [], inlineCounter: 0, insideModel: true };
9
+ }
10
+
11
+ const spec = (pathItem: Record<string, unknown>, extra: Record<string, unknown> = {}) => ({
12
+ openapi: '3.1.0',
13
+ info: { title: 'T', version: '1.0' },
14
+ paths: { '/pets': pathItem },
15
+ ...extra,
16
+ });
17
+
18
+ const ok = { '200': { description: 'ok' } };
19
+
20
+ describe('operation name', () => {
21
+ it('takes `name:` from summary, keeping description as the doc comment', async () => {
22
+ const { root } = await convertAndParse({
23
+ input: spec({ get: { operationId: 'listPets', summary: 'List every pet', description: 'Long prose.', responses: ok } }),
24
+ });
25
+ const op = onlyOperation(root);
26
+ expect(op.name).toBe('List every pet');
27
+ expect(op.sdk).toBe('listPets');
28
+ expect(op.description).toBe('Long prose.');
29
+ });
30
+
31
+ it('sanitizes a summary that `nameText` could not carry', async () => {
32
+ // `nameText` runs to end of line and stops at `}` or a whitespace-preceded `#`. An
33
+ // unsanitized summary would close the operation block early and mis-parse the rest.
34
+ const { root } = await convertAndParse({
35
+ input: spec({ get: { operationId: 'x', summary: 'Close } the brace\nand # comment', responses: ok } }),
36
+ });
37
+ expect(onlyOperation(root).name).toBe('Close the brace and comment');
38
+ });
39
+ });
40
+
41
+ describe('request content types', () => {
42
+ it('keeps a content type outside the three that used to be allowed', async () => {
43
+ const { root } = await convertAndParse({
44
+ input: spec({
45
+ post: {
46
+ operationId: 'upload',
47
+ requestBody: { content: { 'text/csv': { schema: { type: 'string' } }, 'application/vnd.api+json': { schema: { type: 'object', properties: { a: { type: 'string' } } } } } },
48
+ responses: ok,
49
+ },
50
+ }),
51
+ });
52
+ expect(onlyOperation(root).request!.bodies.map(b => b.contentType)).toEqual(['text/csv', 'application/vnd.api+json']);
53
+ });
54
+
55
+ it('warns and skips a mime the grammar cannot express', async () => {
56
+ const { warnings } = await convertAndParse({
57
+ input: spec({
58
+ post: { operationId: 'upload', requestBody: { content: { 'text/plain; charset=utf-8': { schema: { type: 'string' } } } }, responses: ok },
59
+ }),
60
+ });
61
+ expect(warnings.some(w => w.message.includes('not a plain type/subtype'))).toBe(true);
62
+ });
63
+ });
64
+
65
+ describe('warnings for constructs with no .ck equivalent', () => {
66
+ it('warns on head/options/trace rather than dropping them silently', async () => {
67
+ const { warnings } = await convertAndParse({
68
+ input: spec({ get: { operationId: 'x', responses: ok }, head: { responses: ok }, trace: { responses: ok } }),
69
+ });
70
+ expect(warnings.some(w => w.message.includes('`head` operations'))).toBe(true);
71
+ expect(warnings.some(w => w.message.includes('`trace` operations'))).toBe(true);
72
+ });
73
+
74
+ it('warns on a non-numeric response key', async () => {
75
+ const { warnings } = await convertAndParse({
76
+ input: spec({ get: { operationId: 'x', responses: { ...ok, default: { description: 'fallback' }, '4XX': { description: 'client' } } } }),
77
+ });
78
+ expect(warnings.some(w => w.message.includes("'default' is not a numeric status code"))).toBe(true);
79
+ expect(warnings.some(w => w.message.includes("'4XX' is not a numeric status code"))).toBe(true);
80
+ });
81
+
82
+ it('warns on a cookie parameter', async () => {
83
+ const { warnings } = await convertAndParse({
84
+ input: spec({ get: { operationId: 'x', parameters: [{ name: 'sid', in: 'cookie', schema: { type: 'string' } }], responses: ok } }),
85
+ });
86
+ expect(warnings.some(w => w.message.includes('cookie parameters'))).toBe(true);
87
+ });
88
+
89
+ it('warns on numeric constraints `.ck` cannot express', () => {
90
+ const warnings = new WarningCollector();
91
+ schemaToTypeNode({ type: 'integer', exclusiveMinimum: 0, multipleOf: 5 }, schemaCtx(warnings));
92
+ expect(warnings.warnings.map(w => w.message)).toEqual([
93
+ expect.stringContaining('exclusiveMinimum'),
94
+ expect.stringContaining('multipleOf'),
95
+ ]);
96
+ });
97
+ });
98
+
99
+ describe('schema coverage', () => {
100
+ it('maps duration and the uri/email format aliases', () => {
101
+ const ctx = schemaCtx();
102
+ expect(schemaToTypeNode({ type: 'string', format: 'duration' }, ctx)).toEqual({ kind: 'scalar', name: 'duration' });
103
+ expect(schemaToTypeNode({ type: 'string', format: 'idn-email' }, ctx)).toEqual({ kind: 'scalar', name: 'email' });
104
+ expect(schemaToTypeNode({ type: 'string', format: 'uri-reference' }, ctx)).toEqual({ kind: 'scalar', name: 'url' });
105
+ });
106
+
107
+ it('imports additionalProperties: true as mode(loose)', async () => {
108
+ const { root } = await convertAndParse({
109
+ input: spec({ get: { operationId: 'x', responses: ok } }, {
110
+ components: { schemas: { Bag: { type: 'object', properties: { id: { type: 'string' } }, additionalProperties: true } } },
111
+ }),
112
+ });
113
+ expect(root.models.find(m => m.name === 'Bag')!.mode).toBe('loose');
114
+ });
115
+
116
+ it('leaves additionalProperties: false at the strict default', async () => {
117
+ const { root } = await convertAndParse({
118
+ input: spec({ get: { operationId: 'x', responses: ok } }, {
119
+ components: { schemas: { Sealed: { type: 'object', properties: { id: { type: 'string' } }, additionalProperties: false } } },
120
+ }),
121
+ });
122
+ expect(root.models.find(m => m.name === 'Sealed')!.mode).toBeUndefined();
123
+ });
124
+ });
125
+
126
+ describe('spec-level security', () => {
127
+ // `.ck` treats an absent security block as "secured, default policy", so a non-empty OpenAPI
128
+ // requirement is the default and prints nothing. The case that carries information is the
129
+ // opposite one: a spec that is globally *unsecured*, which used to be dropped because
130
+ // `globalSecurity` was collected and never read.
131
+ it('applies a spec-level `security: []` to an operation that does not override it', async () => {
132
+ const { root } = await convertAndParse({
133
+ input: spec({ get: { operationId: 'x', responses: ok }, post: { operationId: 'y', security: [{ apiKey: [] }], responses: ok } }, {
134
+ security: [],
135
+ components: { securitySchemes: { apiKey: { type: 'apiKey', name: 'k', in: 'header' } } },
136
+ }),
137
+ });
138
+ const [get, post] = root.routes[0]!.operations;
139
+ expect(get!.security).toBe('none');
140
+ expect(post!.security).toBeUndefined();
141
+ });
142
+ });
143
+
144
+ describe('schema name sanitization', () => {
145
+ it('prefixes a name that would start with a digit', async () => {
146
+ // `identStart` excludes digits, so "3DModel" would otherwise emit a name the parser rejects.
147
+ const { root } = await convertAndParse({
148
+ input: spec({ get: { operationId: 'x', responses: ok } }, {
149
+ components: { schemas: { '3DModel': { type: 'object', properties: { id: { type: 'string' } } } } },
150
+ }),
151
+ });
152
+ expect(root.models.map(m => m.name)).toContain('_3DModel');
153
+ });
154
+ });
155
+
156
+ describe('tag splitting', () => {
157
+ it('files a model reached only from a query param under its own tag', async () => {
158
+ // `collectParamSourceRefs` was written against the pre-tagged-union `ParamSource`, so a
159
+ // `kind: 'params'` source collected nothing and the model fell through to shared.ck.
160
+ const result = await (await import('../src/convert.js')).convertOpenApiToCk({
161
+ input: {
162
+ openapi: '3.1.0',
163
+ info: { title: 'T', version: '1.0' },
164
+ paths: {
165
+ '/pets': {
166
+ get: {
167
+ operationId: 'listPets',
168
+ tags: ['pets'],
169
+ parameters: [{ name: 'filter', in: 'query', schema: { $ref: '#/components/schemas/PetFilter' } }],
170
+ responses: ok,
171
+ },
172
+ },
173
+ },
174
+ components: { schemas: { PetFilter: { type: 'object', properties: { status: { type: 'string' } } } } },
175
+ },
176
+ split: 'by-tag',
177
+ });
178
+ expect(result.files.get('pets.ck')).toContain('contract PetFilter:');
179
+ expect(result.files.has('shared.ck')).toBe(false);
180
+ });
181
+ });
182
+
183
+ describe('lazy() placement', () => {
184
+ const recursive = {
185
+ components: {
186
+ schemas: {
187
+ TreeNode: {
188
+ type: 'object',
189
+ properties: { id: { type: 'string' }, children: { type: 'array', items: { $ref: '#/components/schemas/TreeNode' } } },
190
+ },
191
+ },
192
+ },
193
+ };
194
+
195
+ it('wraps a self-reference inside the contract, but not the response body naming it', async () => {
196
+ const { root, ck } = await convertAndParse({
197
+ input: spec({
198
+ get: {
199
+ operationId: 'getTree',
200
+ responses: { '200': { description: 'ok', content: { 'application/json': { schema: { $ref: '#/components/schemas/TreeNode' } } } } },
201
+ },
202
+ }, recursive),
203
+ });
204
+
205
+ // Inside the contract the cycle is real and `topoSortModels` cannot order it.
206
+ const children = root.models.find(m => m.name === 'TreeNode')!.fields.find(f => f.name === 'children')!;
207
+ expect(children.type).toEqual({ kind: 'array', item: { kind: 'lazy', inner: { kind: 'ref', name: 'TreeNode' } } });
208
+
209
+ // In the operation the model is already imported and evaluated.
210
+ expect(responseFor(onlyOperation(root), 200).bodies[0]!.bodyType).toEqual({ kind: 'ref', name: 'TreeNode' });
211
+ expect(ck).toContain('application/json: TreeNode');
212
+ });
213
+
214
+ it('leaves request bodies, params and response headers bare too', async () => {
215
+ const { root } = await convertAndParse({
216
+ input: spec({
217
+ post: {
218
+ operationId: 'putTree',
219
+ parameters: [{ name: 'filter', in: 'query', schema: { $ref: '#/components/schemas/TreeNode' } }],
220
+ requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/TreeNode' } } } },
221
+ responses: ok,
222
+ },
223
+ }, recursive),
224
+ });
225
+ const op = onlyOperation(root);
226
+ expect(op.request!.bodies[0]!.bodyType).toEqual({ kind: 'ref', name: 'TreeNode' });
227
+ expect((op.query as { kind: 'params'; nodes: { type: unknown }[] }).nodes[0]!.type).toEqual({ kind: 'ref', name: 'TreeNode' });
228
+ });
229
+
230
+ it('still wraps inside a model extracted from an inline body schema', async () => {
231
+ // The extracted model is a contract body like any other.
232
+ const { root } = await convertAndParse({
233
+ input: spec({
234
+ post: {
235
+ operationId: 'putTree',
236
+ requestBody: {
237
+ content: { 'application/json': { schema: { type: 'object', properties: { root: { $ref: '#/components/schemas/TreeNode' } } } } },
238
+ },
239
+ responses: ok,
240
+ },
241
+ }, recursive),
242
+ });
243
+ const extracted = root.models.find(m => m.name === 'PutTreeRequest')!;
244
+ expect(extracted.fields.find(f => f.name === 'root')!.type).toEqual({ kind: 'lazy', inner: { kind: 'ref', name: 'TreeNode' } });
245
+ });
246
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { emittedResponses, thrownResponses } from '@contractkit/core';
3
+ import { convertAndParse, onlyOperation, responseFor } from './helpers.js';
4
+
5
+ /**
6
+ * OpenAPI cannot say whether a handler *returns* a status or merely documents it; `.ck` can, and
7
+ * every generator downstream depends on the answer. A bodied `404` imported as service-produced
8
+ * makes the generated router responsible for writing it and makes the SDKs hand it back as a
9
+ * value instead of throwing — wrong for what is almost always an error contract.
10
+ *
11
+ * These assert on the parsed AST and on core's own response sets rather than on the emitted
12
+ * text, so they check the meaning the generators will see.
13
+ */
14
+
15
+ function specWith(responses: Record<string, unknown>) {
16
+ return {
17
+ openapi: '3.1.0',
18
+ info: { title: 'T', version: '1.0' },
19
+ paths: { '/pets': { get: { operationId: 'listPets', responses } } },
20
+ components: {
21
+ schemas: {
22
+ Pet: { type: 'object', properties: { id: { type: 'string' } } },
23
+ Error: { type: 'object', properties: { message: { type: 'string' } } },
24
+ },
25
+ },
26
+ };
27
+ }
28
+
29
+ const json = (ref: string) => ({ description: 'd', content: { 'application/json': { schema: { $ref: `#/components/schemas/${ref}` } } } });
30
+
31
+ describe('imported error responses', () => {
32
+ it('marks a bodied 4xx/5xx as documented, leaving 2xx emitted', async () => {
33
+ const { root, ck } = await convertAndParse({
34
+ input: specWith({ '200': json('Pet'), '404': json('Error'), '500': json('Error') }),
35
+ });
36
+ const op = onlyOperation(root);
37
+
38
+ expect(responseFor(op, 200).emit).toBeUndefined();
39
+ expect(responseFor(op, 404).emit).toBe('documented');
40
+ expect(responseFor(op, 500).emit).toBe('documented');
41
+
42
+ // The service produces only the 200; the error bodies reach a client as thrown errors.
43
+ expect(emittedResponses(op).map(r => r.statusCode)).toEqual([200]);
44
+ expect(thrownResponses(op).map(r => r.statusCode)).toEqual([404, 500]);
45
+
46
+ // The modifier is lexical — `404 (documented)` with a space is a parse error.
47
+ expect(ck).toContain('404(documented): {');
48
+ });
49
+
50
+ it('leaves a bare bodyless 4xx alone, so the modifier is never redundant', async () => {
51
+ const { root, ck } = await convertAndParse({
52
+ input: specWith({ '200': json('Pet'), '429': { description: 'slow down' } }),
53
+ });
54
+ const op = onlyOperation(root);
55
+
56
+ // Already not emitted without the marker; `isRedundantDocumented` would warn on it.
57
+ expect(responseFor(op, 429).emit).toBeUndefined();
58
+ expect(responseFor(op, 429).hasBlock).toBeFalsy();
59
+ expect(ck).not.toContain('429(documented)');
60
+ expect(thrownResponses(op).map(r => r.statusCode)).toEqual([429]);
61
+ });
62
+
63
+ it('leaves 3xx emitted, since a client observes it either way', async () => {
64
+ const { root } = await convertAndParse({
65
+ input: specWith({ '200': json('Pet'), '304': { description: 'not modified' } }),
66
+ });
67
+ const op = onlyOperation(root);
68
+ expect(responseFor(op, 304).emit).toBeUndefined();
69
+ });
70
+
71
+ it('documents a bodyless error status that carries only headers', async () => {
72
+ const { root } = await convertAndParse({
73
+ input: specWith({
74
+ '200': json('Pet'),
75
+ '503': { description: 'unavailable', headers: { 'Retry-After': { schema: { type: 'integer' } } } },
76
+ }),
77
+ });
78
+ const op = onlyOperation(root);
79
+ // Headers force a block, which without the marker would mean "the service produces this".
80
+ expect(responseFor(op, 503).emit).toBe('documented');
81
+ expect(thrownResponses(op).map(r => r.statusCode)).toEqual([503]);
82
+ });
83
+
84
+ it('reproduces the old behaviour under errorResponses: emitted', async () => {
85
+ const { root, ck } = await convertAndParse({
86
+ input: specWith({ '200': json('Pet'), '404': json('Error') }),
87
+ errorResponses: 'emitted',
88
+ });
89
+ const op = onlyOperation(root);
90
+ expect(responseFor(op, 404).emit).toBeUndefined();
91
+ expect(emittedResponses(op).map(r => r.statusCode)).toEqual([200, 404]);
92
+ expect(ck).not.toContain('(documented)');
93
+ });
94
+ });