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