@hectoday/openapi 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +63 -7
- package/dist/index.mjs +30 -11
- package/package.json +8 -7
- package/src/index.test.ts +442 -0
- package/src/index.ts +350 -0
package/dist/index.d.mts
CHANGED
|
@@ -24,14 +24,70 @@ interface OpenApiConfig {
|
|
|
24
24
|
security?: Array<Record<string, string[]>>;
|
|
25
25
|
securitySchemes?: Record<string, SecurityScheme>;
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
type OAuth2Scopes = Record<string, string>;
|
|
28
|
+
type OAuth2ImplicitFlow = {
|
|
29
|
+
authorizationUrl: string;
|
|
30
|
+
refreshUrl?: string;
|
|
31
|
+
scopes: OAuth2Scopes;
|
|
32
|
+
};
|
|
33
|
+
type OAuth2PasswordFlow = {
|
|
34
|
+
tokenUrl: string;
|
|
35
|
+
refreshUrl?: string;
|
|
36
|
+
scopes: OAuth2Scopes;
|
|
37
|
+
};
|
|
38
|
+
type OAuth2ClientCredentialsFlow = {
|
|
39
|
+
tokenUrl: string;
|
|
40
|
+
refreshUrl?: string;
|
|
41
|
+
scopes: OAuth2Scopes;
|
|
42
|
+
};
|
|
43
|
+
type OAuth2AuthorizationCodeFlow = {
|
|
44
|
+
authorizationUrl: string;
|
|
45
|
+
tokenUrl: string;
|
|
46
|
+
refreshUrl?: string;
|
|
47
|
+
scopes: OAuth2Scopes;
|
|
48
|
+
};
|
|
49
|
+
type OAuth2FlowMap = {
|
|
50
|
+
implicit: OAuth2ImplicitFlow;
|
|
51
|
+
password: OAuth2PasswordFlow;
|
|
52
|
+
clientCredentials: OAuth2ClientCredentialsFlow;
|
|
53
|
+
authorizationCode: OAuth2AuthorizationCodeFlow;
|
|
54
|
+
};
|
|
55
|
+
type OAuth2Flows = {
|
|
56
|
+
implicit: OAuth2FlowMap["implicit"];
|
|
57
|
+
} | {
|
|
58
|
+
password: OAuth2FlowMap["password"];
|
|
59
|
+
} | {
|
|
60
|
+
clientCredentials: OAuth2FlowMap["clientCredentials"];
|
|
61
|
+
} | {
|
|
62
|
+
authorizationCode: OAuth2FlowMap["authorizationCode"];
|
|
63
|
+
} | ({
|
|
64
|
+
implicit: OAuth2FlowMap["implicit"];
|
|
65
|
+
} & Partial<Omit<OAuth2FlowMap, "implicit">>) | ({
|
|
66
|
+
password: OAuth2FlowMap["password"];
|
|
67
|
+
} & Partial<Omit<OAuth2FlowMap, "password">>) | ({
|
|
68
|
+
clientCredentials: OAuth2FlowMap["clientCredentials"];
|
|
69
|
+
} & Partial<Omit<OAuth2FlowMap, "clientCredentials">>) | ({
|
|
70
|
+
authorizationCode: OAuth2FlowMap["authorizationCode"];
|
|
71
|
+
} & Partial<Omit<OAuth2FlowMap, "authorizationCode">>);
|
|
72
|
+
type SecurityScheme = {
|
|
73
|
+
type: "http";
|
|
74
|
+
scheme: string;
|
|
30
75
|
bearerFormat?: string;
|
|
31
|
-
name?: string;
|
|
32
|
-
in?: "query" | "header" | "cookie";
|
|
33
76
|
description?: string;
|
|
34
|
-
}
|
|
77
|
+
} | {
|
|
78
|
+
type: "apiKey";
|
|
79
|
+
name: string;
|
|
80
|
+
in: "query" | "header" | "cookie";
|
|
81
|
+
description?: string;
|
|
82
|
+
} | {
|
|
83
|
+
type: "oauth2";
|
|
84
|
+
flows: OAuth2Flows;
|
|
85
|
+
description?: string;
|
|
86
|
+
} | {
|
|
87
|
+
type: "openIdConnect";
|
|
88
|
+
openIdConnectUrl: string;
|
|
89
|
+
description?: string;
|
|
90
|
+
};
|
|
35
91
|
interface OpenApiResult {
|
|
36
92
|
/** Creates a GET route that serves the OpenAPI JSON document. */
|
|
37
93
|
spec: (route: {
|
|
@@ -44,4 +100,4 @@ interface OpenApiResult {
|
|
|
44
100
|
}
|
|
45
101
|
declare function openapi(routes: RouteDescriptor[], config: OpenApiConfig): OpenApiResult;
|
|
46
102
|
//#endregion
|
|
47
|
-
export { OpenApiConfig, OpenApiResult, SecurityScheme, openapi };
|
|
103
|
+
export { OAuth2AuthorizationCodeFlow, OAuth2ClientCredentialsFlow, OAuth2Flows, OAuth2ImplicitFlow, OAuth2PasswordFlow, OAuth2Scopes, OpenApiConfig, OpenApiResult, SecurityScheme, openapi };
|
package/dist/index.mjs
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { createDocument
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { createDocument } from "zod-openapi";
|
|
3
3
|
//#region src/index.ts
|
|
4
|
-
|
|
4
|
+
function extractPathParamNames(path) {
|
|
5
|
+
return Array.from(path.matchAll(/:(\w+)/g), (match) => match[1]);
|
|
6
|
+
}
|
|
5
7
|
function toOpenApiPath(path) {
|
|
6
8
|
return path.replace(/:(\w+)/g, "{$1}");
|
|
7
9
|
}
|
|
10
|
+
function getSchemaObjectKeys(schema) {
|
|
11
|
+
return Object.keys(schema.shape);
|
|
12
|
+
}
|
|
13
|
+
function assertPathParamsMatchPath(method, path, schema) {
|
|
14
|
+
const pathParamNames = extractPathParamNames(path);
|
|
15
|
+
const schemaParamNames = getSchemaObjectKeys(schema);
|
|
16
|
+
if (pathParamNames.length === schemaParamNames.length && pathParamNames.every((name) => schemaParamNames.includes(name))) return;
|
|
17
|
+
if (pathParamNames.length === 0) throw new Error(`params schema for ${method} ${path} cannot define path params when the path has none`);
|
|
18
|
+
throw new Error(`params schema for ${method} ${path} must define exactly these path params: ${pathParamNames.join(", ")}`);
|
|
19
|
+
}
|
|
20
|
+
function responseCanHaveBody(status) {
|
|
21
|
+
return !(status >= 100 && status < 200) && status !== 204 && status !== 205 && status !== 304;
|
|
22
|
+
}
|
|
23
|
+
function operationCanHaveResponseBody(method, status) {
|
|
24
|
+
return method.toUpperCase() !== "HEAD" && responseCanHaveBody(status);
|
|
25
|
+
}
|
|
8
26
|
const STATUS_TEXT = {
|
|
9
27
|
100: "Continue",
|
|
10
28
|
101: "Switching Protocols",
|
|
@@ -68,6 +86,9 @@ const STATUS_TEXT = {
|
|
|
68
86
|
510: "Not Extended",
|
|
69
87
|
511: "Network Authentication Required"
|
|
70
88
|
};
|
|
89
|
+
function escapeHtmlAttr(s) {
|
|
90
|
+
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
91
|
+
}
|
|
71
92
|
function scalarHtml(specUrl) {
|
|
72
93
|
return `<!doctype html>
|
|
73
94
|
<html>
|
|
@@ -77,28 +98,26 @@ function scalarHtml(specUrl) {
|
|
|
77
98
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
78
99
|
</head>
|
|
79
100
|
<body>
|
|
80
|
-
<script id="api-reference" data-url="${specUrl}"><\/script>
|
|
101
|
+
<script id="api-reference" data-url="${escapeHtmlAttr(specUrl)}"><\/script>
|
|
81
102
|
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"><\/script>
|
|
82
103
|
</body>
|
|
83
104
|
</html>`;
|
|
84
105
|
}
|
|
85
106
|
function openapi(routes, config) {
|
|
86
|
-
if (!extended) {
|
|
87
|
-
extendZodWithOpenApi(z);
|
|
88
|
-
extended = true;
|
|
89
|
-
}
|
|
90
107
|
const paths = {};
|
|
91
108
|
for (const descriptor of routes) {
|
|
92
109
|
const { method, path, config: routeConfig } = descriptor;
|
|
93
|
-
if (!method || path
|
|
110
|
+
if (!method || path.includes("*") || /:\w+[?+]/.test(path)) continue;
|
|
94
111
|
const openApiPath = toOpenApiPath(path);
|
|
95
112
|
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
96
113
|
const operation = { responses: {} };
|
|
97
114
|
const requestParams = {};
|
|
115
|
+
const inferredPathParams = extractPathParamNames(path);
|
|
98
116
|
if (routeConfig.request?.params) {
|
|
99
117
|
if (!(routeConfig.request.params instanceof z.ZodObject)) throw new Error(`params schema for ${method} ${path} must be a z.object()`);
|
|
118
|
+
assertPathParamsMatchPath(method, path, routeConfig.request.params);
|
|
100
119
|
requestParams.path = routeConfig.request.params;
|
|
101
|
-
}
|
|
120
|
+
} else if (inferredPathParams.length > 0) requestParams.path = z.object(Object.fromEntries(inferredPathParams.map((name) => [name, z.string()])));
|
|
102
121
|
if (routeConfig.request?.query) {
|
|
103
122
|
if (!(routeConfig.request.query instanceof z.ZodObject)) throw new Error(`query schema for ${method} ${path} must be a z.object()`);
|
|
104
123
|
requestParams.query = routeConfig.request.query;
|
|
@@ -108,7 +127,7 @@ function openapi(routes, config) {
|
|
|
108
127
|
if (routeConfig.response && Object.keys(routeConfig.response).length > 0) for (const [status, schema] of Object.entries(routeConfig.response)) {
|
|
109
128
|
const code = Number(status);
|
|
110
129
|
const entry = { description: STATUS_TEXT[code] ?? "Response" };
|
|
111
|
-
if (code
|
|
130
|
+
if (operationCanHaveResponseBody(method, code)) entry.content = { "application/json": { schema } };
|
|
112
131
|
operation.responses[status] = entry;
|
|
113
132
|
}
|
|
114
133
|
else operation.responses = { 200: { description: "OK" } };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hectoday/openapi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "OpenAPI 3.1 spec generation for @hectoday/http routes.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"api",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"directory": "packages/openapi"
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
|
-
"dist"
|
|
24
|
+
"dist",
|
|
25
|
+
"src"
|
|
25
26
|
],
|
|
26
27
|
"type": "module",
|
|
27
28
|
"types": "./src/index.ts",
|
|
@@ -40,19 +41,19 @@
|
|
|
40
41
|
"typecheck": "tsc --noEmit"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|
|
43
|
-
"zod-openapi": "^4.
|
|
44
|
+
"zod-openapi": "^5.4.6"
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
|
-
"@hectoday/http": "^0.
|
|
47
|
+
"@hectoday/http": "^0.3.0",
|
|
47
48
|
"@types/node": "^25.5.0",
|
|
48
49
|
"bumpp": "^11.0.1",
|
|
49
50
|
"typescript": "^5.9.3",
|
|
50
51
|
"vite-plus": "latest",
|
|
51
52
|
"vitest": "npm:@voidzero-dev/vite-plus-test@latest",
|
|
52
|
-
"zod": "^3.
|
|
53
|
+
"zod": "^3.25.0"
|
|
53
54
|
},
|
|
54
55
|
"peerDependencies": {
|
|
55
|
-
"@hectoday/http": "^0.
|
|
56
|
-
"zod": "^3.
|
|
56
|
+
"@hectoday/http": "^0.3.0",
|
|
57
|
+
"zod": "^3.25.0"
|
|
57
58
|
}
|
|
58
59
|
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { describe, expect, test } from "vite-plus/test";
|
|
2
|
+
import * as z from "zod/v4";
|
|
3
|
+
import { route } from "@hectoday/http";
|
|
4
|
+
import { openapi } from "./index";
|
|
5
|
+
import type { OpenApiConfig } from "./index";
|
|
6
|
+
|
|
7
|
+
const securitySchemesTypecheck = {
|
|
8
|
+
bearerAuth: { type: "http", scheme: "bearer" },
|
|
9
|
+
apiKeyAuth: { type: "apiKey", name: "x-api-key", in: "header" },
|
|
10
|
+
oauthAuth: {
|
|
11
|
+
type: "oauth2",
|
|
12
|
+
flows: {
|
|
13
|
+
authorizationCode: {
|
|
14
|
+
authorizationUrl: "https://example.com/oauth/authorize",
|
|
15
|
+
tokenUrl: "https://example.com/oauth/token",
|
|
16
|
+
scopes: { read: "Read access" },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
oidcAuth: {
|
|
21
|
+
type: "openIdConnect",
|
|
22
|
+
openIdConnectUrl: "https://example.com/.well-known/openid-configuration",
|
|
23
|
+
},
|
|
24
|
+
} satisfies NonNullable<OpenApiConfig["securitySchemes"]>;
|
|
25
|
+
|
|
26
|
+
void securitySchemesTypecheck;
|
|
27
|
+
|
|
28
|
+
const invalidOAuth2SecurityScheme = {
|
|
29
|
+
brokenOAuth: {
|
|
30
|
+
type: "oauth2",
|
|
31
|
+
// @ts-expect-error oauth2 security schemes must define at least one flow
|
|
32
|
+
flows: {},
|
|
33
|
+
},
|
|
34
|
+
} satisfies NonNullable<OpenApiConfig["securitySchemes"]>;
|
|
35
|
+
|
|
36
|
+
const invalidAuthorizationCodeFlow = {
|
|
37
|
+
brokenAuthCode: {
|
|
38
|
+
type: "oauth2",
|
|
39
|
+
flows: {
|
|
40
|
+
// @ts-expect-error authorizationCode flow requires both authorizationUrl and tokenUrl
|
|
41
|
+
authorizationCode: {
|
|
42
|
+
scopes: { read: "Read access" },
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
} satisfies NonNullable<OpenApiConfig["securitySchemes"]>;
|
|
47
|
+
|
|
48
|
+
void invalidOAuth2SecurityScheme;
|
|
49
|
+
void invalidAuthorizationCodeFlow;
|
|
50
|
+
|
|
51
|
+
describe("openapi", () => {
|
|
52
|
+
test("generates spec from routes with schemas", () => {
|
|
53
|
+
const routes = [
|
|
54
|
+
route.get("/users", {
|
|
55
|
+
request: {
|
|
56
|
+
query: z.object({
|
|
57
|
+
page: z.coerce.number().int().min(1).default(1),
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
response: {
|
|
61
|
+
200: z.object({ users: z.array(z.string()) }),
|
|
62
|
+
},
|
|
63
|
+
resolve: () => Response.json({ users: [] }),
|
|
64
|
+
}),
|
|
65
|
+
route.post("/users", {
|
|
66
|
+
request: {
|
|
67
|
+
body: z.object({
|
|
68
|
+
name: z.string(),
|
|
69
|
+
email: z.string().email(),
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
response: {
|
|
73
|
+
201: z.object({ id: z.string() }),
|
|
74
|
+
},
|
|
75
|
+
resolve: () => Response.json({ id: "1" }, { status: 201 }),
|
|
76
|
+
}),
|
|
77
|
+
route.get("/users/:id", {
|
|
78
|
+
request: {
|
|
79
|
+
params: z.object({ id: z.string() }),
|
|
80
|
+
},
|
|
81
|
+
resolve: () => Response.json({ id: "1", name: "Alice" }),
|
|
82
|
+
}),
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
const { spec, docs } = openapi(routes, {
|
|
86
|
+
info: { title: "Test API", version: "1.0.0" },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const specRoute = spec(route);
|
|
90
|
+
expect(specRoute.method).toBe("GET");
|
|
91
|
+
expect(specRoute.path).toBe("/openapi.json");
|
|
92
|
+
|
|
93
|
+
const docsRoute = docs(route);
|
|
94
|
+
expect(docsRoute.method).toBe("GET");
|
|
95
|
+
expect(docsRoute.path).toBe("/docs");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("spec route serves JSON document", async () => {
|
|
99
|
+
const routes = [
|
|
100
|
+
route.get("/health", {
|
|
101
|
+
response: { 200: z.object({ ok: z.boolean() }) },
|
|
102
|
+
resolve: () => Response.json({ ok: true }),
|
|
103
|
+
}),
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const { spec } = openapi(routes, {
|
|
107
|
+
info: { title: "Health API", version: "0.1.0" },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const specRoute = spec(route);
|
|
111
|
+
const response = await specRoute.config.resolve({
|
|
112
|
+
request: new Request("http://localhost/openapi.json"),
|
|
113
|
+
input: {
|
|
114
|
+
ok: true,
|
|
115
|
+
params: {},
|
|
116
|
+
query: {},
|
|
117
|
+
body: undefined,
|
|
118
|
+
issues: [],
|
|
119
|
+
failed: [],
|
|
120
|
+
},
|
|
121
|
+
locals: {},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(response.status).toBe(200);
|
|
125
|
+
const doc = await response.json();
|
|
126
|
+
expect(doc.openapi).toBe("3.1.0");
|
|
127
|
+
expect(doc.info.title).toBe("Health API");
|
|
128
|
+
expect(doc.paths["/health"]).toBeDefined();
|
|
129
|
+
expect(doc.paths["/health"].get).toBeDefined();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("docs route serves HTML", async () => {
|
|
133
|
+
const { docs } = openapi([], {
|
|
134
|
+
info: { title: "Test", version: "1.0.0" },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const docsRoute = docs(route);
|
|
138
|
+
const response = await docsRoute.config.resolve({
|
|
139
|
+
request: new Request("http://localhost/docs"),
|
|
140
|
+
input: {
|
|
141
|
+
ok: true,
|
|
142
|
+
params: {},
|
|
143
|
+
query: {},
|
|
144
|
+
body: undefined,
|
|
145
|
+
issues: [],
|
|
146
|
+
failed: [],
|
|
147
|
+
},
|
|
148
|
+
locals: {},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(response.headers.get("content-type")).toBe("text/html");
|
|
152
|
+
const html = await response.text();
|
|
153
|
+
expect(html).toContain("@scalar/api-reference");
|
|
154
|
+
expect(html).toContain("/openapi.json");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("infers string path parameters when no params schema is provided", async () => {
|
|
158
|
+
const routes = [
|
|
159
|
+
route.get("/users/:id", {
|
|
160
|
+
resolve: () => Response.json({ ok: true }),
|
|
161
|
+
}),
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
const { spec } = openapi(routes, {
|
|
165
|
+
info: { title: "Test", version: "1.0.0" },
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const response = await spec(route).config.resolve({
|
|
169
|
+
request: new Request("http://localhost/openapi.json"),
|
|
170
|
+
input: {
|
|
171
|
+
ok: true,
|
|
172
|
+
params: {},
|
|
173
|
+
query: {},
|
|
174
|
+
body: undefined,
|
|
175
|
+
issues: [],
|
|
176
|
+
failed: [],
|
|
177
|
+
},
|
|
178
|
+
locals: {},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const doc = await response.json();
|
|
182
|
+
expect(doc.paths["/users/{id}"].get.parameters).toEqual([
|
|
183
|
+
expect.objectContaining({
|
|
184
|
+
in: "path",
|
|
185
|
+
name: "id",
|
|
186
|
+
required: true,
|
|
187
|
+
}),
|
|
188
|
+
]);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("throws when params schema does not match path parameters", () => {
|
|
192
|
+
expect(() =>
|
|
193
|
+
openapi(
|
|
194
|
+
[
|
|
195
|
+
route.get("/users/:id", {
|
|
196
|
+
request: {
|
|
197
|
+
params: z.object({ slug: z.string() }),
|
|
198
|
+
},
|
|
199
|
+
resolve: () => Response.json({ ok: true }),
|
|
200
|
+
}),
|
|
201
|
+
],
|
|
202
|
+
{ info: { title: "Test", version: "1.0.0" } },
|
|
203
|
+
),
|
|
204
|
+
).toThrow("params schema for GET /users/:id must define exactly these path params: id");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("converts path params from :id to {id}", async () => {
|
|
208
|
+
const routes = [
|
|
209
|
+
route.get("/users/:userId/posts/:postId", {
|
|
210
|
+
request: {
|
|
211
|
+
params: z.object({ userId: z.string(), postId: z.string() }),
|
|
212
|
+
},
|
|
213
|
+
resolve: () => Response.json({}),
|
|
214
|
+
}),
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
const { spec } = openapi(routes, {
|
|
218
|
+
info: { title: "Test", version: "1.0.0" },
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const response = await spec(route).config.resolve({
|
|
222
|
+
request: new Request("http://localhost/openapi.json"),
|
|
223
|
+
input: {
|
|
224
|
+
ok: true,
|
|
225
|
+
params: {},
|
|
226
|
+
query: {},
|
|
227
|
+
body: undefined,
|
|
228
|
+
issues: [],
|
|
229
|
+
failed: [],
|
|
230
|
+
},
|
|
231
|
+
locals: {},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const doc = await response.json();
|
|
235
|
+
expect(doc.paths["/users/{userId}/posts/{postId}"]).toBeDefined();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("skips catch-all and methodless routes", async () => {
|
|
239
|
+
const routes = [
|
|
240
|
+
route.all("/catch", {
|
|
241
|
+
resolve: () => new Response("ok"),
|
|
242
|
+
}),
|
|
243
|
+
route.get("/real", {
|
|
244
|
+
resolve: () => Response.json({ ok: true }),
|
|
245
|
+
}),
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
const { spec } = openapi(routes, {
|
|
249
|
+
info: { title: "Test", version: "1.0.0" },
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const response = await spec(route).config.resolve({
|
|
253
|
+
request: new Request("http://localhost/openapi.json"),
|
|
254
|
+
input: {
|
|
255
|
+
ok: true,
|
|
256
|
+
params: {},
|
|
257
|
+
query: {},
|
|
258
|
+
body: undefined,
|
|
259
|
+
issues: [],
|
|
260
|
+
failed: [],
|
|
261
|
+
},
|
|
262
|
+
locals: {},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const doc = await response.json();
|
|
266
|
+
expect(doc.paths["/catch"]).toBeUndefined();
|
|
267
|
+
expect(doc.paths["/real"]).toBeDefined();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("skips named and nested wildcard paths", async () => {
|
|
271
|
+
const routes = [
|
|
272
|
+
route.get("/files/**", {
|
|
273
|
+
resolve: () => Response.json({ ok: true }),
|
|
274
|
+
}),
|
|
275
|
+
route.get("/files/**:path", {
|
|
276
|
+
resolve: () => Response.json({ ok: true }),
|
|
277
|
+
}),
|
|
278
|
+
route.get("/users/:id", {
|
|
279
|
+
request: { params: z.object({ id: z.string() }) },
|
|
280
|
+
resolve: () => Response.json({ ok: true }),
|
|
281
|
+
}),
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
const { spec } = openapi(routes, {
|
|
285
|
+
info: { title: "Test", version: "1.0.0" },
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const response = await spec(route).config.resolve({
|
|
289
|
+
request: new Request("http://localhost/openapi.json"),
|
|
290
|
+
input: {
|
|
291
|
+
ok: true,
|
|
292
|
+
params: {},
|
|
293
|
+
query: {},
|
|
294
|
+
body: undefined,
|
|
295
|
+
issues: [],
|
|
296
|
+
failed: [],
|
|
297
|
+
},
|
|
298
|
+
locals: {},
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const doc = await response.json();
|
|
302
|
+
expect(doc.paths["/files/**"]).toBeUndefined();
|
|
303
|
+
expect(doc.paths["/files/**:path"]).toBeUndefined();
|
|
304
|
+
expect(doc.paths["/users/{id}"]).toBeDefined();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("custom spec and docs paths", () => {
|
|
308
|
+
const { spec, docs } = openapi([], {
|
|
309
|
+
info: { title: "Test", version: "1.0.0" },
|
|
310
|
+
specPath: "/api/spec.json",
|
|
311
|
+
docsPath: "/api/docs",
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
expect(spec(route).path).toBe("/api/spec.json");
|
|
315
|
+
expect(docs(route).path).toBe("/api/docs");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("includes servers and tags in document", async () => {
|
|
319
|
+
const { spec } = openapi([], {
|
|
320
|
+
info: { title: "Test", version: "1.0.0" },
|
|
321
|
+
servers: [{ url: "https://api.example.com", description: "Production" }],
|
|
322
|
+
tags: [{ name: "users", description: "User operations" }],
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const response = await spec(route).config.resolve({
|
|
326
|
+
request: new Request("http://localhost/openapi.json"),
|
|
327
|
+
input: {
|
|
328
|
+
ok: true,
|
|
329
|
+
params: {},
|
|
330
|
+
query: {},
|
|
331
|
+
body: undefined,
|
|
332
|
+
issues: [],
|
|
333
|
+
failed: [],
|
|
334
|
+
},
|
|
335
|
+
locals: {},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const doc = await response.json();
|
|
339
|
+
expect(doc.servers).toEqual([{ url: "https://api.example.com", description: "Production" }]);
|
|
340
|
+
expect(doc.tags).toEqual([{ name: "users", description: "User operations" }]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("request body is included for POST routes", async () => {
|
|
344
|
+
const routes = [
|
|
345
|
+
route.post("/items", {
|
|
346
|
+
request: {
|
|
347
|
+
body: z.object({ name: z.string() }),
|
|
348
|
+
},
|
|
349
|
+
resolve: () => Response.json({}, { status: 201 }),
|
|
350
|
+
}),
|
|
351
|
+
];
|
|
352
|
+
|
|
353
|
+
const { spec } = openapi(routes, {
|
|
354
|
+
info: { title: "Test", version: "1.0.0" },
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const response = await spec(route).config.resolve({
|
|
358
|
+
request: new Request("http://localhost/openapi.json"),
|
|
359
|
+
input: {
|
|
360
|
+
ok: true,
|
|
361
|
+
params: {},
|
|
362
|
+
query: {},
|
|
363
|
+
body: undefined,
|
|
364
|
+
issues: [],
|
|
365
|
+
failed: [],
|
|
366
|
+
},
|
|
367
|
+
locals: {},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const doc = await response.json();
|
|
371
|
+
const post = doc.paths["/items"].post;
|
|
372
|
+
expect(post.requestBody).toBeDefined();
|
|
373
|
+
expect(post.requestBody.content["application/json"]).toBeDefined();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("omits content for status codes that cannot include a response body", async () => {
|
|
377
|
+
const routes = [
|
|
378
|
+
route.get("/cache", {
|
|
379
|
+
response: {
|
|
380
|
+
204: z.object({ ok: z.boolean() }),
|
|
381
|
+
205: z.object({ reset: z.boolean() }),
|
|
382
|
+
304: z.object({ cached: z.boolean() }),
|
|
383
|
+
},
|
|
384
|
+
resolve: () => new Response(null, { status: 204 }),
|
|
385
|
+
}),
|
|
386
|
+
];
|
|
387
|
+
|
|
388
|
+
const { spec } = openapi(routes, {
|
|
389
|
+
info: { title: "Test", version: "1.0.0" },
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const response = await spec(route).config.resolve({
|
|
393
|
+
request: new Request("http://localhost/openapi.json"),
|
|
394
|
+
input: {
|
|
395
|
+
ok: true,
|
|
396
|
+
params: {},
|
|
397
|
+
query: {},
|
|
398
|
+
body: undefined,
|
|
399
|
+
issues: [],
|
|
400
|
+
failed: [],
|
|
401
|
+
},
|
|
402
|
+
locals: {},
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const doc = await response.json();
|
|
406
|
+
const responses = doc.paths["/cache"].get.responses;
|
|
407
|
+
expect(responses["204"].content).toBeUndefined();
|
|
408
|
+
expect(responses["205"].content).toBeUndefined();
|
|
409
|
+
expect(responses["304"].content).toBeUndefined();
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("omits response content for HEAD operations", async () => {
|
|
413
|
+
const routes = [
|
|
414
|
+
route.head("/health", {
|
|
415
|
+
response: {
|
|
416
|
+
200: z.object({ ok: z.boolean() }),
|
|
417
|
+
},
|
|
418
|
+
resolve: () => new Response(null, { status: 200 }),
|
|
419
|
+
}),
|
|
420
|
+
];
|
|
421
|
+
|
|
422
|
+
const { spec } = openapi(routes, {
|
|
423
|
+
info: { title: "Test", version: "1.0.0" },
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const response = await spec(route).config.resolve({
|
|
427
|
+
request: new Request("http://localhost/openapi.json"),
|
|
428
|
+
input: {
|
|
429
|
+
ok: true,
|
|
430
|
+
params: {},
|
|
431
|
+
query: {},
|
|
432
|
+
body: undefined,
|
|
433
|
+
issues: [],
|
|
434
|
+
failed: [],
|
|
435
|
+
},
|
|
436
|
+
locals: {},
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const doc = await response.json();
|
|
440
|
+
expect(doc.paths["/health"].head.responses["200"].content).toBeUndefined();
|
|
441
|
+
});
|
|
442
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import type { RouteDescriptor } from "@hectoday/http";
|
|
2
|
+
import * as z from "zod/v4";
|
|
3
|
+
import { createDocument } from "zod-openapi";
|
|
4
|
+
import type { ZodOpenApiOperationObject } from "zod-openapi";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Public types
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export interface OpenApiConfig {
|
|
11
|
+
info: {
|
|
12
|
+
title: string;
|
|
13
|
+
version: string;
|
|
14
|
+
description?: string;
|
|
15
|
+
license?: { name: string; url?: string };
|
|
16
|
+
};
|
|
17
|
+
specPath?: string;
|
|
18
|
+
docsPath?: string;
|
|
19
|
+
servers?: Array<{ url: string; description?: string }>;
|
|
20
|
+
tags?: Array<{ name: string; description?: string }>;
|
|
21
|
+
security?: Array<Record<string, string[]>>;
|
|
22
|
+
securitySchemes?: Record<string, SecurityScheme>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type OAuth2Scopes = Record<string, string>;
|
|
26
|
+
|
|
27
|
+
export type OAuth2ImplicitFlow = {
|
|
28
|
+
authorizationUrl: string;
|
|
29
|
+
refreshUrl?: string;
|
|
30
|
+
scopes: OAuth2Scopes;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type OAuth2PasswordFlow = {
|
|
34
|
+
tokenUrl: string;
|
|
35
|
+
refreshUrl?: string;
|
|
36
|
+
scopes: OAuth2Scopes;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type OAuth2ClientCredentialsFlow = {
|
|
40
|
+
tokenUrl: string;
|
|
41
|
+
refreshUrl?: string;
|
|
42
|
+
scopes: OAuth2Scopes;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type OAuth2AuthorizationCodeFlow = {
|
|
46
|
+
authorizationUrl: string;
|
|
47
|
+
tokenUrl: string;
|
|
48
|
+
refreshUrl?: string;
|
|
49
|
+
scopes: OAuth2Scopes;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type OAuth2FlowMap = {
|
|
53
|
+
implicit: OAuth2ImplicitFlow;
|
|
54
|
+
password: OAuth2PasswordFlow;
|
|
55
|
+
clientCredentials: OAuth2ClientCredentialsFlow;
|
|
56
|
+
authorizationCode: OAuth2AuthorizationCodeFlow;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type OAuth2Flows =
|
|
60
|
+
| { implicit: OAuth2FlowMap["implicit"] }
|
|
61
|
+
| { password: OAuth2FlowMap["password"] }
|
|
62
|
+
| { clientCredentials: OAuth2FlowMap["clientCredentials"] }
|
|
63
|
+
| { authorizationCode: OAuth2FlowMap["authorizationCode"] }
|
|
64
|
+
| ({ implicit: OAuth2FlowMap["implicit"] } & Partial<Omit<OAuth2FlowMap, "implicit">>)
|
|
65
|
+
| ({ password: OAuth2FlowMap["password"] } & Partial<Omit<OAuth2FlowMap, "password">>)
|
|
66
|
+
| ({
|
|
67
|
+
clientCredentials: OAuth2FlowMap["clientCredentials"];
|
|
68
|
+
} & Partial<Omit<OAuth2FlowMap, "clientCredentials">>)
|
|
69
|
+
| ({
|
|
70
|
+
authorizationCode: OAuth2FlowMap["authorizationCode"];
|
|
71
|
+
} & Partial<Omit<OAuth2FlowMap, "authorizationCode">>);
|
|
72
|
+
|
|
73
|
+
export type SecurityScheme =
|
|
74
|
+
| {
|
|
75
|
+
type: "http";
|
|
76
|
+
scheme: string;
|
|
77
|
+
bearerFormat?: string;
|
|
78
|
+
description?: string;
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
type: "apiKey";
|
|
82
|
+
name: string;
|
|
83
|
+
in: "query" | "header" | "cookie";
|
|
84
|
+
description?: string;
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
type: "oauth2";
|
|
88
|
+
flows: OAuth2Flows;
|
|
89
|
+
description?: string;
|
|
90
|
+
}
|
|
91
|
+
| {
|
|
92
|
+
type: "openIdConnect";
|
|
93
|
+
openIdConnectUrl: string;
|
|
94
|
+
description?: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export interface OpenApiResult {
|
|
98
|
+
/** Creates a GET route that serves the OpenAPI JSON document. */
|
|
99
|
+
spec: (route: { get: (path: string, config: any) => RouteDescriptor }) => RouteDescriptor;
|
|
100
|
+
/** Creates a GET route that serves the Scalar API reference UI. */
|
|
101
|
+
docs: (route: { get: (path: string, config: any) => RouteDescriptor }) => RouteDescriptor;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Helpers
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
function extractPathParamNames(path: string): string[] {
|
|
109
|
+
return Array.from(path.matchAll(/:(\w+)/g), (match) => match[1]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toOpenApiPath(path: string): string {
|
|
113
|
+
return path.replace(/:(\w+)/g, "{$1}");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getSchemaObjectKeys(schema: z.ZodObject<any>): string[] {
|
|
117
|
+
return Object.keys(schema.shape);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function assertPathParamsMatchPath(method: string, path: string, schema: z.ZodObject<any>): void {
|
|
121
|
+
const pathParamNames = extractPathParamNames(path);
|
|
122
|
+
const schemaParamNames = getSchemaObjectKeys(schema);
|
|
123
|
+
|
|
124
|
+
const hasExactMatch =
|
|
125
|
+
pathParamNames.length === schemaParamNames.length &&
|
|
126
|
+
pathParamNames.every((name) => schemaParamNames.includes(name));
|
|
127
|
+
|
|
128
|
+
if (hasExactMatch) return;
|
|
129
|
+
|
|
130
|
+
if (pathParamNames.length === 0) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`params schema for ${method} ${path} cannot define path params when the path has none`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
throw new Error(
|
|
137
|
+
`params schema for ${method} ${path} must define exactly these path params: ${pathParamNames.join(", ")}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function responseCanHaveBody(status: number): boolean {
|
|
142
|
+
return !(status >= 100 && status < 200) && status !== 204 && status !== 205 && status !== 304;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function operationCanHaveResponseBody(method: string, status: number): boolean {
|
|
146
|
+
return method.toUpperCase() !== "HEAD" && responseCanHaveBody(status);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const STATUS_TEXT: Record<number, string> = {
|
|
150
|
+
100: "Continue",
|
|
151
|
+
101: "Switching Protocols",
|
|
152
|
+
102: "Processing",
|
|
153
|
+
103: "Early Hints",
|
|
154
|
+
200: "OK",
|
|
155
|
+
201: "Created",
|
|
156
|
+
202: "Accepted",
|
|
157
|
+
203: "Non-Authoritative Information",
|
|
158
|
+
204: "No Content",
|
|
159
|
+
205: "Reset Content",
|
|
160
|
+
206: "Partial Content",
|
|
161
|
+
207: "Multi-Status",
|
|
162
|
+
208: "Already Reported",
|
|
163
|
+
226: "IM Used",
|
|
164
|
+
300: "Multiple Choices",
|
|
165
|
+
301: "Moved Permanently",
|
|
166
|
+
302: "Found",
|
|
167
|
+
303: "See Other",
|
|
168
|
+
304: "Not Modified",
|
|
169
|
+
307: "Temporary Redirect",
|
|
170
|
+
308: "Permanent Redirect",
|
|
171
|
+
400: "Bad Request",
|
|
172
|
+
401: "Unauthorized",
|
|
173
|
+
402: "Payment Required",
|
|
174
|
+
403: "Forbidden",
|
|
175
|
+
404: "Not Found",
|
|
176
|
+
405: "Method Not Allowed",
|
|
177
|
+
406: "Not Acceptable",
|
|
178
|
+
407: "Proxy Authentication Required",
|
|
179
|
+
408: "Request Timeout",
|
|
180
|
+
409: "Conflict",
|
|
181
|
+
410: "Gone",
|
|
182
|
+
411: "Length Required",
|
|
183
|
+
412: "Precondition Failed",
|
|
184
|
+
413: "Content Too Large",
|
|
185
|
+
414: "URI Too Long",
|
|
186
|
+
415: "Unsupported Media Type",
|
|
187
|
+
416: "Range Not Satisfiable",
|
|
188
|
+
417: "Expectation Failed",
|
|
189
|
+
418: "I'm a Teapot",
|
|
190
|
+
421: "Misdirected Request",
|
|
191
|
+
422: "Unprocessable Entity",
|
|
192
|
+
423: "Locked",
|
|
193
|
+
424: "Failed Dependency",
|
|
194
|
+
425: "Too Early",
|
|
195
|
+
426: "Upgrade Required",
|
|
196
|
+
428: "Precondition Required",
|
|
197
|
+
429: "Too Many Requests",
|
|
198
|
+
431: "Request Header Fields Too Large",
|
|
199
|
+
451: "Unavailable For Legal Reasons",
|
|
200
|
+
500: "Internal Server Error",
|
|
201
|
+
501: "Not Implemented",
|
|
202
|
+
502: "Bad Gateway",
|
|
203
|
+
503: "Service Unavailable",
|
|
204
|
+
504: "Gateway Timeout",
|
|
205
|
+
505: "HTTP Version Not Supported",
|
|
206
|
+
506: "Variant Also Negotiates",
|
|
207
|
+
507: "Insufficient Storage",
|
|
208
|
+
508: "Loop Detected",
|
|
209
|
+
510: "Not Extended",
|
|
210
|
+
511: "Network Authentication Required",
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
function escapeHtmlAttr(s: string): string {
|
|
214
|
+
return s
|
|
215
|
+
.replace(/&/g, "&")
|
|
216
|
+
.replace(/"/g, """)
|
|
217
|
+
.replace(/</g, "<")
|
|
218
|
+
.replace(/>/g, ">");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function scalarHtml(specUrl: string): string {
|
|
222
|
+
return `<!doctype html>
|
|
223
|
+
<html>
|
|
224
|
+
<head>
|
|
225
|
+
<title>API Reference</title>
|
|
226
|
+
<meta charset="utf-8" />
|
|
227
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
228
|
+
</head>
|
|
229
|
+
<body>
|
|
230
|
+
<script id="api-reference" data-url="${escapeHtmlAttr(specUrl)}"></script>
|
|
231
|
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
|
232
|
+
</body>
|
|
233
|
+
</html>`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Core
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
export function openapi(routes: RouteDescriptor[], config: OpenApiConfig): OpenApiResult {
|
|
241
|
+
const paths: Record<string, Record<string, ZodOpenApiOperationObject>> = {};
|
|
242
|
+
|
|
243
|
+
for (const descriptor of routes) {
|
|
244
|
+
const { method, path, config: routeConfig } = descriptor;
|
|
245
|
+
|
|
246
|
+
// Skip catch-all handlers (route.all) and paths that OpenAPI path
|
|
247
|
+
// templating cannot represent: wildcards (*, **), optional params
|
|
248
|
+
// (:name?), and one-or-more params (:name+).
|
|
249
|
+
if (!method || path.includes("*") || /:\w+[?+]/.test(path)) continue;
|
|
250
|
+
|
|
251
|
+
const openApiPath = toOpenApiPath(path);
|
|
252
|
+
if (!paths[openApiPath]) paths[openApiPath] = {};
|
|
253
|
+
|
|
254
|
+
const operation: ZodOpenApiOperationObject = {
|
|
255
|
+
responses: {},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// --- request params & query (must be z.object() schemas) ---
|
|
259
|
+
const requestParams: Record<string, z.ZodType> = {};
|
|
260
|
+
const inferredPathParams = extractPathParamNames(path);
|
|
261
|
+
|
|
262
|
+
if (routeConfig.request?.params) {
|
|
263
|
+
if (!(routeConfig.request.params instanceof z.ZodObject)) {
|
|
264
|
+
throw new Error(`params schema for ${method} ${path} must be a z.object()`);
|
|
265
|
+
}
|
|
266
|
+
assertPathParamsMatchPath(method, path, routeConfig.request.params);
|
|
267
|
+
requestParams.path = routeConfig.request.params;
|
|
268
|
+
} else if (inferredPathParams.length > 0) {
|
|
269
|
+
requestParams.path = z.object(
|
|
270
|
+
Object.fromEntries(inferredPathParams.map((name) => [name, z.string()])),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (routeConfig.request?.query) {
|
|
275
|
+
if (!(routeConfig.request.query instanceof z.ZodObject)) {
|
|
276
|
+
throw new Error(`query schema for ${method} ${path} must be a z.object()`);
|
|
277
|
+
}
|
|
278
|
+
requestParams.query = routeConfig.request.query;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (Object.keys(requestParams).length > 0) {
|
|
282
|
+
operation.requestParams = requestParams;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// --- request body ---
|
|
286
|
+
if (routeConfig.request?.body) {
|
|
287
|
+
operation.requestBody = {
|
|
288
|
+
content: {
|
|
289
|
+
"application/json": {
|
|
290
|
+
schema: routeConfig.request.body,
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// --- responses ---
|
|
297
|
+
if (routeConfig.response && Object.keys(routeConfig.response).length > 0) {
|
|
298
|
+
for (const [status, schema] of Object.entries(routeConfig.response)) {
|
|
299
|
+
const code = Number(status);
|
|
300
|
+
const entry: Record<string, unknown> = {
|
|
301
|
+
description: STATUS_TEXT[code] ?? "Response",
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
// These HTTP statuses do not allow a response body.
|
|
305
|
+
if (operationCanHaveResponseBody(method, code)) {
|
|
306
|
+
entry.content = {
|
|
307
|
+
"application/json": { schema: schema as z.ZodType },
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
(operation.responses as Record<string, unknown>)[status] = entry;
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
operation.responses = {
|
|
315
|
+
200: { description: "OK" },
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
paths[openApiPath][method.toLowerCase()] = operation;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const specPath = config.specPath ?? "/openapi.json";
|
|
323
|
+
const docsPath = config.docsPath ?? "/docs";
|
|
324
|
+
|
|
325
|
+
const document = createDocument({
|
|
326
|
+
openapi: "3.1.0",
|
|
327
|
+
info: config.info,
|
|
328
|
+
servers: config.servers,
|
|
329
|
+
tags: config.tags,
|
|
330
|
+
security: config.security,
|
|
331
|
+
components: config.securitySchemes
|
|
332
|
+
? { securitySchemes: config.securitySchemes as any }
|
|
333
|
+
: undefined,
|
|
334
|
+
paths,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
spec: (route) =>
|
|
339
|
+
route.get(specPath, {
|
|
340
|
+
resolve: () => Response.json(document),
|
|
341
|
+
}),
|
|
342
|
+
docs: (route) =>
|
|
343
|
+
route.get(docsPath, {
|
|
344
|
+
resolve: () =>
|
|
345
|
+
new Response(scalarHtml(specPath), {
|
|
346
|
+
headers: { "content-type": "text/html" },
|
|
347
|
+
}),
|
|
348
|
+
}),
|
|
349
|
+
};
|
|
350
|
+
}
|