@clov-std/elysia-openapi 0.0.0-stage → 1.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Clov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="https://cdn.jsdelivr.net/gh/ClovLabs/std@main/packages/elysia-openapi/logo-elysia-openapi.svg" alt="Clov Elysia OpenAPI logo" width="200" />
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ # Clov Elysia OpenAPI
6
+
7
+ An OpenAPI 3.2 document and a Scalar reference for your Elysia app, built from the schemas you already wrote on your routes.
8
+
9
+ The document is generated on the first request from `app.routes` and `app.models`, so routes registered after the plugin show up too. It covers `params`, `query`, `headers`, `cookie`, `body` and `response` from routes, guards and macros, and models end up in `components.schemas`.
10
+
11
+ Since it targets 3.2, `QUERY` routes, nested tags (`a/b` becomes `b` under `a`) and JSON Schema 2020-12 are described as they are. The OpenAPI 3.2 types (`OpenAPIObject`, `OperationObject`, `SchemaObject`, ...) are exported as well.
12
+
13
+ ```bash
14
+ bun add @clov-std/elysia-openapi elysia typebox
15
+ ```
16
+
17
+ `elysia` (v2) and `typebox` are peer dependencies.
18
+
19
+ ## Usage
20
+
21
+ ```ts
22
+ import { openapiPlugin } from '@clov-std/elysia-openapi';
23
+ import { Elysia, t } from 'elysia';
24
+
25
+ const app = new Elysia()
26
+ .use(openapiPlugin({ document: { info: { title: 'My API', version: '1.0.0' } } }))
27
+ .get(
28
+ '/users/:id',
29
+ {
30
+ params: t.Object({ id: t.String() }),
31
+ response: t.Object({ id: t.String() }),
32
+ detail: { tags: ['users'], summary: 'Get a user' }
33
+ },
34
+ ({ params }) => ({ id: params.id })
35
+ )
36
+ .listen(3000);
37
+ ```
38
+
39
+ The reference is at `http://localhost:3000/openapi`, the JSON at `http://localhost:3000/openapi/json`. Both still work under a prefix.
40
+
41
+ Each operation gets an `operationId` derived from the method and path (`get` + `/users/:id` gives `getUsersById`), unique across the document.
42
+
43
+ ### Response headers
44
+
45
+ ```ts
46
+ import { withHeaders } from '@clov-std/elysia-openapi';
47
+
48
+ app.get(
49
+ '/users',
50
+ { response: withHeaders(t.Array(t.String()), t.Object({ 'x-total-count': t.Integer() })) },
51
+ () => []
52
+ );
53
+ ```
54
+
55
+ This only documents the headers. Elysia doesn't validate response headers.
56
+
57
+ ## API
58
+
59
+ ### `openapiPlugin(options?)`
60
+
61
+ Returns a function to pass to `app.use`.
62
+
63
+ | Option | Type | Default | Description |
64
+ | ---------- | ------------------------ | -------------------- | ------------------------------------------------------------------------------------- |
65
+ | `path` | `string` | `'/openapi'` | Path of the Scalar reference. |
66
+ | `specPath` | `string` | `` `${path}/json` `` | Path of the JSON document. |
67
+ | `scalar` | `ScalarConfiguration` | - | Scalar configuration, merged over the defaults. |
68
+ | `document` | `Partial<OpenAPIObject>` | - | Document fields to merge, generated `paths` and `components.schemas` take precedence. |
69
+
70
+ ### `withHeaders(schema, headers)`
71
+
72
+ Returns a copy of the response `schema` documented with the `headers` object schema.
73
+
74
+ ## Docs
75
+
76
+ API reference: [clovlabs.github.io/std](https://clovlabs.github.io/std/)
77
+
78
+ ## License
79
+
80
+ MIT, see [LICENSE.md](LICENSE.md).
@@ -0,0 +1,11 @@
1
+ import { type AnyElysia } from 'elysia';
2
+ import type { OpenAPIObject } from './openapi-types-3.2';
3
+ /**
4
+ * Builds the OpenAPI 3.2 document from the routes and models registered on `app`.
5
+ *
6
+ * @param app - The Elysia instance to document
7
+ * @param base - Document fields to merge, generated `paths` and `components.schemas` take precedence on conflicts
8
+ *
9
+ * @returns The OpenAPI document
10
+ */
11
+ export declare const buildDocument: (app: AnyElysia, base?: Partial<OpenAPIObject>) => OpenAPIObject;
@@ -0,0 +1,4 @@
1
+ export { openapiPlugin, type OpenAPIOptions } from './openapi';
2
+ export { withHeaders } from './schema';
3
+ export type { ScalarConfiguration } from './scalar-types';
4
+ export type { SpecificationExtensions, OpenAPIObject, InfoObject, ContactObject, LicenseObject, ServerObject, ServerVariableObject, ComponentsObject, PathsObject, PathItemObject, OperationObject, ExternalDocumentationObject, ParameterLocation, ParameterStyle, ParameterObjectBase, ParameterObjectWithSchema, ParameterObjectWithContent, ParameterObject, RequestBodyObject, MediaTypeObject, EncodingObject, ResponsesObject, ResponseObject, CallbackObject, ExampleObject, LinkObject, HeaderObjectBase, HeaderObjectWithSchema, HeaderObjectWithContent, HeaderObject, TagObject, ReferenceObject, JsonSchemaType, SchemaObjectDefinition, SchemaObject, DiscriminatorObject, XMLNodeType, XMLObject, SecuritySchemeObjectBase, ApiKeySecuritySchemeObject, HttpSecuritySchemeObject, MutualTLSSecuritySchemeObject, OAuth2SecuritySchemeObject, OpenIdConnectSecuritySchemeObject, SecuritySchemeObject, OAuthFlowsObject, OAuthFlowObject, SecurityRequirementObject } from './openapi-types-3.2';
package/dist/index.js ADDED
@@ -0,0 +1,550 @@
1
+ // @bun
2
+ // src/openapi.ts
3
+ import { Elysia } from "elysia";
4
+
5
+ // src/document.ts
6
+ import { StatusMapBack } from "elysia";
7
+ import { isRecordNumber } from "elysia/utils";
8
+ import { IsOptional, IsUnion as IsUnion2 } from "typebox";
9
+
10
+ // src/schema.ts
11
+ import { Intersect, IsIntersect, IsKind, IsObject, IsRef, IsUnion, Union } from "typebox";
12
+ var TEXT_TYPES = new Set(["string", "number", "integer", "boolean"]);
13
+ var componentRef = (name) => `#/components/schemas/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`;
14
+ var uniqueSchemas = (schemas) => schemas.filter((schema, index) => schemas.findIndex((other) => Bun.deepEquals(other, schema)) === index);
15
+ var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
16
+ var toEnum = (schema) => {
17
+ const { anyOf, ...rest } = schema;
18
+ if (!Array.isArray(anyOf) || anyOf.length === 0 || !anyOf.every((branch) => isPlainObject(branch) && ("const" in branch) && Object.keys(branch).every((key) => key === "const" || key === "type")))
19
+ return schema;
20
+ const branches = anyOf;
21
+ const types = new Set(branches.map((branch) => branch.type));
22
+ const [type] = types;
23
+ return {
24
+ ...rest,
25
+ ...types.size === 1 && type !== undefined && { type },
26
+ enum: branches.map((branch) => branch.const)
27
+ };
28
+ };
29
+ var toSchema = (node) => {
30
+ if (Array.isArray(node))
31
+ return node.filter((item) => item?.type !== "undefined").map(toSchema);
32
+ if (!isPlainObject(node))
33
+ return node;
34
+ if (["File", "ArrayBuffer", "Uint8Array"].some((kind) => IsKind(node, kind)))
35
+ return { contentMediaType: "application/octet-stream" };
36
+ if (IsKind(node, "Date"))
37
+ return { type: "string", format: "date-time" };
38
+ if (IsKind(node, "FormData") || IsKind(node, "Void") || IsKind(node, "Undefined"))
39
+ return {};
40
+ if (IsKind(node, "BigInt"))
41
+ return { type: "integer" };
42
+ const schema = {};
43
+ for (const [key, value] of Object.entries(node)) {
44
+ if (key.startsWith("~") || key === "nullable" || key === "headers" || key === "$id")
45
+ continue;
46
+ if (key === "$ref" && typeof value === "string" && !value.startsWith("#") && !value.includes(":"))
47
+ schema[key] = componentRef(value);
48
+ else if (["properties", "patternProperties", "$defs", "dependentSchemas"].includes(key) && isPlainObject(value))
49
+ schema[key] = Object.fromEntries(Object.entries(value).map(([name, sub]) => [name, toSchema(sub)]));
50
+ else if (["const", "enum", "default", "example", "examples"].includes(key) || key.startsWith("x-"))
51
+ schema[key] = value;
52
+ else
53
+ schema[key] = toSchema(value);
54
+ }
55
+ if (node.nullable === true && !("anyOf" in node))
56
+ return { anyOf: [toEnum(schema), { type: "null" }] };
57
+ return toEnum(schema);
58
+ };
59
+ var withHeaders = (schema, headers) => Object.defineProperty(Object.create(Object.getPrototypeOf(schema), Object.getOwnPropertyDescriptors(schema)), "headers", { value: headers, enumerable: true });
60
+ var toRootSchema = (node) => typeof node === "string" ? { $ref: componentRef(node) } : toSchema(node);
61
+ var resolveModel = (node, models) => {
62
+ if (typeof node === "string")
63
+ return models[node];
64
+ if (IsRef(node))
65
+ return models[node.$ref];
66
+ return node;
67
+ };
68
+ var jsonTypeOf = (node) => node && ("type" in node) ? node.type : undefined;
69
+ var descriptionOf = (node) => node && ("description" in node) && typeof node.description === "string" ? node.description : undefined;
70
+ var hasDefault = (node) => node !== undefined && ("default" in node);
71
+ var isText = (node, models) => {
72
+ const schema = resolveModel(node, models);
73
+ return IsUnion(schema) ? schema.anyOf.every((branch) => isText(branch, models)) : TEXT_TYPES.has(jsonTypeOf(schema));
74
+ };
75
+ var isPrimitive = (node, models) => {
76
+ const schema = resolveModel(node, models);
77
+ return IsUnion(schema) ? schema.anyOf.every((branch) => isPrimitive(branch, models)) : jsonTypeOf(schema) === "null" || TEXT_TYPES.has(jsonTypeOf(schema));
78
+ };
79
+ var isRawBinary = (node) => IsKind(node, "ArrayBuffer") || IsKind(node, "Uint8Array");
80
+ var isFile = (node) => IsKind(node, "File");
81
+ var containsFile = (node, models, seen = new Set) => {
82
+ const schema = IsRef(node) ? models[node.$ref] : node;
83
+ if (typeof schema !== "object" || schema === null || seen.has(schema))
84
+ return false;
85
+ seen.add(schema);
86
+ return isFile(schema) || Object.values(schema).some((value) => containsFile(value, models, seen));
87
+ };
88
+ var objectShapeOf = (node, models) => {
89
+ const schema = resolveModel(node, models);
90
+ if (IsObject(schema))
91
+ return {
92
+ properties: schema.properties,
93
+ required: new Set(schema.required)
94
+ };
95
+ const isUnion = IsUnion(schema);
96
+ const branches = isUnion ? schema.anyOf : IsIntersect(schema) ? schema.allOf : [];
97
+ const shapes = branches.map((branch) => objectShapeOf(branch, models)).filter((shape) => shape !== undefined);
98
+ if (shapes.length === 0)
99
+ return;
100
+ const required = shapes.flatMap((shape) => [...shape.required]);
101
+ const names = new Set(shapes.flatMap((shape) => Object.keys(shape.properties)));
102
+ return {
103
+ properties: Object.fromEntries([...names].map((name) => {
104
+ const schemas = uniqueSchemas(shapes.map((shape) => shape.properties[name]).filter((property) => property !== undefined));
105
+ if (schemas.length === 1)
106
+ return [name, schemas[0]];
107
+ return [name, isUnion ? Union(schemas) : Intersect(schemas)];
108
+ })),
109
+ required: new Set(isUnion ? required.filter((name) => shapes.every((shape) => shape.required.has(name))) : required)
110
+ };
111
+ };
112
+
113
+ // src/document.ts
114
+ var FIXED_METHODS = [
115
+ "get",
116
+ "put",
117
+ "post",
118
+ "delete",
119
+ "options",
120
+ "head",
121
+ "patch",
122
+ "trace",
123
+ "query"
124
+ ];
125
+ var isFixedMethod = (method) => FIXED_METHODS.includes(method);
126
+ var FORM_MEDIA_TYPES = [
127
+ "application/json",
128
+ "application/x-www-form-urlencoded",
129
+ "multipart/form-data"
130
+ ];
131
+ var isReference = (value) => ("$ref" in value);
132
+ var toPascalCase = (value) => value.split(/[^a-zA-Z0-9]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
133
+ var toOperationId = (method, path) => method + (path.split("/").filter(Boolean).map((segment) => segment === "*" ? "Any" : segment.startsWith("{") ? `By${toPascalCase(segment.slice(1, -1))}` : toPascalCase(segment)).join("") || "Index");
134
+ var toCombinedSchema = (schemas, keyword) => schemas.length === 1 ? toRootSchema(schemas[0]) : { [keyword]: schemas.map((schema) => toRootSchema(schema)) };
135
+ var toParameters = (location, node, models) => {
136
+ const shape = objectShapeOf(node, models);
137
+ if (!shape)
138
+ return [];
139
+ return Object.entries(shape.properties).filter(([name]) => location !== "header" || !["accept", "content-type", "authorization"].includes(name.toLowerCase())).map(([name, property]) => {
140
+ const resolved = resolveModel(property, models);
141
+ const description = descriptionOf(resolved);
142
+ const common = {
143
+ name,
144
+ in: location,
145
+ ...description !== undefined && { description },
146
+ required: location === "path" || shape.required.has(name) && !hasDefault(resolved)
147
+ };
148
+ const schema = toRootSchema(property);
149
+ return location === "query" && jsonTypeOf(resolved) === "object" ? { ...common, content: { "application/json": { schema } } } : { ...common, schema };
150
+ });
151
+ };
152
+ var toBodyMediaTypes = (parse, bodies, models) => {
153
+ const [parser] = [parse].flat();
154
+ if (typeof parser === "function")
155
+ return ["*/*"];
156
+ if (typeof parser === "string") {
157
+ const shorthands = {
158
+ none: [...FORM_MEDIA_TYPES, "text/plain"],
159
+ text: ["text/plain"],
160
+ json: ["application/json"],
161
+ formdata: ["multipart/form-data", "application/x-www-form-urlencoded"],
162
+ urlencoded: ["application/x-www-form-urlencoded"],
163
+ arrayBuffer: ["application/octet-stream"]
164
+ };
165
+ const mediaTypes = parser.includes("/") ? [parser] : shorthands[parser];
166
+ if (mediaTypes)
167
+ return mediaTypes;
168
+ }
169
+ const schemas = bodies.map((body) => resolveModel(body, models));
170
+ if (schemas.some(isRawBinary))
171
+ return ["application/octet-stream"];
172
+ if (schemas.some((schema) => containsFile(schema, models)))
173
+ return ["multipart/form-data"];
174
+ if (bodies.every((body) => isPrimitive(body, models)))
175
+ return ["application/json", "text/plain"];
176
+ return FORM_MEDIA_TYPES;
177
+ };
178
+ var toRequestBody = (hooks, models, method) => {
179
+ if (["get", "head", "trace"].includes(method))
180
+ return;
181
+ const bodies = uniqueSchemas([hooks, ...hooks.schemas ?? []].map((layer) => layer.body).filter((body) => body !== undefined));
182
+ const [first] = bodies;
183
+ if (first === undefined)
184
+ return;
185
+ const schema = toCombinedSchema(bodies, "allOf");
186
+ const description = descriptionOf(resolveModel(first, models));
187
+ return {
188
+ ...description !== undefined && { description },
189
+ required: !bodies.every((body) => {
190
+ const resolved = resolveModel(body, models);
191
+ return IsOptional(body) || IsOptional(resolved) || IsUnion2(resolved) && resolved.anyOf.some((branch) => jsonTypeOf(resolveModel(branch, models)) === "undefined");
192
+ }),
193
+ content: Object.fromEntries(toBodyMediaTypes(hooks.parse, bodies, models).map((mediaType) => [
194
+ mediaType,
195
+ { schema }
196
+ ]))
197
+ };
198
+ };
199
+ var toResponseContent = (schemas, models) => {
200
+ const [first] = schemas;
201
+ const resolved = resolveModel(first, models);
202
+ const type = jsonTypeOf(resolved);
203
+ if (type === "void" || type === "null" || type === "undefined")
204
+ return;
205
+ if (isRawBinary(resolved) || isFile(resolved))
206
+ return { "application/octet-stream": { schema: toRootSchema(first) } };
207
+ if (schemas.length === 1 && IsUnion2(resolved)) {
208
+ const branches = Map.groupBy(resolved.anyOf, (branch) => {
209
+ const schema = resolveModel(branch, models);
210
+ if (isRawBinary(schema) || isFile(schema))
211
+ return "application/octet-stream";
212
+ return isText(branch, models) ? "text/plain" : "application/json";
213
+ });
214
+ if (branches.size > 1)
215
+ return Object.fromEntries([...branches].map(([mediaType, group]) => [
216
+ mediaType,
217
+ { schema: toCombinedSchema(group, "anyOf") }
218
+ ]));
219
+ }
220
+ return {
221
+ [isText(first, models) ? "text/plain" : "application/json"]: {
222
+ schema: toCombinedSchema(schemas, "anyOf")
223
+ }
224
+ };
225
+ };
226
+ var toResponseHeaders = (schema, models) => {
227
+ const resolved = resolveModel(schema, models);
228
+ const shape = objectShapeOf(resolved && "headers" in resolved ? resolved.headers : undefined, models);
229
+ if (!shape)
230
+ return;
231
+ return Object.fromEntries(Object.entries(shape.properties).map(([name, property]) => {
232
+ const description = descriptionOf(resolveModel(property, models));
233
+ return [
234
+ name,
235
+ {
236
+ ...description !== undefined && { description },
237
+ required: shape.required.has(name),
238
+ schema: toRootSchema(property)
239
+ }
240
+ ];
241
+ }));
242
+ };
243
+ var toResponses = (route, models) => Object.fromEntries(Object.entries(route.responses).map(([status, schemas]) => {
244
+ const description = descriptionOf(resolveModel(schemas[0], models)) ?? StatusMapBack[status];
245
+ const headers = toResponseHeaders(schemas[0], models);
246
+ const content = toResponseContent(schemas, models);
247
+ return [
248
+ status,
249
+ {
250
+ ...description !== undefined && { description },
251
+ ...headers && { headers },
252
+ ...content && { content }
253
+ }
254
+ ];
255
+ }));
256
+ var mergeParameters = (generated, overrides = []) => {
257
+ const parameters = new Map(generated.map((parameter) => [`${parameter.in}:${parameter.name}`, parameter]));
258
+ for (const parameter of overrides)
259
+ parameters.set(isReference(parameter) ? parameter.$ref : `${parameter.in}:${parameter.name}`, parameter);
260
+ return [...parameters.values()];
261
+ };
262
+ var mergeResponses = (generated, overrides = {}) => {
263
+ const responses = { ...generated };
264
+ for (const [status, override] of Object.entries(overrides)) {
265
+ const response = responses[status];
266
+ responses[status] = response && !isReference(response) && !isReference(override) ? { ...response, ...override } : override;
267
+ }
268
+ return responses;
269
+ };
270
+ var mergeRequestBody = (generated, override) => {
271
+ if (!override)
272
+ return generated;
273
+ if (!generated || isReference(override))
274
+ return override;
275
+ const content = { ...generated.content };
276
+ for (const [mediaType, media] of Object.entries(override.content ?? {})) {
277
+ const current = content[mediaType];
278
+ content[mediaType] = current && !isReference(current) && !isReference(media) ? { ...current, ...media } : media;
279
+ }
280
+ return { ...generated, ...override, content };
281
+ };
282
+ var toOperation = (route, models, method) => {
283
+ const { hooks } = route;
284
+ const parameters = new Map;
285
+ for (const layer of [hooks, ...hooks.schemas ?? []])
286
+ for (const [location, key] of [
287
+ ["path", "params"],
288
+ ["query", "query"],
289
+ ["header", "headers"],
290
+ ["cookie", "cookie"]
291
+ ])
292
+ for (const parameter of toParameters(location, layer[key], models))
293
+ parameters.set(`${location}:${parameter.name}`, parameter);
294
+ const {
295
+ hide: _hide,
296
+ parameters: parameterOverrides,
297
+ responses: responseOverrides,
298
+ requestBody: requestBodyOverride,
299
+ ...detail
300
+ } = hooks.detail ?? {};
301
+ const allParameters = mergeParameters([...parameters.values()], parameterOverrides);
302
+ const requestBody = mergeRequestBody(toRequestBody(hooks, models, method), requestBodyOverride);
303
+ const responses = mergeResponses(toResponses(route, models), responseOverrides);
304
+ return {
305
+ ...allParameters.length > 0 && { parameters: allParameters },
306
+ ...requestBody && { requestBody },
307
+ ...Object.keys(responses).length > 0 && { responses },
308
+ ...detail
309
+ };
310
+ };
311
+ var toTemplate = (segment) => {
312
+ const index = segment.indexOf(":");
313
+ return index === -1 ? segment : `${segment.slice(0, index)}{${segment.slice(index + 1).replace(/\?$/, "")}}`;
314
+ };
315
+ var toOpenAPIPaths = (path) => {
316
+ const segments = path.split("/");
317
+ const templated = segments.map(toTemplate);
318
+ const paths = [templated.join("/")];
319
+ segments.forEach((segment, index) => {
320
+ if (segment.includes(":") && segment.endsWith("?"))
321
+ paths.push(templated.slice(0, index).join("/") || "/");
322
+ });
323
+ return paths;
324
+ };
325
+ var isPathParameter = (parameter) => !isReference(parameter) && parameter.in === "path";
326
+ var withPathParameters = (operation, path) => {
327
+ const names = path.match(/\{[^}]+\}/g)?.map((template) => template.slice(1, -1)) ?? [];
328
+ const parameters = (operation.parameters ?? []).filter((parameter) => !isPathParameter(parameter) || names.includes(parameter.name));
329
+ for (const name of names)
330
+ if (!parameters.some((parameter) => isPathParameter(parameter) && parameter.name === name))
331
+ parameters.push({ name, in: "path", required: true, schema: { type: "string" } });
332
+ const { parameters: _parameters, ...rest } = operation;
333
+ return parameters.length > 0 ? { ...rest, parameters } : rest;
334
+ };
335
+ var toTags = (names, base) => {
336
+ const tags = new Map;
337
+ for (const name of names) {
338
+ const segments = name.split("/").filter(Boolean);
339
+ segments.forEach((segment, index) => {
340
+ const tagName = index === segments.length - 1 ? name : segments.slice(0, index + 1).join("/");
341
+ if (!tags.has(tagName))
342
+ tags.set(tagName, {
343
+ name: tagName,
344
+ ...index > 0 && {
345
+ summary: segment,
346
+ parent: segments.slice(0, index).join("/")
347
+ }
348
+ });
349
+ });
350
+ }
351
+ for (const tag of base)
352
+ tags.set(tag.name, { ...tags.get(tag.name), ...tag });
353
+ return [...tags.values()];
354
+ };
355
+ var toStatusMap = (response) => {
356
+ if (response === undefined)
357
+ return {};
358
+ return typeof response === "object" && isRecordNumber(response) ? response : { 200: response };
359
+ };
360
+ var toResponseLayers = (hooks, ownResponse) => {
361
+ const own = toStatusMap(ownResponse);
362
+ const merged = (hooks.schemas ?? []).map((layer) => toStatusMap(layer.response));
363
+ return Object.fromEntries(Object.entries(toStatusMap(hooks.response)).map(([status, schema]) => {
364
+ const layers = uniqueSchemas([own[status], ...merged.map((layer) => layer[status])].filter((layer) => layer !== undefined));
365
+ return [status, layers.length > 0 ? layers : [schema]];
366
+ }));
367
+ };
368
+ var readRoutes = (app) => {
369
+ const internals = new Map(app["~routes"].map(([method, path, , owner, hook]) => [
370
+ `${method} ${path}`,
371
+ {
372
+ config: owner["~config"],
373
+ ownResponse: hook?.response
374
+ }
375
+ ]));
376
+ return app.routes.map(({ method, path, hooks }) => {
377
+ const routeHooks = hooks ?? {};
378
+ const { config, ownResponse } = internals.get(`${method} ${path}`) ?? {};
379
+ const { operationId: _operationId, ...instanceDetail } = config?.detail ?? {};
380
+ const tags = [
381
+ ...new Set([
382
+ ...config?.tags ?? [],
383
+ ...instanceDetail.tags ?? [],
384
+ ...routeHooks.detail?.tags ?? []
385
+ ])
386
+ ].filter(Boolean);
387
+ return {
388
+ method,
389
+ path,
390
+ hooks: {
391
+ ...routeHooks,
392
+ detail: {
393
+ ...instanceDetail,
394
+ ...routeHooks.detail,
395
+ ...tags.length > 0 && { tags }
396
+ }
397
+ },
398
+ responses: toResponseLayers(routeHooks, ownResponse),
399
+ bare: hooks === undefined
400
+ };
401
+ });
402
+ };
403
+ var buildDocument = (app, base = {}) => {
404
+ const models = app.models;
405
+ const paths = {};
406
+ const tagNames = new Set;
407
+ const generated = new Map;
408
+ for (const route of readRoutes(app)) {
409
+ const routeMethod = route.method.toLowerCase();
410
+ if (route.hooks.detail?.hide || routeMethod === "ws")
411
+ continue;
412
+ if (routeMethod === "options" && route.bare && /^\/(\*)?$|\/\*$/.test(route.path))
413
+ continue;
414
+ for (const tag of route.hooks.detail?.tags ?? [])
415
+ tagNames.add(tag);
416
+ const isCatchAll = routeMethod === "*";
417
+ for (const method of isCatchAll ? FIXED_METHODS : [routeMethod]) {
418
+ const operation = toOperation(route, models, method);
419
+ for (const path of toOpenAPIPaths(route.path)) {
420
+ const item = { ...paths[path] };
421
+ if (isCatchAll && isFixedMethod(method) && item[method])
422
+ continue;
423
+ const identified = withPathParameters(operation, path);
424
+ generated.set(identified, identified.operationId ?? toOperationId(method, path));
425
+ if (isFixedMethod(method))
426
+ item[method] = identified;
427
+ else
428
+ item.additionalOperations = {
429
+ ...item.additionalOperations,
430
+ [route.method]: identified
431
+ };
432
+ paths[path] = item;
433
+ }
434
+ }
435
+ }
436
+ const mergedPaths = {
437
+ ...base.paths,
438
+ ...Object.fromEntries(Object.entries(paths).map(([path, item]) => [
439
+ path,
440
+ { ...base.paths?.[path], ...item }
441
+ ]))
442
+ };
443
+ const operations = Object.values(mergedPaths).flatMap((item) => [
444
+ ...FIXED_METHODS.map((method) => item?.[method]),
445
+ ...Object.values(item?.additionalOperations ?? {})
446
+ ]);
447
+ const operationIds = new Set(operations.filter((operation) => operation !== undefined && !generated.has(operation)).map((operation) => operation?.operationId));
448
+ for (const [operation, operationId] of generated) {
449
+ if (!operations.includes(operation))
450
+ continue;
451
+ let candidate = operationId;
452
+ for (let suffix = 2;operationIds.has(candidate); suffix++)
453
+ candidate = `${operationId}${suffix}`;
454
+ operationIds.add(candidate);
455
+ operation.operationId = candidate;
456
+ }
457
+ return {
458
+ openapi: "3.2.1",
459
+ ...base,
460
+ info: {
461
+ title: "Elysia Documentation",
462
+ description: "API documentation for Elysia",
463
+ version: "1.0.0",
464
+ ...base.info
465
+ },
466
+ paths: mergedPaths,
467
+ tags: toTags(tagNames, base.tags ?? []),
468
+ components: {
469
+ ...base.components,
470
+ schemas: {
471
+ ...base.components?.schemas,
472
+ ...Object.fromEntries(Object.entries(models).map(([name, model]) => [
473
+ name,
474
+ toSchema(model)
475
+ ]))
476
+ }
477
+ }
478
+ };
479
+ };
480
+
481
+ // src/openapi.ts
482
+ var renderScalar = (title, configuration) => `<!doctype html>
483
+ <html>
484
+ <head>
485
+ <title>${Bun.escapeHTML(title)}</title>
486
+ <meta charset="utf-8" />
487
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
488
+ </head>
489
+ <body>
490
+ <div id="app"></div>
491
+ <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
492
+ <script>
493
+ Scalar.createApiReference('#app', ${JSON.stringify(configuration).replaceAll("<", "\\u003c")});
494
+ </script>
495
+ </body>
496
+ </html>`;
497
+ var openapiPlugin = ({
498
+ path = "/openapi",
499
+ specPath = `${path}/json`,
500
+ scalar,
501
+ document: base
502
+ } = {}) => {
503
+ const scalarOptions = {
504
+ defaultOpenAllTags: false,
505
+ defaultOpenFirstTag: false,
506
+ documentDownloadType: "both",
507
+ expandAllModelSections: false,
508
+ expandAllResponses: false,
509
+ expandAllSchemaProperties: false,
510
+ hideClientButton: true,
511
+ hideDarkModeToggle: false,
512
+ hideModels: false,
513
+ hideSearch: false,
514
+ hideTestRequestButton: false,
515
+ isEditable: false,
516
+ layout: "modern",
517
+ operationTitleSource: "summary",
518
+ orderRequiredPropertiesFirst: true,
519
+ orderSchemaPropertiesBy: "preserve",
520
+ persistAuth: true,
521
+ showDeveloperTools: "localhost",
522
+ showOperationId: true,
523
+ showSidebar: true,
524
+ telemetry: false,
525
+ withDefaultFonts: true,
526
+ expandAllParameters: true,
527
+ theme: "elysiajs",
528
+ mcp: {
529
+ disabled: true
530
+ },
531
+ agent: {
532
+ disabled: true
533
+ },
534
+ darkMode: true,
535
+ ...scalar
536
+ };
537
+ return (host) => {
538
+ let document;
539
+ const getDocument = () => document ??= buildDocument(host, base);
540
+ host.use(new Elysia({ name: "openapiPlugin" }).guard({ detail: { hide: true } }, (plugin) => plugin.get(path, ({ path: requestPath }) => new Response(renderScalar(getDocument().info.title, {
541
+ ...scalarOptions,
542
+ url: requestPath.replace(/\/$/, "").slice(0, -path.length) + specPath
543
+ }), { headers: { "content-type": "text/html; charset=utf-8" } })).get(specPath, getDocument)));
544
+ return host;
545
+ };
546
+ };
547
+ export {
548
+ openapiPlugin,
549
+ withHeaders
550
+ };