@erenthedeveloper0/zen-openapi 0.1.0-alpha.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.
@@ -0,0 +1,882 @@
1
+ import type {
2
+ AppGraph, CoercePlan, CollectionId, CollectionRecord, JsonSchema, ParamType, PathSegment,
3
+ RouteRecord,
4
+ } from '@erenthedeveloper0/zen-core'
5
+ import { toJsonSchema, isVariantRecord, normaliseMediaType, isMediaProblem } from '@erenthedeveloper0/zen-core'
6
+ import {
7
+ Components, canonical, declaredName, projectSchema, sanitizeName,
8
+ type DocDiagnostic,
9
+ } from './schema.ts'
10
+ import type {
11
+ ExternalDocs, HttpOperation, OpenApiDocument, OpenApiSchema, ParameterObject,
12
+ ParameterLocation, PathItemObject, RequestBodyObject, ResponseObject, SecurityRequirement,
13
+ SecurityScheme, ServerObject, TagObject,
14
+ } from './types.ts'
15
+
16
+ /**
17
+ * `AppGraph → OpenAPIDocument` — rfcs/0001 §29.
18
+ *
19
+ * A pure function, and that is the architectural claim. Every other framework
20
+ * treats OpenAPI as an add-on that *re-describes* what the routes already say,
21
+ * which is why the documentation is always slightly wrong. Here the graph
22
+ * already contains every fact the document needs — paths, methods, param types,
23
+ * request and response schemas per status, tags, collections — so there is
24
+ * nothing to keep in sync because there is nothing duplicated.
25
+ *
26
+ * Nothing in this module runs per request. It is called once, from `onBoot`,
27
+ * against the frozen graph.
28
+ */
29
+
30
+ export interface OpenApiOptions {
31
+ readonly title: string
32
+ readonly version: string
33
+ readonly summary?: string | undefined
34
+ readonly description?: string | undefined
35
+ readonly servers?: readonly ServerObject[] | undefined
36
+ readonly tags?: readonly TagObject[] | undefined
37
+ readonly externalDocs?: ExternalDocs | undefined
38
+ readonly security?: readonly SecurityRequirement[] | undefined
39
+ readonly securitySchemes?: Readonly<Record<string, SecurityScheme>> | undefined
40
+ readonly license?: { readonly name: string; readonly identifier?: string; readonly url?: string } | undefined
41
+ readonly contact?: { readonly name?: string; readonly url?: string; readonly email?: string } | undefined
42
+ /**
43
+ * Document the RFC 9457 envelope Zen actually emits on the error path, as
44
+ * `4XX`/`5XX` responses. On by default: the error shape is a real part of the
45
+ * API contract, and leaving it undocumented is how clients end up parsing it
46
+ * by observation.
47
+ */
48
+ readonly problemDetails?: boolean | undefined
49
+ /** Routes to leave out. The docs endpoints exclude themselves through this. */
50
+ readonly exclude?: ((route: RouteRecord) => boolean) | undefined
51
+ }
52
+
53
+ export interface OpenApiResult {
54
+ readonly document: OpenApiDocument
55
+ /**
56
+ * Everything the generator could not state with confidence — an undeclared
57
+ * response schema, an unconvertible library schema, an anonymous schema shared
58
+ * by four operations. Surfaced rather than silently papered over, because a
59
+ * document that quietly describes less than the API does is worse than one
60
+ * that says so.
61
+ */
62
+ readonly diagnostics: readonly DocDiagnostic[]
63
+ }
64
+
65
+ const METHOD_TO_OPERATION: Readonly<Record<string, HttpOperation>> = {
66
+ GET: 'get', POST: 'post', PUT: 'put', PATCH: 'patch',
67
+ DELETE: 'delete', HEAD: 'head', OPTIONS: 'options',
68
+ }
69
+
70
+ const PROBLEM_REF = '#/components/schemas/ProblemDetails'
71
+
72
+ /**
73
+ * One appearance of a schema in the document.
74
+ *
75
+ * `place` is why this exists: hoisting happens *after* every route is visited,
76
+ * because "is this schema shared?" is not answerable until then. Keeping a
77
+ * setter rather than a copy is what lets the second pass replace an inlined
78
+ * schema with a `$ref` in the document itself.
79
+ */
80
+ interface SchemaUse {
81
+ readonly json: JsonSchema
82
+ readonly original: object | null
83
+ /** Response side. Requests and responses of the same named type are two components. */
84
+ readonly closed: boolean
85
+ readonly where: string
86
+ readonly hint: string
87
+ readonly shape: string
88
+ readonly projected: OpenApiSchema
89
+ readonly place: (schema: OpenApiSchema) => void
90
+ }
91
+
92
+ // ─────────────────────────────────────────────────────────────────────────────
93
+
94
+ export function openapiDocument(graph: AppGraph, options: OpenApiOptions): OpenApiResult {
95
+ return new DocumentBuilder(graph, options).build()
96
+ }
97
+
98
+ class DocumentBuilder {
99
+ readonly #graph: AppGraph
100
+ readonly #opts: OpenApiOptions
101
+ readonly #diagnostics: DocDiagnostic[] = []
102
+ readonly #components = new Components()
103
+ readonly #uses: SchemaUse[] = []
104
+ readonly #operationIds = new Set<string>()
105
+ readonly #collections = new Map<CollectionId, CollectionRecord>()
106
+ readonly #tagsUsed = new Set<string>()
107
+
108
+ constructor(graph: AppGraph, options: OpenApiOptions) {
109
+ this.#graph = graph
110
+ this.#opts = options
111
+ for (const collection of graph.collections) this.#collections.set(collection.id, collection)
112
+ }
113
+
114
+ build(): OpenApiResult {
115
+ const routes = this.#graph.routes.filter((route) => !this.#hidden(route))
116
+ const problems = this.#opts.problemDetails !== false && routes.length > 0
117
+
118
+ // Claimed before any user schema so the `$ref` above can be a constant. A
119
+ // user schema also titled ProblemDetails becomes ProblemDetails2, which is
120
+ // visible in the document rather than silently shadowing the error contract.
121
+ if (problems) this.#components.claim('ProblemDetails', PROBLEM_DETAILS as OpenApiSchema, 'zen:problem-details')
122
+
123
+ const paths = new Map<string, Record<string, unknown>>()
124
+
125
+ for (const route of routes) {
126
+ const method = METHOD_TO_OPERATION[route.method]
127
+ if (method === undefined) {
128
+ this.#warn('ZEN_OAS_METHOD_UNMAPPED', `${route.method} has no OpenAPI equivalent and was omitted.`, `${route.method} ${route.path}`)
129
+ continue
130
+ }
131
+ for (const variant of pathVariants(route.segments)) {
132
+ const item = paths.get(variant.template) ?? {}
133
+ paths.set(variant.template, item)
134
+ const operation = this.#operation(route, variant, problems)
135
+ if (item[method] !== undefined) {
136
+ this.#diagnostics.push({
137
+ severity: 'error',
138
+ code: 'ZEN_OAS_OPERATION_COLLISION',
139
+ message: `Two routes produce ${route.method} ${variant.template}; the second was dropped from the document.`,
140
+ where: `${route.method} ${variant.template}`,
141
+ hint: 'This normally means an optional parameter expanded onto a path another route already owns.',
142
+ })
143
+ continue
144
+ }
145
+ item[method] = operation
146
+ }
147
+ }
148
+
149
+ this.#hoistSharedSchemas()
150
+
151
+ const document: Record<string, unknown> = {
152
+ openapi: '3.1.0',
153
+ info: buildInfo(this.#opts),
154
+ paths: sortRecord(paths),
155
+ }
156
+ if (this.#opts.servers !== undefined) document['servers'] = this.#opts.servers
157
+ if (this.#opts.security !== undefined) document['security'] = this.#opts.security
158
+ if (this.#opts.externalDocs !== undefined) document['externalDocs'] = this.#opts.externalDocs
159
+
160
+ const tags = buildTags(this.#opts.tags, this.#tagsUsed, this.#graph.collections)
161
+ if (tags.length > 0) document['tags'] = tags
162
+
163
+ const schemas = this.#components.toRecord()
164
+ const hasSchemas = Object.keys(schemas).length > 0
165
+ if (hasSchemas || this.#opts.securitySchemes !== undefined) {
166
+ const components: Record<string, unknown> = {}
167
+ if (hasSchemas) components['schemas'] = schemas
168
+ if (this.#opts.securitySchemes !== undefined) components['securitySchemes'] = this.#opts.securitySchemes
169
+ document['components'] = components
170
+ }
171
+
172
+ // A component can be reserved under a provisional name and only later turn
173
+ // out to duplicate one already published — see `Components#fill`. Refs to
174
+ // the provisional name were already emitted, so they are rewritten here.
175
+ const aliases = this.#components.aliases()
176
+ const final = aliases.size === 0 ? document : (rewriteRefs(document, aliases) as Record<string, unknown>)
177
+
178
+ return { document: final as unknown as OpenApiDocument, diagnostics: this.#diagnostics }
179
+ }
180
+
181
+ // ── operations ───────────────────────────────────────────────────────────
182
+
183
+ #operation(route: RouteRecord, variant: PathVariant, problems: boolean): Record<string, unknown> {
184
+ const where = `${route.method} ${variant.template}`
185
+ const meta = readMeta(route)
186
+ const tags = this.#tags(route, meta.tags)
187
+ for (const tag of tags) this.#tagsUsed.add(tag)
188
+
189
+ const baseId = meta.operationId ?? route.name ?? defaultOperationId(route)
190
+ const operationId = this.#operationId(
191
+ variant.suffix === null ? baseId : `${baseId}By${pascal(variant.suffix)}`,
192
+ where,
193
+ )
194
+
195
+ const parameters: ParameterObject[] = [
196
+ ...this.#pathParameters(variant.segments, where),
197
+ ...this.#schemaParameters(route.schema.query, 'query', where, route.coercion?.get('query')),
198
+ ...this.#schemaParameters(route.schema.headers, 'header', where, route.coercion?.get('headers')),
199
+ ...this.#schemaParameters(route.schema.cookies, 'cookie', where, route.coercion?.get('cookies')),
200
+ ]
201
+
202
+ const operation: Record<string, unknown> = {
203
+ operationId,
204
+ responses: this.#responses(route, where, problems),
205
+ }
206
+ if (meta.summary !== undefined) operation['summary'] = meta.summary
207
+ if (meta.description !== undefined) operation['description'] = meta.description
208
+ if (tags.length > 0) operation['tags'] = tags
209
+ if (meta.deprecated) operation['deprecated'] = true
210
+ if (parameters.length > 0) operation['parameters'] = parameters
211
+
212
+ const body = this.#requestBody(route, where)
213
+ if (body !== null) operation['requestBody'] = body
214
+ if (meta.security !== undefined) operation['security'] = meta.security
215
+ if (meta.externalDocs !== undefined) operation['externalDocs'] = meta.externalDocs
216
+
217
+ // §4.4 — the budget, as a vendor extension.
218
+ //
219
+ // A generated client needs it: an SDK that waits thirty seconds for an
220
+ // endpoint the server abandons after two spends twenty-eight seconds
221
+ // holding a socket for an answer that is never coming. Today that number
222
+ // lives in a runbook, if anywhere.
223
+ //
224
+ // It is the *same field* the dispatcher arms from and `explainRoute`
225
+ // prints, so it cannot describe a budget the service does not use — the
226
+ // same property that makes §29.1's documented-fields guarantee worth
227
+ // having, applied to a second fact about the route.
228
+ if (route.timeout !== null) operation['x-zen-timeout-ms'] = route.timeout.ms
229
+
230
+ return operation
231
+ }
232
+
233
+ #requestBody(route: RouteRecord, where: string): RequestBodyObject | null {
234
+ const body = route.schema.body
235
+ if (body === undefined || body === null) return null
236
+
237
+ // `'input'`: a request body is described as the validator *receives* it.
238
+ const json = toJsonSchema(body, 'input')
239
+ if (json === null) {
240
+ this.#warn(
241
+ 'ZEN_OAS_SCHEMA_UNCONVERTIBLE',
242
+ 'The request body schema could not be converted to JSON Schema; the document describes it as unconstrained.',
243
+ `${where} → body`,
244
+ 'Register a converter with registerSchemaConverter(vendor, fn), or use a library exposing toJsonSchema().',
245
+ )
246
+ return { required: true, content: { 'application/json': { schema: {} } } }
247
+ }
248
+
249
+ // Requests are projected *open*. The validator is the authority there and it
250
+ // is the user's schema library, so its converter's output is the honest
251
+ // description of what it accepts — unlike responses, where Zen's own
252
+ // serializer decides what leaves the process (§13.3.1).
253
+ const media: Record<string, unknown> = {}
254
+ const use = this.#record(json, body, false, `${where} → body`, `${route.name ?? defaultOperationId(route)}Body`, (schema) => {
255
+ media['schema'] = schema
256
+ })
257
+ media['schema'] = use.projected
258
+ return { required: true, content: { 'application/json': media as { schema: OpenApiSchema } } }
259
+ }
260
+
261
+ #responses(route: RouteRecord, where: string, problems: boolean): Record<string, ResponseObject> {
262
+ const responses: Record<string, ResponseObject> = {}
263
+ const declared = route.schema.response as unknown as Record<string, unknown> | undefined
264
+
265
+ if (declared === undefined) {
266
+ this.#warn(
267
+ 'ZEN_OAS_RESPONSE_UNDECLARED',
268
+ "No response schema is declared, so this operation's payload is undocumented.",
269
+ where,
270
+ 'Add `response: { 200: Schema }`. It also compiles a serializer that cannot emit undeclared fields (§13.3).',
271
+ )
272
+ responses['default'] = { description: 'Undocumented. This route declares no response schema.' }
273
+ } else {
274
+ for (const status of Object.keys(declared).sort((a, b) => Number(a) - Number(b))) {
275
+ const schema = declared[status]
276
+ if (schema === null || schema === undefined) {
277
+ responses[status] = { description: statusText(Number(status)) }
278
+ continue
279
+ }
280
+
281
+ // §13.4 — the variant form. `content` is a map of media type to schema
282
+ // in OpenAPI already, so a negotiated response needs no new vocabulary
283
+ // here: it is the same object with more than one key, and the reason
284
+ // this generator can write it at all is that the graph carries the
285
+ // media types rather than the document re-describing them (§29.1).
286
+ //
287
+ // The offer *order* is preserved, because `RouteRecord.negotiation`
288
+ // preserved it and it is a real fact about the API: it is what a client
289
+ // sending `Accept: * / *` receives. OpenAPI does not give that order a
290
+ // meaning, but dropping it would throw away something true.
291
+ if (isVariantRecord(schema)) {
292
+ const content: Record<string, { schema: OpenApiSchema }> = {}
293
+ const ordered = route.negotiation?.offers ?? Object.keys(schema)
294
+ for (const media of ordered) {
295
+ const variant = variantSchema(schema as Record<string, unknown>, media)
296
+ if (variant === null || variant === undefined) continue
297
+ const described = this.#describeResponse(
298
+ variant, route, `${where} → response ${status} (${media})`,
299
+ )
300
+ if (described !== null) content[media] = described
301
+ }
302
+ responses[status] = {
303
+ description: statusText(Number(status)),
304
+ content,
305
+ }
306
+ continue
307
+ }
308
+
309
+ const described = this.#describeResponse(schema, route, `${where} → response ${status}`)
310
+ responses[status] = described === null
311
+ ? { description: statusText(Number(status)), content: { 'application/json': { schema: {} } } }
312
+ : {
313
+ description: descriptionOf(toJsonSchema(schema, 'output') ?? {}) ?? statusText(Number(status)),
314
+ content: { 'application/json': described },
315
+ }
316
+ }
317
+ }
318
+
319
+ if (problems) {
320
+ const problem: ResponseObject = {
321
+ description: 'RFC 9457 problem document.',
322
+ content: { 'application/problem+json': { schema: { $ref: PROBLEM_REF } } },
323
+ }
324
+ if (responses['4XX'] === undefined) responses['4XX'] = problem
325
+ if (responses['5XX'] === undefined) responses['5XX'] = problem
326
+ }
327
+ return responses
328
+ }
329
+
330
+ /**
331
+ * One response schema → one `content` entry, or `null` when it will not
332
+ * convert.
333
+ *
334
+ * Extracted when §13.4 arrived, because the negotiated form needs this once
335
+ * per media type and the plain form needs it once. Sharing it is what keeps
336
+ * `200: Schema` and `200: { 'application/json': Schema }` producing the same
337
+ * `$ref` to the same component — which is the property the dedup pass
338
+ * (§29.5) and the drift suite both depend on, and which two copies of this
339
+ * body would have broken the first time one of them was edited.
340
+ */
341
+ #describeResponse(
342
+ schema: unknown,
343
+ route: RouteRecord,
344
+ where: string,
345
+ ): { schema: OpenApiSchema } | null {
346
+ // `'output'`: a response is described as it leaves the serializer.
347
+ const json = toJsonSchema(schema, 'output')
348
+ if (json === null) {
349
+ this.#warn(
350
+ 'ZEN_OAS_SCHEMA_UNCONVERTIBLE',
351
+ 'This response schema could not be converted to JSON Schema; the document describes it as unconstrained.',
352
+ where,
353
+ 'The serializer reports the same schema at boot: the type-level contract holds, the runtime one does not.',
354
+ )
355
+ return null
356
+ }
357
+
358
+ const media: Record<string, unknown> = {}
359
+ const use = this.#record(
360
+ json, schema as object, true, where,
361
+ declaredName(json) ?? `${route.name ?? defaultOperationId(route)}Response`,
362
+ (projected) => { media['schema'] = projected },
363
+ )
364
+ media['schema'] = use.projected
365
+ return media as { schema: OpenApiSchema }
366
+ }
367
+
368
+ // ── parameters ───────────────────────────────────────────────────────────
369
+
370
+ #pathParameters(segments: readonly PathSegment[], where: string): ParameterObject[] {
371
+ const out: ParameterObject[] = []
372
+ for (const segment of segments) {
373
+ if (segment.kind === 'static') continue
374
+
375
+ if (segment.kind === 'wildcard') {
376
+ // OpenAPI has no tail-match notion. Documenting it as a string path
377
+ // parameter is the standard approximation; the extension says so out
378
+ // loud rather than letting a client author assume a single segment.
379
+ out.push({
380
+ name: segment.value,
381
+ in: 'path',
382
+ required: true,
383
+ description: 'Matches the remainder of the path, including "/".',
384
+ schema: { type: 'string' },
385
+ 'x-zen-wildcard': true,
386
+ })
387
+ continue
388
+ }
389
+
390
+ // §5.2's promise, cashed: one `paramType` declaration produced the trie
391
+ // matcher, the parse function, and this schema.
392
+ let schema: OpenApiSchema = { type: 'string' }
393
+ if (segment.type !== undefined) {
394
+ const paramType: ParamType | undefined = this.#graph.paramTypes.get(segment.type)
395
+ if (paramType?.jsonSchema !== undefined) schema = paramType.jsonSchema as OpenApiSchema
396
+ else {
397
+ this.#warn(
398
+ 'ZEN_OAS_PARAM_TYPE_UNDOCUMENTED',
399
+ `Parameter type "<${segment.type}>" contributes no jsonSchema, so ":${segment.value}" is documented as a plain string.`,
400
+ where,
401
+ 'Add a `jsonSchema` fragment to the param type — one declaration, three consumers (§5.2).',
402
+ )
403
+ }
404
+ }
405
+ out.push({ name: segment.value, in: 'path', required: true, schema })
406
+ }
407
+ return out
408
+ }
409
+
410
+ /**
411
+ * Query, header and cookie schemas are objects; OpenAPI wants one parameter
412
+ * per property. The whole schema is projected first so that any `$defs` it
413
+ * carries are hoisted once, then its properties are split apart.
414
+ */
415
+ #schemaParameters(
416
+ source: unknown,
417
+ location: ParameterLocation,
418
+ where: string,
419
+ coercion: CoercePlan | undefined,
420
+ ): ParameterObject[] {
421
+ if (source === undefined || source === null) return []
422
+
423
+ const json = toJsonSchema(source, 'input')
424
+ if (json === null) {
425
+ this.#warn(
426
+ 'ZEN_OAS_SCHEMA_UNCONVERTIBLE',
427
+ `The ${location} schema could not be converted to JSON Schema, so its parameters are undocumented.`,
428
+ where,
429
+ 'Register a converter with registerSchemaConverter(vendor, fn), or use a library exposing toJsonSchema().',
430
+ )
431
+ return []
432
+ }
433
+
434
+ const projected = projectSchema(json, {
435
+ components: this.#components, closed: false, diagnostics: this.#diagnostics, where,
436
+ }) as Record<string, unknown>
437
+
438
+ const properties = projected['properties'] as Record<string, OpenApiSchema> | undefined
439
+ if (properties === undefined) {
440
+ this.#diagnostics.push({
441
+ severity: 'info',
442
+ code: 'ZEN_OAS_PARAMS_NOT_OBJECT',
443
+ message: `The ${location} schema declares no properties at its top level, so no parameters were documented.`,
444
+ where,
445
+ hint: 'Parameters must come from a plain object schema; a $ref or union at the root cannot be split into parameters.',
446
+ })
447
+ return []
448
+ }
449
+
450
+ const required = new Set((projected['required'] as string[] | undefined) ?? [])
451
+ const out: ParameterObject[] = []
452
+ for (const name of Object.keys(properties)) {
453
+ const schema = properties[name]
454
+ if (schema === undefined) continue
455
+ const parameter: Record<string, unknown> = { name, in: location, schema }
456
+ if (required.has(name)) parameter['required'] = true
457
+ if (typeof schema.description === 'string') parameter['description'] = schema.description
458
+ if (schema['deprecated'] === true) parameter['deprecated'] = true
459
+ Object.assign(parameter, listStyle(location, coercion, name))
460
+ out.push(parameter as unknown as ParameterObject)
461
+ }
462
+ return out
463
+ }
464
+
465
+ // ── schema identity — §29.3 ──────────────────────────────────────────────
466
+
467
+ #record(
468
+ json: JsonSchema,
469
+ original: object | null,
470
+ closed: boolean,
471
+ where: string,
472
+ hint: string,
473
+ place: (schema: OpenApiSchema) => void,
474
+ ): SchemaUse {
475
+ const projected = projectSchema(json, {
476
+ components: this.#components, closed, diagnostics: this.#diagnostics, where,
477
+ })
478
+ const use: SchemaUse = { json, original, closed, where, hint, projected, shape: canonical(projected), place }
479
+ this.#uses.push(use)
480
+ return use
481
+ }
482
+
483
+ /**
484
+ * Three passes, in order of authority (§29.3).
485
+ *
486
+ * 1. **Identity** — the same imported schema object used by four routes is
487
+ * one concept, whatever it looks like.
488
+ * 2. **Declared name** — `$id` or `title`. A named schema is always hoisted,
489
+ * even when used once, because the name is the author telling a client
490
+ * generator what to call the type.
491
+ * 3. **Structure** — a backstop for anonymous schemas, and only for those.
492
+ * Two differently-named schemas that happen to have the same shape today
493
+ * are not necessarily the same concept, so this never merges named ones.
494
+ *
495
+ * Single-use anonymous schemas stay inline. Hoisting them would fill
496
+ * `components` with `Schema1..Schema40` and make the document harder to read
497
+ * for no gain.
498
+ */
499
+ #hoistSharedSchemas(): void {
500
+ const byIdentity = new Map<object, SchemaUse[]>()
501
+ const byShape = new Map<string, SchemaUse[]>()
502
+
503
+ for (const use of this.#uses) {
504
+ if (use.original !== null) push(byIdentity, use.original, use)
505
+ push(byShape, use.shape, use)
506
+ }
507
+
508
+ const done = new Set<SchemaUse>()
509
+
510
+ for (const use of this.#uses) {
511
+ if (done.has(use)) continue
512
+ // Already a component: either a `$defs` entry the projector hoisted, or a
513
+ // titled schema it recognised. Claiming a second component *for a `$ref`*
514
+ // would publish a pointer to a pointer.
515
+ if (use.projected.$ref !== undefined) continue
516
+
517
+ const name = declaredName(use.json)
518
+ // Identity only merges uses that also project identically: the same object
519
+ // used once as a request body and once as a response is two shapes,
520
+ // because only one of them is closed.
521
+ const identical = (use.original === null ? [use] : byIdentity.get(use.original) ?? [use])
522
+ .filter((other) => other.shape === use.shape)
523
+ const structural = byShape.get(use.shape) ?? [use]
524
+ const shared = identical.length > 1 || structural.length > 1
525
+
526
+ if (name === null && !shared) continue
527
+
528
+ if (name === null) {
529
+ this.#diagnostics.push({
530
+ severity: 'info',
531
+ code: 'ZEN_OAS_ANONYMOUS_SHARED',
532
+ message: `An anonymous schema is used by ${structural.length} operations and was named automatically.`,
533
+ where: use.where,
534
+ hint: 'Give the schema a title (or $id) so generated clients get a stable type name across releases.',
535
+ })
536
+ }
537
+
538
+ const key = name === null ? `shape:${use.shape}` : `named:${name}:${use.shape}`
539
+ const preferred = pascal(sanitizeName(name ?? use.hint))
540
+ const variant = use.closed ? undefined : 'Input'
541
+ const component = this.#components.claim(preferred, use.projected, key, variant)
542
+ if (name !== null && component !== preferred && component !== `${preferred}${variant ?? ''}`) {
543
+ this.#warn(
544
+ 'ZEN_OAS_TITLE_COLLISION',
545
+ `Two different schemas are both titled "${name}"; this one was published as "${component}".`,
546
+ use.where,
547
+ 'Give them distinct titles — a generated client names its types from these.',
548
+ )
549
+ }
550
+
551
+ const ref: OpenApiSchema = { $ref: `#/components/schemas/${component}` }
552
+ for (const member of structural) {
553
+ member.place(ref)
554
+ done.add(member)
555
+ }
556
+ }
557
+ }
558
+
559
+ // ── helpers ──────────────────────────────────────────────────────────────
560
+
561
+ #hidden(route: RouteRecord): boolean {
562
+ if (route.meta.get('hidden') === true) return true
563
+ return this.#opts.exclude?.(route) === true
564
+ }
565
+
566
+ /** Tags come from the collection chain, outermost first, then route metadata. */
567
+ #tags(route: RouteRecord, extra: readonly string[] | undefined): string[] {
568
+ const chain: string[][] = []
569
+ let current = route.collection === null ? undefined : this.#collections.get(route.collection)
570
+ while (current !== undefined) {
571
+ chain.unshift([...current.tags])
572
+ current = current.parent === null ? undefined : this.#collections.get(current.parent)
573
+ }
574
+ const out: string[] = []
575
+ for (const tags of chain) for (const tag of tags) if (!out.includes(tag)) out.push(tag)
576
+ for (const tag of extra ?? []) if (!out.includes(tag)) out.push(tag)
577
+ return out
578
+ }
579
+
580
+ #operationId(base: string, where: string): string {
581
+ if (!this.#operationIds.has(base)) {
582
+ this.#operationIds.add(base)
583
+ return base
584
+ }
585
+ this.#warn(
586
+ 'ZEN_OAS_OPERATION_ID_COLLISION',
587
+ `operationId "${base}" is already taken; this operation was renamed.`,
588
+ where,
589
+ 'Give the route an explicit `name`. operationId is what generated clients call the method.',
590
+ )
591
+ for (let n = 2; ; n++) {
592
+ const candidate = `${base}_${n}`
593
+ if (!this.#operationIds.has(candidate)) {
594
+ this.#operationIds.add(candidate)
595
+ return candidate
596
+ }
597
+ }
598
+ }
599
+
600
+ #warn(code: string, message: string, where: string, hint?: string): void {
601
+ this.#diagnostics.push({ severity: 'warning', code, message, where, hint })
602
+ }
603
+ }
604
+
605
+ const REF_PREFIX = '#/components/schemas/'
606
+
607
+ /** Rewrites every `$ref` through the alias map, wherever it appears. */
608
+ function rewriteRefs(node: unknown, aliases: ReadonlyMap<string, string>): unknown {
609
+ if (Array.isArray(node)) return node.map((item) => rewriteRefs(item, aliases))
610
+ if (typeof node !== 'object' || node === null) return node
611
+
612
+ const out: Record<string, unknown> = {}
613
+ for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
614
+ if (key === '$ref' && typeof value === 'string' && value.startsWith(REF_PREFIX)) {
615
+ const target = aliases.get(value.slice(REF_PREFIX.length))
616
+ out[key] = target === undefined ? value : `${REF_PREFIX}${target}`
617
+ continue
618
+ }
619
+ out[key] = rewriteRefs(value, aliases)
620
+ }
621
+ return out
622
+ }
623
+
624
+ function push<K, V>(map: Map<K, V[]>, key: K, value: V): void {
625
+ const list = map.get(key)
626
+ if (list === undefined) map.set(key, [value])
627
+ else list.push(value)
628
+ }
629
+
630
+ // ─────────────────────────────────────────────────────────────────────────────
631
+ // Paths
632
+ // ─────────────────────────────────────────────────────────────────────────────
633
+
634
+ export interface PathVariant {
635
+ readonly template: string
636
+ readonly segments: readonly PathSegment[]
637
+ /** The optional parameter this variant adds, used to keep operationIds unique. */
638
+ readonly suffix: string | null
639
+ }
640
+
641
+ /**
642
+ * `/posts/:slug?` becomes two paths, not one path with `required: false`.
643
+ *
644
+ * OpenAPI has no optional path parameter — the spec requires `required: true`
645
+ * for `in: 'path'` — so the only correct representation is two concrete paths,
646
+ * which is also exactly what the router does at build time (`expandOptional`,
647
+ * §5.2). A generated client therefore gets both call shapes instead of one that
648
+ * cannot be expressed.
649
+ */
650
+ export function pathVariants(segments: readonly PathSegment[]): PathVariant[] {
651
+ let trailingOptional = 0
652
+ for (let i = segments.length - 1; i >= 0; i--) {
653
+ const segment = segments[i] as PathSegment
654
+ if (segment.kind === 'param' && segment.optional === true) trailingOptional++
655
+ else break
656
+ }
657
+
658
+ if (trailingOptional === 0) return [{ template: templateOf(segments), segments, suffix: null }]
659
+
660
+ const variants: PathVariant[] = []
661
+ const base = segments.length - trailingOptional
662
+ for (let extra = 0; extra <= trailingOptional; extra++) {
663
+ const slice = segments.slice(0, base + extra)
664
+ const added = extra === 0 ? null : (segments[base + extra - 1] as PathSegment).value
665
+ variants.push({ template: templateOf(slice), segments: slice, suffix: added })
666
+ }
667
+ return variants
668
+ }
669
+
670
+ function templateOf(segments: readonly PathSegment[]): string {
671
+ if (segments.length === 0) return '/'
672
+ let out = ''
673
+ for (const segment of segments) {
674
+ out += segment.kind === 'static' ? `/${segment.value}` : `/{${segment.value}}`
675
+ }
676
+ return out
677
+ }
678
+
679
+ // ─────────────────────────────────────────────────────────────────────────────
680
+ // Metadata
681
+ // ─────────────────────────────────────────────────────────────────────────────
682
+
683
+ interface ResolvedMeta {
684
+ readonly summary: string | undefined
685
+ readonly description: string | undefined
686
+ readonly tags: readonly string[] | undefined
687
+ readonly operationId: string | undefined
688
+ readonly deprecated: boolean
689
+ readonly security: readonly SecurityRequirement[] | undefined
690
+ readonly externalDocs: ExternalDocs | undefined
691
+ }
692
+
693
+ function readMeta(route: RouteRecord): ResolvedMeta {
694
+ const meta = route.meta
695
+ const text = (key: string): string | undefined => {
696
+ const value = meta.get(key)
697
+ return typeof value === 'string' && value.length > 0 ? value : undefined
698
+ }
699
+ const tags = meta.get('tags')
700
+ const security = meta.get('security')
701
+ const externalDocs = meta.get('externalDocs')
702
+ return {
703
+ summary: text('summary'),
704
+ description: text('description'),
705
+ operationId: text('operationId'),
706
+ tags: Array.isArray(tags) ? (tags.filter((tag) => typeof tag === 'string') as string[]) : undefined,
707
+ deprecated: meta.get('deprecated') === true,
708
+ security: Array.isArray(security) ? (security as readonly SecurityRequirement[]) : undefined,
709
+ externalDocs:
710
+ typeof externalDocs === 'object' && externalDocs !== null ? (externalDocs as ExternalDocs) : undefined,
711
+ }
712
+ }
713
+
714
+ function buildTags(
715
+ declared: readonly TagObject[] | undefined,
716
+ used: ReadonlySet<string>,
717
+ collections: readonly CollectionRecord[],
718
+ ): TagObject[] {
719
+ const out: TagObject[] = []
720
+ const seen = new Set<string>()
721
+ for (const tag of declared ?? []) {
722
+ out.push(tag)
723
+ seen.add(tag.name)
724
+ }
725
+ const describedBy = new Map<string, string>()
726
+ for (const collection of collections) {
727
+ const description = collection.meta.get('description')
728
+ if (typeof description !== 'string') continue
729
+ for (const tag of collection.tags) describedBy.set(tag, description)
730
+ }
731
+ for (const name of [...used].sort()) {
732
+ if (seen.has(name)) continue
733
+ const description = describedBy.get(name)
734
+ out.push(description === undefined ? { name } : { name, description })
735
+ }
736
+ return out
737
+ }
738
+
739
+ function defaultOperationId(route: RouteRecord): string {
740
+ let out = route.method.toLowerCase()
741
+ for (const segment of route.segments) {
742
+ out += segment.kind === 'static' ? pascal(segment.value) : `By${pascal(segment.value)}`
743
+ }
744
+ return out
745
+ }
746
+
747
+ function pascal(raw: string): string {
748
+ return raw
749
+ .split(/[^A-Za-z0-9]+/)
750
+ .filter((part) => part.length > 0)
751
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
752
+ .join('')
753
+ }
754
+
755
+ /**
756
+ * The schema a variant record declares for one media type.
757
+ *
758
+ * Keys are matched through `normaliseMediaType` rather than compared directly,
759
+ * for the same reason the planner does it: `RouteRecord.negotiation.offers`
760
+ * holds the normalised names, and a route that wrote `Application/JSON` would
761
+ * otherwise be documented as having no schema at all — a silent hole in the
762
+ * document produced by a difference in capitalisation.
763
+ */
764
+ function variantSchema(variants: Record<string, unknown>, media: string): unknown {
765
+ for (const raw of Object.keys(variants)) {
766
+ const parsed = normaliseMediaType(raw)
767
+ if (!isMediaProblem(parsed) && parsed.media === media) return variants[raw]
768
+ }
769
+ return undefined
770
+ }
771
+
772
+ function descriptionOf(json: JsonSchema): string | undefined {
773
+ return typeof json.description === 'string' && json.description.length > 0 ? json.description : undefined
774
+ }
775
+
776
+ /** Sorted, because a document that reorders itself between runs cannot be diffed. */
777
+ function sortRecord(map: ReadonlyMap<string, Record<string, unknown>>): Record<string, PathItemObject> {
778
+ const out: Record<string, PathItemObject> = {}
779
+ for (const key of [...map.keys()].sort()) out[key] = map.get(key) as unknown as PathItemObject
780
+ return out
781
+ }
782
+
783
+ function buildInfo(options: OpenApiOptions): Record<string, unknown> {
784
+ const info: Record<string, unknown> = { title: options.title, version: options.version }
785
+ if (options.summary !== undefined) info['summary'] = options.summary
786
+ if (options.description !== undefined) info['description'] = options.description
787
+ if (options.license !== undefined) info['license'] = options.license
788
+ if (options.contact !== undefined) info['contact'] = options.contact
789
+ return info
790
+ }
791
+
792
+ const STATUS_TEXT: Readonly<Record<number, string>> = {
793
+ 200: 'OK', 201: 'Created', 202: 'Accepted', 204: 'No Content',
794
+ 301: 'Moved Permanently', 302: 'Found', 303: 'See Other', 304: 'Not Modified',
795
+ 307: 'Temporary Redirect', 308: 'Permanent Redirect',
796
+ 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found',
797
+ 405: 'Method Not Allowed', 406: 'Not Acceptable', 408: 'Request Timeout', 409: 'Conflict',
798
+ 413: 'Payload Too Large', 415: 'Unsupported Media Type', 422: 'Unprocessable Content',
799
+ 429: 'Too Many Requests',
800
+ 500: 'Internal Server Error', 502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Timeout',
801
+ }
802
+
803
+ function statusText(status: number): string {
804
+ return STATUS_TEXT[status] ?? `Status ${status}`
805
+ }
806
+
807
+ /**
808
+ * The error envelope Zen actually produces — `ZenError#toProblem`, served as
809
+ * `application/problem+json`. `debug` appears only when `dev` is on, which is
810
+ * why it is optional and why `additionalProperties` is still `false`.
811
+ */
812
+ const PROBLEM_DETAILS: JsonSchema = {
813
+ type: 'object',
814
+ title: 'ProblemDetails',
815
+ description: 'RFC 9457 problem document. Every Zen error response has this shape.',
816
+ properties: {
817
+ type: { type: 'string', format: 'uri', description: 'Stable documentation URL for the error code.' },
818
+ title: { type: 'string', description: 'Human-readable summary. Never leaks internals for non-exposed errors.' },
819
+ status: { type: 'integer' },
820
+ instance: { type: 'string', description: 'The request path this occurred on.' },
821
+ code: { type: 'string', description: 'Stable Zen error code — rfcs/0001 Annex B.' },
822
+ requestId: { type: 'string' },
823
+ errors: {
824
+ type: 'array',
825
+ description: 'Present on validation failures: one entry per failing field, across every source that failed.',
826
+ items: {
827
+ type: 'object',
828
+ properties: {
829
+ source: {
830
+ type: 'string',
831
+ enum: ['params', 'query', 'headers', 'cookies', 'body'],
832
+ description: 'Which part of the request the issue is in.',
833
+ },
834
+ path: { type: 'array', items: { type: ['string', 'integer'] } },
835
+ code: { type: 'string' },
836
+ message: { type: 'string' },
837
+ expected: { type: 'string' },
838
+ received: { type: 'string' },
839
+ },
840
+ required: ['path', 'code', 'message'],
841
+ additionalProperties: false,
842
+ },
843
+ },
844
+ debug: { type: 'object', description: 'Development mode only — stack and cause.', additionalProperties: true },
845
+ },
846
+ required: ['type', 'title', 'status', 'instance', 'code', 'requestId'],
847
+ additionalProperties: false,
848
+ }
849
+
850
+
851
+ /**
852
+ * How a list parameter is spelled on the wire — §11.4 projected into §29.
853
+ *
854
+ * OpenAPI's default for a `query` parameter is `style: form, explode: true`,
855
+ * which is the `repeat` spelling: `?tags=a&tags=b`. An application that has
856
+ * asked for `comma` parses `?tags=a,b` instead, and a generated client working
857
+ * from the default would send a request the server reads as one element named
858
+ * `a,b`. So the one case that differs from the default is written down, and the
859
+ * cases that agree with it are not — a document that restates every default is
860
+ * a document nobody diffs.
861
+ *
862
+ * The value comes from `route.coercion`, which is the *same plan the coercer
863
+ * was generated from*. That is the point rather than a convenience: this is the
864
+ * §29.1 property applied to request parameters — the document cannot describe a
865
+ * serialization the service does not parse, because there is one structure and
866
+ * both read it.
867
+ *
868
+ * `header` needs nothing: `style: simple` is the OpenAPI default there and it
869
+ * already means comma-separated, which is what §11.4 defaults headers to.
870
+ */
871
+ function listStyle(
872
+ location: ParameterLocation,
873
+ plan: CoercePlan | undefined,
874
+ name: string,
875
+ ): Record<string, unknown> {
876
+ if (plan === undefined || location !== 'query') return {}
877
+ const field = plan.fields.find((f) => f.key === name)
878
+ const op = field?.op
879
+ if (op === undefined || op === null || op.kind !== 'array') return {}
880
+ if (op.split !== ',') return {}
881
+ return { style: 'form', explode: false }
882
+ }