@veducio/keja 0.0.0-stage → 0.2.3
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 +7 -0
- package/README.md +39 -2
- package/examples/starter/.env.example +6 -0
- package/examples/starter/keja.config.ts +7 -0
- package/examples/starter/keja.versioning.ts +5 -0
- package/examples/starter/migrations/0001_init.sql +1 -0
- package/examples/starter/package.json +14 -0
- package/examples/starter/tsconfig.json +9 -0
- package/package.json +62 -4
- package/src/access/cloudflare.test.ts +131 -0
- package/src/access/cloudflare.ts +193 -0
- package/src/argus/call.test.ts +98 -0
- package/src/argus/call.ts +153 -0
- package/src/argus/heartbeat.ts +65 -0
- package/src/argus/register.test.ts +120 -0
- package/src/argus/register.ts +145 -0
- package/src/cli/banner.test.ts +98 -0
- package/src/cli/banner.ts +209 -0
- package/src/cli/commands/build.ts +12 -0
- package/src/cli/commands/dev.ts +12 -0
- package/src/cli/commands/generate.ts +12 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/migrate.ts +12 -0
- package/src/cli/commands/seed.ts +12 -0
- package/src/cli/commands/start.ts +12 -0
- package/src/cli/dev-reload.test.ts +87 -0
- package/src/cli/dev-reload.ts +182 -0
- package/src/cli/generate-project.ts +39 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/init-project.test.ts +312 -0
- package/src/cli/init-project.ts +416 -0
- package/src/cli/migrate-project.ts +31 -0
- package/src/cli/run-project.test.ts +50 -0
- package/src/cli/run-project.ts +93 -0
- package/src/cli/seed-project.ts +46 -0
- package/src/db/client.test.ts +49 -0
- package/src/db/client.ts +61 -0
- package/src/db/migrate.test.ts +141 -0
- package/src/db/migrate.ts +146 -0
- package/src/db/owned-sql.ts +202 -0
- package/src/db/postgres.ts +90 -0
- package/src/env/required-env.test.ts +64 -0
- package/src/env/required-env.ts +120 -0
- package/src/errors/framework-errors.ts +295 -0
- package/src/http/create-app.test.ts +340 -0
- package/src/http/create-app.ts +196 -0
- package/src/http/openapi.ts +111 -0
- package/src/process/slot.ts +18 -0
- package/src/project/config.test.ts +140 -0
- package/src/project/config.ts +413 -0
- package/src/project/load.test.ts +60 -0
- package/src/project/load.ts +158 -0
- package/src/project/reload-graph.test.ts +52 -0
- package/src/project/reload-graph.ts +175 -0
- package/src/repository/define.ts +123 -0
- package/src/repository/generate.test.ts +58 -0
- package/src/repository/generate.ts +117 -0
- package/src/repository/query.test.ts +99 -0
- package/src/repository/query.ts +287 -0
- package/src/repository/runtime.test.ts +154 -0
- package/src/repository/runtime.ts +434 -0
- package/src/repository/sql.test.ts +78 -0
- package/src/repository/sql.ts +282 -0
- package/src/route/define.test.ts +65 -0
- package/src/route/define.ts +307 -0
- package/src/route/middleware.test.ts +63 -0
- package/src/route/middleware.ts +144 -0
- package/src/seed/apply.test.ts +65 -0
- package/src/seed/apply.ts +11 -0
- package/src/seed/define.test.ts +51 -0
- package/src/seed/define.ts +55 -0
- package/src/seed/load.test.ts +54 -0
- package/src/seed/load.ts +67 -0
- package/src/seed/report.test.ts +16 -0
- package/src/seed/report.ts +15 -0
- package/src/self-test/checks.ts +88 -0
- package/src/self-test/run.test.ts +128 -0
- package/src/self-test/run.ts +105 -0
- package/src/server/identity.test.ts +57 -0
- package/src/server/identity.ts +116 -0
- package/src/server/index.test.ts +15 -0
- package/src/server/index.ts +169 -0
- package/src/server/swap.test.ts +89 -0
- package/src/server/swap.ts +29 -0
- package/src/typeid/id.test.ts +46 -0
- package/src/typeid/id.ts +136 -0
- package/src/typeid/owned.test.ts +73 -0
- package/src/typeid/owned.ts +193 -0
- package/src/vault/client.test.ts +122 -0
- package/src/vault/client.ts +191 -0
- package/src/versioning/define.test.ts +79 -0
- package/src/versioning/define.ts +173 -0
- package/src/versioning/select.test.ts +79 -0
- package/src/versioning/select.ts +62 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { frameworkErrors } from "../errors/framework-errors.ts";
|
|
4
|
+
import { defineRoute, response } from "../route/define.ts";
|
|
5
|
+
import { defineMiddleware } from "../route/middleware.ts";
|
|
6
|
+
import { defineVersions } from "../versioning/define.ts";
|
|
7
|
+
import { createHttpApp } from "./create-app.ts";
|
|
8
|
+
|
|
9
|
+
const versions = defineVersions({
|
|
10
|
+
v1: { releasedAt: "2020-01-15" },
|
|
11
|
+
v2: { releasedAt: "2024-06-01" },
|
|
12
|
+
}).versions;
|
|
13
|
+
|
|
14
|
+
const userResponse = z.object({
|
|
15
|
+
id: z.string(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const publicRoutes = [
|
|
19
|
+
defineRoute({
|
|
20
|
+
method: "get",
|
|
21
|
+
path: "/users/:id",
|
|
22
|
+
version: "v1",
|
|
23
|
+
request: {
|
|
24
|
+
params: z.object({ id: z.string().min(1) }),
|
|
25
|
+
},
|
|
26
|
+
responses: {
|
|
27
|
+
User: response({ code: 200, json: userResponse }),
|
|
28
|
+
},
|
|
29
|
+
handler: ({ params, respond }) => respond.User({ id: params.id }),
|
|
30
|
+
}),
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const serviceRoutes = [
|
|
34
|
+
defineRoute({
|
|
35
|
+
method: "post",
|
|
36
|
+
path: "/jobs",
|
|
37
|
+
version: "v2",
|
|
38
|
+
request: {
|
|
39
|
+
body: z.object({ name: z.string() }),
|
|
40
|
+
},
|
|
41
|
+
responses: {
|
|
42
|
+
Created: response({ code: 201, json: z.object({ name: z.string() }) }),
|
|
43
|
+
},
|
|
44
|
+
handler: ({ body, respond }) => respond.Created(body),
|
|
45
|
+
}),
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
describe("createHttpApp", () => {
|
|
49
|
+
const publicApp = createHttpApp({
|
|
50
|
+
title: "Public",
|
|
51
|
+
versions,
|
|
52
|
+
routes: publicRoutes,
|
|
53
|
+
server: "http://127.0.0.1:3500",
|
|
54
|
+
});
|
|
55
|
+
const serviceApp = createHttpApp({
|
|
56
|
+
title: "Service",
|
|
57
|
+
versions,
|
|
58
|
+
routes: serviceRoutes,
|
|
59
|
+
server: "https://service.example",
|
|
60
|
+
postOnly: true,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("serves health, OpenAPI, and Scalar docs on both servers", async () => {
|
|
64
|
+
for (const app of [publicApp, serviceApp]) {
|
|
65
|
+
const health = await app.request("/_health");
|
|
66
|
+
expect(health.status).toBe(200);
|
|
67
|
+
await expect(health.json()).resolves.toEqual({ status: "ok" });
|
|
68
|
+
|
|
69
|
+
const docs = await app.request("/docs");
|
|
70
|
+
expect(docs.status).toBe(200);
|
|
71
|
+
expect(docs.headers.get("content-type")).toContain("text/html");
|
|
72
|
+
const html = await docs.text();
|
|
73
|
+
expect(html).toContain('"hideClientButton": true');
|
|
74
|
+
expect(html).toContain('"showDeveloperTools": "never"');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const identified = createHttpApp({
|
|
78
|
+
title: "Public",
|
|
79
|
+
versions,
|
|
80
|
+
routes: publicRoutes,
|
|
81
|
+
identity: {
|
|
82
|
+
name: "billing",
|
|
83
|
+
public: ["http://192.168.1.10:3000"],
|
|
84
|
+
service: ["http://192.168.1.10:3001"],
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const identity = await identified.request("/_identity");
|
|
88
|
+
expect(identity.status).toBe(200);
|
|
89
|
+
await expect(identity.json()).resolves.toEqual({
|
|
90
|
+
name: "billing",
|
|
91
|
+
public: ["http://192.168.1.10:3000"],
|
|
92
|
+
service: ["http://192.168.1.10:3001"],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const spec = await publicApp.request("/_openapi.json");
|
|
96
|
+
const document = (await spec.json()) as {
|
|
97
|
+
openapi: string;
|
|
98
|
+
paths: Record<string, unknown>;
|
|
99
|
+
servers?: { url: string }[];
|
|
100
|
+
};
|
|
101
|
+
expect(document.openapi).toMatch(/^3\./);
|
|
102
|
+
expect(document.paths["/v1/users/{id}"]).toBeDefined();
|
|
103
|
+
expect(document.servers).toEqual([{ url: "http://127.0.0.1:3500" }]);
|
|
104
|
+
|
|
105
|
+
const serviceSpec = await serviceApp.request("/_openapi.json");
|
|
106
|
+
const serviceDocument = (await serviceSpec.json()) as {
|
|
107
|
+
servers?: { url: string }[];
|
|
108
|
+
};
|
|
109
|
+
expect(serviceDocument.servers).toEqual([
|
|
110
|
+
{ url: "https://service.example" },
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("runs the newest available handler and validates the request", async () => {
|
|
115
|
+
const found = await publicApp.request("/v2/users/ada");
|
|
116
|
+
expect(found.status).toBe(200);
|
|
117
|
+
await expect(found.json()).resolves.toEqual({ id: "ada" });
|
|
118
|
+
|
|
119
|
+
const dated = await publicApp.request("/2020-09-20/users/ada");
|
|
120
|
+
expect(dated.status).toBe(200);
|
|
121
|
+
|
|
122
|
+
const invalid = await publicApp.request("/latest/users/");
|
|
123
|
+
expect(invalid.status).toBe(404);
|
|
124
|
+
await expect(invalid.json()).resolves.toEqual(frameworkErrors.notFound);
|
|
125
|
+
|
|
126
|
+
const missing = await publicApp.request("/2019-01-01/users/ada");
|
|
127
|
+
expect(missing.status).toBe(404);
|
|
128
|
+
await expect(missing.json()).resolves.toEqual(
|
|
129
|
+
frameworkErrors.unknownVersion,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const service = await serviceApp.request("/v1/jobs", {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { "content-type": "application/json" },
|
|
135
|
+
body: JSON.stringify({ name: "sync" }),
|
|
136
|
+
});
|
|
137
|
+
expect(service.status).toBe(404);
|
|
138
|
+
await expect(service.json()).resolves.toEqual(frameworkErrors.notFound);
|
|
139
|
+
|
|
140
|
+
const getJob = await serviceApp.request("/v2/jobs");
|
|
141
|
+
expect(getJob.status).toBe(405);
|
|
142
|
+
await expect(getJob.json()).resolves.toEqual(
|
|
143
|
+
frameworkErrors.methodNotAllowed,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const created = await serviceApp.request("/current/jobs", {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { "content-type": "application/json" },
|
|
149
|
+
body: JSON.stringify({ name: "sync" }),
|
|
150
|
+
});
|
|
151
|
+
expect(created.status).toBe(201);
|
|
152
|
+
|
|
153
|
+
const rejected = await serviceApp.request("/v2/jobs", {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "content-type": "application/json" },
|
|
156
|
+
body: JSON.stringify({}),
|
|
157
|
+
});
|
|
158
|
+
expect(rejected.status).toBe(400);
|
|
159
|
+
await expect(rejected.json()).resolves.toMatchObject(
|
|
160
|
+
frameworkErrors.invalidRequest,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("rejects a service route that is not POST", () => {
|
|
165
|
+
expect(() =>
|
|
166
|
+
createHttpApp({
|
|
167
|
+
title: "Service",
|
|
168
|
+
versions,
|
|
169
|
+
routes: publicRoutes,
|
|
170
|
+
postOnly: true,
|
|
171
|
+
}),
|
|
172
|
+
).toThrow("GET /users/:id must use POST");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("fills constant response fields that are not handler arguments", async () => {
|
|
176
|
+
const app = createHttpApp({
|
|
177
|
+
title: "Public",
|
|
178
|
+
versions,
|
|
179
|
+
routes: [
|
|
180
|
+
defineRoute({
|
|
181
|
+
method: "get",
|
|
182
|
+
path: "/missing/:id",
|
|
183
|
+
version: "v1",
|
|
184
|
+
request: { params: z.object({ id: z.string() }) },
|
|
185
|
+
responses: {
|
|
186
|
+
IdDoesNotExist: response({
|
|
187
|
+
code: 404,
|
|
188
|
+
json: z.object({
|
|
189
|
+
id: z.string(),
|
|
190
|
+
error: z.literal("ID_DOES_NOT_EXIST"),
|
|
191
|
+
}),
|
|
192
|
+
const: { error: "ID_DOES_NOT_EXIST" },
|
|
193
|
+
}),
|
|
194
|
+
Gone: response({
|
|
195
|
+
code: 410,
|
|
196
|
+
json: z.object({ error: z.literal("GONE") }),
|
|
197
|
+
const: { error: "GONE" },
|
|
198
|
+
}),
|
|
199
|
+
},
|
|
200
|
+
handler: ({ params, respond }) =>
|
|
201
|
+
params.id === "gone"
|
|
202
|
+
? respond.Gone()
|
|
203
|
+
: respond.IdDoesNotExist(params),
|
|
204
|
+
}),
|
|
205
|
+
],
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const missing = await app.request("/v1/missing/ada");
|
|
209
|
+
expect(missing.status).toBe(404);
|
|
210
|
+
await expect(missing.json()).resolves.toEqual({
|
|
211
|
+
id: "ada",
|
|
212
|
+
error: "ID_DOES_NOT_EXIST",
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const gone = await app.request("/v1/missing/gone");
|
|
216
|
+
expect(gone.status).toBe(410);
|
|
217
|
+
await expect(gone.json()).resolves.toEqual({ error: "GONE" });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("runs code around next and passes values into the handler", async () => {
|
|
221
|
+
const events: string[] = [];
|
|
222
|
+
const server = defineMiddleware(async ({ next }) => {
|
|
223
|
+
events.push("server-before");
|
|
224
|
+
const response = await next();
|
|
225
|
+
events.push("server-after");
|
|
226
|
+
response.headers.set("x-server", "yes");
|
|
227
|
+
return response;
|
|
228
|
+
});
|
|
229
|
+
const route = defineMiddleware(async ({ version, params, next }) => {
|
|
230
|
+
events.push("route-before");
|
|
231
|
+
const response = await next({ seen: `${version}:${params.id}` });
|
|
232
|
+
events.push("route-after");
|
|
233
|
+
response.headers.set("x-route", "yes");
|
|
234
|
+
return response;
|
|
235
|
+
});
|
|
236
|
+
const app = createHttpApp({
|
|
237
|
+
title: "Public",
|
|
238
|
+
versions,
|
|
239
|
+
middleware: [server],
|
|
240
|
+
routes: [
|
|
241
|
+
defineRoute({
|
|
242
|
+
method: "get",
|
|
243
|
+
path: "/items/:id",
|
|
244
|
+
version: "v1",
|
|
245
|
+
request: { params: z.object({ id: z.string() }) },
|
|
246
|
+
middleware: [route],
|
|
247
|
+
responses: {
|
|
248
|
+
Ok: response({
|
|
249
|
+
code: 200,
|
|
250
|
+
json: z.object({ seen: z.string() }),
|
|
251
|
+
}),
|
|
252
|
+
},
|
|
253
|
+
handler: ({ seen, respond }) => {
|
|
254
|
+
events.push("handler");
|
|
255
|
+
return respond.Ok({ seen });
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const health = await app.request("/_health");
|
|
262
|
+
expect(health.status).toBe(200);
|
|
263
|
+
expect(events).toEqual([]);
|
|
264
|
+
|
|
265
|
+
const result = await app.request("/v2/items/ada");
|
|
266
|
+
expect(result.status).toBe(200);
|
|
267
|
+
expect(result.headers.get("x-server")).toBe("yes");
|
|
268
|
+
expect(result.headers.get("x-route")).toBe("yes");
|
|
269
|
+
await expect(result.json()).resolves.toEqual({ seen: "v2:ada" });
|
|
270
|
+
expect(events).toEqual([
|
|
271
|
+
"server-before",
|
|
272
|
+
"route-before",
|
|
273
|
+
"handler",
|
|
274
|
+
"route-after",
|
|
275
|
+
"server-after",
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("stops the chain when middleware returns its own response", async () => {
|
|
280
|
+
let handled = false;
|
|
281
|
+
const app = createHttpApp({
|
|
282
|
+
title: "Public",
|
|
283
|
+
versions,
|
|
284
|
+
routes: [
|
|
285
|
+
defineRoute({
|
|
286
|
+
method: "get",
|
|
287
|
+
path: "/items/:id",
|
|
288
|
+
version: "v1",
|
|
289
|
+
middleware: [
|
|
290
|
+
defineMiddleware(async () =>
|
|
291
|
+
Response.json({ message: "Unauthorized" }, { status: 401 }),
|
|
292
|
+
),
|
|
293
|
+
],
|
|
294
|
+
responses: {
|
|
295
|
+
Ok: response({ code: 200, json: z.object({}) }),
|
|
296
|
+
},
|
|
297
|
+
handler: ({ respond }) => {
|
|
298
|
+
handled = true;
|
|
299
|
+
return respond.Ok({});
|
|
300
|
+
},
|
|
301
|
+
}),
|
|
302
|
+
],
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const result = await app.request("/v1/items/ada");
|
|
306
|
+
expect(result.status).toBe(401);
|
|
307
|
+
await expect(result.json()).resolves.toEqual({
|
|
308
|
+
message: "Unauthorized",
|
|
309
|
+
});
|
|
310
|
+
expect(handled).toBe(false);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("returns the framework error when a response misses its schema", async () => {
|
|
314
|
+
const app = createHttpApp({
|
|
315
|
+
title: "Public",
|
|
316
|
+
versions,
|
|
317
|
+
routes: [
|
|
318
|
+
defineRoute({
|
|
319
|
+
method: "get",
|
|
320
|
+
path: "/items/:id",
|
|
321
|
+
version: "v1",
|
|
322
|
+
request: { params: z.object({ id: z.string() }) },
|
|
323
|
+
responses: {
|
|
324
|
+
Ok: response({
|
|
325
|
+
code: 200,
|
|
326
|
+
json: z.object({ id: z.string() }),
|
|
327
|
+
}),
|
|
328
|
+
},
|
|
329
|
+
handler: ({ respond }) => respond.Ok({ id: 1 } as never),
|
|
330
|
+
}),
|
|
331
|
+
],
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const result = await app.request("/v1/items/ada");
|
|
335
|
+
expect(result.status).toBe(500);
|
|
336
|
+
await expect(result.json()).resolves.toMatchObject(
|
|
337
|
+
frameworkErrors.invalidResponse,
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { Scalar } from "@scalar/hono-api-reference";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { frameworkError, projectError } from "../errors/framework-errors.ts";
|
|
4
|
+
import type { RouteDefinition } from "../route/define.ts";
|
|
5
|
+
import {
|
|
6
|
+
type MiddlewareDefinition,
|
|
7
|
+
runMiddleware,
|
|
8
|
+
} from "../route/middleware.ts";
|
|
9
|
+
import type { ServiceIdentity } from "../server/identity.ts";
|
|
10
|
+
import {
|
|
11
|
+
resolveVersionToken,
|
|
12
|
+
type VersionDefinition,
|
|
13
|
+
} from "../versioning/define.ts";
|
|
14
|
+
import { selectRoute } from "../versioning/select.ts";
|
|
15
|
+
import { createOpenApiDocument } from "./openapi.ts";
|
|
16
|
+
|
|
17
|
+
export function createHttpApp(options: {
|
|
18
|
+
title: string;
|
|
19
|
+
versions: readonly VersionDefinition[];
|
|
20
|
+
routes: readonly RouteDefinition[];
|
|
21
|
+
middleware?: readonly MiddlewareDefinition<object>[];
|
|
22
|
+
server?: string | null;
|
|
23
|
+
identity?: ServiceIdentity;
|
|
24
|
+
postOnly?: boolean;
|
|
25
|
+
}) {
|
|
26
|
+
validateRoutes(options.versions, options.routes, options.postOnly === true);
|
|
27
|
+
const document = createOpenApiDocument(
|
|
28
|
+
options.title,
|
|
29
|
+
options.routes,
|
|
30
|
+
options.server,
|
|
31
|
+
);
|
|
32
|
+
const app = new Hono();
|
|
33
|
+
app.get("/_health", (c) => c.json({ status: "ok" }));
|
|
34
|
+
if (options.identity) {
|
|
35
|
+
app.get("/_identity", (c) => c.json(options.identity));
|
|
36
|
+
}
|
|
37
|
+
app.get("/_openapi.json", (c) => c.json(document));
|
|
38
|
+
app.get(
|
|
39
|
+
"/docs",
|
|
40
|
+
Scalar({
|
|
41
|
+
url: "/_openapi.json",
|
|
42
|
+
hideClientButton: true,
|
|
43
|
+
showDeveloperTools: "never",
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
app.all("/*", (c) =>
|
|
47
|
+
dispatch(
|
|
48
|
+
c.req.raw,
|
|
49
|
+
options.versions,
|
|
50
|
+
options.routes,
|
|
51
|
+
options.middleware ?? [],
|
|
52
|
+
options.postOnly === true,
|
|
53
|
+
),
|
|
54
|
+
);
|
|
55
|
+
return app;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function dispatch(
|
|
59
|
+
request: Request,
|
|
60
|
+
versions: readonly VersionDefinition[],
|
|
61
|
+
routes: readonly RouteDefinition[],
|
|
62
|
+
middleware: readonly MiddlewareDefinition<object>[],
|
|
63
|
+
postOnly: boolean,
|
|
64
|
+
) {
|
|
65
|
+
if (postOnly && request.method !== "POST") {
|
|
66
|
+
return Response.json(frameworkError("methodNotAllowed"), { status: 405 });
|
|
67
|
+
}
|
|
68
|
+
const url = new URL(request.url);
|
|
69
|
+
const pathname = url.pathname;
|
|
70
|
+
const slash = pathname.indexOf("/", 1);
|
|
71
|
+
const token = slash === -1 ? pathname.slice(1) : pathname.slice(1, slash);
|
|
72
|
+
const remainder = slash === -1 ? "/" : pathname.slice(slash);
|
|
73
|
+
if (token.length === 0) {
|
|
74
|
+
return Response.json(frameworkError("notFound"), { status: 404 });
|
|
75
|
+
}
|
|
76
|
+
const requested = resolveVersionToken(token, versions);
|
|
77
|
+
if (!requested) {
|
|
78
|
+
return Response.json(frameworkError("unknownVersion"), { status: 404 });
|
|
79
|
+
}
|
|
80
|
+
const selected = selectRoute(
|
|
81
|
+
routes,
|
|
82
|
+
versions,
|
|
83
|
+
request.method,
|
|
84
|
+
remainder,
|
|
85
|
+
requested,
|
|
86
|
+
);
|
|
87
|
+
if (!selected) {
|
|
88
|
+
return Response.json(frameworkError("notFound"), { status: 404 });
|
|
89
|
+
}
|
|
90
|
+
return runMiddleware(
|
|
91
|
+
[
|
|
92
|
+
...middleware.map((definition) => ({
|
|
93
|
+
definition,
|
|
94
|
+
allowFields: false,
|
|
95
|
+
})),
|
|
96
|
+
...selected.route.middleware.map((definition) => ({
|
|
97
|
+
definition,
|
|
98
|
+
allowFields: true,
|
|
99
|
+
})),
|
|
100
|
+
],
|
|
101
|
+
{
|
|
102
|
+
request,
|
|
103
|
+
version: requested.id,
|
|
104
|
+
params: selected.params,
|
|
105
|
+
},
|
|
106
|
+
async (fields) => {
|
|
107
|
+
const parsed = await parseRequest(
|
|
108
|
+
selected.route,
|
|
109
|
+
selected.params,
|
|
110
|
+
url,
|
|
111
|
+
request,
|
|
112
|
+
);
|
|
113
|
+
if (!parsed.ok) {
|
|
114
|
+
return Response.json(
|
|
115
|
+
frameworkError("invalidRequest", { issues: parsed.issues }),
|
|
116
|
+
{ status: 400 },
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return selected.route.handle({ ...parsed.input, fields });
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function parseRequest(
|
|
125
|
+
route: RouteDefinition,
|
|
126
|
+
params: Record<string, string>,
|
|
127
|
+
url: URL,
|
|
128
|
+
request: Request,
|
|
129
|
+
) {
|
|
130
|
+
const input: {
|
|
131
|
+
params?: unknown;
|
|
132
|
+
query?: unknown;
|
|
133
|
+
body?: unknown;
|
|
134
|
+
} = {};
|
|
135
|
+
if (route.request.params) {
|
|
136
|
+
const parsed = route.request.params.safeParse(params);
|
|
137
|
+
if (!parsed.success) {
|
|
138
|
+
return { ok: false as const, issues: parsed.error.issues };
|
|
139
|
+
}
|
|
140
|
+
input.params = parsed.data;
|
|
141
|
+
}
|
|
142
|
+
if (route.request.query) {
|
|
143
|
+
const parsed = route.request.query.safeParse(
|
|
144
|
+
Object.fromEntries(url.searchParams),
|
|
145
|
+
);
|
|
146
|
+
if (!parsed.success) {
|
|
147
|
+
return { ok: false as const, issues: parsed.error.issues };
|
|
148
|
+
}
|
|
149
|
+
input.query = parsed.data;
|
|
150
|
+
}
|
|
151
|
+
if (route.request.body) {
|
|
152
|
+
let body: unknown;
|
|
153
|
+
try {
|
|
154
|
+
body = await request.json();
|
|
155
|
+
} catch {
|
|
156
|
+
return {
|
|
157
|
+
ok: false as const,
|
|
158
|
+
issues: [{ message: "Request body must be JSON" }],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const parsed = route.request.body.safeParse(body);
|
|
162
|
+
if (!parsed.success) {
|
|
163
|
+
return { ok: false as const, issues: parsed.error.issues };
|
|
164
|
+
}
|
|
165
|
+
input.body = parsed.data;
|
|
166
|
+
}
|
|
167
|
+
return { ok: true as const, input };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validateRoutes(
|
|
171
|
+
versions: readonly VersionDefinition[],
|
|
172
|
+
routes: readonly RouteDefinition[],
|
|
173
|
+
postOnly: boolean,
|
|
174
|
+
) {
|
|
175
|
+
const known = new Set(versions.map((version) => version.id));
|
|
176
|
+
const seen = new Set<string>();
|
|
177
|
+
for (const route of routes) {
|
|
178
|
+
if (postOnly && route.method !== "post") {
|
|
179
|
+
throw projectError(
|
|
180
|
+
"serviceMethod",
|
|
181
|
+
`${route.method.toUpperCase()} ${route.path} must use POST`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (!known.has(route.version)) {
|
|
185
|
+
throw projectError(
|
|
186
|
+
"unknownRouteVersion",
|
|
187
|
+
`${route.method.toUpperCase()} ${route.path} uses unknown version ${route.version}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const key = `${route.method} ${route.path} ${route.version}`;
|
|
191
|
+
if (seen.has(key)) {
|
|
192
|
+
throw projectError("duplicateRoute", `Duplicate route ${key}`);
|
|
193
|
+
}
|
|
194
|
+
seen.add(key);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import { type ZodType, z } from "zod";
|
|
3
|
+
import type { RouteDefinition, RouteResponse } from "../route/define.ts";
|
|
4
|
+
|
|
5
|
+
export function createOpenApiDocument(
|
|
6
|
+
title: string,
|
|
7
|
+
routes: readonly RouteDefinition[],
|
|
8
|
+
server?: string | null,
|
|
9
|
+
) {
|
|
10
|
+
const spec = new OpenAPIHono();
|
|
11
|
+
for (const route of routes) {
|
|
12
|
+
spec.openapi(
|
|
13
|
+
createRoute({
|
|
14
|
+
method: route.method,
|
|
15
|
+
path: openApiPath(route.version, route.path),
|
|
16
|
+
request: openApiRequest(route) as never,
|
|
17
|
+
responses: openApiResponses(route.responses) as never,
|
|
18
|
+
}),
|
|
19
|
+
(c) => c.json({}, 200) as never,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return spec.getOpenAPIDocument({
|
|
23
|
+
openapi: "3.0.0",
|
|
24
|
+
info: {
|
|
25
|
+
title,
|
|
26
|
+
version: "0.1.0",
|
|
27
|
+
},
|
|
28
|
+
...(server ? { servers: [{ url: server }] } : {}),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function openApiPath(version: string, path: string) {
|
|
33
|
+
const converted = path.replaceAll(/:([A-Za-z0-9_]+)/g, "{$1}");
|
|
34
|
+
return `/${version}${converted.startsWith("/") ? converted : `/${converted}`}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function openApiRequest(route: RouteDefinition) {
|
|
38
|
+
const request: {
|
|
39
|
+
params?: ZodType;
|
|
40
|
+
query?: ZodType;
|
|
41
|
+
body?: {
|
|
42
|
+
content: {
|
|
43
|
+
"application/json": {
|
|
44
|
+
schema: ZodType;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
required: true;
|
|
48
|
+
};
|
|
49
|
+
} = {};
|
|
50
|
+
if (route.request.params) {
|
|
51
|
+
request.params = route.request.params;
|
|
52
|
+
}
|
|
53
|
+
if (route.request.query) {
|
|
54
|
+
request.query = route.request.query;
|
|
55
|
+
}
|
|
56
|
+
if (route.request.body) {
|
|
57
|
+
request.body = {
|
|
58
|
+
content: {
|
|
59
|
+
"application/json": {
|
|
60
|
+
schema: route.request.body,
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
required: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return request;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function openApiResponses(responses: readonly RouteResponse[]) {
|
|
70
|
+
const documented: Record<
|
|
71
|
+
number,
|
|
72
|
+
{
|
|
73
|
+
description: string;
|
|
74
|
+
content: {
|
|
75
|
+
"application/json": {
|
|
76
|
+
schema: ZodType;
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
> = {};
|
|
81
|
+
const grouped = new Map<number, RouteResponse[]>();
|
|
82
|
+
for (const defined of responses) {
|
|
83
|
+
grouped.set(defined.code, [...(grouped.get(defined.code) ?? []), defined]);
|
|
84
|
+
}
|
|
85
|
+
for (const [status, group] of grouped) {
|
|
86
|
+
documented[status] = {
|
|
87
|
+
description: group.map((defined) => defined.name).join(", "),
|
|
88
|
+
content: {
|
|
89
|
+
"application/json": {
|
|
90
|
+
schema: group.length === 1 ? group[0].schema : unionSchema(group),
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (Object.keys(documented).length === 0) {
|
|
96
|
+
documented[200] = {
|
|
97
|
+
description: "200",
|
|
98
|
+
content: {
|
|
99
|
+
"application/json": {
|
|
100
|
+
schema: z.object({}),
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return documented;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function unionSchema(group: RouteResponse[]) {
|
|
109
|
+
const schemas = group.map((defined) => defined.schema);
|
|
110
|
+
return z.union(schemas as [ZodType, ZodType, ...ZodType[]]);
|
|
111
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export function processValue<Value extends object>(
|
|
2
|
+
name: string,
|
|
3
|
+
create: () => Value,
|
|
4
|
+
) {
|
|
5
|
+
const key = Symbol.for(`keja.${name}`);
|
|
6
|
+
const host = globalThis as Record<symbol, Value | undefined>;
|
|
7
|
+
const existing = host[key];
|
|
8
|
+
if (existing) {
|
|
9
|
+
return existing;
|
|
10
|
+
}
|
|
11
|
+
const created = create();
|
|
12
|
+
host[key] = created;
|
|
13
|
+
return created;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function processBrand(name: string) {
|
|
17
|
+
return Symbol.for(`keja.${name}`);
|
|
18
|
+
}
|