@veducio/keja 0.0.0-stage → 0.3.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/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/grant/grant.test.ts +130 -0
- package/src/grant/grant.ts +268 -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 +143 -0
- package/src/project/config.ts +450 -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 +171 -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 +128 -0
- package/src/vault/client.ts +282 -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,282 @@
|
|
|
1
|
+
import { projectError } from "../errors/framework-errors.ts";
|
|
2
|
+
|
|
3
|
+
export type ColumnType =
|
|
4
|
+
| "text"
|
|
5
|
+
| "boolean"
|
|
6
|
+
| "timestamptz"
|
|
7
|
+
| "integer"
|
|
8
|
+
| "bigint"
|
|
9
|
+
| "numeric"
|
|
10
|
+
| "jsonb";
|
|
11
|
+
|
|
12
|
+
export type DomainColumn = {
|
|
13
|
+
name: string;
|
|
14
|
+
type: ColumnType;
|
|
15
|
+
nullable: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type TableSchema = {
|
|
19
|
+
name: string;
|
|
20
|
+
columns: DomainColumn[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const reservedTypes = {
|
|
24
|
+
_id: "text",
|
|
25
|
+
_object: "text",
|
|
26
|
+
_tenant: "text",
|
|
27
|
+
_archived: "boolean",
|
|
28
|
+
_createdAt: "timestamptz",
|
|
29
|
+
_updatedAt: "timestamptz",
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
const typePatterns: readonly { pattern: RegExp; type: ColumnType }[] = [
|
|
33
|
+
{ pattern: /^timestamptz\b/i, type: "timestamptz" },
|
|
34
|
+
{ pattern: /^timestamp\s+with\s+time\s+zone\b/i, type: "timestamptz" },
|
|
35
|
+
{ pattern: /^boolean\b/i, type: "boolean" },
|
|
36
|
+
{ pattern: /^bool\b/i, type: "boolean" },
|
|
37
|
+
{ pattern: /^bigint\b/i, type: "bigint" },
|
|
38
|
+
{ pattern: /^int8\b/i, type: "bigint" },
|
|
39
|
+
{ pattern: /^integer\b/i, type: "integer" },
|
|
40
|
+
{ pattern: /^int4\b/i, type: "integer" },
|
|
41
|
+
{ pattern: /^int\b/i, type: "integer" },
|
|
42
|
+
{ pattern: /^numeric\b/i, type: "numeric" },
|
|
43
|
+
{ pattern: /^decimal\b/i, type: "numeric" },
|
|
44
|
+
{ pattern: /^jsonb\b/i, type: "jsonb" },
|
|
45
|
+
{ pattern: /^text\b/i, type: "text" },
|
|
46
|
+
{ pattern: /^varchar\b/i, type: "text" },
|
|
47
|
+
{ pattern: /^character\s+varying\b/i, type: "text" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
type ParsedColumn = DomainColumn;
|
|
51
|
+
|
|
52
|
+
type WorkingTable = {
|
|
53
|
+
name: string;
|
|
54
|
+
reserved: Map<string, ParsedColumn>;
|
|
55
|
+
columns: DomainColumn[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function readTables(files: readonly { file: string; sql: string }[]) {
|
|
59
|
+
const tables = new Map<string, WorkingTable>();
|
|
60
|
+
for (const file of files) {
|
|
61
|
+
for (const statement of statements(stripComments(file.sql))) {
|
|
62
|
+
applyStatement(tables, file.file, statement);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [...tables.values()]
|
|
66
|
+
.map((table) => finishTable(table))
|
|
67
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function applyStatement(
|
|
71
|
+
tables: Map<string, WorkingTable>,
|
|
72
|
+
file: string,
|
|
73
|
+
statement: string,
|
|
74
|
+
) {
|
|
75
|
+
const create = statement.match(
|
|
76
|
+
/^create\s+table\s+(?:if\s+not\s+exists\s+)?([\s\S]+)$/i,
|
|
77
|
+
);
|
|
78
|
+
if (create?.[1]) {
|
|
79
|
+
createTable(tables, file, create[1]);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const alter = statement.match(
|
|
83
|
+
/^alter\s+table\s+(.+?)\s+add\s+(?:column\s+)?(?:if\s+not\s+exists\s+)?([\s\S]+)$/i,
|
|
84
|
+
);
|
|
85
|
+
if (alter?.[1] && alter[2]) {
|
|
86
|
+
addColumn(tables, file, alter[1].trim(), alter[2].trim());
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function createTable(
|
|
91
|
+
tables: Map<string, WorkingTable>,
|
|
92
|
+
file: string,
|
|
93
|
+
rest: string,
|
|
94
|
+
) {
|
|
95
|
+
const open = rest.indexOf("(");
|
|
96
|
+
const close = rest.lastIndexOf(")");
|
|
97
|
+
if (open === -1 || close === -1 || close < open) {
|
|
98
|
+
throw projectError(
|
|
99
|
+
"invalidRepositorySql",
|
|
100
|
+
`${file} has a CREATE TABLE Keja cannot read`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const name = tableName(rest.slice(0, open).trim(), file);
|
|
104
|
+
if (tables.has(name)) {
|
|
105
|
+
throw projectError(
|
|
106
|
+
"invalidRepositorySql",
|
|
107
|
+
`${file} creates ${name} more than once`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const table: WorkingTable = {
|
|
111
|
+
name,
|
|
112
|
+
reserved: new Map(),
|
|
113
|
+
columns: [],
|
|
114
|
+
};
|
|
115
|
+
for (const part of splitTopLevel(rest.slice(open + 1, close))) {
|
|
116
|
+
if (isConstraint(part)) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const column = readColumn(part, file, name);
|
|
120
|
+
storeColumn(table, column, file);
|
|
121
|
+
}
|
|
122
|
+
tables.set(name, table);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function addColumn(
|
|
126
|
+
tables: Map<string, WorkingTable>,
|
|
127
|
+
file: string,
|
|
128
|
+
tableToken: string,
|
|
129
|
+
definition: string,
|
|
130
|
+
) {
|
|
131
|
+
const name = tableName(tableToken, file);
|
|
132
|
+
const table = tables.get(name);
|
|
133
|
+
if (!table) {
|
|
134
|
+
throw projectError(
|
|
135
|
+
"invalidRepositorySql",
|
|
136
|
+
`${file} adds a column to missing table ${name}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const column = readColumn(definition, file, name);
|
|
140
|
+
storeColumn(table, column, file);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function storeColumn(table: WorkingTable, column: ParsedColumn, file: string) {
|
|
144
|
+
if (column.name in reservedTypes) {
|
|
145
|
+
if (table.reserved.has(column.name)) {
|
|
146
|
+
throw projectError(
|
|
147
|
+
"invalidRepositorySql",
|
|
148
|
+
`${file} defines ${table.name}.${column.name} more than once`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
table.reserved.set(column.name, column);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (column.name.startsWith("_")) {
|
|
155
|
+
throw projectError(
|
|
156
|
+
"invalidRepositorySql",
|
|
157
|
+
`${file} column ${table.name}.${column.name} is reserved`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (table.columns.some((item) => item.name === column.name)) {
|
|
161
|
+
throw projectError(
|
|
162
|
+
"invalidRepositorySql",
|
|
163
|
+
`${file} defines ${table.name}.${column.name} more than once`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
table.columns.push(column);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function finishTable(table: WorkingTable): TableSchema {
|
|
170
|
+
for (const [name, type] of Object.entries(reservedTypes)) {
|
|
171
|
+
const column = table.reserved.get(name);
|
|
172
|
+
if (!column || column.nullable || column.type !== type) {
|
|
173
|
+
throw projectError(
|
|
174
|
+
"invalidRepositorySql",
|
|
175
|
+
`${table.name}.${name} must be ${type} not null`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { name: table.name, columns: table.columns };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function readColumn(definition: string, file: string, table: string) {
|
|
183
|
+
const ident = readIdent(definition.trim());
|
|
184
|
+
if (!ident) {
|
|
185
|
+
throw projectError(
|
|
186
|
+
"invalidRepositorySql",
|
|
187
|
+
`${file} has a column in ${table} Keja cannot read`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
const mapped = typePatterns.find((item) => item.pattern.test(ident.rest));
|
|
191
|
+
if (!mapped) {
|
|
192
|
+
throw projectError(
|
|
193
|
+
"invalidRepositorySql",
|
|
194
|
+
`${file} column ${table}.${ident.name} has no TypeScript type`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
const nullable = !/\b(?:not\s+null|primary\s+key)\b/i.test(ident.rest);
|
|
198
|
+
return { name: ident.name, type: mapped.type, nullable };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function tableName(token: string, file: string) {
|
|
202
|
+
const parts = token
|
|
203
|
+
.trim()
|
|
204
|
+
.split(".")
|
|
205
|
+
.map((part) => readIdent(part.trim())?.name)
|
|
206
|
+
.filter((part): part is string => part !== undefined);
|
|
207
|
+
const name = parts.at(-1);
|
|
208
|
+
if (!name || !/^[a-z_][a-z0-9_]*$/.test(name)) {
|
|
209
|
+
throw projectError(
|
|
210
|
+
"invalidRepositorySql",
|
|
211
|
+
`${file} has a table name Keja cannot read`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return name;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isConstraint(part: string) {
|
|
218
|
+
return /^(?:check|constraint|primary|unique|foreign)\b/i.test(part.trim());
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function readIdent(value: string) {
|
|
222
|
+
const quoted = value.match(/^"([^"]+)"(.*)$/s);
|
|
223
|
+
if (quoted?.[1] !== undefined) {
|
|
224
|
+
return { name: quoted[1], rest: quoted[2]?.trim() ?? "" };
|
|
225
|
+
}
|
|
226
|
+
const plain = value.match(/^([A-Za-z_][A-Za-z0-9_]*)(.*)$/s);
|
|
227
|
+
if (!plain?.[1]) {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
return { name: plain[1].toLowerCase(), rest: plain[2]?.trim() ?? "" };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function stripComments(sql: string) {
|
|
234
|
+
return sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--.*$/gm, " ");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function statements(sql: string) {
|
|
238
|
+
return splitTopLevel(sql, ";").filter((statement) => statement.trim() !== "");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function splitTopLevel(value: string, separator = ",") {
|
|
242
|
+
const parts: string[] = [];
|
|
243
|
+
let current = "";
|
|
244
|
+
let depth = 0;
|
|
245
|
+
let quote: "'" | '"' | null = null;
|
|
246
|
+
for (let index = 0; index < value.length; index++) {
|
|
247
|
+
const char = value[index] ?? "";
|
|
248
|
+
if (quote) {
|
|
249
|
+
current += char;
|
|
250
|
+
if (char === quote && value[index + 1] === quote) {
|
|
251
|
+
current += value[index + 1];
|
|
252
|
+
index += 1;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (char === quote) {
|
|
256
|
+
quote = null;
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (char === "'" || char === '"') {
|
|
261
|
+
quote = char;
|
|
262
|
+
current += char;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (char === "(") {
|
|
266
|
+
depth += 1;
|
|
267
|
+
}
|
|
268
|
+
if (char === ")") {
|
|
269
|
+
depth -= 1;
|
|
270
|
+
}
|
|
271
|
+
if (char === separator && depth === 0) {
|
|
272
|
+
parts.push(current.trim());
|
|
273
|
+
current = "";
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
current += char;
|
|
277
|
+
}
|
|
278
|
+
if (current.trim()) {
|
|
279
|
+
parts.push(current.trim());
|
|
280
|
+
}
|
|
281
|
+
return parts;
|
|
282
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineRoute, response } from "./define.ts";
|
|
4
|
+
|
|
5
|
+
const ok = response({ code: 200, json: z.object({ id: z.string() }) });
|
|
6
|
+
|
|
7
|
+
describe("defineRoute", () => {
|
|
8
|
+
it("rejects a method, a path, and a schema that are not usable", () => {
|
|
9
|
+
expect(() =>
|
|
10
|
+
route({
|
|
11
|
+
method: "GET" as "get",
|
|
12
|
+
path: "/posts",
|
|
13
|
+
responses: { Ok: ok },
|
|
14
|
+
}),
|
|
15
|
+
).toThrow("Method GET must be get, post, put, patch, or delete");
|
|
16
|
+
expect(() =>
|
|
17
|
+
route({
|
|
18
|
+
method: "get",
|
|
19
|
+
path: "posts",
|
|
20
|
+
responses: { Ok: ok },
|
|
21
|
+
}),
|
|
22
|
+
).toThrow("Path posts must start with /");
|
|
23
|
+
expect(() =>
|
|
24
|
+
route({
|
|
25
|
+
method: "get",
|
|
26
|
+
path: "/posts",
|
|
27
|
+
responses: {
|
|
28
|
+
Posts: response({
|
|
29
|
+
code: 200,
|
|
30
|
+
json: z.array(undefined as never),
|
|
31
|
+
}),
|
|
32
|
+
},
|
|
33
|
+
}),
|
|
34
|
+
).toThrow("GET /posts response Posts must be a Zod schema");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("accepts a nested path and a response array", () => {
|
|
38
|
+
expect(() =>
|
|
39
|
+
route({
|
|
40
|
+
method: "get",
|
|
41
|
+
path: "/orgs/:orgId/teams/:teamId",
|
|
42
|
+
responses: {
|
|
43
|
+
Teams: response({
|
|
44
|
+
code: 200,
|
|
45
|
+
json: z.object({
|
|
46
|
+
teams: z.array(z.object({ id: z.string() })),
|
|
47
|
+
}),
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
).not.toThrow();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
function route(definition: {
|
|
56
|
+
method: "get" | "post" | "put" | "patch" | "delete";
|
|
57
|
+
path: string;
|
|
58
|
+
responses: Record<string, { code: number; json: z.ZodType }>;
|
|
59
|
+
}) {
|
|
60
|
+
return defineRoute({
|
|
61
|
+
...definition,
|
|
62
|
+
version: "v1",
|
|
63
|
+
handler: () => new Response(),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import type { ZodType } from "zod";
|
|
2
|
+
import { frameworkError, projectError } from "../errors/framework-errors.ts";
|
|
3
|
+
import { readVersionNumber, type VersionId } from "../versioning/define.ts";
|
|
4
|
+
import { isMiddleware, type MiddlewareDefinition } from "./middleware.ts";
|
|
5
|
+
|
|
6
|
+
export type HttpMethod = "get" | "post" | "put" | "patch" | "delete";
|
|
7
|
+
|
|
8
|
+
export type RequestSchemas = {
|
|
9
|
+
params?: ZodType;
|
|
10
|
+
query?: ZodType;
|
|
11
|
+
body?: ZodType;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type RouteResponse = {
|
|
15
|
+
name: string;
|
|
16
|
+
code: number;
|
|
17
|
+
schema: ZodType;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type DefinedInput<Request extends RequestSchemas> = {
|
|
21
|
+
[Key in keyof Request & keyof RequestSchemas as Request[Key] extends ZodType
|
|
22
|
+
? Key
|
|
23
|
+
: never]: Request[Key] extends ZodType ? Request[Key]["_output"] : never;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type ResponseDefinition = {
|
|
27
|
+
code: number;
|
|
28
|
+
json: ZodType;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type ResponseMap = Record<string, ResponseDefinition>;
|
|
32
|
+
|
|
33
|
+
type JsonOutput<Schema> = Schema extends ZodType ? Schema["_output"] : never;
|
|
34
|
+
|
|
35
|
+
type ResponseSchema<Definition> = Definition extends { json: infer Schema }
|
|
36
|
+
? Schema
|
|
37
|
+
: never;
|
|
38
|
+
|
|
39
|
+
type ResponseConstants<Definition> = Definition extends {
|
|
40
|
+
const: infer Constants extends object;
|
|
41
|
+
}
|
|
42
|
+
? Constants
|
|
43
|
+
: never;
|
|
44
|
+
|
|
45
|
+
type VariableBody<Definition> =
|
|
46
|
+
ResponseConstants<Definition> extends never
|
|
47
|
+
? JsonOutput<ResponseSchema<Definition>>
|
|
48
|
+
: Omit<
|
|
49
|
+
JsonOutput<ResponseSchema<Definition>>,
|
|
50
|
+
keyof ResponseConstants<Definition>
|
|
51
|
+
>;
|
|
52
|
+
|
|
53
|
+
type RespondFns<Responses extends ResponseMap> = {
|
|
54
|
+
[Name in keyof Responses]: keyof VariableBody<Responses[Name]> extends never
|
|
55
|
+
? () => Response
|
|
56
|
+
: (body: VariableBody<Responses[Name]>) => Response;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type UnionToIntersection<Union> = (
|
|
60
|
+
Union extends unknown
|
|
61
|
+
? (value: Union) => void
|
|
62
|
+
: never
|
|
63
|
+
) extends (value: infer Intersection) => void
|
|
64
|
+
? Intersection
|
|
65
|
+
: never;
|
|
66
|
+
|
|
67
|
+
type FieldsOf<Middleware> =
|
|
68
|
+
Middleware extends MiddlewareDefinition<infer Fields>
|
|
69
|
+
? Fields
|
|
70
|
+
: Record<never, never>;
|
|
71
|
+
|
|
72
|
+
type MergedFields<List extends readonly MiddlewareDefinition<object>[]> =
|
|
73
|
+
List extends readonly []
|
|
74
|
+
? Record<never, never>
|
|
75
|
+
: UnionToIntersection<FieldsOf<List[number]>>;
|
|
76
|
+
|
|
77
|
+
export type RouteDefinition = {
|
|
78
|
+
method: HttpMethod;
|
|
79
|
+
path: string;
|
|
80
|
+
version: string;
|
|
81
|
+
request: RequestSchemas;
|
|
82
|
+
responses: RouteResponse[];
|
|
83
|
+
middleware: readonly MiddlewareDefinition<object>[];
|
|
84
|
+
handle: (input: {
|
|
85
|
+
params?: unknown;
|
|
86
|
+
query?: unknown;
|
|
87
|
+
body?: unknown;
|
|
88
|
+
fields: Record<string, unknown>;
|
|
89
|
+
}) => Response | Promise<Response>;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const routeBrand = "keja.route";
|
|
93
|
+
const httpMethods = ["get", "post", "put", "patch", "delete"] as const;
|
|
94
|
+
|
|
95
|
+
const routePath =
|
|
96
|
+
/^\/(?:$|(?:[A-Za-z0-9._~-]+|:[A-Za-z_][A-Za-z0-9_]*)(?:\/(?:[A-Za-z0-9._~-]+|:[A-Za-z_][A-Za-z0-9_]*))*\/?)$/;
|
|
97
|
+
|
|
98
|
+
export function response<
|
|
99
|
+
const Code extends number,
|
|
100
|
+
Schema extends ZodType,
|
|
101
|
+
>(definition: { code: Code; json: Schema }): { code: Code; json: Schema };
|
|
102
|
+
export function response<
|
|
103
|
+
const Code extends number,
|
|
104
|
+
Schema extends ZodType,
|
|
105
|
+
const Constants extends Partial<Schema["_output"]>,
|
|
106
|
+
>(definition: {
|
|
107
|
+
code: Code;
|
|
108
|
+
json: Schema;
|
|
109
|
+
const: Constants;
|
|
110
|
+
}): { code: Code; json: Schema; const: Constants };
|
|
111
|
+
export function response(definition: {
|
|
112
|
+
code: number;
|
|
113
|
+
json: ZodType;
|
|
114
|
+
const?: object;
|
|
115
|
+
}) {
|
|
116
|
+
return definition;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function defineRoute<
|
|
120
|
+
const Version extends string,
|
|
121
|
+
const Request extends RequestSchemas,
|
|
122
|
+
const Responses extends ResponseMap,
|
|
123
|
+
const MiddlewareList extends readonly MiddlewareDefinition<object>[] = [],
|
|
124
|
+
>(definition: {
|
|
125
|
+
method: HttpMethod;
|
|
126
|
+
path: string;
|
|
127
|
+
version: VersionId<Version> extends never
|
|
128
|
+
? "Version must use the form v1, v2, v3"
|
|
129
|
+
: Version;
|
|
130
|
+
request?: Request;
|
|
131
|
+
middleware?: MiddlewareList;
|
|
132
|
+
responses: Responses;
|
|
133
|
+
handler: (
|
|
134
|
+
input: DefinedInput<Request> &
|
|
135
|
+
MergedFields<MiddlewareList> & { respond: RespondFns<Responses> },
|
|
136
|
+
) => Response | Promise<Response>;
|
|
137
|
+
}): RouteDefinition {
|
|
138
|
+
if (
|
|
139
|
+
typeof definition.method !== "string" ||
|
|
140
|
+
!httpMethods.includes(definition.method)
|
|
141
|
+
) {
|
|
142
|
+
throw projectError(
|
|
143
|
+
"invalidRoute",
|
|
144
|
+
`Method ${String(definition.method)} must be get, post, put, patch, or delete`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (typeof definition.path !== "string" || !routePath.test(definition.path)) {
|
|
148
|
+
throw projectError(
|
|
149
|
+
"invalidRoute",
|
|
150
|
+
`Path ${String(definition.path)} must start with /`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const request = definition.request as RequestSchemas | undefined;
|
|
154
|
+
if (request) {
|
|
155
|
+
for (const key of ["params", "query", "body"] as const) {
|
|
156
|
+
if (request[key] !== undefined) {
|
|
157
|
+
assertSchema(
|
|
158
|
+
request[key],
|
|
159
|
+
`${definition.method.toUpperCase()} ${definition.path} request.${key}`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (readVersionNumber(definition.version) === null) {
|
|
165
|
+
throw projectError(
|
|
166
|
+
"invalidVersionId",
|
|
167
|
+
`Version ${definition.version} must use the form v1, v2, v3`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const responses = Object.entries(definition.responses).map(
|
|
171
|
+
([name, defined]) => {
|
|
172
|
+
if (!defined || typeof defined !== "object" || !("json" in defined)) {
|
|
173
|
+
throw projectError(
|
|
174
|
+
"invalidRoute",
|
|
175
|
+
`${definition.method.toUpperCase()} ${definition.path} response ${name} must be created with response(...)`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
assertSchema(
|
|
179
|
+
defined.json,
|
|
180
|
+
`${definition.method.toUpperCase()} ${definition.path} response ${name}`,
|
|
181
|
+
);
|
|
182
|
+
if (
|
|
183
|
+
!Number.isInteger(defined.code) ||
|
|
184
|
+
defined.code < 100 ||
|
|
185
|
+
defined.code > 599
|
|
186
|
+
) {
|
|
187
|
+
throw projectError(
|
|
188
|
+
"invalidStatusCode",
|
|
189
|
+
`Response ${name} has an invalid status code`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
name,
|
|
194
|
+
code: defined.code,
|
|
195
|
+
schema: defined.json,
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
const respond = Object.fromEntries(
|
|
200
|
+
Object.entries(definition.responses).map(([name, defined]) => [
|
|
201
|
+
name,
|
|
202
|
+
(body: unknown) => {
|
|
203
|
+
const fixed = defined as ResponseDefinition & { const?: object };
|
|
204
|
+
const parsed = fixed.json.safeParse({
|
|
205
|
+
...(isRecord(body) ? body : {}),
|
|
206
|
+
...(fixed.const ?? {}),
|
|
207
|
+
});
|
|
208
|
+
if (!parsed.success) {
|
|
209
|
+
return Response.json(
|
|
210
|
+
frameworkError("invalidResponse", { issues: parsed.error.issues }),
|
|
211
|
+
{ status: 500 },
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return Response.json(parsed.data, { status: defined.code });
|
|
215
|
+
},
|
|
216
|
+
]),
|
|
217
|
+
) as RespondFns<Responses>;
|
|
218
|
+
const middleware: MiddlewareDefinition<object>[] = [];
|
|
219
|
+
for (const item of definition.middleware ?? []) {
|
|
220
|
+
if (!isMiddleware(item)) {
|
|
221
|
+
throw projectError(
|
|
222
|
+
"invalidRouteMiddleware",
|
|
223
|
+
`${definition.method.toUpperCase()} ${definition.path} middleware must be created with defineMiddleware(...)`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
middleware.push(item);
|
|
227
|
+
}
|
|
228
|
+
const route: RouteDefinition = {
|
|
229
|
+
method: definition.method,
|
|
230
|
+
path: definition.path,
|
|
231
|
+
version: definition.version,
|
|
232
|
+
request: definition.request ?? {},
|
|
233
|
+
responses,
|
|
234
|
+
middleware,
|
|
235
|
+
handle: (input) =>
|
|
236
|
+
definition.handler({
|
|
237
|
+
...input.fields,
|
|
238
|
+
...(input.params === undefined ? {} : { params: input.params }),
|
|
239
|
+
...(input.query === undefined ? {} : { query: input.query }),
|
|
240
|
+
...(input.body === undefined ? {} : { body: input.body }),
|
|
241
|
+
respond,
|
|
242
|
+
} as DefinedInput<Request> &
|
|
243
|
+
MergedFields<MiddlewareList> & { respond: RespondFns<Responses> }),
|
|
244
|
+
};
|
|
245
|
+
return Object.assign(route, { [routeBrand]: true });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function assertSchema(value: unknown, label: string, seen = new Set<object>()) {
|
|
249
|
+
if (!isSchema(value)) {
|
|
250
|
+
throw projectError("invalidRoute", `${label} must be a Zod schema`);
|
|
251
|
+
}
|
|
252
|
+
if (seen.has(value)) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
seen.add(value);
|
|
256
|
+
const def = value._zod.def;
|
|
257
|
+
if (!def) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if ("element" in def) {
|
|
261
|
+
assertSchema(def.element, label, seen);
|
|
262
|
+
}
|
|
263
|
+
if ("innerType" in def) {
|
|
264
|
+
assertSchema(def.innerType, label, seen);
|
|
265
|
+
}
|
|
266
|
+
if (def.shape && typeof def.shape === "object") {
|
|
267
|
+
for (const nested of Object.values(def.shape)) {
|
|
268
|
+
assertSchema(nested, label, seen);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (Array.isArray(def.options)) {
|
|
272
|
+
for (const option of def.options) {
|
|
273
|
+
assertSchema(option, label, seen);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (Array.isArray(def.items)) {
|
|
277
|
+
for (const item of def.items) {
|
|
278
|
+
assertSchema(item, label, seen);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isSchema(value: unknown): value is {
|
|
284
|
+
_zod: { def?: Record<string, unknown> };
|
|
285
|
+
} {
|
|
286
|
+
return (
|
|
287
|
+
typeof value === "object" &&
|
|
288
|
+
value !== null &&
|
|
289
|
+
"_zod" in value &&
|
|
290
|
+
typeof (value as { safeParse?: unknown }).safeParse === "function"
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
295
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function isRoute(value: unknown): value is RouteDefinition {
|
|
299
|
+
return (
|
|
300
|
+
typeof value === "object" &&
|
|
301
|
+
value !== null &&
|
|
302
|
+
routeBrand in value &&
|
|
303
|
+
(value as Record<string, unknown>)[routeBrand] === true &&
|
|
304
|
+
"method" in value &&
|
|
305
|
+
"path" in value
|
|
306
|
+
);
|
|
307
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
defineMiddleware,
|
|
4
|
+
middlewareList,
|
|
5
|
+
runMiddleware,
|
|
6
|
+
} from "./middleware.ts";
|
|
7
|
+
|
|
8
|
+
const context = {
|
|
9
|
+
request: new Request("http://127.0.0.1/v1/items/ada"),
|
|
10
|
+
version: "v1",
|
|
11
|
+
params: { id: "ada" },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("middlewareList", () => {
|
|
15
|
+
it("accepts one middleware or a list of them", () => {
|
|
16
|
+
const step = defineMiddleware(async ({ next }) => next());
|
|
17
|
+
expect(middlewareList(step)).toEqual([step]);
|
|
18
|
+
expect(middlewareList([step, step])).toEqual([step, step]);
|
|
19
|
+
expect(middlewareList({})).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("runMiddleware", () => {
|
|
24
|
+
it("rejects a second next() call", async () => {
|
|
25
|
+
const step = defineMiddleware(async ({ next }) => {
|
|
26
|
+
await next();
|
|
27
|
+
return next();
|
|
28
|
+
});
|
|
29
|
+
await expect(
|
|
30
|
+
runMiddleware(
|
|
31
|
+
[{ definition: step, allowFields: true }],
|
|
32
|
+
context,
|
|
33
|
+
() => new Response(),
|
|
34
|
+
),
|
|
35
|
+
).rejects.toThrow("next() was already called");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("rejects values passed by server middleware", async () => {
|
|
39
|
+
const step = defineMiddleware(async ({ next }) => next({ user: "ada" }));
|
|
40
|
+
await expect(
|
|
41
|
+
runMiddleware(
|
|
42
|
+
[{ definition: step, allowFields: false }],
|
|
43
|
+
context,
|
|
44
|
+
() => new Response(),
|
|
45
|
+
),
|
|
46
|
+
).rejects.toThrow("Server middleware cannot pass values to next()");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects handler fields named params, query, body, or respond", async () => {
|
|
50
|
+
for (const key of ["params", "query", "body", "respond"] as const) {
|
|
51
|
+
const step = defineMiddleware(async ({ next }) =>
|
|
52
|
+
next({ [key]: true } as never),
|
|
53
|
+
);
|
|
54
|
+
await expect(
|
|
55
|
+
runMiddleware(
|
|
56
|
+
[{ definition: step, allowFields: true }],
|
|
57
|
+
context,
|
|
58
|
+
() => new Response(),
|
|
59
|
+
),
|
|
60
|
+
).rejects.toThrow(`Middleware cannot pass reserved field ${key}`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|