@fragno-dev/core 0.0.1
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/.turbo/turbo-build.log +61 -0
- package/.turbo/turbo-types$colon$check.log +2 -0
- package/dist/api/api.d.ts +2 -0
- package/dist/api/api.js +3 -0
- package/dist/api-CBDGZiLC.d.ts +278 -0
- package/dist/api-CBDGZiLC.d.ts.map +1 -0
- package/dist/api-DgHfYjq2.js +54 -0
- package/dist/api-DgHfYjq2.js.map +1 -0
- package/dist/client/client.d.ts +3 -0
- package/dist/client/client.js +6 -0
- package/dist/client/client.svelte.d.ts +33 -0
- package/dist/client/client.svelte.d.ts.map +1 -0
- package/dist/client/client.svelte.js +123 -0
- package/dist/client/client.svelte.js.map +1 -0
- package/dist/client/react.d.ts +58 -0
- package/dist/client/react.d.ts.map +1 -0
- package/dist/client/react.js +80 -0
- package/dist/client/react.js.map +1 -0
- package/dist/client/vanilla.d.ts +61 -0
- package/dist/client/vanilla.d.ts.map +1 -0
- package/dist/client/vanilla.js +136 -0
- package/dist/client/vanilla.js.map +1 -0
- package/dist/client/vue.d.ts +39 -0
- package/dist/client/vue.d.ts.map +1 -0
- package/dist/client/vue.js +108 -0
- package/dist/client/vue.js.map +1 -0
- package/dist/client-DWjxKDnE.js +703 -0
- package/dist/client-DWjxKDnE.js.map +1 -0
- package/dist/client-XFdAy-IQ.d.ts +287 -0
- package/dist/client-XFdAy-IQ.d.ts.map +1 -0
- package/dist/integrations/astro.d.ts +18 -0
- package/dist/integrations/astro.d.ts.map +1 -0
- package/dist/integrations/astro.js +16 -0
- package/dist/integrations/astro.js.map +1 -0
- package/dist/integrations/next-js.d.ts +15 -0
- package/dist/integrations/next-js.d.ts.map +1 -0
- package/dist/integrations/next-js.js +17 -0
- package/dist/integrations/next-js.js.map +1 -0
- package/dist/integrations/react-ssr.d.ts +19 -0
- package/dist/integrations/react-ssr.d.ts.map +1 -0
- package/dist/integrations/react-ssr.js +38 -0
- package/dist/integrations/react-ssr.js.map +1 -0
- package/dist/integrations/svelte-kit.d.ts +21 -0
- package/dist/integrations/svelte-kit.d.ts.map +1 -0
- package/dist/integrations/svelte-kit.js +18 -0
- package/dist/integrations/svelte-kit.js.map +1 -0
- package/dist/mod.d.ts +3 -0
- package/dist/mod.js +177 -0
- package/dist/mod.js.map +1 -0
- package/dist/route-Bp6eByhz.js +331 -0
- package/dist/route-Bp6eByhz.js.map +1 -0
- package/dist/ssr-tJHqcNSw.js +48 -0
- package/dist/ssr-tJHqcNSw.js.map +1 -0
- package/package.json +127 -0
- package/src/api/api.test.ts +140 -0
- package/src/api/api.ts +106 -0
- package/src/api/error.ts +47 -0
- package/src/api/fragment.test.ts +509 -0
- package/src/api/fragment.ts +277 -0
- package/src/api/internal/path-runtime.test.ts +121 -0
- package/src/api/internal/path-type.test.ts +602 -0
- package/src/api/internal/path.ts +322 -0
- package/src/api/internal/response-stream.ts +118 -0
- package/src/api/internal/route.test.ts +56 -0
- package/src/api/internal/route.ts +9 -0
- package/src/api/request-input-context.test.ts +437 -0
- package/src/api/request-input-context.ts +201 -0
- package/src/api/request-middleware.test.ts +544 -0
- package/src/api/request-middleware.ts +126 -0
- package/src/api/request-output-context.test.ts +626 -0
- package/src/api/request-output-context.ts +175 -0
- package/src/api/route.test.ts +176 -0
- package/src/api/route.ts +152 -0
- package/src/client/client-builder.test.ts +264 -0
- package/src/client/client-error.test.ts +15 -0
- package/src/client/client-error.ts +141 -0
- package/src/client/client-types.test.ts +493 -0
- package/src/client/client.ssr.test.ts +173 -0
- package/src/client/client.svelte.test.ts +837 -0
- package/src/client/client.svelte.ts +278 -0
- package/src/client/client.test.ts +1690 -0
- package/src/client/client.ts +1035 -0
- package/src/client/component.test.svelte +21 -0
- package/src/client/internal/ndjson-streaming.test.ts +457 -0
- package/src/client/internal/ndjson-streaming.ts +248 -0
- package/src/client/react.test.ts +947 -0
- package/src/client/react.ts +241 -0
- package/src/client/vanilla.test.ts +867 -0
- package/src/client/vanilla.ts +265 -0
- package/src/client/vue.test.ts +754 -0
- package/src/client/vue.ts +242 -0
- package/src/http/http-status.ts +60 -0
- package/src/integrations/astro.ts +17 -0
- package/src/integrations/next-js.ts +31 -0
- package/src/integrations/react-ssr.ts +40 -0
- package/src/integrations/svelte-kit.ts +41 -0
- package/src/mod.ts +20 -0
- package/src/util/async.test.ts +85 -0
- package/src/util/async.ts +96 -0
- package/src/util/content-type.test.ts +136 -0
- package/src/util/content-type.ts +84 -0
- package/src/util/nanostores.test.ts +28 -0
- package/src/util/nanostores.ts +65 -0
- package/src/util/ssr.ts +75 -0
- package/src/util/types-util.ts +16 -0
- package/tsconfig.json +10 -0
- package/tsdown.config.ts +21 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import { test, expect, describe, expectTypeOf } from "vitest";
|
|
2
|
+
import { defineFragment, createFragment } from "./fragment";
|
|
3
|
+
import { defineRoute } from "./route";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { FragnoApiValidationError } from "./error";
|
|
6
|
+
|
|
7
|
+
describe("Request Middleware", () => {
|
|
8
|
+
test("middleware can intercept and return early", async () => {
|
|
9
|
+
const config = { apiKey: "test" };
|
|
10
|
+
|
|
11
|
+
const fragment = defineFragment<typeof config>("test-lib").withServices(() => ({
|
|
12
|
+
auth: { isAuthorized: (token?: string) => token === "valid-token" },
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const routes = [
|
|
16
|
+
defineRoute({
|
|
17
|
+
method: "GET",
|
|
18
|
+
path: "/protected",
|
|
19
|
+
handler: async (_input, { json }) => {
|
|
20
|
+
return json({ message: "You accessed protected resource" });
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
const instance = createFragment(fragment, config, routes, {
|
|
26
|
+
mountRoute: "/api",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Add middleware that checks authorization
|
|
30
|
+
const withAuth = instance.withMiddleware(async ({ queryParams }, { services, error }) => {
|
|
31
|
+
const q = queryParams.get("q");
|
|
32
|
+
|
|
33
|
+
if (services.auth.isAuthorized(q ?? undefined)) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return error({ message: "Unauthorized", code: "UNAUTHORIZED" }, 401);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Test unauthorized request
|
|
41
|
+
const unauthorizedReq = new Request("http://localhost/api/protected", {
|
|
42
|
+
method: "GET",
|
|
43
|
+
});
|
|
44
|
+
const unauthorizedRes = await withAuth.handler(unauthorizedReq);
|
|
45
|
+
expect(unauthorizedRes.status).toBe(401);
|
|
46
|
+
const unauthorizedBody = await unauthorizedRes.json();
|
|
47
|
+
expect(unauthorizedBody).toEqual({
|
|
48
|
+
error: "Unauthorized",
|
|
49
|
+
code: "UNAUTHORIZED",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Test authorized request
|
|
53
|
+
const authorizedReq = new Request("http://localhost/api/protected?q=valid-token", {
|
|
54
|
+
method: "GET",
|
|
55
|
+
});
|
|
56
|
+
const authorizedRes = await withAuth.handler(authorizedReq);
|
|
57
|
+
expect(authorizedRes.status).toBe(200);
|
|
58
|
+
const authorizedBody = await authorizedRes.json();
|
|
59
|
+
expect(authorizedBody).toEqual({
|
|
60
|
+
message: "You accessed protected resource",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("ifMatchesRoute - middleware has access to matched route information", async () => {
|
|
65
|
+
const config = {};
|
|
66
|
+
|
|
67
|
+
const fragment = defineFragment<typeof config>("test-lib");
|
|
68
|
+
|
|
69
|
+
const routes = [
|
|
70
|
+
defineRoute({
|
|
71
|
+
method: "GET",
|
|
72
|
+
path: "/users",
|
|
73
|
+
outputSchema: z.object({ id: z.number(), name: z.string() }),
|
|
74
|
+
handler: async (_, { json }) =>
|
|
75
|
+
json({
|
|
76
|
+
id: 1,
|
|
77
|
+
name: "John Doe",
|
|
78
|
+
}),
|
|
79
|
+
}),
|
|
80
|
+
defineRoute({
|
|
81
|
+
method: "POST",
|
|
82
|
+
path: "/users/:id",
|
|
83
|
+
inputSchema: z.object({ name: z.string() }),
|
|
84
|
+
outputSchema: z.object({ id: z.number(), name: z.string() }),
|
|
85
|
+
handler: async ({ input, pathParams }, { json }) => {
|
|
86
|
+
const body = await input.valid();
|
|
87
|
+
|
|
88
|
+
return json({
|
|
89
|
+
id: +pathParams.id,
|
|
90
|
+
name: body?.name,
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
] as const;
|
|
95
|
+
|
|
96
|
+
const instance = createFragment(fragment, config, routes, {
|
|
97
|
+
mountRoute: "/api",
|
|
98
|
+
}).withMiddleware(async ({ ifMatchesRoute }) => {
|
|
99
|
+
const result = await ifMatchesRoute(
|
|
100
|
+
"POST",
|
|
101
|
+
"/users/:id",
|
|
102
|
+
async ({ path, pathParams, input }, { error }) => {
|
|
103
|
+
expectTypeOf(path).toEqualTypeOf<"/users/:id">();
|
|
104
|
+
expectTypeOf(pathParams).toEqualTypeOf<{ id: string }>();
|
|
105
|
+
|
|
106
|
+
expectTypeOf(input.schema).toEqualTypeOf<z.ZodObject<{ name: z.ZodString }>>();
|
|
107
|
+
|
|
108
|
+
return error(
|
|
109
|
+
{
|
|
110
|
+
message: "Creating users has been disabled.",
|
|
111
|
+
code: "CREATE_USERS_DISABLED",
|
|
112
|
+
},
|
|
113
|
+
403,
|
|
114
|
+
);
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
if (result) {
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// This was a request to any other route
|
|
123
|
+
return undefined;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Request to POST, should be disabled.
|
|
127
|
+
const req = new Request("http://localhost/api/users/123", {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: { "Content-Type": "application/json" },
|
|
130
|
+
body: JSON.stringify({ name: "John Doe" }),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const res = await instance.handler(req);
|
|
134
|
+
expect(res.status).toBe(403);
|
|
135
|
+
|
|
136
|
+
expect(await res.json()).toEqual({
|
|
137
|
+
error: "Creating users has been disabled.",
|
|
138
|
+
code: "CREATE_USERS_DISABLED",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Request to GET, should be enabled.
|
|
142
|
+
const getReq = new Request("http://localhost/api/users", {
|
|
143
|
+
method: "GET",
|
|
144
|
+
});
|
|
145
|
+
const getRes = await instance.handler(getReq);
|
|
146
|
+
expect(getRes.status).toBe(200);
|
|
147
|
+
expect(await getRes.json()).toEqual({
|
|
148
|
+
id: 1,
|
|
149
|
+
name: "John Doe",
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("ifMatchesRoute - not called for other routes", async () => {
|
|
154
|
+
const config = {};
|
|
155
|
+
|
|
156
|
+
const fragment = defineFragment<typeof config>("test-lib");
|
|
157
|
+
|
|
158
|
+
const routes = [
|
|
159
|
+
defineRoute({
|
|
160
|
+
method: "GET",
|
|
161
|
+
path: "/users",
|
|
162
|
+
outputSchema: z.object({ id: z.number(), name: z.string() }),
|
|
163
|
+
handler: async (_, { json }) =>
|
|
164
|
+
json({
|
|
165
|
+
id: 1,
|
|
166
|
+
name: "John Doe",
|
|
167
|
+
}),
|
|
168
|
+
}),
|
|
169
|
+
defineRoute({
|
|
170
|
+
method: "POST",
|
|
171
|
+
path: "/users/:id",
|
|
172
|
+
inputSchema: z.object({ name: z.string() }),
|
|
173
|
+
outputSchema: z.object({ id: z.number(), name: z.string() }),
|
|
174
|
+
handler: async ({ input, pathParams }, { json }) => {
|
|
175
|
+
const body = await input.valid();
|
|
176
|
+
|
|
177
|
+
return json({
|
|
178
|
+
id: +pathParams.id,
|
|
179
|
+
name: body?.name,
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
] as const;
|
|
184
|
+
|
|
185
|
+
let middlewareCalled = false;
|
|
186
|
+
|
|
187
|
+
const instance = createFragment(fragment, config, routes, {
|
|
188
|
+
mountRoute: "/api",
|
|
189
|
+
}).withMiddleware(async ({ ifMatchesRoute }) => {
|
|
190
|
+
return ifMatchesRoute("GET", "/users", () => {
|
|
191
|
+
middlewareCalled = true;
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Request to POST, should be disabled.
|
|
196
|
+
const req = new Request("http://localhost/api/users/123", {
|
|
197
|
+
method: "POST",
|
|
198
|
+
headers: { "Content-Type": "application/json" },
|
|
199
|
+
body: JSON.stringify({ name: "John Doe" }),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const res = await instance.handler(req);
|
|
203
|
+
expect(res.status).toBe(200);
|
|
204
|
+
|
|
205
|
+
expect(await res.json()).toEqual({
|
|
206
|
+
id: 123,
|
|
207
|
+
name: "John Doe",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(middlewareCalled).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("ifMatchesRoute - can return undefined", async () => {
|
|
214
|
+
const config = {};
|
|
215
|
+
|
|
216
|
+
const fragment = defineFragment<typeof config>("test-lib");
|
|
217
|
+
|
|
218
|
+
const routes = [
|
|
219
|
+
defineRoute({
|
|
220
|
+
method: "GET",
|
|
221
|
+
path: "/users",
|
|
222
|
+
outputSchema: z.object({ id: z.number(), name: z.string() }),
|
|
223
|
+
handler: async (_, { json }) =>
|
|
224
|
+
json({
|
|
225
|
+
id: 1,
|
|
226
|
+
name: "John Doe",
|
|
227
|
+
}),
|
|
228
|
+
}),
|
|
229
|
+
] as const;
|
|
230
|
+
|
|
231
|
+
let middlewareCalled = false;
|
|
232
|
+
|
|
233
|
+
const instance = createFragment(fragment, config, routes, {
|
|
234
|
+
mountRoute: "/api",
|
|
235
|
+
}).withMiddleware(async ({ ifMatchesRoute }) => {
|
|
236
|
+
await ifMatchesRoute("GET", "/users", async ({ path, pathParams, input }) => {
|
|
237
|
+
expectTypeOf(path).toEqualTypeOf<"/users">();
|
|
238
|
+
expectTypeOf(pathParams).toEqualTypeOf<{ [x: string]: never }>();
|
|
239
|
+
expectTypeOf(input).toEqualTypeOf<undefined>();
|
|
240
|
+
|
|
241
|
+
middlewareCalled = true;
|
|
242
|
+
|
|
243
|
+
return undefined;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return undefined;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const getReq = new Request("http://localhost/api/users", {
|
|
250
|
+
method: "GET",
|
|
251
|
+
});
|
|
252
|
+
const getRes = await instance.handler(getReq);
|
|
253
|
+
expect(getRes.status).toBe(200);
|
|
254
|
+
expect(await getRes.json()).toEqual({
|
|
255
|
+
id: 1,
|
|
256
|
+
name: "John Doe",
|
|
257
|
+
});
|
|
258
|
+
expect(middlewareCalled).toBe(true);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("only one middleware is supported", async () => {
|
|
262
|
+
const config = {};
|
|
263
|
+
|
|
264
|
+
const fragment = defineFragment<typeof config>("test-lib");
|
|
265
|
+
|
|
266
|
+
const routes = [
|
|
267
|
+
defineRoute({
|
|
268
|
+
method: "GET",
|
|
269
|
+
path: "/test",
|
|
270
|
+
handler: async (_input, output) => {
|
|
271
|
+
return output.json({ message: "test" });
|
|
272
|
+
},
|
|
273
|
+
}),
|
|
274
|
+
] as const;
|
|
275
|
+
|
|
276
|
+
const instance = createFragment(fragment, config, routes);
|
|
277
|
+
|
|
278
|
+
const withMiddleware = instance.withMiddleware(async () => {
|
|
279
|
+
return undefined;
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Trying to add a third middleware should throw
|
|
283
|
+
expect(() => {
|
|
284
|
+
withMiddleware.withMiddleware(async () => {
|
|
285
|
+
return undefined;
|
|
286
|
+
});
|
|
287
|
+
}).toThrow("Middleware already set");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("middleware and handler can both consume request body without double consumption", async () => {
|
|
291
|
+
const config = {};
|
|
292
|
+
|
|
293
|
+
const fragment = defineFragment<typeof config>("test-lib");
|
|
294
|
+
|
|
295
|
+
const routes = [
|
|
296
|
+
defineRoute({
|
|
297
|
+
method: "POST",
|
|
298
|
+
path: "/users",
|
|
299
|
+
inputSchema: z.object({ name: z.string() }),
|
|
300
|
+
outputSchema: z.object({ id: z.number(), name: z.string() }),
|
|
301
|
+
handler: async ({ input }, { json }) => {
|
|
302
|
+
const body = await input.valid();
|
|
303
|
+
return json({
|
|
304
|
+
id: 1,
|
|
305
|
+
name: body?.name,
|
|
306
|
+
});
|
|
307
|
+
},
|
|
308
|
+
}),
|
|
309
|
+
] as const;
|
|
310
|
+
|
|
311
|
+
const instance = createFragment(fragment, config, routes, {
|
|
312
|
+
mountRoute: "/api",
|
|
313
|
+
}).withMiddleware(async ({ ifMatchesRoute }) => {
|
|
314
|
+
// Middleware consumes the request body
|
|
315
|
+
const result = await ifMatchesRoute("POST", "/users", async ({ input }) => {
|
|
316
|
+
const body = await input.valid();
|
|
317
|
+
// Middleware can read the body
|
|
318
|
+
expect(body).toEqual({ name: "John Doe" });
|
|
319
|
+
return undefined; // Continue to handler
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
return result;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const req = new Request("http://localhost/api/users", {
|
|
326
|
+
method: "POST",
|
|
327
|
+
headers: { "Content-Type": "application/json" },
|
|
328
|
+
body: JSON.stringify({ name: "John Doe" }),
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const res = await instance.handler(req);
|
|
332
|
+
expect(res.status).toBe(200);
|
|
333
|
+
expect(await res.json()).toEqual({
|
|
334
|
+
id: 1,
|
|
335
|
+
name: "John Doe",
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("middleware can modify path parameters", async () => {
|
|
340
|
+
const fragment = defineFragment("test-lib");
|
|
341
|
+
|
|
342
|
+
const routes = [
|
|
343
|
+
defineRoute({
|
|
344
|
+
method: "GET",
|
|
345
|
+
path: "/users/:id",
|
|
346
|
+
outputSchema: z.object({ id: z.number(), name: z.string(), role: z.string() }),
|
|
347
|
+
handler: async ({ pathParams, query }, { json }) => {
|
|
348
|
+
return json({
|
|
349
|
+
id: +pathParams.id,
|
|
350
|
+
name: "John Doe",
|
|
351
|
+
role: query.get("role") ?? "user",
|
|
352
|
+
});
|
|
353
|
+
},
|
|
354
|
+
}),
|
|
355
|
+
] as const;
|
|
356
|
+
|
|
357
|
+
const instance = createFragment(fragment, {}, routes, {
|
|
358
|
+
mountRoute: "/api",
|
|
359
|
+
}).withMiddleware(async ({ ifMatchesRoute }) => {
|
|
360
|
+
// Middleware can read path and query parameters
|
|
361
|
+
const result = await ifMatchesRoute("GET", "/users/:id", async ({ pathParams }) => {
|
|
362
|
+
pathParams.id = "9999";
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
return result;
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// Test with admin role
|
|
369
|
+
const adminReq = new Request("http://localhost/api/users/123?role=admin", {
|
|
370
|
+
method: "GET",
|
|
371
|
+
});
|
|
372
|
+
const adminRes = await instance.handler(adminReq);
|
|
373
|
+
expect(adminRes.status).toBe(200);
|
|
374
|
+
expect(await adminRes.json()).toEqual({
|
|
375
|
+
id: 9999,
|
|
376
|
+
name: "John Doe",
|
|
377
|
+
role: "admin",
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("middleware calling input.valid() can catch validation error", async () => {
|
|
382
|
+
const config = {};
|
|
383
|
+
|
|
384
|
+
const fragment = defineFragment<typeof config>("test-lib");
|
|
385
|
+
|
|
386
|
+
const routes = [
|
|
387
|
+
defineRoute({
|
|
388
|
+
method: "POST",
|
|
389
|
+
path: "/users",
|
|
390
|
+
inputSchema: z.object({
|
|
391
|
+
name: z.string().min(1, "Name is required"),
|
|
392
|
+
email: z.string().email("Invalid email format"),
|
|
393
|
+
}),
|
|
394
|
+
outputSchema: z.object({ id: z.number(), name: z.string(), email: z.string() }),
|
|
395
|
+
handler: async ({ input }, { json }) => {
|
|
396
|
+
const body = await input.valid();
|
|
397
|
+
return json({
|
|
398
|
+
id: 1,
|
|
399
|
+
name: body.name,
|
|
400
|
+
email: body.email,
|
|
401
|
+
});
|
|
402
|
+
},
|
|
403
|
+
}),
|
|
404
|
+
] as const;
|
|
405
|
+
|
|
406
|
+
const instance = createFragment(fragment, config, routes, {
|
|
407
|
+
mountRoute: "/api",
|
|
408
|
+
}).withMiddleware(async ({ ifMatchesRoute }) => {
|
|
409
|
+
// Middleware tries to validate the input
|
|
410
|
+
const result = await ifMatchesRoute("POST", "/users", async ({ input }, { error }) => {
|
|
411
|
+
try {
|
|
412
|
+
await input.valid();
|
|
413
|
+
return undefined; // Continue to handler if valid
|
|
414
|
+
} catch (validationError) {
|
|
415
|
+
expect(validationError).toBeInstanceOf(FragnoApiValidationError);
|
|
416
|
+
|
|
417
|
+
return error(
|
|
418
|
+
{
|
|
419
|
+
message: "Request validation failed in middleware",
|
|
420
|
+
code: "MIDDLEWARE_VALIDATION_ERROR",
|
|
421
|
+
},
|
|
422
|
+
400,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
return result;
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Test with invalid request (missing required fields)
|
|
431
|
+
const invalidReq = new Request("http://localhost/api/users", {
|
|
432
|
+
method: "POST",
|
|
433
|
+
headers: { "Content-Type": "application/json" },
|
|
434
|
+
body: JSON.stringify({ name: "" }), // Invalid: empty name and missing email
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
const res = await instance.handler(invalidReq);
|
|
438
|
+
expect(res.status).toBe(400);
|
|
439
|
+
|
|
440
|
+
const body = await res.json();
|
|
441
|
+
expect(body).toEqual({
|
|
442
|
+
error: "Request validation failed in middleware",
|
|
443
|
+
code: "MIDDLEWARE_VALIDATION_ERROR",
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("middleware calling input.valid() can ignore validation error", async () => {
|
|
448
|
+
const config = {};
|
|
449
|
+
|
|
450
|
+
const fragment = defineFragment<typeof config>("test-lib");
|
|
451
|
+
|
|
452
|
+
const routes = [
|
|
453
|
+
defineRoute({
|
|
454
|
+
method: "POST",
|
|
455
|
+
path: "/users",
|
|
456
|
+
inputSchema: z.object({
|
|
457
|
+
name: z.string().min(1, "Name is required"),
|
|
458
|
+
email: z.email("Invalid email format"),
|
|
459
|
+
}),
|
|
460
|
+
outputSchema: z.object({ id: z.number(), name: z.string(), email: z.string() }),
|
|
461
|
+
handler: async (_ctx, { error }) => {
|
|
462
|
+
return error(
|
|
463
|
+
{
|
|
464
|
+
message: "Handler should not be called",
|
|
465
|
+
code: "HANDLER_SHOULD_NOT_BE_CALLED",
|
|
466
|
+
},
|
|
467
|
+
400,
|
|
468
|
+
);
|
|
469
|
+
},
|
|
470
|
+
}),
|
|
471
|
+
] as const;
|
|
472
|
+
|
|
473
|
+
const instance = createFragment(fragment, config, routes, {
|
|
474
|
+
mountRoute: "/api",
|
|
475
|
+
}).withMiddleware(async ({ ifMatchesRoute }) => {
|
|
476
|
+
// Middleware tries to validate the input
|
|
477
|
+
const result = await ifMatchesRoute("POST", "/users", async ({ input }) => {
|
|
478
|
+
await input.valid();
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
return result;
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// Test with invalid request (missing required fields)
|
|
485
|
+
const invalidReq = new Request("http://localhost/api/users", {
|
|
486
|
+
method: "POST",
|
|
487
|
+
headers: { "Content-Type": "application/json" },
|
|
488
|
+
body: JSON.stringify({ name: "" }), // Invalid: empty name and missing email
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const res = await instance.handler(invalidReq);
|
|
492
|
+
expect(res.status).toBe(400);
|
|
493
|
+
|
|
494
|
+
const body = await res.json();
|
|
495
|
+
expect(body).toEqual({
|
|
496
|
+
message: "Validation failed",
|
|
497
|
+
issues: expect.any(Array),
|
|
498
|
+
code: "FRAGNO_VALIDATION_ERROR",
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// TODO: This is not currently supported
|
|
503
|
+
test.todo("middleware can modify query parameters", async () => {
|
|
504
|
+
const fragment = defineFragment("test-lib");
|
|
505
|
+
|
|
506
|
+
const routes = [
|
|
507
|
+
defineRoute({
|
|
508
|
+
method: "GET",
|
|
509
|
+
path: "/users/:id",
|
|
510
|
+
outputSchema: z.object({ id: z.number(), name: z.string(), role: z.string() }),
|
|
511
|
+
handler: async ({ pathParams, query }, { json }) => {
|
|
512
|
+
return json({
|
|
513
|
+
id: +pathParams.id,
|
|
514
|
+
name: "John Doe",
|
|
515
|
+
role: query.get("role") ?? "user",
|
|
516
|
+
});
|
|
517
|
+
},
|
|
518
|
+
}),
|
|
519
|
+
] as const;
|
|
520
|
+
|
|
521
|
+
const instance = createFragment(fragment, {}, routes, {
|
|
522
|
+
mountRoute: "/api",
|
|
523
|
+
}).withMiddleware(async ({ ifMatchesRoute }) => {
|
|
524
|
+
// Middleware can read path and query parameters
|
|
525
|
+
const result = await ifMatchesRoute("GET", "/users/:id", async ({ query }) => {
|
|
526
|
+
query.set("role", "some-other-role-defined-in-middleware");
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
return result;
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// Test with admin role
|
|
533
|
+
const adminReq = new Request("http://localhost/api/users/123?role=admin", {
|
|
534
|
+
method: "GET",
|
|
535
|
+
});
|
|
536
|
+
const adminRes = await instance.handler(adminReq);
|
|
537
|
+
expect(adminRes.status).toBe(200);
|
|
538
|
+
expect(await adminRes.json()).toEqual({
|
|
539
|
+
id: 123,
|
|
540
|
+
name: "John Doe",
|
|
541
|
+
role: "some-other-role-defined-in-middleware",
|
|
542
|
+
});
|
|
543
|
+
});
|
|
544
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import type { ExtractRouteByPath, ExtractRoutePath } from "../client/client";
|
|
3
|
+
import type { HTTPMethod } from "./api";
|
|
4
|
+
import type { ExtractPathParams } from "./internal/path";
|
|
5
|
+
import type { RequestBodyType } from "./request-input-context";
|
|
6
|
+
import type { AnyFragnoRouteConfig } from "./route";
|
|
7
|
+
import { RequestInputContext } from "./request-input-context";
|
|
8
|
+
import { OutputContext, RequestOutputContext } from "./request-output-context";
|
|
9
|
+
|
|
10
|
+
export type FragnoMiddlewareCallback<
|
|
11
|
+
TRoutes extends readonly AnyFragnoRouteConfig[],
|
|
12
|
+
TDeps,
|
|
13
|
+
TServices extends Record<string, unknown>,
|
|
14
|
+
> = (
|
|
15
|
+
inputContext: RequestMiddlewareInputContext<TRoutes>,
|
|
16
|
+
outputContext: RequestMiddlewareOutputContext<TDeps, TServices>,
|
|
17
|
+
) => Promise<Response | undefined> | Response | undefined;
|
|
18
|
+
|
|
19
|
+
export interface RequestMiddlewareOptions {
|
|
20
|
+
path: string;
|
|
21
|
+
method: HTTPMethod;
|
|
22
|
+
pathParams?: Record<string, string>;
|
|
23
|
+
searchParams: URLSearchParams;
|
|
24
|
+
body: RequestBodyType;
|
|
25
|
+
request: Request;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class RequestMiddlewareOutputContext<
|
|
29
|
+
const TDeps,
|
|
30
|
+
const TServices extends Record<string, unknown>,
|
|
31
|
+
> extends OutputContext<unknown, string> {
|
|
32
|
+
readonly #deps: TDeps;
|
|
33
|
+
readonly #services: TServices;
|
|
34
|
+
|
|
35
|
+
constructor(deps: TDeps, services: TServices) {
|
|
36
|
+
super();
|
|
37
|
+
this.#deps = deps;
|
|
38
|
+
this.#services = services;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get deps(): TDeps {
|
|
42
|
+
return this.#deps;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get services(): TServices {
|
|
46
|
+
return this.#services;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class RequestMiddlewareInputContext<const TRoutes extends readonly AnyFragnoRouteConfig[]> {
|
|
51
|
+
readonly #options: RequestMiddlewareOptions;
|
|
52
|
+
readonly #route: TRoutes[number];
|
|
53
|
+
|
|
54
|
+
constructor(routes: TRoutes, options: RequestMiddlewareOptions) {
|
|
55
|
+
this.#options = options;
|
|
56
|
+
|
|
57
|
+
const route = routes.find(
|
|
58
|
+
(route) => route.path === options.path && route.method === options.method,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
if (!route) {
|
|
62
|
+
throw new Error(`Route not found: ${options.path} ${options.method}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
this.#route = route;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get path(): string {
|
|
69
|
+
return this.#options.path;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get method(): HTTPMethod {
|
|
73
|
+
return this.#options.method;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get pathParams(): Record<string, string> {
|
|
77
|
+
return this.#options.pathParams ?? {};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get queryParams(): URLSearchParams {
|
|
81
|
+
return this.#options.searchParams;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get inputSchema(): StandardSchemaV1 | undefined {
|
|
85
|
+
return this.#route.inputSchema;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
get outputSchema(): StandardSchemaV1 | undefined {
|
|
89
|
+
return this.#route.outputSchema;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Defined as a field so that `this` reference stays in tact when destructuring
|
|
93
|
+
ifMatchesRoute = async <
|
|
94
|
+
const TMethod extends HTTPMethod,
|
|
95
|
+
const TPath extends ExtractRoutePath<TRoutes>,
|
|
96
|
+
const TRoute extends ExtractRouteByPath<TRoutes, TPath, TMethod> = ExtractRouteByPath<
|
|
97
|
+
TRoutes,
|
|
98
|
+
TPath,
|
|
99
|
+
TMethod
|
|
100
|
+
>,
|
|
101
|
+
>(
|
|
102
|
+
method: TMethod,
|
|
103
|
+
path: TPath,
|
|
104
|
+
handler: (
|
|
105
|
+
...args: Parameters<TRoute["handler"]>
|
|
106
|
+
) => Promise<Response | undefined | void> | Response | undefined | void,
|
|
107
|
+
): Promise<Response | undefined> => {
|
|
108
|
+
if (this.path !== path || this.method !== method) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// TODO(Wilco): We should support reading/modifying headers here.
|
|
113
|
+
const inputContext = await RequestInputContext.fromRequest({
|
|
114
|
+
request: this.#options.request,
|
|
115
|
+
method: this.#options.method,
|
|
116
|
+
path: path,
|
|
117
|
+
pathParams: this.pathParams as ExtractPathParams<TPath>,
|
|
118
|
+
inputSchema: this.#route.inputSchema,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const outputContext = new RequestOutputContext(this.#route.outputSchema);
|
|
122
|
+
|
|
123
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
124
|
+
return (handler as any)(inputContext, outputContext);
|
|
125
|
+
};
|
|
126
|
+
}
|