@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.
@@ -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
  });
@@ -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",
@@ -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"),