@contractkit/plugin-openapi 0.8.1
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 +35 -0
- package/.turbo/turbo-build.log +15 -0
- package/.turbo/turbo-test$colon$ci.log +105 -0
- package/.turbo/turbo-test.log +14 -0
- package/CHANGELOG.md +103 -0
- package/README.md +78 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/clover.xml +403 -0
- package/coverage/coverage-final.json +3 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +131 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/src/codegen-openapi.ts.html +2308 -0
- package/coverage/src/index.html +116 -0
- package/coverage/tests/helpers.ts.html +616 -0
- package/coverage/tests/index.html +116 -0
- package/dist/codegen-openapi.d.ts +44 -0
- package/dist/codegen-openapi.d.ts.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +701 -0
- package/dist/index.js.map +1 -0
- package/dist/src/codegen-openapi.d.ts +38 -0
- package/dist/src/codegen-openapi.d.ts.map +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/tests/codegen-openapi.test.d.ts +2 -0
- package/dist/tests/codegen-openapi.test.d.ts.map +1 -0
- package/dist/tests/helpers.d.ts +31 -0
- package/dist/tests/helpers.d.ts.map +1 -0
- package/eslint.config.js +6 -0
- package/package.json +43 -0
- package/src/codegen-openapi.ts +741 -0
- package/src/index.ts +43 -0
- package/tests/codegen-openapi.test.ts +767 -0
- package/tests/helpers.ts +177 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseCk, decomposeCk, applyOptionsDefaults, DiagnosticCollector } from '@contractkit/core';
|
|
3
|
+
import { generateOpenApi, toYaml } from '../src/codegen-openapi.js';
|
|
4
|
+
import {
|
|
5
|
+
scalarType,
|
|
6
|
+
arrayType,
|
|
7
|
+
enumType,
|
|
8
|
+
refType,
|
|
9
|
+
unionType,
|
|
10
|
+
discriminatedUnionType,
|
|
11
|
+
inlineObjectType,
|
|
12
|
+
literalType,
|
|
13
|
+
recordType,
|
|
14
|
+
tupleType,
|
|
15
|
+
field,
|
|
16
|
+
model,
|
|
17
|
+
contractRoot,
|
|
18
|
+
opParam,
|
|
19
|
+
opRequest,
|
|
20
|
+
opResponse,
|
|
21
|
+
opOperation,
|
|
22
|
+
opRoute,
|
|
23
|
+
opRoot,
|
|
24
|
+
} from './helpers.js';
|
|
25
|
+
|
|
26
|
+
// ─── YAML serializer ──────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
describe('toYaml', () => {
|
|
29
|
+
it('serializes simple scalars', () => {
|
|
30
|
+
expect(toYaml('hello')).toBe('hello');
|
|
31
|
+
expect(toYaml(42)).toBe('42');
|
|
32
|
+
expect(toYaml(true)).toBe('true');
|
|
33
|
+
expect(toYaml(null)).toBe('null');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('quotes strings that look like YAML reserved words', () => {
|
|
37
|
+
expect(toYaml('true')).toBe("'true'");
|
|
38
|
+
expect(toYaml('null')).toBe("'null'");
|
|
39
|
+
expect(toYaml('yes')).toBe("'yes'");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('quotes empty strings', () => {
|
|
43
|
+
expect(toYaml('')).toBe("''");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('quotes strings starting with digits', () => {
|
|
47
|
+
expect(toYaml('3.1.0')).toBe("'3.1.0'");
|
|
48
|
+
expect(toYaml('0.0.1')).toBe("'0.0.1'");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('serializes flat objects', () => {
|
|
52
|
+
const result = toYaml({ name: 'test', count: 5 });
|
|
53
|
+
expect(result).toContain('name: test');
|
|
54
|
+
expect(result).toContain('count: 5');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('serializes nested objects', () => {
|
|
58
|
+
const result = toYaml({ info: { title: 'API', version: '1.0' } });
|
|
59
|
+
expect(result).toContain('info:');
|
|
60
|
+
expect(result).toContain(' title: API');
|
|
61
|
+
expect(result).toContain(" version: '1.0'");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('serializes simple arrays inline', () => {
|
|
65
|
+
const result = toYaml({ required: ['id', 'name'] });
|
|
66
|
+
expect(result).toContain('required: [id, name]');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('serializes object arrays as block sequences', () => {
|
|
70
|
+
const result = toYaml({
|
|
71
|
+
servers: [{ url: 'https://api.example.com', description: 'Production' }],
|
|
72
|
+
});
|
|
73
|
+
expect(result).toContain('servers:');
|
|
74
|
+
expect(result).toContain("- url: 'https://api.example.com'");
|
|
75
|
+
expect(result).toContain(' description: Production');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ─── generateOpenApi ──────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe('generateOpenApi', () => {
|
|
82
|
+
// ─── Basic structure ─────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
describe('document structure', () => {
|
|
85
|
+
it('generates openapi 3.1.0 header', () => {
|
|
86
|
+
const output = generateOpenApi({
|
|
87
|
+
contractRoots: [],
|
|
88
|
+
opRoots: [],
|
|
89
|
+
config: {},
|
|
90
|
+
});
|
|
91
|
+
expect(output).toContain("openapi: '3.1.0'");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('uses config info values', () => {
|
|
95
|
+
const output = generateOpenApi({
|
|
96
|
+
contractRoots: [],
|
|
97
|
+
opRoots: [],
|
|
98
|
+
config: {
|
|
99
|
+
info: { title: 'My API', version: '2.0.0', description: 'A test API' },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
expect(output).toContain("title: 'My API'");
|
|
103
|
+
expect(output).toContain("version: '2.0.0'");
|
|
104
|
+
expect(output).toContain("description: 'A test API'");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('defaults title and version when not specified', () => {
|
|
108
|
+
const output = generateOpenApi({
|
|
109
|
+
contractRoots: [],
|
|
110
|
+
opRoots: [],
|
|
111
|
+
config: {},
|
|
112
|
+
});
|
|
113
|
+
expect(output).toContain('title: API');
|
|
114
|
+
expect(output).toContain("version: '0.0.1'");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('includes servers when configured', () => {
|
|
118
|
+
const output = generateOpenApi({
|
|
119
|
+
contractRoots: [],
|
|
120
|
+
opRoots: [],
|
|
121
|
+
config: {
|
|
122
|
+
servers: [{ url: 'https://api.example.com', description: 'Prod' }],
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
expect(output).toContain('servers:');
|
|
126
|
+
expect(output).toContain("url: 'https://api.example.com'");
|
|
127
|
+
expect(output).toContain('description: Prod');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('includes security when configured', () => {
|
|
131
|
+
const output = generateOpenApi({
|
|
132
|
+
contractRoots: [],
|
|
133
|
+
opRoots: [],
|
|
134
|
+
config: {
|
|
135
|
+
security: [{ bearerAuth: [] }],
|
|
136
|
+
},
|
|
137
|
+
securitySchemes: {
|
|
138
|
+
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
expect(output).toContain('securitySchemes:');
|
|
142
|
+
expect(output).toContain('bearerAuth:');
|
|
143
|
+
expect(output).toContain('type: http');
|
|
144
|
+
expect(output).toContain('scheme: bearer');
|
|
145
|
+
expect(output).toContain('security:');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ─── Schema generation ──────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
describe('component schemas', () => {
|
|
152
|
+
it('generates schema for simple model', () => {
|
|
153
|
+
const dto = contractRoot([
|
|
154
|
+
model('User', [
|
|
155
|
+
field('id', scalarType('uuid')),
|
|
156
|
+
field('name', scalarType('string', { min: 1, max: 100 })),
|
|
157
|
+
field('email', scalarType('email')),
|
|
158
|
+
]),
|
|
159
|
+
]);
|
|
160
|
+
const output = generateOpenApi({
|
|
161
|
+
contractRoots: [dto],
|
|
162
|
+
opRoots: [],
|
|
163
|
+
config: {},
|
|
164
|
+
});
|
|
165
|
+
expect(output).toContain('schemas:');
|
|
166
|
+
expect(output).toContain('User:');
|
|
167
|
+
expect(output).toContain('type: object');
|
|
168
|
+
expect(output).toContain('format: uuid');
|
|
169
|
+
expect(output).toContain('format: email');
|
|
170
|
+
expect(output).toContain('minLength: 1');
|
|
171
|
+
expect(output).toContain('maxLength: 100');
|
|
172
|
+
expect(output).toContain('required: [id, name, email]');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('handles optional fields by omitting from required', () => {
|
|
176
|
+
const dto = contractRoot([model('User', [field('id', scalarType('uuid')), field('bio', scalarType('string'), { optional: true })])]);
|
|
177
|
+
const output = generateOpenApi({
|
|
178
|
+
contractRoots: [dto],
|
|
179
|
+
opRoots: [],
|
|
180
|
+
config: {},
|
|
181
|
+
});
|
|
182
|
+
expect(output).toContain('required: [id]');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('marks readonly/writeonly fields', () => {
|
|
186
|
+
const dto = contractRoot([
|
|
187
|
+
model('User', [
|
|
188
|
+
field('id', scalarType('uuid'), { visibility: 'readonly' }),
|
|
189
|
+
field('password', scalarType('string'), { visibility: 'writeonly' }),
|
|
190
|
+
field('name', scalarType('string')),
|
|
191
|
+
]),
|
|
192
|
+
]);
|
|
193
|
+
const output = generateOpenApi({
|
|
194
|
+
contractRoots: [dto],
|
|
195
|
+
opRoots: [],
|
|
196
|
+
config: {},
|
|
197
|
+
});
|
|
198
|
+
expect(output).toContain('readOnly: true');
|
|
199
|
+
expect(output).toContain('writeOnly: true');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('handles default values', () => {
|
|
203
|
+
const dto = contractRoot([
|
|
204
|
+
model('Config', [field('active', scalarType('boolean'), { default: true }), field('pageSize', scalarType('int'), { default: 25 })]),
|
|
205
|
+
]);
|
|
206
|
+
const output = generateOpenApi({
|
|
207
|
+
contractRoots: [dto],
|
|
208
|
+
opRoots: [],
|
|
209
|
+
config: {},
|
|
210
|
+
});
|
|
211
|
+
expect(output).toContain('default: true');
|
|
212
|
+
expect(output).toContain('default: 25');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('generates enum schema', () => {
|
|
216
|
+
const dto = contractRoot([model('Status', [], { type: enumType('active', 'inactive', 'pending') })]);
|
|
217
|
+
const output = generateOpenApi({
|
|
218
|
+
contractRoots: [dto],
|
|
219
|
+
opRoots: [],
|
|
220
|
+
config: {},
|
|
221
|
+
});
|
|
222
|
+
expect(output).toContain('type: string');
|
|
223
|
+
expect(output).toContain('enum: [active, inactive, pending]');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('generates array field schema', () => {
|
|
227
|
+
const dto = contractRoot([model('Response', [field('items', arrayType(refType('Item')))])]);
|
|
228
|
+
const output = generateOpenApi({
|
|
229
|
+
contractRoots: [dto],
|
|
230
|
+
opRoots: [],
|
|
231
|
+
config: {},
|
|
232
|
+
});
|
|
233
|
+
expect(output).toContain('type: array');
|
|
234
|
+
expect(output).toContain("'$ref': '#/components/schemas/Item'");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('generates model with base (allOf)', () => {
|
|
238
|
+
const dto = contractRoot([model('Admin', [field('role', enumType('admin', 'superadmin'))], { bases: ['User'] })]);
|
|
239
|
+
const output = generateOpenApi({
|
|
240
|
+
contractRoots: [dto],
|
|
241
|
+
opRoots: [],
|
|
242
|
+
config: {},
|
|
243
|
+
});
|
|
244
|
+
expect(output).toContain('allOf:');
|
|
245
|
+
expect(output).toContain("'$ref': '#/components/schemas/User'");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('emits one $ref per base for multi-base inheritance', () => {
|
|
249
|
+
const dto = contractRoot([model('Test5', [field('e', scalarType('string'))], { bases: ['A', 'B', 'C', 'D'] })]);
|
|
250
|
+
const output = generateOpenApi({ contractRoots: [dto], opRoots: [], config: {} });
|
|
251
|
+
expect(output).toContain("'$ref': '#/components/schemas/A'");
|
|
252
|
+
expect(output).toContain("'$ref': '#/components/schemas/B'");
|
|
253
|
+
expect(output).toContain("'$ref': '#/components/schemas/C'");
|
|
254
|
+
expect(output).toContain("'$ref': '#/components/schemas/D'");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('handles scalar type constraints', () => {
|
|
258
|
+
const dto = contractRoot([
|
|
259
|
+
model('Pagination', [field('page', scalarType('int', { min: 0 })), field('pageSize', scalarType('int', { min: 1, max: 100 }))]),
|
|
260
|
+
]);
|
|
261
|
+
const output = generateOpenApi({
|
|
262
|
+
contractRoots: [dto],
|
|
263
|
+
opRoots: [],
|
|
264
|
+
config: {},
|
|
265
|
+
});
|
|
266
|
+
expect(output).toContain('type: integer');
|
|
267
|
+
expect(output).toContain('minimum: 0');
|
|
268
|
+
expect(output).toContain('minimum: 1');
|
|
269
|
+
expect(output).toContain('maximum: 100');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('handles record type', () => {
|
|
273
|
+
const dto = contractRoot([model('Metadata', [], { type: recordType(scalarType('string'), scalarType('string')) })]);
|
|
274
|
+
const output = generateOpenApi({
|
|
275
|
+
contractRoots: [dto],
|
|
276
|
+
opRoots: [],
|
|
277
|
+
config: {},
|
|
278
|
+
});
|
|
279
|
+
expect(output).toContain('type: object');
|
|
280
|
+
expect(output).toContain('additionalProperties:');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('handles description on models and fields', () => {
|
|
284
|
+
const dto = contractRoot([
|
|
285
|
+
model('User', [field('name', scalarType('string'), { description: 'The user name' })], { description: 'A user object' }),
|
|
286
|
+
]);
|
|
287
|
+
const output = generateOpenApi({
|
|
288
|
+
contractRoots: [dto],
|
|
289
|
+
opRoots: [],
|
|
290
|
+
config: {},
|
|
291
|
+
});
|
|
292
|
+
expect(output).toContain("description: 'A user object'");
|
|
293
|
+
expect(output).toContain("description: 'The user name'");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('emits oneOf with a discriminator block for discriminated unions', () => {
|
|
297
|
+
const card = model('Card', [field('kind', literalType('card')), field('last4', scalarType('string'))]);
|
|
298
|
+
const bank = model('Bank', [field('kind', literalType('bank')), field('accountId', scalarType('string'))]);
|
|
299
|
+
const method = model('PaymentMethod', [], { type: discriminatedUnionType('kind', refType('Card'), refType('Bank')) });
|
|
300
|
+
const root = contractRoot([card, bank, method]);
|
|
301
|
+
const op = opRoot([opRoute('/methods', [opOperation('get', { responses: [opResponse(200, 'PaymentMethod', 'application/json')] })])]);
|
|
302
|
+
const output = generateOpenApi({
|
|
303
|
+
contractRoots: [root],
|
|
304
|
+
opRoots: [op],
|
|
305
|
+
config: {},
|
|
306
|
+
});
|
|
307
|
+
expect(output).toContain('PaymentMethod:');
|
|
308
|
+
expect(output).toContain('oneOf:');
|
|
309
|
+
expect(output).toContain('discriminator:');
|
|
310
|
+
expect(output).toContain('propertyName: kind');
|
|
311
|
+
expect(output).toContain("card: '#/components/schemas/Card'");
|
|
312
|
+
expect(output).toContain("bank: '#/components/schemas/Bank'");
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ─── Path generation ────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
describe('paths', () => {
|
|
319
|
+
it('converts :param to {param} in paths', () => {
|
|
320
|
+
const op = opRoot([opRoute('/users/{id}', [opOperation('get')], [opParam('id', scalarType('uuid'))])]);
|
|
321
|
+
const output = generateOpenApi({
|
|
322
|
+
contractRoots: [],
|
|
323
|
+
opRoots: [op],
|
|
324
|
+
config: {},
|
|
325
|
+
});
|
|
326
|
+
expect(output).toContain("'/users/{id}':");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('generates GET operation', () => {
|
|
330
|
+
const op = opRoot([
|
|
331
|
+
opRoute('/users', [
|
|
332
|
+
opOperation('get', {
|
|
333
|
+
responses: [opResponse(200, 'User', 'application/json')],
|
|
334
|
+
}),
|
|
335
|
+
]),
|
|
336
|
+
]);
|
|
337
|
+
const output = generateOpenApi({
|
|
338
|
+
contractRoots: [],
|
|
339
|
+
opRoots: [op],
|
|
340
|
+
config: {},
|
|
341
|
+
});
|
|
342
|
+
expect(output).toContain('get:');
|
|
343
|
+
expect(output).toContain('200:');
|
|
344
|
+
expect(output).toContain("'application/json':");
|
|
345
|
+
expect(output).toContain("'$ref': '#/components/schemas/User'");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it('generates POST with request body', () => {
|
|
349
|
+
const op = opRoot([
|
|
350
|
+
opRoute('/users', [
|
|
351
|
+
opOperation('post', {
|
|
352
|
+
request: opRequest('CreateUser'),
|
|
353
|
+
responses: [opResponse(201, 'User', 'application/json')],
|
|
354
|
+
}),
|
|
355
|
+
]),
|
|
356
|
+
]);
|
|
357
|
+
const output = generateOpenApi({
|
|
358
|
+
contractRoots: [],
|
|
359
|
+
opRoots: [op],
|
|
360
|
+
config: {},
|
|
361
|
+
});
|
|
362
|
+
expect(output).toContain('post:');
|
|
363
|
+
expect(output).toContain('requestBody:');
|
|
364
|
+
expect(output).toContain('required: true');
|
|
365
|
+
expect(output).toContain("'$ref': '#/components/schemas/CreateUser'");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('generates path parameters', () => {
|
|
369
|
+
const op = opRoot([opRoute('/users/{userId}', [opOperation('get')], [opParam('userId', scalarType('uuid'))])]);
|
|
370
|
+
const output = generateOpenApi({
|
|
371
|
+
contractRoots: [],
|
|
372
|
+
opRoots: [op],
|
|
373
|
+
config: {},
|
|
374
|
+
});
|
|
375
|
+
expect(output).toContain('parameters:');
|
|
376
|
+
expect(output).toContain('name: userId');
|
|
377
|
+
expect(output).toContain('in: path');
|
|
378
|
+
expect(output).toContain('format: uuid');
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('generates query parameters', () => {
|
|
382
|
+
const op = opRoot([
|
|
383
|
+
opRoute('/users', [
|
|
384
|
+
opOperation('get', {
|
|
385
|
+
query: [opParam('page', scalarType('int')), opParam('limit', scalarType('int'))],
|
|
386
|
+
}),
|
|
387
|
+
]),
|
|
388
|
+
]);
|
|
389
|
+
const output = generateOpenApi({
|
|
390
|
+
contractRoots: [],
|
|
391
|
+
opRoots: [op],
|
|
392
|
+
config: {},
|
|
393
|
+
});
|
|
394
|
+
expect(output).toContain('name: page');
|
|
395
|
+
expect(output).toContain('in: query');
|
|
396
|
+
expect(output).toContain('name: limit');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('generates header parameters', () => {
|
|
400
|
+
const op = opRoot([
|
|
401
|
+
opRoute('/users', [
|
|
402
|
+
opOperation('get', {
|
|
403
|
+
headers: [opParam('authorization', scalarType('string'))],
|
|
404
|
+
}),
|
|
405
|
+
]),
|
|
406
|
+
]);
|
|
407
|
+
const output = generateOpenApi({
|
|
408
|
+
contractRoots: [],
|
|
409
|
+
opRoots: [op],
|
|
410
|
+
config: {},
|
|
411
|
+
});
|
|
412
|
+
expect(output).toContain('name: authorization');
|
|
413
|
+
expect(output).toContain('in: header');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('uses operationId from service binding', () => {
|
|
417
|
+
const op = opRoot([opRoute('/users', [opOperation('get', { service: 'UserService.listUsers' })])]);
|
|
418
|
+
const output = generateOpenApi({
|
|
419
|
+
contractRoots: [],
|
|
420
|
+
opRoots: [op],
|
|
421
|
+
config: {},
|
|
422
|
+
});
|
|
423
|
+
expect(output).toContain('operationId: listUsers');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('uses operationId from sdk name', () => {
|
|
427
|
+
const op = opRoot([opRoute('/users', [opOperation('get', { sdk: 'getUsers' })])]);
|
|
428
|
+
const output = generateOpenApi({
|
|
429
|
+
contractRoots: [],
|
|
430
|
+
opRoots: [op],
|
|
431
|
+
config: {},
|
|
432
|
+
});
|
|
433
|
+
expect(output).toContain('operationId: getUsers');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('generates 204 No content response', () => {
|
|
437
|
+
const op = opRoot([
|
|
438
|
+
opRoute(
|
|
439
|
+
'/users/{id}',
|
|
440
|
+
[
|
|
441
|
+
opOperation('delete', {
|
|
442
|
+
responses: [opResponse(204)],
|
|
443
|
+
}),
|
|
444
|
+
],
|
|
445
|
+
[opParam('id', scalarType('uuid'))],
|
|
446
|
+
),
|
|
447
|
+
]);
|
|
448
|
+
const output = generateOpenApi({
|
|
449
|
+
contractRoots: [],
|
|
450
|
+
opRoots: [op],
|
|
451
|
+
config: {},
|
|
452
|
+
});
|
|
453
|
+
expect(output).toContain('204:');
|
|
454
|
+
expect(output).toContain("description: 'No content'");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('includes operation description', () => {
|
|
458
|
+
const op = opRoot([opRoute('/users', [opOperation('get', { description: 'List all users' })])]);
|
|
459
|
+
const output = generateOpenApi({
|
|
460
|
+
contractRoots: [],
|
|
461
|
+
opRoots: [op],
|
|
462
|
+
config: {},
|
|
463
|
+
});
|
|
464
|
+
expect(output).toContain("description: 'List all users'");
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('emits response headers', () => {
|
|
468
|
+
const op = opRoot([
|
|
469
|
+
opRoute('/transfers/{id}', [
|
|
470
|
+
opOperation('get', {
|
|
471
|
+
responses: [
|
|
472
|
+
{
|
|
473
|
+
statusCode: 200,
|
|
474
|
+
contentType: 'application/json',
|
|
475
|
+
bodyType: refType('Transfer'),
|
|
476
|
+
headers: [
|
|
477
|
+
{ name: 'preference-applied', optional: true, type: scalarType('string') },
|
|
478
|
+
{ name: 'etag', optional: false, type: scalarType('string'), description: 'cache validator' },
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
],
|
|
482
|
+
}),
|
|
483
|
+
]),
|
|
484
|
+
]);
|
|
485
|
+
const output = generateOpenApi({ contractRoots: [], opRoots: [op], config: {} });
|
|
486
|
+
expect(output).toContain('headers:');
|
|
487
|
+
expect(output).toContain('preference-applied:');
|
|
488
|
+
expect(output).toContain('etag:');
|
|
489
|
+
expect(output).toContain("description: 'cache validator'");
|
|
490
|
+
expect(output).toContain('required: true');
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it('handles inline object response body', () => {
|
|
494
|
+
const op = opRoot([
|
|
495
|
+
opRoute('/users', [
|
|
496
|
+
opOperation('get', {
|
|
497
|
+
responses: [
|
|
498
|
+
opResponse(
|
|
499
|
+
200,
|
|
500
|
+
inlineObjectType([field('meta', refType('Pagination')), field('data', arrayType(refType('User')))]),
|
|
501
|
+
'application/json',
|
|
502
|
+
),
|
|
503
|
+
],
|
|
504
|
+
}),
|
|
505
|
+
]),
|
|
506
|
+
]);
|
|
507
|
+
const output = generateOpenApi({
|
|
508
|
+
contractRoots: [],
|
|
509
|
+
opRoots: [op],
|
|
510
|
+
config: {},
|
|
511
|
+
});
|
|
512
|
+
expect(output).toContain('type: object');
|
|
513
|
+
expect(output).toContain('meta:');
|
|
514
|
+
expect(output).toContain('data:');
|
|
515
|
+
});
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// ─── Multiple op files combined ─────────────────────────────
|
|
519
|
+
|
|
520
|
+
describe('combining multiple files', () => {
|
|
521
|
+
it('merges paths from multiple op files', () => {
|
|
522
|
+
const op1 = opRoot([opRoute('/users', [opOperation('get')])], 'users.op');
|
|
523
|
+
const op2 = opRoot([opRoute('/orders', [opOperation('get')])], 'orders.op');
|
|
524
|
+
const output = generateOpenApi({
|
|
525
|
+
contractRoots: [],
|
|
526
|
+
opRoots: [op1, op2],
|
|
527
|
+
config: {},
|
|
528
|
+
});
|
|
529
|
+
expect(output).toContain('/users');
|
|
530
|
+
expect(output).toContain('/orders');
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it('merges schemas from multiple contract files', () => {
|
|
534
|
+
const dto1 = contractRoot([model('User', [field('id', scalarType('uuid'))])], 'user.ck');
|
|
535
|
+
const dto2 = contractRoot([model('Order', [field('id', scalarType('uuid'))])], 'order.ck');
|
|
536
|
+
const output = generateOpenApi({
|
|
537
|
+
contractRoots: [dto1, dto2],
|
|
538
|
+
opRoots: [],
|
|
539
|
+
config: {},
|
|
540
|
+
});
|
|
541
|
+
expect(output).toContain('User:');
|
|
542
|
+
expect(output).toContain('Order:');
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
describe('route modifiers', () => {
|
|
548
|
+
describe('internal', () => {
|
|
549
|
+
it('excludes an internal operation from paths', () => {
|
|
550
|
+
const op = opRoot([
|
|
551
|
+
opRoute('/users', [
|
|
552
|
+
opOperation('get', { responses: [opResponse(200)] }),
|
|
553
|
+
opOperation('post', { modifiers: ['internal'], responses: [opResponse(201)] }),
|
|
554
|
+
]),
|
|
555
|
+
]);
|
|
556
|
+
const output = generateOpenApi({ contractRoots: [], opRoots: [op], config: {} });
|
|
557
|
+
expect(output).toContain('get:');
|
|
558
|
+
expect(output).not.toContain('post:');
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('includes internal operations when config.includeInternal is true', () => {
|
|
562
|
+
const op = opRoot([
|
|
563
|
+
opRoute('/users', [
|
|
564
|
+
opOperation('get', { responses: [opResponse(200)] }),
|
|
565
|
+
opOperation('post', { modifiers: ['internal'], responses: [opResponse(201)] }),
|
|
566
|
+
]),
|
|
567
|
+
]);
|
|
568
|
+
const output = generateOpenApi({ contractRoots: [], opRoots: [op], config: { includeInternal: true } });
|
|
569
|
+
expect(output).toContain('get:');
|
|
570
|
+
expect(output).toContain('post:');
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('excludes all operations when route is internal', () => {
|
|
574
|
+
const op = opRoot([
|
|
575
|
+
opRoute(
|
|
576
|
+
'/admin/users',
|
|
577
|
+
[opOperation('get', { responses: [opResponse(200)] }), opOperation('delete', { responses: [opResponse(204)] })],
|
|
578
|
+
undefined,
|
|
579
|
+
['internal'],
|
|
580
|
+
),
|
|
581
|
+
]);
|
|
582
|
+
const output = generateOpenApi({ contractRoots: [], opRoots: [op], config: {} });
|
|
583
|
+
expect(output).not.toContain('/admin/users');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it('operation-level override on internal route makes that operation visible', () => {
|
|
587
|
+
const op = opRoot([
|
|
588
|
+
opRoute(
|
|
589
|
+
'/admin/users',
|
|
590
|
+
[
|
|
591
|
+
opOperation('get', { modifiers: ['deprecated'], responses: [opResponse(200)] }),
|
|
592
|
+
opOperation('post', { responses: [opResponse(201)] }),
|
|
593
|
+
],
|
|
594
|
+
undefined,
|
|
595
|
+
['internal'],
|
|
596
|
+
),
|
|
597
|
+
]);
|
|
598
|
+
const output = generateOpenApi({ contractRoots: [], opRoots: [op], config: {} });
|
|
599
|
+
expect(output).toContain('/admin/users');
|
|
600
|
+
expect(output).toContain('get:');
|
|
601
|
+
expect(output).not.toContain('post:');
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
describe('schema filtering — internal operations', () => {
|
|
606
|
+
it('excludes schemas only referenced by internal operations', () => {
|
|
607
|
+
const dto = contractRoot([
|
|
608
|
+
model('PublicModel', [field('id', scalarType('uuid'))]),
|
|
609
|
+
model('InternalModel', [field('secret', scalarType('string'))]),
|
|
610
|
+
]);
|
|
611
|
+
const op = opRoot([
|
|
612
|
+
opRoute('/public', [opOperation('get', { responses: [opResponse(200, refType('PublicModel'))] })]),
|
|
613
|
+
opRoute('/internal', [
|
|
614
|
+
opOperation('post', {
|
|
615
|
+
modifiers: ['internal'],
|
|
616
|
+
responses: [opResponse(201, refType('InternalModel'))],
|
|
617
|
+
}),
|
|
618
|
+
]),
|
|
619
|
+
]);
|
|
620
|
+
const output = generateOpenApi({ contractRoots: [dto], opRoots: [op], config: {} });
|
|
621
|
+
expect(output).toContain('PublicModel:');
|
|
622
|
+
expect(output).not.toContain('InternalModel:');
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('transitively includes schemas referenced by public types', () => {
|
|
626
|
+
const dto = contractRoot([
|
|
627
|
+
model('Order', [field('item', refType('OrderItem'))]),
|
|
628
|
+
model('OrderItem', [field('name', scalarType('string'))]),
|
|
629
|
+
model('InternalData', [field('x', scalarType('string'))]),
|
|
630
|
+
]);
|
|
631
|
+
const op = opRoot([
|
|
632
|
+
opRoute('/orders', [opOperation('get', { responses: [opResponse(200, refType('Order'))] })]),
|
|
633
|
+
opRoute('/admin', [
|
|
634
|
+
opOperation('get', {
|
|
635
|
+
modifiers: ['internal'],
|
|
636
|
+
responses: [opResponse(200, refType('InternalData'))],
|
|
637
|
+
}),
|
|
638
|
+
]),
|
|
639
|
+
]);
|
|
640
|
+
const output = generateOpenApi({ contractRoots: [dto], opRoots: [op], config: {} });
|
|
641
|
+
expect(output).toContain('Order:');
|
|
642
|
+
expect(output).toContain('OrderItem:');
|
|
643
|
+
expect(output).not.toContain('InternalData:');
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('excludes all schemas when all operations are internal', () => {
|
|
647
|
+
const dto = contractRoot([model('Secret', [field('key', scalarType('string'))])]);
|
|
648
|
+
const op = opRoot([
|
|
649
|
+
opRoute(
|
|
650
|
+
'/admin',
|
|
651
|
+
[
|
|
652
|
+
opOperation('get', {
|
|
653
|
+
modifiers: ['internal'],
|
|
654
|
+
responses: [opResponse(200, refType('Secret'))],
|
|
655
|
+
}),
|
|
656
|
+
],
|
|
657
|
+
undefined,
|
|
658
|
+
['internal'],
|
|
659
|
+
),
|
|
660
|
+
]);
|
|
661
|
+
const output = generateOpenApi({ contractRoots: [dto], opRoots: [op], config: {} });
|
|
662
|
+
expect(output).not.toContain('Secret:');
|
|
663
|
+
expect(output).not.toContain('schemas:');
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it('includes all schemas when there are no op files', () => {
|
|
667
|
+
const dto = contractRoot([model('Foo', [field('id', scalarType('uuid'))]), model('Bar', [field('name', scalarType('string'))])]);
|
|
668
|
+
const output = generateOpenApi({ contractRoots: [dto], opRoots: [], config: {} });
|
|
669
|
+
expect(output).toContain('Foo:');
|
|
670
|
+
expect(output).toContain('Bar:');
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
describe('deprecated', () => {
|
|
675
|
+
it('sets deprecated: true for a deprecated operation', () => {
|
|
676
|
+
const op = opRoot([opRoute('/users', [opOperation('get', { modifiers: ['deprecated'], responses: [opResponse(200)] })])]);
|
|
677
|
+
const output = generateOpenApi({ contractRoots: [], opRoots: [op], config: {} });
|
|
678
|
+
expect(output).toContain('deprecated: true');
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
it('does not set deprecated for a normal operation', () => {
|
|
682
|
+
const op = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200)] })])]);
|
|
683
|
+
const output = generateOpenApi({ contractRoots: [], opRoots: [op], config: {} });
|
|
684
|
+
expect(output).not.toContain('deprecated:');
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('cascades route-level deprecated to all operations', () => {
|
|
688
|
+
const op = opRoot([
|
|
689
|
+
opRoute(
|
|
690
|
+
'/users',
|
|
691
|
+
[opOperation('get', { responses: [opResponse(200)] }), opOperation('post', { responses: [opResponse(201)] })],
|
|
692
|
+
undefined,
|
|
693
|
+
['deprecated'],
|
|
694
|
+
),
|
|
695
|
+
]);
|
|
696
|
+
const output = generateOpenApi({ contractRoots: [], opRoots: [op], config: {} });
|
|
697
|
+
const deprecatedCount = (output.match(/deprecated: true/g) ?? []).length;
|
|
698
|
+
expect(deprecatedCount).toBe(2);
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
describe('options-level header globals', () => {
|
|
704
|
+
function compileToOpenApi(source: string): string {
|
|
705
|
+
const diag = new DiagnosticCollector();
|
|
706
|
+
const ck = parseCk(source, 'widgets.ck', diag);
|
|
707
|
+
applyOptionsDefaults(ck, diag);
|
|
708
|
+
const { op } = decomposeCk(ck);
|
|
709
|
+
return generateOpenApi({ contractRoots: [], opRoots: [op], config: {} });
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
it('renders global response headers on every status code, including bodyless and 4xx/5xx', () => {
|
|
713
|
+
const output = compileToOpenApi(`
|
|
714
|
+
options { response: { headers: { x-request-id: uuid } } }
|
|
715
|
+
operation /widgets/{id}: {
|
|
716
|
+
params: { id: uuid }
|
|
717
|
+
delete: {
|
|
718
|
+
response: {
|
|
719
|
+
204:
|
|
720
|
+
404:
|
|
721
|
+
500: { application/json: ApiError }
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}`);
|
|
725
|
+
// Each of the three status code sections should declare x-request-id under headers:
|
|
726
|
+
const matches = output.match(/x-request-id:/g) ?? [];
|
|
727
|
+
expect(matches.length).toBeGreaterThanOrEqual(3);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('omits global response headers on a status code that opts out via headers: none', () => {
|
|
731
|
+
const output = compileToOpenApi(`
|
|
732
|
+
options { response: { headers: { x-request-id: uuid } } }
|
|
733
|
+
operation /widgets: {
|
|
734
|
+
get: {
|
|
735
|
+
response: {
|
|
736
|
+
200: { application/json: Widget }
|
|
737
|
+
404: { headers: none }
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}`);
|
|
741
|
+
// Slice the document at the 404 marker — we expect no header for that response.
|
|
742
|
+
const after404 = output.split(/^\s*'404':/m)[1] ?? '';
|
|
743
|
+
const beforeNext = after404.split(/^\s*'\d{3}':/m)[0] ?? '';
|
|
744
|
+
expect(beforeNext).not.toContain('x-request-id');
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('renders global request headers as parameters on every operation', () => {
|
|
748
|
+
const output = compileToOpenApi(`
|
|
749
|
+
options { request: { headers: {
|
|
750
|
+
x-request-id: uuid
|
|
751
|
+
authorization: string
|
|
752
|
+
} } }
|
|
753
|
+
operation /widgets: {
|
|
754
|
+
get: { response: { 200: { application/json: Widget } } }
|
|
755
|
+
post: {
|
|
756
|
+
request: { application/json: Widget }
|
|
757
|
+
response: { 201: { application/json: Widget } }
|
|
758
|
+
}
|
|
759
|
+
}`);
|
|
760
|
+
// Both operations should carry the global headers as parameters.
|
|
761
|
+
const requestIdParams = (output.match(/name: x-request-id\b/g) ?? []).length;
|
|
762
|
+
const authParams = (output.match(/name: authorization\b/g) ?? []).length;
|
|
763
|
+
expect(requestIdParams).toBe(2);
|
|
764
|
+
expect(authParams).toBe(2);
|
|
765
|
+
expect(output.match(/in: header/g)?.length).toBeGreaterThanOrEqual(4);
|
|
766
|
+
});
|
|
767
|
+
});
|