@veducio/keja 0.2.3 → 0.4.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/package.json +1 -1
- package/src/grant/grant.test.ts +189 -0
- package/src/grant/grant.ts +289 -0
- package/src/grant/settings.ts +67 -0
- package/src/http/batch.ts +307 -0
- package/src/http/create-app.test.ts +141 -0
- package/src/http/create-app.ts +25 -0
- package/src/http/openapi.ts +94 -0
- package/src/project/config.test.ts +39 -0
- package/src/project/config.ts +189 -5
- package/src/project/load.ts +6 -0
- package/src/route/define.ts +14 -0
- package/src/server/index.ts +21 -1
- package/src/vault/client.ts +92 -1
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { frameworkError } from "../errors/framework-errors.ts";
|
|
2
|
+
import type { RouteDefinition } from "../route/define.ts";
|
|
3
|
+
import {
|
|
4
|
+
type MiddlewareDefinition,
|
|
5
|
+
runMiddleware,
|
|
6
|
+
} from "../route/middleware.ts";
|
|
7
|
+
import {
|
|
8
|
+
isAfterVersion,
|
|
9
|
+
type VersionDefinition,
|
|
10
|
+
} from "../versioning/define.ts";
|
|
11
|
+
|
|
12
|
+
const methods = ["get", "post", "put", "patch", "delete"] as const;
|
|
13
|
+
const batchLimit = 25;
|
|
14
|
+
|
|
15
|
+
type BatchCall = {
|
|
16
|
+
method: (typeof methods)[number];
|
|
17
|
+
path: string;
|
|
18
|
+
params?: Record<string, unknown>;
|
|
19
|
+
query?: Record<string, unknown>;
|
|
20
|
+
body?: unknown;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export async function runBatch(
|
|
24
|
+
request: Request,
|
|
25
|
+
versions: readonly VersionDefinition[],
|
|
26
|
+
routes: readonly RouteDefinition[],
|
|
27
|
+
middleware: readonly MiddlewareDefinition<object>[],
|
|
28
|
+
requested: VersionDefinition,
|
|
29
|
+
token: string,
|
|
30
|
+
postOnly: boolean,
|
|
31
|
+
) {
|
|
32
|
+
const calls = await readCalls(request);
|
|
33
|
+
if (!calls.ok) {
|
|
34
|
+
return Response.json(
|
|
35
|
+
frameworkError("invalidRequest", { issues: calls.issues }),
|
|
36
|
+
{ status: 400 },
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const results = [];
|
|
40
|
+
for (const call of calls.calls) {
|
|
41
|
+
results.push(
|
|
42
|
+
await runCall(
|
|
43
|
+
request,
|
|
44
|
+
versions,
|
|
45
|
+
routes,
|
|
46
|
+
middleware,
|
|
47
|
+
requested,
|
|
48
|
+
token,
|
|
49
|
+
postOnly,
|
|
50
|
+
call,
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return Response.json({ results }, { status: 207 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readCalls(request: Request) {
|
|
58
|
+
let body: unknown;
|
|
59
|
+
try {
|
|
60
|
+
body = await request.json();
|
|
61
|
+
} catch {
|
|
62
|
+
return {
|
|
63
|
+
ok: false as const,
|
|
64
|
+
issues: [{ message: "Request body must be JSON" }],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (!isRecord(body) || !Array.isArray(body.requests)) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false as const,
|
|
70
|
+
issues: [{ message: "requests must be an array" }],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (body.requests.length === 0 || body.requests.length > batchLimit) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false as const,
|
|
76
|
+
issues: [{ message: `requests must contain 1 to ${batchLimit} calls` }],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const calls: BatchCall[] = [];
|
|
80
|
+
for (const item of body.requests) {
|
|
81
|
+
const call = readCall(item);
|
|
82
|
+
if (!call.ok) {
|
|
83
|
+
return call;
|
|
84
|
+
}
|
|
85
|
+
calls.push(call.call);
|
|
86
|
+
}
|
|
87
|
+
return { ok: true as const, calls };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readCall(item: unknown) {
|
|
91
|
+
if (!isRecord(item)) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false as const,
|
|
94
|
+
issues: [{ message: "Each request must be an object" }],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const method =
|
|
98
|
+
typeof item.method === "string" ? item.method.toLowerCase() : "";
|
|
99
|
+
if (!methods.includes(method as BatchCall["method"])) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false as const,
|
|
102
|
+
issues: [{ message: "method must be get, post, put, patch, or delete" }],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (typeof item.path !== "string" || !item.path.startsWith("/")) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false as const,
|
|
108
|
+
issues: [{ message: "path must start with /" }],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (item.params !== undefined && !isRecord(item.params)) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false as const,
|
|
114
|
+
issues: [{ message: "params must be an object" }],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (item.query !== undefined && !isRecord(item.query)) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false as const,
|
|
120
|
+
issues: [{ message: "query must be an object" }],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
ok: true as const,
|
|
125
|
+
call: {
|
|
126
|
+
method: method as BatchCall["method"],
|
|
127
|
+
path: item.path,
|
|
128
|
+
params: item.params as Record<string, unknown> | undefined,
|
|
129
|
+
query: item.query as Record<string, unknown> | undefined,
|
|
130
|
+
body: item.body,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function runCall(
|
|
136
|
+
request: Request,
|
|
137
|
+
versions: readonly VersionDefinition[],
|
|
138
|
+
routes: readonly RouteDefinition[],
|
|
139
|
+
middleware: readonly MiddlewareDefinition<object>[],
|
|
140
|
+
requested: VersionDefinition,
|
|
141
|
+
token: string,
|
|
142
|
+
postOnly: boolean,
|
|
143
|
+
call: BatchCall,
|
|
144
|
+
) {
|
|
145
|
+
if (postOnly && call.method !== "post") {
|
|
146
|
+
return {
|
|
147
|
+
status: 405,
|
|
148
|
+
body: frameworkError("methodNotAllowed"),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const selected = selectBatchRoute(
|
|
152
|
+
routes,
|
|
153
|
+
versions,
|
|
154
|
+
call.method,
|
|
155
|
+
call.path,
|
|
156
|
+
requested,
|
|
157
|
+
);
|
|
158
|
+
if (!selected) {
|
|
159
|
+
return { status: 404, body: frameworkError("notFound") };
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const response = await runMiddleware(
|
|
163
|
+
[
|
|
164
|
+
...middleware.map((definition) => ({
|
|
165
|
+
definition,
|
|
166
|
+
allowFields: false,
|
|
167
|
+
})),
|
|
168
|
+
...selected.route.middleware.map((definition) => ({
|
|
169
|
+
definition,
|
|
170
|
+
allowFields: true,
|
|
171
|
+
})),
|
|
172
|
+
],
|
|
173
|
+
{
|
|
174
|
+
request: callRequest(request, call, token),
|
|
175
|
+
version: requested.id,
|
|
176
|
+
params: stringParams(call.params),
|
|
177
|
+
},
|
|
178
|
+
async (fields) => {
|
|
179
|
+
const parsed = parseCall(selected.route, call);
|
|
180
|
+
if (!parsed.ok) {
|
|
181
|
+
return Response.json(
|
|
182
|
+
frameworkError("invalidRequest", { issues: parsed.issues }),
|
|
183
|
+
{ status: 400 },
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return selected.route.handle({ ...parsed.input, fields });
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
return await callResult(response);
|
|
190
|
+
} catch {
|
|
191
|
+
return { status: 500, body: { message: "The call failed." } };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function parseCall(route: RouteDefinition, call: BatchCall) {
|
|
196
|
+
const input: {
|
|
197
|
+
params?: unknown;
|
|
198
|
+
query?: unknown;
|
|
199
|
+
body?: unknown;
|
|
200
|
+
} = {};
|
|
201
|
+
if (route.request.params) {
|
|
202
|
+
const parsed = route.request.params.safeParse(call.params ?? {});
|
|
203
|
+
if (!parsed.success) {
|
|
204
|
+
return { ok: false as const, issues: parsed.error.issues };
|
|
205
|
+
}
|
|
206
|
+
input.params = parsed.data;
|
|
207
|
+
}
|
|
208
|
+
if (route.request.query) {
|
|
209
|
+
const parsed = route.request.query.safeParse(call.query ?? {});
|
|
210
|
+
if (!parsed.success) {
|
|
211
|
+
return { ok: false as const, issues: parsed.error.issues };
|
|
212
|
+
}
|
|
213
|
+
input.query = parsed.data;
|
|
214
|
+
}
|
|
215
|
+
if (route.request.body) {
|
|
216
|
+
if (call.body === undefined) {
|
|
217
|
+
return {
|
|
218
|
+
ok: false as const,
|
|
219
|
+
issues: [{ message: "Request body must be JSON" }],
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const parsed = route.request.body.safeParse(call.body);
|
|
223
|
+
if (!parsed.success) {
|
|
224
|
+
return { ok: false as const, issues: parsed.error.issues };
|
|
225
|
+
}
|
|
226
|
+
input.body = parsed.data;
|
|
227
|
+
}
|
|
228
|
+
return { ok: true as const, input };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function selectBatchRoute(
|
|
232
|
+
routes: readonly RouteDefinition[],
|
|
233
|
+
versions: readonly VersionDefinition[],
|
|
234
|
+
method: string,
|
|
235
|
+
path: string,
|
|
236
|
+
requested: VersionDefinition,
|
|
237
|
+
) {
|
|
238
|
+
const matches = [];
|
|
239
|
+
for (const route of routes) {
|
|
240
|
+
if (route.method !== method || route.path !== path) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const version = versions.find((item) => item.id === route.version);
|
|
244
|
+
if (!version || isAfterVersion(version, requested)) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
matches.push({ route, version });
|
|
248
|
+
}
|
|
249
|
+
matches.sort((left, right) => {
|
|
250
|
+
if (left.version.releasedAt !== right.version.releasedAt) {
|
|
251
|
+
return right.version.releasedAt - left.version.releasedAt;
|
|
252
|
+
}
|
|
253
|
+
return right.version.number - left.version.number;
|
|
254
|
+
});
|
|
255
|
+
return matches[0] ?? null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function callRequest(request: Request, call: BatchCall, token: string) {
|
|
259
|
+
const url = new URL(request.url);
|
|
260
|
+
url.pathname = `/${token}${call.path}`;
|
|
261
|
+
url.search = "";
|
|
262
|
+
for (const [key, value] of Object.entries(call.query ?? {})) {
|
|
263
|
+
if (typeof value === "string") {
|
|
264
|
+
url.searchParams.set(key, value);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const headers = new Headers(request.headers);
|
|
268
|
+
headers.delete("content-length");
|
|
269
|
+
const sendBody = call.body !== undefined && call.method !== "get";
|
|
270
|
+
if (sendBody) {
|
|
271
|
+
headers.set("content-type", "application/json");
|
|
272
|
+
}
|
|
273
|
+
const init: RequestInit = {
|
|
274
|
+
method: call.method.toUpperCase(),
|
|
275
|
+
headers,
|
|
276
|
+
};
|
|
277
|
+
if (sendBody) {
|
|
278
|
+
init.body = JSON.stringify(call.body);
|
|
279
|
+
}
|
|
280
|
+
return new Request(url.href, init);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function stringParams(params: Record<string, unknown> | undefined) {
|
|
284
|
+
const values: Record<string, string> = {};
|
|
285
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
286
|
+
if (typeof value === "string") {
|
|
287
|
+
values[key] = value;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return values;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function callResult(response: Response) {
|
|
294
|
+
const text = await response.text();
|
|
295
|
+
if (text.length === 0) {
|
|
296
|
+
return { status: response.status, body: null };
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
return { status: response.status, body: JSON.parse(text) as unknown };
|
|
300
|
+
} catch {
|
|
301
|
+
return { status: response.status, body: text };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
306
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
307
|
+
}
|
|
@@ -96,10 +96,23 @@ describe("createHttpApp", () => {
|
|
|
96
96
|
const document = (await spec.json()) as {
|
|
97
97
|
openapi: string;
|
|
98
98
|
paths: Record<string, unknown>;
|
|
99
|
+
tags?: { name: string }[];
|
|
99
100
|
servers?: { url: string }[];
|
|
100
101
|
};
|
|
101
102
|
expect(document.openapi).toMatch(/^3\./);
|
|
102
103
|
expect(document.paths["/v1/users/{id}"]).toBeDefined();
|
|
104
|
+
expect(document.paths["/{version}/batch"]).toBeDefined();
|
|
105
|
+
expect(document.paths["/v1/batch"]).toBeUndefined();
|
|
106
|
+
expect(document.paths["/v2/batch"]).toBeUndefined();
|
|
107
|
+
expect(document.tags?.map((tag) => tag.name)).toEqual([
|
|
108
|
+
"Batch",
|
|
109
|
+
"v1",
|
|
110
|
+
"v2",
|
|
111
|
+
]);
|
|
112
|
+
expect(
|
|
113
|
+
(document.paths["/v1/users/{id}"] as { get?: { tags?: string[] } }).get
|
|
114
|
+
?.tags,
|
|
115
|
+
).toEqual(["v1"]);
|
|
103
116
|
expect(document.servers).toEqual([{ url: "http://127.0.0.1:3500" }]);
|
|
104
117
|
|
|
105
118
|
const serviceSpec = await serviceApp.request("/_openapi.json");
|
|
@@ -337,4 +350,132 @@ describe("createHttpApp", () => {
|
|
|
337
350
|
frameworkErrors.invalidResponse,
|
|
338
351
|
);
|
|
339
352
|
});
|
|
353
|
+
|
|
354
|
+
it("runs named routes from a versioned batch", async () => {
|
|
355
|
+
const batch = await publicApp.request("/v2/batch", {
|
|
356
|
+
method: "POST",
|
|
357
|
+
headers: { "content-type": "application/json" },
|
|
358
|
+
body: JSON.stringify({
|
|
359
|
+
requests: [
|
|
360
|
+
{
|
|
361
|
+
method: "get",
|
|
362
|
+
path: "/users/:id",
|
|
363
|
+
params: { id: "ada" },
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
method: "get",
|
|
367
|
+
path: "/users/:id",
|
|
368
|
+
params: { id: "" },
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
method: "get",
|
|
372
|
+
path: "/missing",
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
}),
|
|
376
|
+
});
|
|
377
|
+
expect(batch.status).toBe(207);
|
|
378
|
+
await expect(batch.json()).resolves.toEqual({
|
|
379
|
+
results: [
|
|
380
|
+
{ status: 200, body: { id: "ada" } },
|
|
381
|
+
{
|
|
382
|
+
status: 400,
|
|
383
|
+
body: expect.objectContaining(frameworkErrors.invalidRequest),
|
|
384
|
+
},
|
|
385
|
+
{ status: 404, body: frameworkErrors.notFound },
|
|
386
|
+
],
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const service = await serviceApp.request("/latest/batch", {
|
|
390
|
+
method: "POST",
|
|
391
|
+
headers: { "content-type": "application/json" },
|
|
392
|
+
body: JSON.stringify({
|
|
393
|
+
requests: [
|
|
394
|
+
{ method: "post", path: "/jobs", body: { name: "sync" } },
|
|
395
|
+
{ method: "get", path: "/jobs" },
|
|
396
|
+
],
|
|
397
|
+
}),
|
|
398
|
+
});
|
|
399
|
+
expect(service.status).toBe(207);
|
|
400
|
+
await expect(service.json()).resolves.toEqual({
|
|
401
|
+
results: [
|
|
402
|
+
{ status: 201, body: { name: "sync" } },
|
|
403
|
+
{ status: 405, body: frameworkErrors.methodNotAllowed },
|
|
404
|
+
],
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const empty = await publicApp.request("/v1/batch", {
|
|
408
|
+
method: "POST",
|
|
409
|
+
headers: { "content-type": "application/json" },
|
|
410
|
+
body: JSON.stringify({ requests: [] }),
|
|
411
|
+
});
|
|
412
|
+
expect(empty.status).toBe(400);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("turns batch off when disabled", async () => {
|
|
416
|
+
const app = createHttpApp({
|
|
417
|
+
title: "Public",
|
|
418
|
+
versions,
|
|
419
|
+
routes: publicRoutes,
|
|
420
|
+
batch: false,
|
|
421
|
+
});
|
|
422
|
+
const missing = await app.request("/v1/batch", {
|
|
423
|
+
method: "POST",
|
|
424
|
+
headers: { "content-type": "application/json" },
|
|
425
|
+
body: JSON.stringify({
|
|
426
|
+
requests: [
|
|
427
|
+
{ method: "get", path: "/users/:id", params: { id: "ada" } },
|
|
428
|
+
],
|
|
429
|
+
}),
|
|
430
|
+
});
|
|
431
|
+
expect(missing.status).toBe(404);
|
|
432
|
+
const document = (await (await app.request("/_openapi.json")).json()) as {
|
|
433
|
+
paths: Record<string, unknown>;
|
|
434
|
+
};
|
|
435
|
+
expect(document.paths["/{version}/batch"]).toBeUndefined();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("uses an optional route name in OpenAPI", async () => {
|
|
439
|
+
const app = createHttpApp({
|
|
440
|
+
title: "Public",
|
|
441
|
+
versions,
|
|
442
|
+
routes: [
|
|
443
|
+
defineRoute({
|
|
444
|
+
method: "get",
|
|
445
|
+
path: "/named",
|
|
446
|
+
name: "Named route",
|
|
447
|
+
version: "v1",
|
|
448
|
+
responses: {
|
|
449
|
+
Ok: response({ code: 200, json: z.object({}) }),
|
|
450
|
+
},
|
|
451
|
+
handler: ({ respond }) => respond.Ok({}),
|
|
452
|
+
}),
|
|
453
|
+
],
|
|
454
|
+
});
|
|
455
|
+
const document = (await (await app.request("/_openapi.json")).json()) as {
|
|
456
|
+
paths: Record<string, { get?: { summary?: string; tags?: string[] } }>;
|
|
457
|
+
};
|
|
458
|
+
expect(document.paths["/v1/named"]?.get?.summary).toBe("Named route");
|
|
459
|
+
expect(document.paths["/v1/named"]?.get?.tags).toEqual(["v1"]);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it("reserves POST /batch", () => {
|
|
463
|
+
expect(() =>
|
|
464
|
+
createHttpApp({
|
|
465
|
+
title: "Public",
|
|
466
|
+
versions,
|
|
467
|
+
routes: [
|
|
468
|
+
defineRoute({
|
|
469
|
+
method: "post",
|
|
470
|
+
path: "/batch",
|
|
471
|
+
version: "v1",
|
|
472
|
+
responses: {
|
|
473
|
+
Ok: response({ code: 200, json: z.object({}) }),
|
|
474
|
+
},
|
|
475
|
+
handler: ({ respond }) => respond.Ok({}),
|
|
476
|
+
}),
|
|
477
|
+
],
|
|
478
|
+
}),
|
|
479
|
+
).toThrow("POST /batch is reserved");
|
|
480
|
+
});
|
|
340
481
|
});
|
package/src/http/create-app.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type VersionDefinition,
|
|
13
13
|
} from "../versioning/define.ts";
|
|
14
14
|
import { selectRoute } from "../versioning/select.ts";
|
|
15
|
+
import { runBatch } from "./batch.ts";
|
|
15
16
|
import { createOpenApiDocument } from "./openapi.ts";
|
|
16
17
|
|
|
17
18
|
export function createHttpApp(options: {
|
|
@@ -22,12 +23,17 @@ export function createHttpApp(options: {
|
|
|
22
23
|
server?: string | null;
|
|
23
24
|
identity?: ServiceIdentity;
|
|
24
25
|
postOnly?: boolean;
|
|
26
|
+
batch?: boolean;
|
|
25
27
|
}) {
|
|
28
|
+
const batch = options.batch !== false;
|
|
26
29
|
validateRoutes(options.versions, options.routes, options.postOnly === true);
|
|
27
30
|
const document = createOpenApiDocument(
|
|
28
31
|
options.title,
|
|
29
32
|
options.routes,
|
|
30
33
|
options.server,
|
|
34
|
+
options.versions,
|
|
35
|
+
options.postOnly === true,
|
|
36
|
+
batch,
|
|
31
37
|
);
|
|
32
38
|
const app = new Hono();
|
|
33
39
|
app.get("/_health", (c) => c.json({ status: "ok" }));
|
|
@@ -50,6 +56,7 @@ export function createHttpApp(options: {
|
|
|
50
56
|
options.routes,
|
|
51
57
|
options.middleware ?? [],
|
|
52
58
|
options.postOnly === true,
|
|
59
|
+
batch,
|
|
53
60
|
),
|
|
54
61
|
);
|
|
55
62
|
return app;
|
|
@@ -61,6 +68,7 @@ async function dispatch(
|
|
|
61
68
|
routes: readonly RouteDefinition[],
|
|
62
69
|
middleware: readonly MiddlewareDefinition<object>[],
|
|
63
70
|
postOnly: boolean,
|
|
71
|
+
batch: boolean,
|
|
64
72
|
) {
|
|
65
73
|
if (postOnly && request.method !== "POST") {
|
|
66
74
|
return Response.json(frameworkError("methodNotAllowed"), { status: 405 });
|
|
@@ -77,6 +85,20 @@ async function dispatch(
|
|
|
77
85
|
if (!requested) {
|
|
78
86
|
return Response.json(frameworkError("unknownVersion"), { status: 404 });
|
|
79
87
|
}
|
|
88
|
+
if (request.method === "POST" && remainder === "/batch") {
|
|
89
|
+
if (!batch) {
|
|
90
|
+
return Response.json(frameworkError("notFound"), { status: 404 });
|
|
91
|
+
}
|
|
92
|
+
return runBatch(
|
|
93
|
+
request,
|
|
94
|
+
versions,
|
|
95
|
+
routes,
|
|
96
|
+
middleware,
|
|
97
|
+
requested,
|
|
98
|
+
token,
|
|
99
|
+
postOnly,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
80
102
|
const selected = selectRoute(
|
|
81
103
|
routes,
|
|
82
104
|
versions,
|
|
@@ -175,6 +197,9 @@ function validateRoutes(
|
|
|
175
197
|
const known = new Set(versions.map((version) => version.id));
|
|
176
198
|
const seen = new Set<string>();
|
|
177
199
|
for (const route of routes) {
|
|
200
|
+
if (route.method === "post" && route.path === "/batch") {
|
|
201
|
+
throw projectError("invalidRoute", "POST /batch is reserved");
|
|
202
|
+
}
|
|
178
203
|
if (postOnly && route.method !== "post") {
|
|
179
204
|
throw projectError(
|
|
180
205
|
"serviceMethod",
|
package/src/http/openapi.ts
CHANGED
|
@@ -1,34 +1,128 @@
|
|
|
1
1
|
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
|
|
2
2
|
import { type ZodType, z } from "zod";
|
|
3
3
|
import type { RouteDefinition, RouteResponse } from "../route/define.ts";
|
|
4
|
+
import type { VersionDefinition } from "../versioning/define.ts";
|
|
5
|
+
|
|
6
|
+
const callMethods = ["get", "post", "put", "patch", "delete"] as const;
|
|
7
|
+
|
|
8
|
+
const batchResponse = z.object({
|
|
9
|
+
results: z.array(
|
|
10
|
+
z.object({
|
|
11
|
+
status: z.number().int(),
|
|
12
|
+
body: z.unknown().nullable(),
|
|
13
|
+
}),
|
|
14
|
+
),
|
|
15
|
+
});
|
|
4
16
|
|
|
5
17
|
export function createOpenApiDocument(
|
|
6
18
|
title: string,
|
|
7
19
|
routes: readonly RouteDefinition[],
|
|
8
20
|
server?: string | null,
|
|
21
|
+
versions: readonly VersionDefinition[] = [],
|
|
22
|
+
postOnly = false,
|
|
23
|
+
batch = true,
|
|
9
24
|
) {
|
|
10
25
|
const spec = new OpenAPIHono();
|
|
26
|
+
if (batch && versions.length > 0) {
|
|
27
|
+
const versionIds = versions.map((version) => version.id);
|
|
28
|
+
spec.openapi(
|
|
29
|
+
createRoute({
|
|
30
|
+
method: "post",
|
|
31
|
+
path: "/{version}/batch",
|
|
32
|
+
tags: ["Batch"],
|
|
33
|
+
summary: "Call several routes",
|
|
34
|
+
description:
|
|
35
|
+
"Runs each named route under this version token. Each result has the status and JSON body that call would return on its own. The version segment is a catalog id, a release date, latest, or current.",
|
|
36
|
+
request: {
|
|
37
|
+
params: z.object({
|
|
38
|
+
version: z.string().meta({
|
|
39
|
+
description:
|
|
40
|
+
"A version token such as a catalog id, a release date, latest, or current.",
|
|
41
|
+
examples: [versionIds[0]],
|
|
42
|
+
}),
|
|
43
|
+
}),
|
|
44
|
+
body: {
|
|
45
|
+
content: {
|
|
46
|
+
"application/json": {
|
|
47
|
+
schema: batchRequest(postOnly),
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
required: true,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
responses: {
|
|
54
|
+
207: {
|
|
55
|
+
description: "Multi-Response",
|
|
56
|
+
content: {
|
|
57
|
+
"application/json": {
|
|
58
|
+
schema: batchResponse,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
400: {
|
|
63
|
+
description: "The batch body is missing calls or names one badly",
|
|
64
|
+
content: {
|
|
65
|
+
"application/json": {
|
|
66
|
+
schema: z.object({
|
|
67
|
+
code: z.literal(1003),
|
|
68
|
+
name: z.literal("InvalidRequest"),
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
75
|
+
(c) => c.json({ results: [] }, 207) as never,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
11
78
|
for (const route of routes) {
|
|
12
79
|
spec.openapi(
|
|
13
80
|
createRoute({
|
|
14
81
|
method: route.method,
|
|
15
82
|
path: openApiPath(route.version, route.path),
|
|
83
|
+
tags: [route.version],
|
|
84
|
+
...(route.name ? { summary: route.name } : {}),
|
|
16
85
|
request: openApiRequest(route) as never,
|
|
17
86
|
responses: openApiResponses(route.responses) as never,
|
|
18
87
|
}),
|
|
19
88
|
(c) => c.json({}, 200) as never,
|
|
20
89
|
);
|
|
21
90
|
}
|
|
91
|
+
const tags = [
|
|
92
|
+
...(batch && versions.length > 0 ? [{ name: "Batch" }] : []),
|
|
93
|
+
...versions.map((version) => ({ name: version.id })),
|
|
94
|
+
];
|
|
22
95
|
return spec.getOpenAPIDocument({
|
|
23
96
|
openapi: "3.0.0",
|
|
24
97
|
info: {
|
|
25
98
|
title,
|
|
26
99
|
version: "0.1.0",
|
|
27
100
|
},
|
|
101
|
+
...(tags.length > 0 ? { tags } : {}),
|
|
28
102
|
...(server ? { servers: [{ url: server }] } : {}),
|
|
29
103
|
});
|
|
30
104
|
}
|
|
31
105
|
|
|
106
|
+
function batchRequest(postOnly: boolean) {
|
|
107
|
+
return z.object({
|
|
108
|
+
requests: z
|
|
109
|
+
.array(
|
|
110
|
+
z.object({
|
|
111
|
+
method: z.enum(postOnly ? ["post"] : callMethods),
|
|
112
|
+
path: z.string().meta({
|
|
113
|
+
description:
|
|
114
|
+
"The route path, including :name where the route has parameters.",
|
|
115
|
+
}),
|
|
116
|
+
params: z.record(z.string(), z.unknown()).optional(),
|
|
117
|
+
query: z.record(z.string(), z.unknown()).optional(),
|
|
118
|
+
body: z.unknown().optional(),
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
.min(1)
|
|
122
|
+
.max(25),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
32
126
|
function openApiPath(version: string, path: string) {
|
|
33
127
|
const converted = path.replaceAll(/:([A-Za-z0-9_]+)/g, "{$1}");
|
|
34
128
|
return `/${version}${converted.startsWith("/") ? converted : `/${converted}`}`;
|
|
@@ -103,6 +103,43 @@ describe("defineConfig", () => {
|
|
|
103
103
|
});
|
|
104
104
|
expect(defineConfig().vault).toEqual({ enabled: false });
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
it("reads grants and batch settings from config", () => {
|
|
108
|
+
expect(
|
|
109
|
+
defineConfig({
|
|
110
|
+
grants: {
|
|
111
|
+
enabled: true,
|
|
112
|
+
mode: "transit",
|
|
113
|
+
transit_key: "keja-grants",
|
|
114
|
+
},
|
|
115
|
+
}).grants,
|
|
116
|
+
).toEqual({
|
|
117
|
+
enabled: true,
|
|
118
|
+
mode: "transit",
|
|
119
|
+
max_exp: 900,
|
|
120
|
+
transit_key: "keja-grants",
|
|
121
|
+
transit_mount: "transit",
|
|
122
|
+
});
|
|
123
|
+
expect(
|
|
124
|
+
defineConfig({
|
|
125
|
+
grants: {
|
|
126
|
+
enabled: true,
|
|
127
|
+
mode: "secret",
|
|
128
|
+
secret: "test-secret",
|
|
129
|
+
},
|
|
130
|
+
}).grants,
|
|
131
|
+
).toEqual({
|
|
132
|
+
enabled: true,
|
|
133
|
+
mode: "secret",
|
|
134
|
+
max_exp: 900,
|
|
135
|
+
secret: "test-secret",
|
|
136
|
+
secret_vault: null,
|
|
137
|
+
secret_field: "secret",
|
|
138
|
+
});
|
|
139
|
+
expect(defineConfig({ batch: false }).batch).toEqual({ enabled: false });
|
|
140
|
+
expect(defineConfig().grants).toEqual({ enabled: false });
|
|
141
|
+
expect(defineConfig().batch).toEqual({ enabled: true });
|
|
142
|
+
});
|
|
106
143
|
});
|
|
107
144
|
|
|
108
145
|
describe("loadConfig", () => {
|
|
@@ -114,6 +151,8 @@ describe("loadConfig", () => {
|
|
|
114
151
|
argus: { enabled: false },
|
|
115
152
|
database: { enabled: false },
|
|
116
153
|
vault: { enabled: false },
|
|
154
|
+
grants: { enabled: false },
|
|
155
|
+
batch: { enabled: true },
|
|
117
156
|
versioning: path.resolve(root, "keja.versioning.ts"),
|
|
118
157
|
public: path.resolve(root, "src/public"),
|
|
119
158
|
service: path.resolve(root, "src/service"),
|