@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 { DependencyNotMet, projectError } from "../errors/framework-errors.ts";
|
|
2
|
+
import { processValue } from "../process/slot.ts";
|
|
3
|
+
import type { VaultSettings } from "../project/config.ts";
|
|
4
|
+
|
|
5
|
+
const state = processValue("vault", () => ({
|
|
6
|
+
active: null as VaultSettings | null,
|
|
7
|
+
fetch: undefined as VaultClientOptions["fetch"] | undefined,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
export class VaultError extends Error {
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "VaultError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type VaultClientOptions = {
|
|
18
|
+
address?: string;
|
|
19
|
+
token?: string;
|
|
20
|
+
namespace?: string;
|
|
21
|
+
mount?: string;
|
|
22
|
+
fetch?: (url: string, init?: RequestInit) => Promise<Response>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function setVault(settings: VaultSettings | null) {
|
|
26
|
+
state.active = settings;
|
|
27
|
+
state.fetch = undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function connectVault(
|
|
31
|
+
settings: VaultSettings,
|
|
32
|
+
fetchImpl?: VaultClientOptions["fetch"],
|
|
33
|
+
) {
|
|
34
|
+
const fetch = fetchImpl ?? globalThis.fetch;
|
|
35
|
+
let response: Response;
|
|
36
|
+
try {
|
|
37
|
+
response = await fetch(`${settings.address}/v1/auth/token/lookup-self`, {
|
|
38
|
+
headers: vaultHeaders(settings.token, settings.namespace),
|
|
39
|
+
});
|
|
40
|
+
} catch (error) {
|
|
41
|
+
const detail = error instanceof Error ? error.message : "request failed";
|
|
42
|
+
throw projectError("vaultInit", detail);
|
|
43
|
+
}
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw projectError(
|
|
46
|
+
"vaultInit",
|
|
47
|
+
(await vaultFailure(response, "auth/token/lookup-self")).message,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
setVault(settings);
|
|
51
|
+
state.fetch = fetchImpl;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function createVault(options: VaultClientOptions = {}) {
|
|
55
|
+
const address = vaultAddress(options.address ?? envValue("VAULT_ADDR"));
|
|
56
|
+
const token = required(
|
|
57
|
+
options.token ?? envValue("VAULT_TOKEN"),
|
|
58
|
+
"VAULT_TOKEN",
|
|
59
|
+
);
|
|
60
|
+
const namespace = options.namespace ?? envValue("VAULT_NAMESPACE");
|
|
61
|
+
const mount = secretMount(
|
|
62
|
+
options.mount ?? envValue("VAULT_KV_MOUNT") ?? "secret",
|
|
63
|
+
);
|
|
64
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
65
|
+
const headers = () => vaultHeaders(token, namespace);
|
|
66
|
+
return {
|
|
67
|
+
async secret<
|
|
68
|
+
Data extends Record<string, unknown> = Record<string, unknown>,
|
|
69
|
+
>(path: string) {
|
|
70
|
+
const secretPath = normalizePath(path);
|
|
71
|
+
const response = await fetchImpl(
|
|
72
|
+
`${address}/v1/${mount}/data/${secretPath}`,
|
|
73
|
+
{
|
|
74
|
+
headers: headers(),
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
if (!response.ok) {
|
|
78
|
+
throw await vaultFailure(response, `${mount}/${secretPath}`);
|
|
79
|
+
}
|
|
80
|
+
const body = (await response.json()) as {
|
|
81
|
+
data?: { data?: Data };
|
|
82
|
+
};
|
|
83
|
+
if (!body.data?.data || typeof body.data.data !== "object") {
|
|
84
|
+
throw new VaultError(`Vault secret ${mount}/${secretPath} has no data`);
|
|
85
|
+
}
|
|
86
|
+
return body.data.data;
|
|
87
|
+
},
|
|
88
|
+
transitKey(mountName: string, key: string) {
|
|
89
|
+
const transit = secretMount(mountName);
|
|
90
|
+
const name = secretMount(key);
|
|
91
|
+
return vaultSend<{ type?: unknown; latest_version?: unknown }>(
|
|
92
|
+
fetchImpl,
|
|
93
|
+
`${address}/v1/${transit}/keys/${name}`,
|
|
94
|
+
{ headers: headers() },
|
|
95
|
+
`${transit}/keys/${name}`,
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
transitSign(mountName: string, key: string, body: TransitSignBody) {
|
|
99
|
+
const transit = secretMount(mountName);
|
|
100
|
+
const name = secretMount(key);
|
|
101
|
+
return vaultSend<{ signature?: unknown }>(
|
|
102
|
+
fetchImpl,
|
|
103
|
+
`${address}/v1/${transit}/sign/${name}`,
|
|
104
|
+
{
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: jsonHeaders(headers()),
|
|
107
|
+
body: JSON.stringify(body),
|
|
108
|
+
},
|
|
109
|
+
`${transit}/sign/${name}`,
|
|
110
|
+
);
|
|
111
|
+
},
|
|
112
|
+
transitVerify(mountName: string, key: string, body: TransitVerifyBody) {
|
|
113
|
+
const transit = secretMount(mountName);
|
|
114
|
+
const name = secretMount(key);
|
|
115
|
+
return vaultSend<{ valid?: unknown }>(
|
|
116
|
+
fetchImpl,
|
|
117
|
+
`${address}/v1/${transit}/verify/${name}`,
|
|
118
|
+
{
|
|
119
|
+
method: "POST",
|
|
120
|
+
headers: jsonHeaders(headers()),
|
|
121
|
+
body: JSON.stringify(body),
|
|
122
|
+
},
|
|
123
|
+
`${transit}/verify/${name}`,
|
|
124
|
+
);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function openVault(feature: string) {
|
|
130
|
+
const settings = requireVaultSettings(feature);
|
|
131
|
+
return createVault({
|
|
132
|
+
address: settings.address,
|
|
133
|
+
token: settings.token,
|
|
134
|
+
namespace: settings.namespace ?? undefined,
|
|
135
|
+
mount: settings.mount,
|
|
136
|
+
fetch: state.fetch,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function requireVaultSettings(feature: string) {
|
|
141
|
+
if (!state.active) {
|
|
142
|
+
throw new DependencyNotMet(feature, "vault");
|
|
143
|
+
}
|
|
144
|
+
return state.active;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type TransitSignBody = {
|
|
148
|
+
input: string;
|
|
149
|
+
hash_algorithm?: string;
|
|
150
|
+
marshaling_algorithm?: string;
|
|
151
|
+
signature_algorithm?: string;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
type TransitVerifyBody = TransitSignBody & {
|
|
155
|
+
signature: string;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export function vaultSecret<
|
|
159
|
+
Data extends Record<string, unknown> = Record<string, unknown>,
|
|
160
|
+
>(path: string, options?: VaultClientOptions) {
|
|
161
|
+
if (!options) {
|
|
162
|
+
if (!state.active) {
|
|
163
|
+
throw new DependencyNotMet("vaultSecret", "vault");
|
|
164
|
+
}
|
|
165
|
+
return createVault({
|
|
166
|
+
address: state.active.address,
|
|
167
|
+
token: state.active.token,
|
|
168
|
+
namespace: state.active.namespace ?? undefined,
|
|
169
|
+
mount: state.active.mount,
|
|
170
|
+
fetch: state.fetch,
|
|
171
|
+
}).secret<Data>(path);
|
|
172
|
+
}
|
|
173
|
+
return createVault(options).secret<Data>(path);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function vaultAddress(value: string | null) {
|
|
177
|
+
if (!value) {
|
|
178
|
+
throw new VaultError("VAULT_ADDR is required");
|
|
179
|
+
}
|
|
180
|
+
let url: URL;
|
|
181
|
+
try {
|
|
182
|
+
url = new URL(value);
|
|
183
|
+
} catch {
|
|
184
|
+
throw new VaultError("VAULT_ADDR must be an absolute http or https URL");
|
|
185
|
+
}
|
|
186
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
187
|
+
throw new VaultError("VAULT_ADDR must be an absolute http or https URL");
|
|
188
|
+
}
|
|
189
|
+
return url.href.replace(/\/$/, "");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function secretMount(value: string) {
|
|
193
|
+
const mount = value.trim().replace(/^\/+|\/+$/g, "");
|
|
194
|
+
if (!/^[A-Za-z0-9_-]+$/.test(mount)) {
|
|
195
|
+
throw new VaultError(`invalid Vault mount: ${value}`);
|
|
196
|
+
}
|
|
197
|
+
return mount;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function normalizePath(path: string) {
|
|
201
|
+
const cleaned = path.trim().replace(/^\/+|\/+$/g, "");
|
|
202
|
+
const parts = cleaned.split("/");
|
|
203
|
+
if (
|
|
204
|
+
!cleaned ||
|
|
205
|
+
parts.some((part) => part.length === 0 || part === "." || part === "..")
|
|
206
|
+
) {
|
|
207
|
+
throw new VaultError(`invalid Vault secret path: ${path}`);
|
|
208
|
+
}
|
|
209
|
+
return parts.map((part) => encodeURIComponent(part)).join("/");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function vaultSend<Data>(
|
|
213
|
+
fetchImpl: VaultClientOptions["fetch"],
|
|
214
|
+
url: string,
|
|
215
|
+
init: RequestInit,
|
|
216
|
+
path: string,
|
|
217
|
+
) {
|
|
218
|
+
const fetch = fetchImpl ?? globalThis.fetch;
|
|
219
|
+
const response = await fetch(url, init);
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
throw await vaultFailure(response, path);
|
|
222
|
+
}
|
|
223
|
+
const body = (await response.json()) as { data?: Data };
|
|
224
|
+
if (!body.data || typeof body.data !== "object") {
|
|
225
|
+
throw new VaultError(`Vault ${path} has no data`);
|
|
226
|
+
}
|
|
227
|
+
return body.data;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function jsonHeaders(headers: Headers) {
|
|
231
|
+
headers.set("Content-Type", "application/json");
|
|
232
|
+
return headers;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function vaultHeaders(token: string, namespace: string | null) {
|
|
236
|
+
const headers = new Headers({ "X-Vault-Token": token });
|
|
237
|
+
if (namespace) {
|
|
238
|
+
headers.set("X-Vault-Namespace", namespace);
|
|
239
|
+
}
|
|
240
|
+
return headers;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function vaultFailure(response: Response, path: string) {
|
|
244
|
+
const errors = await vaultErrors(response);
|
|
245
|
+
if (response.status === 404) {
|
|
246
|
+
return new VaultError(`Vault secret ${path} was not found`);
|
|
247
|
+
}
|
|
248
|
+
const detail = errors.length > 0 ? `: ${errors.join(", ")}` : "";
|
|
249
|
+
return new VaultError(
|
|
250
|
+
`Vault rejected ${path} with status ${response.status}${detail}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function vaultErrors(response: Response) {
|
|
255
|
+
try {
|
|
256
|
+
const body = (await response.json()) as { errors?: unknown };
|
|
257
|
+
if (!Array.isArray(body.errors)) {
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
return body.errors.filter(
|
|
261
|
+
(item): item is string => typeof item === "string",
|
|
262
|
+
);
|
|
263
|
+
} catch {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function required(value: string | null, name: string) {
|
|
269
|
+
if (!value) {
|
|
270
|
+
throw new VaultError(`${name} is required`);
|
|
271
|
+
}
|
|
272
|
+
return value;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function envValue(name: string) {
|
|
276
|
+
const runtime = globalThis as {
|
|
277
|
+
Bun?: { env?: Record<string, string | undefined> };
|
|
278
|
+
};
|
|
279
|
+
const value = runtime.Bun?.env?.[name] ?? process.env[name];
|
|
280
|
+
const trimmed = value?.trim();
|
|
281
|
+
return trimmed ? trimmed : null;
|
|
282
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineRoute, response } from "../route/define.ts";
|
|
4
|
+
import {
|
|
5
|
+
defineVersions,
|
|
6
|
+
parseReleasedAt,
|
|
7
|
+
resolveVersionToken,
|
|
8
|
+
} from "./define.ts";
|
|
9
|
+
|
|
10
|
+
const versions = defineVersions({
|
|
11
|
+
v1: { releasedAt: "2020-01-15" },
|
|
12
|
+
v2: { releasedAt: "01-06-2024" },
|
|
13
|
+
}).versions;
|
|
14
|
+
|
|
15
|
+
describe("parseReleasedAt", () => {
|
|
16
|
+
it("accepts calendar dates and unix timestamps", () => {
|
|
17
|
+
expect(parseReleasedAt("2020-09-20")).toBe(Date.UTC(2020, 8, 20));
|
|
18
|
+
expect(parseReleasedAt("20-09-2020")).toBe(Date.UTC(2020, 8, 20));
|
|
19
|
+
expect(parseReleasedAt(1_600_560_000)).toBe(1_600_560_000_000);
|
|
20
|
+
expect(parseReleasedAt(1_600_560_000_000)).toBe(1_600_560_000_000);
|
|
21
|
+
expect(parseReleasedAt("2020-13-40")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("defineVersions", () => {
|
|
26
|
+
it("accepts v followed by a whole number", () => {
|
|
27
|
+
expect(
|
|
28
|
+
defineVersions({
|
|
29
|
+
v0: { releasedAt: "2020-01-15" },
|
|
30
|
+
v10: { releasedAt: "2024-06-01" },
|
|
31
|
+
}).versions.map((version) => version.number),
|
|
32
|
+
).toEqual([0, 10]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("rejects a version that is not v followed by a whole number", () => {
|
|
36
|
+
expect(() =>
|
|
37
|
+
defineVersions({
|
|
38
|
+
"v1.2": { releasedAt: "2020-01-15" },
|
|
39
|
+
} as never),
|
|
40
|
+
).toThrow("Version v1.2 must use the form v1, v2, v3");
|
|
41
|
+
expect(() =>
|
|
42
|
+
defineVersions({
|
|
43
|
+
v01: { releasedAt: "2020-01-15" },
|
|
44
|
+
} as never),
|
|
45
|
+
).toThrow("Version v01 must use the form v1, v2, v3");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("defineRoute version", () => {
|
|
50
|
+
it("rejects a version that is not v followed by a whole number", () => {
|
|
51
|
+
expect(() =>
|
|
52
|
+
defineRoute({
|
|
53
|
+
method: "get",
|
|
54
|
+
path: "/users",
|
|
55
|
+
version: "v1.2",
|
|
56
|
+
responses: {
|
|
57
|
+
Ok: response({ code: 200, json: z.object({}) }),
|
|
58
|
+
},
|
|
59
|
+
handler: () => new Response(),
|
|
60
|
+
} as never),
|
|
61
|
+
).toThrow("Version v1.2 must use the form v1, v2, v3");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("resolveVersionToken", () => {
|
|
66
|
+
it("selects the newest version that is not after the requested date", () => {
|
|
67
|
+
expect(resolveVersionToken("2020-09-20", versions)?.id).toBe("v1");
|
|
68
|
+
expect(resolveVersionToken("2024-06-01", versions)?.id).toBe("v2");
|
|
69
|
+
expect(resolveVersionToken("15-01-2020", versions)?.id).toBe("v1");
|
|
70
|
+
expect(resolveVersionToken("2019-01-01", versions)).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("selects named versions and the latest keyword", () => {
|
|
74
|
+
expect(resolveVersionToken("v1", versions)?.id).toBe("v1");
|
|
75
|
+
expect(resolveVersionToken("v9", versions)).toBeNull();
|
|
76
|
+
expect(resolveVersionToken("latest", versions)?.id).toBe("v2");
|
|
77
|
+
expect(resolveVersionToken("CURRENT", versions)?.id).toBe("v2");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { projectError } from "../errors/framework-errors.ts";
|
|
2
|
+
|
|
3
|
+
export type VersionDefinition = {
|
|
4
|
+
id: string;
|
|
5
|
+
number: number;
|
|
6
|
+
releasedAt: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const versionKey = /^v(0|[1-9]\d*)$/;
|
|
10
|
+
const isoDate = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
11
|
+
const dayMonthYear = /^(\d{2})-(\d{2})-(\d{4})$/;
|
|
12
|
+
|
|
13
|
+
export function parseReleasedAt(value: string | number): number | null {
|
|
14
|
+
if (typeof value === "number") {
|
|
15
|
+
return unixMilliseconds(value);
|
|
16
|
+
}
|
|
17
|
+
const text = value.trim();
|
|
18
|
+
if (/^\d+$/.test(text)) {
|
|
19
|
+
return unixMilliseconds(Number(text));
|
|
20
|
+
}
|
|
21
|
+
const iso = isoDate.exec(text);
|
|
22
|
+
if (iso) {
|
|
23
|
+
return calendarUtc(Number(iso[1]), Number(iso[2]), Number(iso[3]));
|
|
24
|
+
}
|
|
25
|
+
const dmy = dayMonthYear.exec(text);
|
|
26
|
+
if (dmy) {
|
|
27
|
+
return calendarUtc(Number(dmy[3]), Number(dmy[2]), Number(dmy[1]));
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type WholeNumber<Value extends string> = Value extends `${bigint}`
|
|
33
|
+
? Value extends `-${string}` | `0${string}`
|
|
34
|
+
? Value extends "0"
|
|
35
|
+
? Value
|
|
36
|
+
: never
|
|
37
|
+
: Value
|
|
38
|
+
: never;
|
|
39
|
+
|
|
40
|
+
export type VersionId<Value extends string> = Value extends `v${infer Rest}`
|
|
41
|
+
? WholeNumber<Rest> extends never
|
|
42
|
+
? never
|
|
43
|
+
: Value
|
|
44
|
+
: never;
|
|
45
|
+
|
|
46
|
+
type VersionRecord = Record<string, { releasedAt: string | number }>;
|
|
47
|
+
|
|
48
|
+
type CheckedVersions<Versions extends VersionRecord> = {
|
|
49
|
+
[Key in keyof Versions]: Key extends string
|
|
50
|
+
? VersionId<Key> extends never
|
|
51
|
+
? "Version must use the form v1, v2, v3"
|
|
52
|
+
: Versions[Key]
|
|
53
|
+
: never;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export function readVersionNumber(id: string): number | null {
|
|
57
|
+
const match = versionKey.exec(id);
|
|
58
|
+
if (!match) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const number = Number(match[1]);
|
|
62
|
+
if (!Number.isSafeInteger(number) || String(number) !== match[1]) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function defineVersions<const Versions extends VersionRecord>(
|
|
69
|
+
versions: Versions & CheckedVersions<Versions>,
|
|
70
|
+
) {
|
|
71
|
+
const parsed = Object.entries(versions).map(([id, version]) => {
|
|
72
|
+
const number = readVersionNumber(id);
|
|
73
|
+
if (number === null) {
|
|
74
|
+
throw projectError(
|
|
75
|
+
"invalidVersionId",
|
|
76
|
+
`Version ${id} must use the form v1, v2, v3`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const releasedAt = parseReleasedAt(version.releasedAt);
|
|
80
|
+
if (releasedAt === null) {
|
|
81
|
+
throw projectError(
|
|
82
|
+
"invalidReleaseDate",
|
|
83
|
+
`Version ${id} has an invalid release date`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
id,
|
|
88
|
+
number,
|
|
89
|
+
releasedAt,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
versions: parsed.sort(compareVersions),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isVersions(
|
|
98
|
+
value: unknown,
|
|
99
|
+
): value is { versions: VersionDefinition[] } {
|
|
100
|
+
return (
|
|
101
|
+
typeof value === "object" &&
|
|
102
|
+
value !== null &&
|
|
103
|
+
"versions" in value &&
|
|
104
|
+
Array.isArray(value.versions)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function newestVersion(versions: readonly VersionDefinition[]) {
|
|
109
|
+
return [...versions].sort(compareVersions).at(-1) ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function resolveVersionToken(
|
|
113
|
+
token: string,
|
|
114
|
+
versions: readonly VersionDefinition[],
|
|
115
|
+
) {
|
|
116
|
+
if (versions.length === 0) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const keyword = token.toLowerCase();
|
|
120
|
+
if (keyword === "latest" || keyword === "current") {
|
|
121
|
+
return newestVersion(versions);
|
|
122
|
+
}
|
|
123
|
+
if (versionKey.test(token)) {
|
|
124
|
+
return versions.find((version) => version.id === token) ?? null;
|
|
125
|
+
}
|
|
126
|
+
const releasedAt = parseReleasedAt(token);
|
|
127
|
+
if (releasedAt === null) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const eligible = versions.filter(
|
|
131
|
+
(version) => version.releasedAt <= releasedAt,
|
|
132
|
+
);
|
|
133
|
+
return newestVersion(eligible);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function isAfterVersion(
|
|
137
|
+
candidate: VersionDefinition,
|
|
138
|
+
requested: VersionDefinition,
|
|
139
|
+
) {
|
|
140
|
+
if (candidate.releasedAt !== requested.releasedAt) {
|
|
141
|
+
return candidate.releasedAt > requested.releasedAt;
|
|
142
|
+
}
|
|
143
|
+
return candidate.number > requested.number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function unixMilliseconds(value: number) {
|
|
147
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
return value >= 1_000_000_000_000 ? value : value * 1000;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function calendarUtc(year: number, month: number, day: number) {
|
|
154
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
158
|
+
if (
|
|
159
|
+
date.getUTCFullYear() !== year ||
|
|
160
|
+
date.getUTCMonth() !== month - 1 ||
|
|
161
|
+
date.getUTCDate() !== day
|
|
162
|
+
) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
return date.getTime();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function compareVersions(left: VersionDefinition, right: VersionDefinition) {
|
|
169
|
+
if (left.releasedAt !== right.releasedAt) {
|
|
170
|
+
return left.releasedAt - right.releasedAt;
|
|
171
|
+
}
|
|
172
|
+
return left.number - right.number;
|
|
173
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineRoute, response } from "../route/define.ts";
|
|
4
|
+
import { defineVersions } from "./define.ts";
|
|
5
|
+
import { selectRoute } from "./select.ts";
|
|
6
|
+
|
|
7
|
+
const versions = defineVersions({
|
|
8
|
+
v1: { releasedAt: "2020-01-15" },
|
|
9
|
+
v2: { releasedAt: "2024-06-01" },
|
|
10
|
+
}).versions;
|
|
11
|
+
|
|
12
|
+
const user = z.object({ id: z.string() });
|
|
13
|
+
|
|
14
|
+
const routes = [
|
|
15
|
+
defineRoute({
|
|
16
|
+
method: "get",
|
|
17
|
+
path: "/users/:id",
|
|
18
|
+
version: "v1",
|
|
19
|
+
request: { params: user },
|
|
20
|
+
responses: {
|
|
21
|
+
User: response({ code: 200, json: user }),
|
|
22
|
+
},
|
|
23
|
+
handler: ({ params, respond }) => respond.User(params),
|
|
24
|
+
}),
|
|
25
|
+
defineRoute({
|
|
26
|
+
method: "get",
|
|
27
|
+
path: "/reports",
|
|
28
|
+
version: "v2",
|
|
29
|
+
responses: {
|
|
30
|
+
Ready: response({ code: 200, json: z.object({ ok: z.literal(true) }) }),
|
|
31
|
+
},
|
|
32
|
+
handler: ({ respond }) => respond.Ready({ ok: true }),
|
|
33
|
+
}),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
describe("selectRoute", () => {
|
|
37
|
+
it("uses the newest handler that is not after the requested version", () => {
|
|
38
|
+
const v2 = versions.find((version) => version.id === "v2");
|
|
39
|
+
const v1 = versions.find((version) => version.id === "v1");
|
|
40
|
+
if (!v2 || !v1) {
|
|
41
|
+
throw new Error("missing versions");
|
|
42
|
+
}
|
|
43
|
+
expect(
|
|
44
|
+
selectRoute(routes, versions, "GET", "/users/1", v2)?.route.version,
|
|
45
|
+
).toBe("v1");
|
|
46
|
+
expect(selectRoute(routes, versions, "GET", "/reports", v1)).toBeNull();
|
|
47
|
+
expect(
|
|
48
|
+
selectRoute(routes, versions, "GET", "/reports", v2)?.route.version,
|
|
49
|
+
).toBe("v2");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("matches a route nested several segments deep", () => {
|
|
53
|
+
const v1 = versions.find((version) => version.id === "v1");
|
|
54
|
+
if (!v1) {
|
|
55
|
+
throw new Error("missing versions");
|
|
56
|
+
}
|
|
57
|
+
const deep = defineRoute({
|
|
58
|
+
method: "get",
|
|
59
|
+
path: "/orgs/:orgId/teams/:teamId/members/:memberId",
|
|
60
|
+
version: "v1",
|
|
61
|
+
responses: {
|
|
62
|
+
Member: response({ code: 200, json: z.object({ id: z.string() }) }),
|
|
63
|
+
},
|
|
64
|
+
handler: ({ respond }) => respond.Member({ id: "ada" }),
|
|
65
|
+
});
|
|
66
|
+
const selected = selectRoute(
|
|
67
|
+
[deep],
|
|
68
|
+
versions,
|
|
69
|
+
"GET",
|
|
70
|
+
"/orgs/acme/teams/core/members/ada",
|
|
71
|
+
v1,
|
|
72
|
+
);
|
|
73
|
+
expect(selected?.params).toEqual({
|
|
74
|
+
orgId: "acme",
|
|
75
|
+
teamId: "core",
|
|
76
|
+
memberId: "ada",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { RouteDefinition } from "../route/define.ts";
|
|
2
|
+
import {
|
|
3
|
+
isAfterVersion,
|
|
4
|
+
type VersionDefinition,
|
|
5
|
+
} from "../versioning/define.ts";
|
|
6
|
+
|
|
7
|
+
export function matchPath(pattern: string, pathname: string) {
|
|
8
|
+
const expected = segments(pattern);
|
|
9
|
+
const actual = segments(pathname);
|
|
10
|
+
if (expected.length !== actual.length) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const params: Record<string, string> = {};
|
|
14
|
+
for (const [index, part] of expected.entries()) {
|
|
15
|
+
const value = actual[index] ?? "";
|
|
16
|
+
if (part.startsWith(":")) {
|
|
17
|
+
params[part.slice(1)] = decodeURIComponent(value);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (part !== value) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return params;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function selectRoute(
|
|
28
|
+
routes: readonly RouteDefinition[],
|
|
29
|
+
versions: readonly VersionDefinition[],
|
|
30
|
+
method: string,
|
|
31
|
+
pathname: string,
|
|
32
|
+
requested: VersionDefinition,
|
|
33
|
+
) {
|
|
34
|
+
const matches = [];
|
|
35
|
+
for (const route of routes) {
|
|
36
|
+
if (route.method !== method.toLowerCase()) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const params = matchPath(route.path, pathname);
|
|
40
|
+
if (!params) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const version = versions.find((item) => item.id === route.version);
|
|
44
|
+
if (!version || isAfterVersion(version, requested)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
matches.push({ route, params, version });
|
|
48
|
+
}
|
|
49
|
+
matches.sort((left, right) => {
|
|
50
|
+
if (left.version.releasedAt !== right.version.releasedAt) {
|
|
51
|
+
return right.version.releasedAt - left.version.releasedAt;
|
|
52
|
+
}
|
|
53
|
+
return right.version.number - left.version.number;
|
|
54
|
+
});
|
|
55
|
+
return matches[0] ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function segments(value: string) {
|
|
59
|
+
const normalized =
|
|
60
|
+
value.length > 1 && value.endsWith("/") ? value.slice(0, -1) : value;
|
|
61
|
+
return normalized.split("/").filter((part) => part.length > 0);
|
|
62
|
+
}
|