@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.
Files changed (42) hide show
  1. package/.turbo/turbo-build$colon$ci.log +35 -0
  2. package/.turbo/turbo-build.log +15 -0
  3. package/.turbo/turbo-test$colon$ci.log +105 -0
  4. package/.turbo/turbo-test.log +14 -0
  5. package/CHANGELOG.md +103 -0
  6. package/README.md +78 -0
  7. package/coverage/base.css +224 -0
  8. package/coverage/block-navigation.js +87 -0
  9. package/coverage/clover.xml +403 -0
  10. package/coverage/coverage-final.json +3 -0
  11. package/coverage/favicon.png +0 -0
  12. package/coverage/index.html +131 -0
  13. package/coverage/prettify.css +1 -0
  14. package/coverage/prettify.js +2 -0
  15. package/coverage/sort-arrow-sprite.png +0 -0
  16. package/coverage/sorter.js +210 -0
  17. package/coverage/src/codegen-openapi.ts.html +2308 -0
  18. package/coverage/src/index.html +116 -0
  19. package/coverage/tests/helpers.ts.html +616 -0
  20. package/coverage/tests/index.html +116 -0
  21. package/dist/codegen-openapi.d.ts +44 -0
  22. package/dist/codegen-openapi.d.ts.map +1 -0
  23. package/dist/index.d.ts +10 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +701 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/src/codegen-openapi.d.ts +38 -0
  28. package/dist/src/codegen-openapi.d.ts.map +1 -0
  29. package/dist/src/index.d.ts +4 -0
  30. package/dist/src/index.d.ts.map +1 -0
  31. package/dist/tests/codegen-openapi.test.d.ts +2 -0
  32. package/dist/tests/codegen-openapi.test.d.ts.map +1 -0
  33. package/dist/tests/helpers.d.ts +31 -0
  34. package/dist/tests/helpers.d.ts.map +1 -0
  35. package/eslint.config.js +6 -0
  36. package/package.json +43 -0
  37. package/src/codegen-openapi.ts +741 -0
  38. package/src/index.ts +43 -0
  39. package/tests/codegen-openapi.test.ts +767 -0
  40. package/tests/helpers.ts +177 -0
  41. package/tsconfig.json +9 -0
  42. package/vitest.config.ts +14 -0
@@ -0,0 +1,741 @@
1
+ import type {
2
+ ContractRootNode,
3
+ OpRootNode,
4
+ ContractTypeNode,
5
+ FieldNode,
6
+ ModelNode,
7
+ OpRouteNode,
8
+ OpOperationNode,
9
+ ParamSource,
10
+ } from '@contractkit/core';
11
+ import { resolveModifiers, resolveSecurity, SECURITY_NONE } from '@contractkit/core';
12
+
13
+ export interface OpenApiServerEntry {
14
+ url: string;
15
+ description?: string;
16
+ }
17
+
18
+ export interface OpenApiSecurityScheme {
19
+ type: string;
20
+ scheme?: string;
21
+ bearerFormat?: string;
22
+ name?: string;
23
+ in?: string;
24
+ }
25
+
26
+ export interface OpenApiConfig {
27
+ baseDir?: string;
28
+ output?: string;
29
+ info?: {
30
+ title?: string;
31
+ version?: string;
32
+ description?: string;
33
+ };
34
+ servers?: OpenApiServerEntry[];
35
+ /** Global OpenAPI security requirements (e.g. [{ bearerAuth: [] }]). Distinct from scheme definitions. */
36
+ security?: Record<string, string[]>[];
37
+ /**
38
+ * Whether to document operations marked `internal`. Defaults to `false` — internal ops
39
+ * are omitted from the spec so external consumers don't see them. Set to `true` for an
40
+ * internal-use spec.
41
+ */
42
+ includeInternal?: boolean;
43
+ }
44
+
45
+ // ─── Type reachability ────────────────────────────────────────────────────
46
+
47
+ function collectRefsFromType(type: ContractTypeNode, out: Set<string>): void {
48
+ switch (type.kind) {
49
+ case 'ref':
50
+ out.add(type.name);
51
+ break;
52
+ case 'array':
53
+ collectRefsFromType(type.item, out);
54
+ break;
55
+ case 'tuple':
56
+ for (const item of type.items) collectRefsFromType(item, out);
57
+ break;
58
+ case 'record':
59
+ collectRefsFromType(type.value, out);
60
+ break;
61
+ case 'union':
62
+ case 'discriminatedUnion':
63
+ case 'intersection':
64
+ for (const member of type.members) collectRefsFromType(member, out);
65
+ break;
66
+ case 'lazy':
67
+ collectRefsFromType(type.inner, out);
68
+ break;
69
+ case 'inlineObject':
70
+ for (const field of type.fields) collectRefsFromType(field.type, out);
71
+ break;
72
+ }
73
+ }
74
+
75
+ function collectParamSourceRefs(source: ParamSource | undefined, out: Set<string>): void {
76
+ if (!source) return;
77
+ if (source.kind === 'ref') {
78
+ out.add(source.name);
79
+ return;
80
+ }
81
+ if (source.kind === 'params') {
82
+ for (const p of source.nodes) collectRefsFromType(p.type, out);
83
+ return;
84
+ }
85
+ collectRefsFromType(source.node, out);
86
+ }
87
+
88
+ /** Collect all type names directly referenced by public operations (seed set). */
89
+ function collectPublicTypeRefs(opRoots: OpRootNode[], includeInternal = false): Set<string> {
90
+ const refs = new Set<string>();
91
+ for (const opRoot of opRoots) {
92
+ for (const route of opRoot.routes) {
93
+ for (const op of route.operations) {
94
+ if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
95
+ if (op.request) {
96
+ for (const body of op.request.bodies) collectRefsFromType(body.bodyType, refs);
97
+ }
98
+ for (const resp of op.responses) {
99
+ if (resp.bodyType) collectRefsFromType(resp.bodyType, refs);
100
+ if (resp.headers) {
101
+ for (const h of resp.headers) collectRefsFromType(h.type, refs);
102
+ }
103
+ }
104
+ collectParamSourceRefs(route.params, refs);
105
+ collectParamSourceRefs(op.query, refs);
106
+ collectParamSourceRefs(op.headers, refs);
107
+ }
108
+ }
109
+ }
110
+ return refs;
111
+ }
112
+
113
+ /** BFS-expand seed type names through the contract model graph. */
114
+ function computeReachableSchemas(seeds: Set<string>, modelMap: Map<string, ModelNode>): Set<string> {
115
+ const reachable = new Set<string>(seeds);
116
+ const frontier = [...seeds];
117
+ while (frontier.length > 0) {
118
+ const name = frontier.pop()!;
119
+ const model = modelMap.get(name);
120
+ if (!model) continue;
121
+ const refs = new Set<string>();
122
+ if (model.type) collectRefsFromType(model.type, refs);
123
+ for (const field of model.fields) collectRefsFromType(field.type, refs);
124
+ if (model.bases) for (const b of model.bases) refs.add(b);
125
+ for (const ref of refs) {
126
+ if (!reachable.has(ref)) {
127
+ reachable.add(ref);
128
+ frontier.push(ref);
129
+ }
130
+ }
131
+ }
132
+ return reachable;
133
+ }
134
+
135
+ // ─── Public entry point ────────────────────────────────────────────────────
136
+
137
+ export interface OpenApiCodegenContext {
138
+ contractRoots: ContractRootNode[];
139
+ opRoots: OpRootNode[];
140
+ config: OpenApiConfig;
141
+ /** Named OpenAPI security scheme definitions to include in components.securitySchemes */
142
+ securitySchemes?: Record<string, OpenApiSecurityScheme>;
143
+ }
144
+
145
+ export function generateOpenApi(ctx: OpenApiCodegenContext): string {
146
+ const { contractRoots, opRoots, config, securitySchemes } = ctx;
147
+ const includeInternal = config.includeInternal ?? false;
148
+
149
+ const doc: Record<string, unknown> = {
150
+ openapi: '3.1.0',
151
+ info: {
152
+ title: config.info?.title ?? 'API',
153
+ version: config.info?.version ?? '0.0.1',
154
+ ...(config.info?.description ? { description: config.info.description } : {}),
155
+ },
156
+ };
157
+
158
+ if (config.servers && config.servers.length > 0) {
159
+ doc.servers = config.servers;
160
+ }
161
+
162
+ if (config.security && config.security.length > 0) {
163
+ doc.security = config.security;
164
+ }
165
+
166
+ // Build component schemas from all contract models
167
+ const allSchemas: Record<string, unknown> = {};
168
+ const modelMap = new Map<string, ModelNode>();
169
+
170
+ for (const contractRoot of contractRoots) {
171
+ for (const model of contractRoot.models) {
172
+ modelMap.set(model.name, model);
173
+ }
174
+ }
175
+ for (const contractRoot of contractRoots) {
176
+ for (const model of contractRoot.models) {
177
+ allSchemas[model.name] = modelToSchema(model, modelMap);
178
+ }
179
+ }
180
+
181
+ // Build paths from all operation files
182
+ const paths: Record<string, Record<string, unknown>> = {};
183
+
184
+ for (const opRoot of opRoots) {
185
+ for (const route of opRoot.routes) {
186
+ const oaPath = convertPath(route.path);
187
+
188
+ for (const op of route.operations) {
189
+ const mods = resolveModifiers(route, op);
190
+ if (!includeInternal && mods.includes('internal')) continue;
191
+ // Lazily initialize the path object so all-internal routes
192
+ // leave no empty entry in the output
193
+ if (!paths[oaPath]) paths[oaPath] = {};
194
+ const operation = buildOperation(route, op);
195
+ if (mods.includes('deprecated')) (operation as Record<string, unknown>).deprecated = true;
196
+ paths[oaPath][op.method] = operation;
197
+ }
198
+ }
199
+ }
200
+
201
+ doc.paths = paths;
202
+
203
+ // Filter schemas to only include types reachable from public operations.
204
+ // When there are no op files, all schemas are included (no filtering).
205
+ const schemas: Record<string, unknown> =
206
+ opRoots.length > 0
207
+ ? (() => {
208
+ const reachable = computeReachableSchemas(collectPublicTypeRefs(opRoots, includeInternal), modelMap);
209
+ const filtered: Record<string, unknown> = {};
210
+ for (const [name, schema] of Object.entries(allSchemas)) {
211
+ if (reachable.has(name)) filtered[name] = schema;
212
+ }
213
+ return filtered;
214
+ })()
215
+ : allSchemas;
216
+
217
+ const components: Record<string, unknown> = {};
218
+ if (Object.keys(schemas).length > 0) {
219
+ components.schemas = schemas;
220
+ }
221
+ if (securitySchemes && Object.keys(securitySchemes).length > 0) {
222
+ components.securitySchemes = securitySchemes;
223
+ }
224
+ if (Object.keys(components).length > 0) {
225
+ doc.components = components;
226
+ }
227
+
228
+ return toYaml(doc);
229
+ }
230
+
231
+ // ─── Path conversion ──────────────────────────────────────────────────────
232
+
233
+ /** Path is already in OpenAPI `{param}` style — return as-is. */
234
+ function convertPath(path: string): string {
235
+ return path;
236
+ }
237
+
238
+ // ─── Schema conversion ───────────────────────────────────────────────────
239
+
240
+ function modelToSchema(model: ModelNode, modelMap?: Map<string, ModelNode>): Record<string, unknown> {
241
+ // Type alias (no fields)
242
+ if (model.type) {
243
+ const schema = typeToSchema(model.type, modelMap);
244
+ if (model.description) schema.description = model.description;
245
+ return schema;
246
+ }
247
+
248
+ const properties: Record<string, unknown> = {};
249
+ const required: string[] = [];
250
+
251
+ for (const field of model.fields) {
252
+ const prop = fieldToSchema(field, modelMap);
253
+ properties[field.name] = prop;
254
+ if (!field.optional) {
255
+ required.push(field.name);
256
+ }
257
+ }
258
+
259
+ const schema: Record<string, unknown> = {
260
+ type: 'object',
261
+ properties,
262
+ };
263
+
264
+ if (required.length > 0) {
265
+ schema.required = required;
266
+ }
267
+
268
+ if (model.bases && model.bases.length > 0) {
269
+ return {
270
+ allOf: [...model.bases.map(b => ({ $ref: `#/components/schemas/${b}` })), schema],
271
+ };
272
+ }
273
+
274
+ if (model.description) {
275
+ schema.description = model.description;
276
+ }
277
+ if (model.deprecated) {
278
+ schema.deprecated = true;
279
+ }
280
+
281
+ return schema;
282
+ }
283
+
284
+ function fieldToSchema(field: FieldNode, modelMap?: Map<string, ModelNode>): Record<string, unknown> {
285
+ let schema = typeToSchema(field.type, modelMap);
286
+
287
+ if (field.nullable) {
288
+ schema = wrapNullable(schema);
289
+ }
290
+ if (field.visibility === 'readonly') {
291
+ schema.readOnly = true;
292
+ } else if (field.visibility === 'writeonly') {
293
+ schema.writeOnly = true;
294
+ }
295
+ if (field.default !== undefined) {
296
+ schema.default = field.default;
297
+ }
298
+ if (field.description) {
299
+ schema.description = field.description;
300
+ }
301
+ if (field.deprecated) {
302
+ schema.deprecated = true;
303
+ }
304
+
305
+ return schema;
306
+ }
307
+
308
+ function typeToSchema(type: ContractTypeNode, modelMap?: Map<string, ModelNode>): Record<string, unknown> {
309
+ switch (type.kind) {
310
+ case 'scalar':
311
+ return scalarToSchema(type);
312
+ case 'array':
313
+ return arrayToSchema(type, modelMap);
314
+ case 'tuple':
315
+ return { type: 'array', prefixItems: type.items.map(i => typeToSchema(i, modelMap)) };
316
+ case 'record':
317
+ return { type: 'object', additionalProperties: typeToSchema(type.value, modelMap) };
318
+ case 'enum':
319
+ return { type: 'string', enum: type.values };
320
+ case 'literal':
321
+ return { const: type.value };
322
+ case 'union':
323
+ return { oneOf: type.members.map(m => typeToSchema(m, modelMap)) };
324
+ case 'discriminatedUnion': {
325
+ const oneOf = type.members.map(m => typeToSchema(m, modelMap));
326
+ const mapping: Record<string, string> = {};
327
+ for (const member of type.members) {
328
+ if (member.kind !== 'ref') continue;
329
+ const literalValues = resolveDiscriminatorLiterals(member.name, type.discriminator, modelMap);
330
+ if (literalValues.length === 0) continue;
331
+ for (const v of literalValues) {
332
+ mapping[v] = `#/components/schemas/${member.name}`;
333
+ }
334
+ }
335
+ const result: Record<string, unknown> = {
336
+ oneOf,
337
+ discriminator: { propertyName: type.discriminator },
338
+ };
339
+ if (Object.keys(mapping).length > 0) {
340
+ (result.discriminator as Record<string, unknown>).mapping = mapping;
341
+ }
342
+ return result;
343
+ }
344
+ case 'intersection':
345
+ return { allOf: type.members.map(m => typeToSchema(m, modelMap)) };
346
+ case 'ref':
347
+ return { $ref: `#/components/schemas/${type.name}` };
348
+ case 'inlineObject':
349
+ return inlineObjectToSchema(type.fields, modelMap);
350
+ case 'lazy':
351
+ return typeToSchema(type.inner, modelMap);
352
+ }
353
+ }
354
+
355
+ /** Resolve literal values of a model's discriminator field. Returns [] if not resolvable. */
356
+ function resolveDiscriminatorLiterals(modelName: string, discriminator: string, modelMap?: Map<string, ModelNode>): string[] {
357
+ if (!modelMap) return [];
358
+ const model = modelMap.get(modelName);
359
+ if (!model) return [];
360
+ const field = model.fields.find(f => f.name === discriminator);
361
+ if (!field) return [];
362
+ if (field.type.kind === 'literal') return [String(field.type.value)];
363
+ if (field.type.kind === 'enum') return field.type.values;
364
+ return [];
365
+ }
366
+
367
+ function scalarToSchema(type: import('@contractkit/core').ScalarTypeNode): Record<string, unknown> {
368
+ const s: Record<string, unknown> = {};
369
+
370
+ switch (type.name) {
371
+ case 'string':
372
+ s.type = 'string';
373
+ if (type.min !== undefined) s.minLength = Number(type.min);
374
+ if (type.max !== undefined) s.maxLength = Number(type.max);
375
+ if (type.len !== undefined) {
376
+ s.minLength = type.len;
377
+ s.maxLength = type.len;
378
+ }
379
+ if (type.regex) s.pattern = type.regex;
380
+ break;
381
+ case 'number':
382
+ s.type = 'number';
383
+ if (type.min !== undefined) s.minimum = Number(type.min);
384
+ if (type.max !== undefined) s.maximum = Number(type.max);
385
+ break;
386
+ case 'int':
387
+ s.type = 'integer';
388
+ if (type.min !== undefined) s.minimum = Number(type.min);
389
+ if (type.max !== undefined) s.maximum = Number(type.max);
390
+ break;
391
+ case 'bigint':
392
+ s.type = 'integer';
393
+ s.format = 'int64';
394
+ break;
395
+ case 'boolean':
396
+ s.type = 'boolean';
397
+ break;
398
+ case 'date':
399
+ s.type = 'string';
400
+ s.format = 'date';
401
+ break;
402
+ case 'datetime':
403
+ s.type = 'string';
404
+ s.format = 'date-time';
405
+ break;
406
+ case 'duration':
407
+ s.type = 'string';
408
+ s.format = 'duration';
409
+ break;
410
+ case 'email':
411
+ s.type = 'string';
412
+ s.format = 'email';
413
+ break;
414
+ case 'url':
415
+ s.type = 'string';
416
+ s.format = 'uri';
417
+ break;
418
+ case 'uuid':
419
+ s.type = 'string';
420
+ s.format = 'uuid';
421
+ break;
422
+ case 'unknown':
423
+ // No type constraint
424
+ break;
425
+ case 'null':
426
+ s.type = 'null';
427
+ break;
428
+ case 'object':
429
+ s.type = 'object';
430
+ break;
431
+ case 'binary':
432
+ s.type = 'string';
433
+ s.format = 'binary';
434
+ break;
435
+ case 'json':
436
+ // Any JSON value — no type constraint
437
+ break;
438
+ }
439
+
440
+ return s;
441
+ }
442
+
443
+ function arrayToSchema(type: import('@contractkit/core').ArrayTypeNode, modelMap?: Map<string, ModelNode>): Record<string, unknown> {
444
+ const s: Record<string, unknown> = { type: 'array', items: typeToSchema(type.item, modelMap) };
445
+ if (type.min !== undefined) s.minItems = type.min;
446
+ if (type.max !== undefined) s.maxItems = type.max;
447
+ return s;
448
+ }
449
+
450
+ function inlineObjectToSchema(fields: FieldNode[], modelMap?: Map<string, ModelNode>): Record<string, unknown> {
451
+ const properties: Record<string, unknown> = {};
452
+ const required: string[] = [];
453
+
454
+ for (const field of fields) {
455
+ properties[field.name] = fieldToSchema(field, modelMap);
456
+ if (!field.optional) {
457
+ required.push(field.name);
458
+ }
459
+ }
460
+
461
+ const schema: Record<string, unknown> = { type: 'object', properties };
462
+ if (required.length > 0) schema.required = required;
463
+ return schema;
464
+ }
465
+
466
+ function wrapNullable(schema: Record<string, unknown>): Record<string, unknown> {
467
+ // OpenAPI 3.1 uses JSON Schema nullable via oneOf or type array
468
+ if (schema.$ref) {
469
+ return { oneOf: [schema, { type: 'null' }] };
470
+ }
471
+ if (typeof schema.type === 'string') {
472
+ schema.type = [schema.type, 'null'];
473
+ }
474
+ return schema;
475
+ }
476
+
477
+ // ─── Operation building ─────────────────────────────────────────────────
478
+
479
+ function buildOperation(route: OpRouteNode, op: OpOperationNode): Record<string, unknown> {
480
+ const operation: Record<string, unknown> = {};
481
+
482
+ // operationId from service binding or SDK name
483
+ if (op.sdk) {
484
+ operation.operationId = op.sdk;
485
+ } else if (op.service) {
486
+ const methodPart = op.service.split('.').pop();
487
+ if (methodPart) operation.operationId = methodPart;
488
+ }
489
+
490
+ if (op.description) {
491
+ operation.description = op.description;
492
+ }
493
+
494
+ // Parameters: path params + query + headers
495
+ const parameters: Record<string, unknown>[] = [];
496
+
497
+ if (route.params) {
498
+ parameters.push(...paramSourceToParams(route.params, 'path'));
499
+ }
500
+ if (op.query) {
501
+ parameters.push(...paramSourceToParams(op.query, 'query'));
502
+ }
503
+ if (op.headers) {
504
+ parameters.push(...paramSourceToParams(op.headers, 'header'));
505
+ }
506
+
507
+ if (parameters.length > 0) {
508
+ operation.parameters = parameters;
509
+ }
510
+
511
+ // Request body
512
+ if (op.request && op.request.bodies.length > 0) {
513
+ const content: Record<string, { schema: ReturnType<typeof typeToSchema> }> = {};
514
+ for (const body of op.request.bodies) {
515
+ content[body.contentType] = { schema: typeToSchema(body.bodyType) };
516
+ }
517
+ operation.requestBody = { required: true, content };
518
+ }
519
+
520
+ // Effective security (operation-level wins; falls back to route-level)
521
+ // security: none → empty array (explicit public endpoint, overrides global default)
522
+ // security: { fields } → omit operation-level entry (rely on global security from config)
523
+ const effectiveSecurity = resolveSecurity(route, op);
524
+ if (effectiveSecurity === SECURITY_NONE) {
525
+ operation.security = [];
526
+ }
527
+
528
+ // Responses
529
+ const responses: Record<string, unknown> = {};
530
+ for (const resp of op.responses) {
531
+ const statusKey = String(resp.statusCode);
532
+ const responseObject: Record<string, unknown> = {
533
+ description: statusDescription(resp.statusCode),
534
+ };
535
+ if (resp.bodyType && resp.contentType) {
536
+ responseObject.content = {
537
+ [resp.contentType]: {
538
+ schema: typeToSchema(resp.bodyType),
539
+ },
540
+ };
541
+ }
542
+ if (resp.headers && resp.headers.length > 0) {
543
+ const headers: Record<string, unknown> = {};
544
+ for (const h of resp.headers) {
545
+ const headerObject: Record<string, unknown> = {
546
+ schema: typeToSchema(h.type),
547
+ };
548
+ if (!h.optional) headerObject.required = true;
549
+ if (h.description) headerObject.description = h.description;
550
+ headers[h.name] = headerObject;
551
+ }
552
+ responseObject.headers = headers;
553
+ }
554
+ responses[statusKey] = responseObject;
555
+ }
556
+
557
+ if (Object.keys(responses).length > 0) {
558
+ operation.responses = responses;
559
+ }
560
+
561
+ return operation;
562
+ }
563
+
564
+ function paramSourceToParams(source: ParamSource, location: 'path' | 'query' | 'header'): Record<string, unknown>[] {
565
+ if (source.kind === 'ref') {
566
+ // Type reference name used as param source — emit a single $ref
567
+ return [
568
+ {
569
+ name: source.name,
570
+ in: location,
571
+ required: location === 'path',
572
+ schema: { $ref: `#/components/schemas/${source.name}` },
573
+ },
574
+ ];
575
+ }
576
+
577
+ if (source.kind === 'params') {
578
+ // Inline param declarations
579
+ return source.nodes.map(p => ({
580
+ name: p.name,
581
+ in: location,
582
+ required: location === 'path',
583
+ schema: typeToSchema(p.type),
584
+ }));
585
+ }
586
+
587
+ // ContractTypeNode (inline object or other type) — if it's an inlineObject, expand fields
588
+ if (source.node.kind === 'inlineObject') {
589
+ return source.node.fields.map(f => ({
590
+ name: f.name,
591
+ in: location,
592
+ required: location === 'path' ? true : !f.optional,
593
+ schema: typeToSchema(f.type),
594
+ }));
595
+ }
596
+
597
+ // For a ref type used as param source
598
+ if (source.node.kind === 'ref') {
599
+ return [
600
+ {
601
+ name: source.node.name,
602
+ in: location,
603
+ required: location === 'path',
604
+ schema: { $ref: `#/components/schemas/${source.node.name}` },
605
+ },
606
+ ];
607
+ }
608
+
609
+ return [];
610
+ }
611
+
612
+ function statusDescription(code: number): string {
613
+ const descriptions: Record<number, string> = {
614
+ 200: 'Successful response',
615
+ 201: 'Created',
616
+ 204: 'No content',
617
+ 400: 'Bad request',
618
+ 401: 'Unauthorized',
619
+ 403: 'Forbidden',
620
+ 404: 'Not found',
621
+ 409: 'Conflict',
622
+ 422: 'Unprocessable entity',
623
+ 500: 'Internal server error',
624
+ };
625
+ return descriptions[code] ?? `Response ${code}`;
626
+ }
627
+
628
+ // ─── YAML serializer ──────────────────────────────────────────────────────
629
+
630
+ /**
631
+ * Minimal YAML serializer sufficient for OpenAPI documents.
632
+ * Avoids external dependency while producing clean, readable output.
633
+ */
634
+ export function toYaml(value: unknown, indent = 0): string {
635
+ if (value === null || value === undefined) return 'null';
636
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
637
+ if (typeof value === 'number') return String(value);
638
+ if (typeof value === 'bigint') return String(value);
639
+
640
+ if (typeof value === 'string') {
641
+ return yamlString(value);
642
+ }
643
+
644
+ if (Array.isArray(value)) {
645
+ if (value.length === 0) return '[]';
646
+
647
+ // Check if all items are simple scalars (for inline arrays like enum values, required lists)
648
+ if (value.every(v => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')) {
649
+ const items = value.map(v => (typeof v === 'string' ? yamlString(v) : String(v)));
650
+ const inline = `[${items.join(', ')}]`;
651
+ if (inline.length < 80) return inline;
652
+ }
653
+
654
+ const lines: string[] = [];
655
+ const pad = ' '.repeat(indent);
656
+ for (const item of value) {
657
+ if (isPlainObject(item)) {
658
+ const entries = Object.entries(item as Record<string, unknown>);
659
+ if (entries.length > 0) {
660
+ const [firstKey, firstVal] = entries[0]!;
661
+ const firstValStr = isComplex(firstVal) ? `\n${toYamlValue(firstVal, indent + 2)}` : ` ${toYaml(firstVal, indent + 2)}`;
662
+ lines.push(`${pad}- ${yamlKey(firstKey)}:${firstValStr}`);
663
+ for (let i = 1; i < entries.length; i++) {
664
+ const [k, v] = entries[i]!;
665
+ const valStr = isComplex(v) ? `\n${toYamlValue(v, indent + 2)}` : ` ${toYaml(v, indent + 2)}`;
666
+ lines.push(`${pad} ${yamlKey(k)}:${valStr}`);
667
+ }
668
+ } else {
669
+ lines.push(`${pad}- {}`);
670
+ }
671
+ } else {
672
+ lines.push(`${pad}- ${toYaml(item, indent + 1)}`);
673
+ }
674
+ }
675
+ return lines.join('\n');
676
+ }
677
+
678
+ if (isPlainObject(value)) {
679
+ const obj = value as Record<string, unknown>;
680
+ const entries = Object.entries(obj);
681
+ if (entries.length === 0) return '{}';
682
+
683
+ const pad = ' '.repeat(indent);
684
+ const lines: string[] = [];
685
+ for (const [key, val] of entries) {
686
+ if (isComplex(val)) {
687
+ lines.push(`${pad}${yamlKey(key)}:`);
688
+ lines.push(toYamlValue(val, indent + 1));
689
+ } else {
690
+ lines.push(`${pad}${yamlKey(key)}: ${toYaml(val, indent + 1)}`);
691
+ }
692
+ }
693
+ return lines.join('\n');
694
+ }
695
+
696
+ return String(value);
697
+ }
698
+
699
+ function toYamlValue(value: unknown, indent: number): string {
700
+ if (Array.isArray(value)) {
701
+ return toYaml(value, indent);
702
+ }
703
+ if (isPlainObject(value)) {
704
+ return toYaml(value, indent);
705
+ }
706
+ return ' '.repeat(indent) + toYaml(value, indent);
707
+ }
708
+
709
+ function isComplex(value: unknown): boolean {
710
+ if (Array.isArray(value)) {
711
+ // Simple scalar arrays can be inlined
712
+ if (value.every(v => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean')) {
713
+ const items = value.map(v => (typeof v === 'string' ? yamlString(v) : String(v)));
714
+ return `[${items.join(', ')}]`.length >= 80;
715
+ }
716
+ return true;
717
+ }
718
+ return isPlainObject(value);
719
+ }
720
+
721
+ function isPlainObject(value: unknown): boolean {
722
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
723
+ }
724
+
725
+ function yamlString(s: string): string {
726
+ // Use plain style if safe, otherwise single-quoted
727
+ if (s === '') return "''";
728
+ if (/^[\w./-]+$/.test(s) && !/^(true|false|null|yes|no|on|off)$/i.test(s) && !/^\d/.test(s)) {
729
+ return s;
730
+ }
731
+ // Single-quote, escaping internal single quotes by doubling
732
+ return `'${s.replace(/'/g, "''")}'`;
733
+ }
734
+
735
+ function yamlKey(key: string): string {
736
+ // Keys with special chars need quoting
737
+ if (/^[\w-]+$/.test(key) && !/^(true|false|null|yes|no|on|off)$/i.test(key)) {
738
+ return key;
739
+ }
740
+ return `'${key.replace(/'/g, "''")}'`;
741
+ }