@clov-std/elysia-openapi 0.0.0-stage → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +21 -0
- package/README.md +79 -2
- package/dist/document.d.ts +11 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +550 -0
- package/dist/openapi-types-3.2.d.ts +510 -0
- package/dist/openapi.d.ts +12 -0
- package/dist/scalar-types.d.ts +83 -0
- package/dist/schema.d.ts +50 -0
- package/package.json +48 -5
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
};
|