@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/LICENSE.MD +9 -0
- package/README.md +39 -0
- package/dist/index.cjs +649 -0
- package/dist/index.d.cts +306 -0
- package/dist/index.d.mts +306 -0
- package/dist/index.mjs +644 -0
- package/package.json +78 -0
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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 };
|