@kizunajs/openapi 2.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,644 @@
1
+ import { z } from "zod";
2
+ import { createPlugin } from "kizunajs/plugin";
3
+ import { contractOf, rawResponse } from "kizunajs/adapter";
4
+ import { stringify } from "yaml";
5
+ import { cacheHeaders, createGenerator, deprecationHeaders, globalRegistrySchemas, isBinarySchema, isFileSchema, isNamedStream, isStreamResponse, isVoidSchema, parsePath, readDiscriminatedUnion, readDiscriminatorLiteral, readMeta, readMetaId, readObjectShape, resolveResponseBody, resolveResponseCache, resolveResponseContentType, resolveResponseEtag, resolveResponseHeaders, streamContentType, streamMode } from "kizunajs/generator";
6
+ import { getStatusText } from "kizunajs";
7
+ //#region src/generator.ts
8
+ const streamJsonSchema = (response, toJsonSchema) => {
9
+ const mode = streamMode(response);
10
+ if (mode === "text") return { type: "string" };
11
+ if (mode === "binary") return {
12
+ type: "string",
13
+ format: "binary"
14
+ };
15
+ const eventFields = {
16
+ id: { type: "string" },
17
+ retry: { type: "integer" }
18
+ };
19
+ const message = (data, event) => ({
20
+ type: "object",
21
+ required: event === void 0 ? ["data"] : ["event", "data"],
22
+ properties: {
23
+ ...event === void 0 ? {} : { event: { const: event } },
24
+ data,
25
+ ...eventFields
26
+ }
27
+ });
28
+ if (isNamedStream(response.stream)) return { oneOf: Object.entries(response.stream).map(([event, schema]) => message(toJsonSchema(schema, "output"), event)) };
29
+ return message(toJsonSchema(response.stream, "output"));
30
+ };
31
+ const convertPath = (path) => {
32
+ const { segments } = parsePath(path);
33
+ return segments.map((segment) => segment.kind === "literal" ? segment.value : `{${segment.value}}`).join("");
34
+ };
35
+ const COMPONENT_REF_BASE = "#/components/schemas/";
36
+ const ZOD_DEF_REF_BASE = "#/$defs/";
37
+ const omit = (input, keys) => {
38
+ const result = {};
39
+ for (const [key, value] of Object.entries(input)) if (!keys.includes(key)) result[key] = value;
40
+ return result;
41
+ };
42
+ const rewriteRefs = (value) => {
43
+ if (Array.isArray(value)) return value.map(rewriteRefs);
44
+ if (value && typeof value === "object") {
45
+ const result = {};
46
+ for (const [key, child] of Object.entries(value)) if (key === "$ref" && typeof child === "string" && child.startsWith(ZOD_DEF_REF_BASE)) result[key] = COMPONENT_REF_BASE + child.slice(8);
47
+ else result[key] = rewriteRefs(child);
48
+ return result;
49
+ }
50
+ return value;
51
+ };
52
+ const applyFileBinary = (zodSchema, jsonSchema) => {
53
+ if (!jsonSchema || typeof jsonSchema !== "object") return;
54
+ const shape = readObjectShape(zodSchema);
55
+ if (!shape) return;
56
+ const properties = jsonSchema.properties;
57
+ if (!properties) return;
58
+ for (const [key, child] of Object.entries(shape)) if (isFileSchema(child)) properties[key] = {
59
+ type: "string",
60
+ format: "binary"
61
+ };
62
+ else applyFileBinary(child, properties[key]);
63
+ };
64
+ const buildDiscriminatorBlock = (propertyName, options) => {
65
+ const mapping = {};
66
+ let mappingComplete = true;
67
+ for (const variant of options) {
68
+ const variantId = readMetaId(variant);
69
+ const literal = readDiscriminatorLiteral(variant, propertyName);
70
+ if (variantId === void 0 || literal === void 0) {
71
+ mappingComplete = false;
72
+ break;
73
+ }
74
+ mapping[String(literal)] = COMPONENT_REF_BASE + variantId;
75
+ }
76
+ return mappingComplete && Object.keys(mapping).length > 0 ? {
77
+ propertyName,
78
+ mapping
79
+ } : { propertyName };
80
+ };
81
+ /**
82
+ * kizuna's metadata widens JSON Schema: `deprecated` may carry a message and
83
+ * `example` holds one value or several. Emit `deprecated: true` and an
84
+ * `examples` array; the message stays in outputs with a place for it, like
85
+ * Swift's `@available`.
86
+ */
87
+ const normalizeMeta = ({ jsonSchema }) => {
88
+ if (typeof jsonSchema.deprecated === "string") jsonSchema.deprecated = true;
89
+ if ("example" in jsonSchema) {
90
+ const given = Array.isArray(jsonSchema.example) ? jsonSchema.example : [jsonSchema.example];
91
+ const declared = Array.isArray(jsonSchema.examples) ? jsonSchema.examples : [];
92
+ jsonSchema.examples = [...given, ...declared];
93
+ delete jsonSchema.example;
94
+ }
95
+ };
96
+ const toJsonSchema = (schema, io = "output") => {
97
+ const id = readMetaId(schema);
98
+ if (id) return { $ref: COMPONENT_REF_BASE + id };
99
+ const discriminated = readDiscriminatedUnion(schema);
100
+ if (discriminated) return {
101
+ ...rewriteRefs(omit(z.toJSONSchema(schema, {
102
+ unrepresentable: "any",
103
+ io,
104
+ override: normalizeMeta
105
+ }), ["$defs", "$schema"])),
106
+ discriminator: buildDiscriminatorBlock(discriminated.discriminator, discriminated.options)
107
+ };
108
+ const result = rewriteRefs(omit(z.toJSONSchema(schema, {
109
+ unrepresentable: "any",
110
+ io,
111
+ override: normalizeMeta
112
+ }), ["$defs", "$schema"]));
113
+ applyFileBinary(schema, result);
114
+ return result;
115
+ };
116
+ const buildComponentSchemas = () => {
117
+ const discriminators = /* @__PURE__ */ new Map();
118
+ const modelRegistry = z.registry();
119
+ for (const [id, schema] of globalRegistrySchemas().entries()) {
120
+ modelRegistry.add(schema, readMeta(schema));
121
+ const def = readDiscriminatedUnion(schema);
122
+ if (def) discriminators.set(id, buildDiscriminatorBlock(def.discriminator, def.options));
123
+ }
124
+ const schemas = z.toJSONSchema(modelRegistry, {
125
+ uri: (id) => COMPONENT_REF_BASE + id,
126
+ unrepresentable: "any",
127
+ io: "output",
128
+ override: normalizeMeta
129
+ }).schemas;
130
+ const cleaned = {};
131
+ for (const [id, schema] of Object.entries(schemas)) {
132
+ const base = rewriteRefs(omit(schema, [
133
+ "$schema",
134
+ "$defs",
135
+ "$id"
136
+ ]));
137
+ const discriminator = discriminators.get(id);
138
+ cleaned[id] = discriminator ? {
139
+ ...base,
140
+ discriminator
141
+ } : base;
142
+ }
143
+ return Object.keys(cleaned).length > 0 ? cleaned : void 0;
144
+ };
145
+ const deriveHeadOperation = (getOperation) => {
146
+ const operation = structuredClone(getOperation);
147
+ delete operation.operationId;
148
+ for (const response of Object.values(operation.responses)) delete response.content;
149
+ return operation;
150
+ };
151
+ const openApiGenerator = createGenerator((options, api) => {
152
+ const paths = {};
153
+ return {
154
+ processRoute({ routeKey, route, routeTags, deprecated }) {
155
+ const openApiPath = convertPath(route.path);
156
+ const method = route.method.toLowerCase();
157
+ let operation = {
158
+ summary: route.summary,
159
+ description: route.description,
160
+ responses: {}
161
+ };
162
+ if (options.setOperationId) operation.operationId = options.setOperationId === "concatenated-path" ? routeKey : routeKey.slice(routeKey.lastIndexOf(".") + 1);
163
+ if (deprecated) operation.deprecated = true;
164
+ const resolveTagTitle = (key) => options.tagLookup?.get(key)?.title ?? key;
165
+ const mergedTags = [...new Set([...routeTags, ...route.tags ?? []].map(resolveTagTitle))];
166
+ if (mergedTags.length > 0) operation.tags = mergedTags;
167
+ if (route.security && route.security.length > 0) {
168
+ const emittedSecurity = toOpenApiSecurity(route.security, api);
169
+ if (emittedSecurity.length > 0) operation.security = emittedSecurity;
170
+ const customGuards = customGuardsFor(route.security, api);
171
+ if (customGuards.length > 0) operation["x-kizuna-guarded"] = customGuards;
172
+ if (route.roles !== void 0) operation["x-kizuna-roles"] = [...route.roles];
173
+ if (route.requires !== void 0) operation["x-kizuna-requires"] = requiresExtension(route.requires);
174
+ }
175
+ if (route.externalDocs) operation.externalDocs = route.externalDocs;
176
+ const parameters = [];
177
+ const pathParamNames = parsePath(route.path).paramNames;
178
+ if (pathParamNames.length > 0) {
179
+ const pathProperties = (route.pathParams ? toJsonSchema(route.pathParams, "input") : void 0)?.properties ?? {};
180
+ for (const name of pathParamNames) parameters.push({
181
+ name,
182
+ in: "path",
183
+ required: true,
184
+ schema: pathProperties[name] ?? { type: "string" }
185
+ });
186
+ }
187
+ if (route.query) {
188
+ const querySchema = toJsonSchema(route.query, "input");
189
+ const queryProperties = querySchema.properties ?? {};
190
+ const queryRequired = querySchema.required ?? [];
191
+ for (const [name, valueSchema] of Object.entries(queryProperties)) parameters.push({
192
+ name,
193
+ in: "query",
194
+ required: queryRequired.includes(name),
195
+ schema: valueSchema
196
+ });
197
+ }
198
+ if (route.headers) {
199
+ const headersSchema = toJsonSchema(route.headers, "input");
200
+ const headerProperties = headersSchema.properties ?? {};
201
+ const headerRequired = headersSchema.required ?? [];
202
+ for (const [name, valueSchema] of Object.entries(headerProperties)) parameters.push({
203
+ name,
204
+ in: "header",
205
+ required: headerRequired.includes(name),
206
+ schema: valueSchema
207
+ });
208
+ }
209
+ if (parameters.length > 0) operation.parameters = parameters;
210
+ if (route.body && !isVoidSchema(route.body)) {
211
+ const contentType = route.contentType ?? "application/json";
212
+ operation.requestBody = {
213
+ required: true,
214
+ content: { [contentType]: { schema: toJsonSchema(route.body, "input") } }
215
+ };
216
+ }
217
+ for (const [statusKey, responseValue] of Object.entries(route.responses)) {
218
+ const headersSchema = resolveResponseHeaders(responseValue);
219
+ const description = getStatusText(Number(statusKey));
220
+ const headersObject = headersSchema ? Object.fromEntries(Object.entries(toJsonSchema(headersSchema, "output").properties ?? {}).map(([name, schema]) => [name, {
221
+ schema,
222
+ required: false
223
+ }])) : void 0;
224
+ if (isStreamResponse(responseValue)) {
225
+ operation.responses[statusKey] = {
226
+ description,
227
+ ...headersObject ? { headers: headersObject } : {},
228
+ content: { [streamContentType(responseValue)]: { schema: streamJsonSchema(responseValue, toJsonSchema) } }
229
+ };
230
+ continue;
231
+ }
232
+ const bodySchema = resolveResponseBody(responseValue);
233
+ const mediaType = resolveResponseContentType(responseValue) ?? (Number(statusKey) >= 400 ? "application/problem+json" : "application/json");
234
+ operation.responses[statusKey] = {
235
+ description,
236
+ ...headersObject ? { headers: headersObject } : {},
237
+ ...!isVoidSchema(bodySchema) && route.method !== "HEAD" ? { content: { [mediaType]: { schema: isBinarySchema(bodySchema) ? {
238
+ type: "string",
239
+ format: "binary"
240
+ } : toJsonSchema(bodySchema, "output") } } } : {}
241
+ };
242
+ }
243
+ if (route.body || route.query) {
244
+ const validationSchema = {
245
+ type: "object",
246
+ required: [
247
+ "type",
248
+ "title",
249
+ "status",
250
+ "detail",
251
+ "errors"
252
+ ],
253
+ properties: {
254
+ type: { type: "string" },
255
+ title: { type: "string" },
256
+ status: { type: "integer" },
257
+ detail: { type: "string" },
258
+ errors: {
259
+ type: "array",
260
+ items: {
261
+ type: "object",
262
+ required: [
263
+ "code",
264
+ "path",
265
+ "message"
266
+ ],
267
+ properties: {
268
+ code: { type: "string" },
269
+ path: {
270
+ type: "array",
271
+ items: { type: "string" }
272
+ },
273
+ message: { type: "string" }
274
+ }
275
+ }
276
+ }
277
+ }
278
+ };
279
+ const existing = operation.responses["400"];
280
+ if (existing) {
281
+ const existingSchema = existing.content?.["application/problem+json"]?.schema;
282
+ operation.responses["400"] = {
283
+ description: getStatusText(400),
284
+ content: { "application/problem+json": { schema: { oneOf: [existingSchema, validationSchema].filter(Boolean) } } }
285
+ };
286
+ } else operation.responses["400"] = {
287
+ description: getStatusText(400),
288
+ content: { "application/problem+json": { schema: validationSchema } }
289
+ };
290
+ }
291
+ const announced = deprecationHeaders(route);
292
+ if (Object.keys(announced).length > 0) {
293
+ const documented = {};
294
+ if (announced["deprecation"]) documented["Deprecation"] = {
295
+ description: "The date the route became deprecated, per RFC 9745.",
296
+ schema: { type: "string" }
297
+ };
298
+ if (announced["sunset"]) documented["Sunset"] = {
299
+ description: "When the route will be removed, per RFC 8594.",
300
+ schema: { type: "string" }
301
+ };
302
+ if (announced["link"]) documented["Link"] = {
303
+ description: "Links to documentation about the deprecation and the retirement policy.",
304
+ schema: { type: "string" }
305
+ };
306
+ for (const response of Object.values(operation.responses)) response.headers = {
307
+ ...documented,
308
+ ...response.headers
309
+ };
310
+ }
311
+ for (const [statusKey, response] of Object.entries(operation.responses)) {
312
+ const declared = route.responses[Number(statusKey)];
313
+ const policy = cacheHeaders(resolveResponseCache(declared));
314
+ const documented = {};
315
+ if (policy["cache-control"]) documented["Cache-Control"] = {
316
+ description: "How this response may be cached, per RFC 9111.",
317
+ schema: {
318
+ type: "string",
319
+ example: policy["cache-control"]
320
+ }
321
+ };
322
+ if (policy["vary"]) documented["Vary"] = {
323
+ description: "The request headers that change this response, which a cache keys on.",
324
+ schema: {
325
+ type: "string",
326
+ example: policy["vary"]
327
+ }
328
+ };
329
+ if (resolveResponseEtag(declared)) documented["ETag"] = {
330
+ description: "The entity tag of this response, per RFC 9110. Send it back as `If-None-Match` to be answered `304 Not Modified` when it still holds.",
331
+ schema: { type: "string" }
332
+ };
333
+ if (Object.keys(documented).length === 0) continue;
334
+ response.headers = {
335
+ ...documented,
336
+ ...response.headers
337
+ };
338
+ }
339
+ if (options.operationMapper) operation = options.operationMapper(operation, route, routeKey);
340
+ if (!paths[openApiPath]) paths[openApiPath] = {};
341
+ paths[openApiPath][method] = operation;
342
+ },
343
+ finalize() {
344
+ if (options.derivedHead) for (const operations of Object.values(paths)) {
345
+ if (!operations["get"] || operations["head"]) continue;
346
+ operations["head"] = deriveHeadOperation(operations["get"]);
347
+ }
348
+ const document = {
349
+ openapi: options.openApiVersion ?? "3.1.0",
350
+ info: options.info,
351
+ paths
352
+ };
353
+ if (options.servers) document.servers = options.servers;
354
+ if (options.externalDocs) document.externalDocs = options.externalDocs;
355
+ const componentSchemas = buildComponentSchemas();
356
+ const securitySchemes = buildSecuritySchemes(api);
357
+ if (componentSchemas || securitySchemes) document.components = {
358
+ ...securitySchemes ? { securitySchemes } : {},
359
+ ...componentSchemas ? { schemas: componentSchemas } : {}
360
+ };
361
+ const renderer = (format) => {
362
+ if (format === "yaml") return stringify(document);
363
+ return document;
364
+ };
365
+ return renderer;
366
+ }
367
+ };
368
+ });
369
+ /**
370
+ * The permissions a route requires, as `x-kizuna-requires`: what is acted on,
371
+ * mapped to the verbs the caller has to hold.
372
+ */
373
+ const requiresExtension = (requires) => Object.fromEntries(Object.entries(requires).map(([resource, verbs]) => [resource, [...verbs]]));
374
+ /**
375
+ * Whether a scheme is a `custom` identity: registered, but with no OpenAPI scheme
376
+ * to emit. Dropped from `security`, surfaced under `x-kizuna-guarded`.
377
+ */
378
+ const isCustomScheme = (name, api) => {
379
+ const scheme = api.securitySchemes?.[name];
380
+ return scheme !== void 0 && scheme.openapi === void 0;
381
+ };
382
+ /**
383
+ * Map a route's resolved `security` (scheme names / `{ scheme: scopes }` maps)
384
+ * to the OpenAPI `operation.security` shape. Any `custom` scheme is dropped (see
385
+ * {@link isCustomScheme}); a requirement object left empty is omitted.
386
+ */
387
+ const toOpenApiSecurity = (security, api) => {
388
+ const emittedName = (name) => api.securitySchemes?.[name]?.scheme ?? name;
389
+ const result = [];
390
+ for (const entry of security) {
391
+ if (typeof entry === "string") {
392
+ if (!isCustomScheme(entry, api)) result.push({ [emittedName(entry)]: [] });
393
+ continue;
394
+ }
395
+ const describable = Object.entries(entry).filter(([scheme]) => !isCustomScheme(scheme, api));
396
+ if (describable.length > 0) result.push(Object.fromEntries(describable.map(([scheme, scopes]) => [emittedName(scheme), [...scopes ?? []]])));
397
+ }
398
+ return result;
399
+ };
400
+ /**
401
+ * The `custom` schemes guarding a route, in their registered names, for the
402
+ * `x-kizuna-guarded` extension.
403
+ */
404
+ const customGuardsFor = (security, api) => {
405
+ const emittedName = (name) => api.securitySchemes?.[name]?.scheme ?? name;
406
+ const names = [];
407
+ for (const entry of security) {
408
+ const schemeNames = typeof entry === "string" ? [entry] : Object.keys(entry);
409
+ for (const name of schemeNames) if (isCustomScheme(name, api) && !names.includes(emittedName(name))) names.push(emittedName(name));
410
+ }
411
+ return names;
412
+ };
413
+ /**
414
+ * Build the `components.securitySchemes` object from the identities registered
415
+ * on the api. Each contributes its OpenAPI definition under its name.
416
+ */
417
+ const buildSecuritySchemes = (api) => {
418
+ const schemes = api.securitySchemes;
419
+ if (!schemes || Object.keys(schemes).length === 0) return void 0;
420
+ const result = {};
421
+ for (const [name, scheme] of Object.entries(schemes)) {
422
+ if (!scheme.openapi) continue;
423
+ result[scheme.scheme ?? name] = scheme.openapi;
424
+ }
425
+ return Object.keys(result).length > 0 ? result : void 0;
426
+ };
427
+ const buildTagLookup = (api) => new Map(Object.entries(api.tags?.tags ?? {}));
428
+ /**
429
+ * Document-level tag definitions from the api's declared tag set, one
430
+ * entry per declared tag, in declaration order, named by its `title`.
431
+ */
432
+ const tagsFromApi = (api) => {
433
+ const declared = api.tags?.tags;
434
+ if (!declared) return [];
435
+ const tags = [];
436
+ for (const options of Object.values(declared)) {
437
+ const entry = { name: options.title };
438
+ if (options.description) entry.description = options.description;
439
+ if (options.externalDocs) entry.externalDocs = options.externalDocs;
440
+ tags.push(entry);
441
+ }
442
+ return tags;
443
+ };
444
+ /**
445
+ * So a build step does not restate the options and drift from what is served.
446
+ */
447
+ const optionsFromInstalledPlugin = (api) => {
448
+ for (const declaration of Object.values(api.plugins ?? {})) if (declaration.slug === "openApi") return declaration.props;
449
+ throw new Error("generateOpenApi reads its options from the config. Name `openApiPlugin` under `plugins` with the API's `info`.");
450
+ };
451
+ /**
452
+ * Render from options held directly, for the plugin's own routes.
453
+ */
454
+ function renderOpenApi(api, options) {
455
+ const renderer = openApiGenerator(api, {
456
+ ...options,
457
+ tagLookup: buildTagLookup(api)
458
+ });
459
+ const tags = tagsFromApi(api);
460
+ if (tags.length > 0) renderer("json").tags = tags;
461
+ return renderer;
462
+ }
463
+ /**
464
+ * Generate an OpenAPI 3.1.0 document from an api. Returns a renderer: call
465
+ * it with `'json'` or `'yaml'`. Options come from the `openApiPlugin` installed
466
+ * on the api, so the document matches the one the server serves.
467
+ *
468
+ * Pass `overrides` for what only a build step knows, such as the public
469
+ * `servers` list.
470
+ */
471
+ function generateOpenApi(api, overrides) {
472
+ const options = optionsFromInstalledPlugin(api);
473
+ return renderOpenApi(api, overrides ? {
474
+ ...options,
475
+ ...overrides
476
+ } : options);
477
+ }
478
+ //#endregion
479
+ //#region src/docs-html.ts
480
+ const SCALAR_CDN = "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1";
481
+ const SWAGGER_CDN = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5";
482
+ const escapeHtml = (value) => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
483
+ /**
484
+ * JSON for embedding in an inline `<script>`. Escaping `<` keeps a `<\/script>`
485
+ * inside a config string from closing the tag early.
486
+ */
487
+ const embedJson = (value) => JSON.stringify(value, null, 4).replace(/</g, "\\u003c").split("\n").join("\n ");
488
+ const scalarPage = (title, cdnUrl, configuration) => `<!doctype html>
489
+ <html lang="en">
490
+ <head>
491
+ <meta charset="utf-8" />
492
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
493
+ <title>${escapeHtml(title)}</title>
494
+ </head>
495
+ <body>
496
+ <div id="app"></div>
497
+ <script src="${escapeHtml(cdnUrl)}"><\/script>
498
+ <script>
499
+ Scalar.createApiReference('#app', ${embedJson(configuration)});
500
+ <\/script>
501
+ </body>
502
+ </html>
503
+ `;
504
+ const swaggerPage = (title, cdnUrl, configuration) => {
505
+ const base = cdnUrl.replace(/\/$/, "");
506
+ return `<!doctype html>
507
+ <html lang="en">
508
+ <head>
509
+ <meta charset="utf-8" />
510
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
511
+ <title>${escapeHtml(title)}</title>
512
+ <link rel="stylesheet" href="${escapeHtml(base)}/swagger-ui.css" />
513
+ </head>
514
+ <body>
515
+ <div id="app"></div>
516
+ <script src="${escapeHtml(base)}/swagger-ui-bundle.js"><\/script>
517
+ <script>
518
+ SwaggerUIBundle(${embedJson(configuration)});
519
+ <\/script>
520
+ </body>
521
+ </html>
522
+ `;
523
+ };
524
+ /**
525
+ * Render a self-contained page that loads an API reference UI over your OpenAPI
526
+ * document. `openApiPlugin` calls this, so reach for it directly only when
527
+ * kizuna has no adapter for your framework.
528
+ *
529
+ * @example
530
+ * ```ts
531
+ * const html = renderDocsHtml({
532
+ * specUrl: '/openapi.json',
533
+ * pageTitle: 'My API',
534
+ * });
535
+ * ```
536
+ */
537
+ const renderDocsHtml = (options) => {
538
+ const title = options.pageTitle ?? "API Reference";
539
+ if ((options.provider ?? "scalar") === "swagger") return swaggerPage(title, options.cdnUrl ?? SWAGGER_CDN, {
540
+ dom_id: "#app",
541
+ ...options.specUrl ? { url: options.specUrl } : { spec: options.specContent },
542
+ ...options.configuration
543
+ });
544
+ return scalarPage(title, options.cdnUrl ?? SCALAR_CDN, {
545
+ ...options.specUrl ? { url: options.specUrl } : { content: options.specContent },
546
+ ...options.configuration
547
+ });
548
+ };
549
+ //#endregion
550
+ //#region src/document-target.ts
551
+ /**
552
+ * An OpenAPI document target for `kizuna.config.ts`.
553
+ *
554
+ * @example
555
+ * openApiDocument({ output: './openapi.yaml' });
556
+ */
557
+ const openApiDocument = ({ output, ...overrides }) => ({
558
+ kind: "openapi",
559
+ output,
560
+ generate: (contract) => {
561
+ const render = generateOpenApi(contract, overrides);
562
+ return output.endsWith(".json") ? `${JSON.stringify(render("json"), null, 4)}\n` : render("yaml");
563
+ }
564
+ });
565
+ //#endregion
566
+ //#region src/server.ts
567
+ const HTML = "text/html; charset=utf-8";
568
+ const JSON_TYPE = "application/json";
569
+ const YAML = "text/yaml; charset=utf-8";
570
+ const sent = (body, contentType) => new Response(body, {
571
+ status: 200,
572
+ headers: { "content-type": contentType }
573
+ });
574
+ /**
575
+ * What answers the routes `openApiPlugin` declares: the reference UI, and the
576
+ * document itself where the declaration published it.
577
+ */
578
+ const openApiServe = (props, api) => {
579
+ const spec = renderOpenApi(contractOf(api), props);
580
+ return { router: {
581
+ page: () => rawResponse(sent(renderDocsHtml({
582
+ specUrl: props.jsonPath,
583
+ specContent: props.jsonPath === void 0 ? spec("json") : void 0,
584
+ provider: props.provider,
585
+ pageTitle: props.pageTitle ?? spec("json").info.title,
586
+ cdnUrl: props.cdnUrl,
587
+ configuration: props.configuration
588
+ }), HTML)),
589
+ json: () => rawResponse(sent(JSON.stringify(spec("json")), JSON_TYPE)),
590
+ yaml: () => rawResponse(sent(spec("yaml"), YAML))
591
+ } };
592
+ };
593
+ /**
594
+ * Serve an API reference UI for the OpenAPI document, and the document itself
595
+ * if you publish it.
596
+ *
597
+ * The routes are public. Gate them with your framework's own middleware if that
598
+ * is not what you want.
599
+ *
600
+ * @example
601
+ * ```ts
602
+ * export default defineConfig({
603
+ * routes,
604
+ * plugins: [
605
+ * openApiPlugin({
606
+ * info: {
607
+ * title: 'My API',
608
+ * version: '1.0.0',
609
+ * },
610
+ * }),
611
+ * ],
612
+ * });
613
+ * ```
614
+ */
615
+ const declare = (slug, props) => createPlugin({
616
+ slug,
617
+ routes: {
618
+ ...props.docsPath === void 0 ? {} : { page: {
619
+ method: "GET",
620
+ path: props.docsPath,
621
+ summary: "API reference",
622
+ responses: { 200: z.string() }
623
+ } },
624
+ ...props.jsonPath === void 0 ? {} : { json: {
625
+ method: "GET",
626
+ path: props.jsonPath,
627
+ summary: "OpenAPI document",
628
+ responses: { 200: z.unknown() }
629
+ } },
630
+ ...props.yamlPath === void 0 ? {} : { yaml: {
631
+ method: "GET",
632
+ path: props.yamlPath,
633
+ summary: "OpenAPI document, as YAML",
634
+ responses: { 200: z.string() }
635
+ } }
636
+ },
637
+ props,
638
+ serve: (pluginProps, api) => openApiServe(pluginProps, api)
639
+ });
640
+ function openApiPlugin(props) {
641
+ return declare(props.slug ?? "openApi", props);
642
+ }
643
+ //#endregion
644
+ export { generateOpenApi, openApiDocument, openApiPlugin, renderDocsHtml, renderOpenApi };