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