@contractkit/openapi-to-ck 0.10.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$ci.log +7 -7
- package/.turbo/turbo-test$colon$ci.log +30 -26
- package/CHANGELOG.md +109 -0
- package/README.md +30 -8
- package/dist/ast-to-ck.d.ts +32 -16
- package/dist/ast-to-ck.d.ts.map +1 -1
- package/dist/{chunk-JPI3AQ7V.js → chunk-Z53MK4FM.js} +196 -390
- package/dist/chunk-Z53MK4FM.js.map +1 -0
- package/dist/convert.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/normalize.d.ts +6 -2
- package/dist/normalize.d.ts.map +1 -1
- package/dist/paths-to-ast.d.ts +2 -0
- package/dist/paths-to-ast.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +18 -3
- package/dist/plugin.js.map +1 -1
- package/dist/schema-to-ast.d.ts +13 -1
- package/dist/schema-to-ast.d.ts.map +1 -1
- package/dist/tag-splitter.d.ts.map +1 -1
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -5
- package/src/ast-to-ck.ts +29 -453
- package/src/convert.ts +57 -3
- package/src/normalize.ts +87 -11
- package/src/paths-to-ast.ts +92 -11
- package/src/plugin.ts +17 -2
- package/src/schema-to-ast.ts +51 -7
- package/src/tag-splitter.ts +21 -16
- package/src/types.ts +28 -0
- package/tests/__snapshots__/kitchen-sink.ck +102 -0
- package/tests/ast-to-ck.test.ts +34 -17
- package/tests/component-refs.test.ts +114 -0
- package/tests/coverage.test.ts +246 -0
- package/tests/error-responses.test.ts +94 -0
- package/tests/fixtures/kitchen-sink-3.1.json +100 -0
- package/tests/helpers.ts +40 -0
- package/tests/kitchen-sink.test.ts +116 -0
- package/tests/schema-to-ast.test.ts +11 -2
- package/dist/chunk-JPI3AQ7V.js.map +0 -1
|
@@ -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
|
+
}
|
package/tests/ast-to-ck.test.ts
CHANGED
|
@@ -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(
|
|
42
|
-
expect(serializeType(scalarType('string', { regex: '
|
|
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
|
-
|
|
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('
|
|
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')
|
|
140
|
+
models: [model('User', [field('id', scalarType('uuid'))])],
|
|
132
141
|
});
|
|
133
|
-
|
|
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('
|
|
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('
|
|
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('
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
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('
|
|
515
|
-
// `.ck` string literals have no escape sequences, so a value
|
|
516
|
-
//
|
|
517
|
-
|
|
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
|
+
});
|