@fragno-dev/core 0.1.3 → 0.1.5
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 +19 -19
- package/CHANGELOG.md +13 -0
- package/dist/api/api.d.ts +1 -1
- package/dist/api/fragment-builder.d.ts +2 -2
- package/dist/api/fragment-instantiation.d.ts +2 -2
- package/dist/api/fragment-instantiation.js +2 -2
- package/dist/{api-BX90b4-D.d.ts → api-B1-h7jPC.d.ts} +5 -6
- package/dist/api-B1-h7jPC.d.ts.map +1 -0
- package/dist/client/client.d.ts +2 -2
- package/dist/client/client.js +2 -2
- package/dist/client/client.svelte.d.ts +2 -2
- package/dist/client/client.svelte.js +2 -2
- package/dist/client/react.d.ts +2 -2
- package/dist/client/react.js +2 -2
- package/dist/client/solid.d.ts +2 -2
- package/dist/client/solid.js +2 -2
- package/dist/client/vanilla.d.ts +2 -2
- package/dist/client/vanilla.js +2 -2
- package/dist/client/vue.d.ts +2 -2
- package/dist/client/vue.js +2 -2
- package/dist/{client-C6LChM0Y.js → client-YUZaNg5U.js} +2 -2
- package/dist/{client-C6LChM0Y.js.map → client-YUZaNg5U.js.map} +1 -1
- package/dist/{fragment-builder-BZr2JkuW.d.ts → fragment-builder-DsqUOfJ5.d.ts} +4 -2
- package/dist/{fragment-builder-BZr2JkuW.d.ts.map → fragment-builder-DsqUOfJ5.d.ts.map} +1 -1
- package/dist/{fragment-instantiation-DMw8OKMC.js → fragment-instantiation-Cp0K8zdS.js} +24 -6
- package/dist/fragment-instantiation-Cp0K8zdS.js.map +1 -0
- package/dist/mod.d.ts +2 -2
- package/dist/mod.js +2 -2
- package/dist/{route-D1MZR6JL.js → route-Dk1GyqHs.js} +10 -10
- package/dist/route-Dk1GyqHs.js.map +1 -0
- package/dist/test/test.d.ts +2 -2
- package/dist/test/test.js +2 -2
- package/dist/test/test.js.map +1 -1
- package/package.json +3 -3
- package/src/api/fragment-instantiation.ts +36 -5
- package/src/api/request-input-context.test.ts +37 -29
- package/src/api/request-input-context.ts +16 -14
- package/src/api/request-middleware.test.ts +251 -0
- package/src/api/request-middleware.ts +1 -1
- package/src/test/test.test.ts +56 -1
- package/src/test/test.ts +1 -1
- package/dist/api-BX90b4-D.d.ts.map +0 -1
- package/dist/fragment-instantiation-DMw8OKMC.js.map +0 -1
- package/dist/route-D1MZR6JL.js.map +0 -1
|
@@ -42,61 +42,67 @@ describe("RequestContext", () => {
|
|
|
42
42
|
pathParams: {},
|
|
43
43
|
searchParams: new URLSearchParams(),
|
|
44
44
|
headers: new Headers(),
|
|
45
|
-
|
|
45
|
+
parsedBody: undefined,
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
const { path, pathParams, query: searchParams, input, rawBody
|
|
48
|
+
const { path, pathParams, query: searchParams, input, rawBody } = ctx;
|
|
49
49
|
|
|
50
50
|
expect(path).toBe("/");
|
|
51
51
|
expect(pathParams).toEqual({});
|
|
52
52
|
expect(searchParams).toBeInstanceOf(URLSearchParams);
|
|
53
53
|
expect(input).toBeUndefined();
|
|
54
|
-
expect(
|
|
54
|
+
expect(rawBody).toBeUndefined();
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
test("Should support body in constructor", () => {
|
|
58
58
|
const jsonBody = { test: "data" };
|
|
59
|
+
const rawBodyText = JSON.stringify(jsonBody);
|
|
59
60
|
const ctx = new RequestInputContext({
|
|
60
61
|
method: "POST",
|
|
61
62
|
path: "/api/test",
|
|
62
63
|
pathParams: {},
|
|
63
64
|
searchParams: new URLSearchParams(),
|
|
64
65
|
headers: new Headers(),
|
|
65
|
-
|
|
66
|
+
parsedBody: jsonBody,
|
|
67
|
+
rawBody: rawBodyText,
|
|
66
68
|
});
|
|
67
69
|
|
|
68
|
-
expect(ctx.rawBody).toEqual(
|
|
70
|
+
expect(ctx.rawBody).toEqual(rawBodyText);
|
|
69
71
|
});
|
|
70
72
|
|
|
71
73
|
test("Should support FormData body", () => {
|
|
72
74
|
const formData = new FormData();
|
|
73
75
|
formData.append("key", "value");
|
|
76
|
+
const rawBodyText = "form-data-as-text";
|
|
74
77
|
|
|
75
78
|
const ctx = new RequestInputContext({
|
|
76
79
|
path: "/api/form",
|
|
77
80
|
pathParams: {},
|
|
78
81
|
searchParams: new URLSearchParams(),
|
|
79
82
|
headers: new Headers(),
|
|
80
|
-
|
|
83
|
+
parsedBody: formData,
|
|
84
|
+
rawBody: rawBodyText,
|
|
81
85
|
method: "POST",
|
|
82
86
|
});
|
|
83
87
|
|
|
84
|
-
expect(ctx.rawBody).toBe(
|
|
88
|
+
expect(ctx.rawBody).toBe(rawBodyText);
|
|
85
89
|
});
|
|
86
90
|
|
|
87
91
|
test("Should support Blob body", () => {
|
|
88
92
|
const blob = new Blob(["test content"], { type: "text/plain" });
|
|
93
|
+
const rawBodyText = "test content";
|
|
89
94
|
|
|
90
95
|
const ctx = new RequestInputContext({
|
|
91
96
|
path: "/api/upload",
|
|
92
97
|
pathParams: {},
|
|
93
98
|
searchParams: new URLSearchParams(),
|
|
94
99
|
headers: new Headers(),
|
|
95
|
-
|
|
100
|
+
parsedBody: blob,
|
|
101
|
+
rawBody: rawBodyText,
|
|
96
102
|
method: "POST",
|
|
97
103
|
});
|
|
98
104
|
|
|
99
|
-
expect(ctx.rawBody).toBe(
|
|
105
|
+
expect(ctx.rawBody).toBe(rawBodyText);
|
|
100
106
|
});
|
|
101
107
|
|
|
102
108
|
test("Should create RequestContext with fromRequest static method", async () => {
|
|
@@ -105,10 +111,10 @@ describe("RequestContext", () => {
|
|
|
105
111
|
body: JSON.stringify({ test: "data" }),
|
|
106
112
|
});
|
|
107
113
|
|
|
108
|
-
const bodyData = { test: "data" };
|
|
109
114
|
const url = new URL(request.url);
|
|
110
115
|
const clonedReq = request.clone();
|
|
111
116
|
const body = clonedReq.body instanceof ReadableStream ? await clonedReq.json() : undefined;
|
|
117
|
+
const rawBodyText = JSON.stringify({ test: "data" });
|
|
112
118
|
|
|
113
119
|
const state = new MutableRequestState({
|
|
114
120
|
pathParams: {},
|
|
@@ -123,10 +129,11 @@ describe("RequestContext", () => {
|
|
|
123
129
|
path: "/api/test",
|
|
124
130
|
pathParams: {},
|
|
125
131
|
state,
|
|
132
|
+
rawBody: rawBodyText,
|
|
126
133
|
});
|
|
127
134
|
|
|
128
135
|
expect(ctx.path).toBe("/api/test");
|
|
129
|
-
expect(ctx.rawBody).toEqual(
|
|
136
|
+
expect(ctx.rawBody).toEqual(rawBodyText);
|
|
130
137
|
});
|
|
131
138
|
|
|
132
139
|
test("Should create RequestContext with fromSSRContext static method", () => {
|
|
@@ -139,7 +146,8 @@ describe("RequestContext", () => {
|
|
|
139
146
|
});
|
|
140
147
|
|
|
141
148
|
expect(ctx.path).toBe("/api/ssr");
|
|
142
|
-
|
|
149
|
+
// rawBody is not set in fromSSRContext
|
|
150
|
+
expect(ctx.rawBody).toBeUndefined();
|
|
143
151
|
});
|
|
144
152
|
|
|
145
153
|
describe("Input handling", () => {
|
|
@@ -149,7 +157,7 @@ describe("RequestContext", () => {
|
|
|
149
157
|
pathParams: {},
|
|
150
158
|
searchParams: new URLSearchParams(),
|
|
151
159
|
headers: new Headers(),
|
|
152
|
-
|
|
160
|
+
parsedBody: { test: "data" },
|
|
153
161
|
method: "POST",
|
|
154
162
|
});
|
|
155
163
|
|
|
@@ -162,7 +170,7 @@ describe("RequestContext", () => {
|
|
|
162
170
|
pathParams: {},
|
|
163
171
|
searchParams: new URLSearchParams(),
|
|
164
172
|
headers: new Headers(),
|
|
165
|
-
|
|
173
|
+
parsedBody: { test: "data" },
|
|
166
174
|
inputSchema: validStringSchema,
|
|
167
175
|
method: "POST",
|
|
168
176
|
});
|
|
@@ -178,7 +186,7 @@ describe("RequestContext", () => {
|
|
|
178
186
|
pathParams: {},
|
|
179
187
|
searchParams: new URLSearchParams(),
|
|
180
188
|
headers: new Headers(),
|
|
181
|
-
|
|
189
|
+
parsedBody: "test string",
|
|
182
190
|
inputSchema: validStringSchema,
|
|
183
191
|
method: "POST",
|
|
184
192
|
});
|
|
@@ -193,7 +201,7 @@ describe("RequestContext", () => {
|
|
|
193
201
|
pathParams: {},
|
|
194
202
|
searchParams: new URLSearchParams(),
|
|
195
203
|
headers: new Headers(),
|
|
196
|
-
|
|
204
|
+
parsedBody: 123, // Invalid for string schema
|
|
197
205
|
inputSchema: invalidSchema,
|
|
198
206
|
method: "POST",
|
|
199
207
|
});
|
|
@@ -207,7 +215,7 @@ describe("RequestContext", () => {
|
|
|
207
215
|
pathParams: {},
|
|
208
216
|
searchParams: new URLSearchParams(),
|
|
209
217
|
headers: new Headers(),
|
|
210
|
-
|
|
218
|
+
parsedBody: 123,
|
|
211
219
|
inputSchema: invalidSchema,
|
|
212
220
|
method: "POST",
|
|
213
221
|
});
|
|
@@ -236,13 +244,13 @@ describe("RequestContext", () => {
|
|
|
236
244
|
pathParams: {},
|
|
237
245
|
searchParams: new URLSearchParams(),
|
|
238
246
|
headers: new Headers(),
|
|
239
|
-
|
|
247
|
+
parsedBody: 123,
|
|
240
248
|
inputSchema: invalidSchema,
|
|
241
249
|
shouldValidateInput: false,
|
|
242
250
|
method: "POST",
|
|
243
251
|
});
|
|
244
252
|
|
|
245
|
-
// Should return the
|
|
253
|
+
// Should return the parsed body without validation when validation is disabled
|
|
246
254
|
const result = await ctx.input?.valid();
|
|
247
255
|
expect(result).toBe(123);
|
|
248
256
|
});
|
|
@@ -256,7 +264,7 @@ describe("RequestContext", () => {
|
|
|
256
264
|
pathParams: {},
|
|
257
265
|
searchParams: new URLSearchParams(),
|
|
258
266
|
headers: new Headers(),
|
|
259
|
-
|
|
267
|
+
parsedBody: formData,
|
|
260
268
|
inputSchema: validStringSchema,
|
|
261
269
|
method: "POST",
|
|
262
270
|
});
|
|
@@ -274,7 +282,7 @@ describe("RequestContext", () => {
|
|
|
274
282
|
pathParams: {},
|
|
275
283
|
searchParams: new URLSearchParams(),
|
|
276
284
|
headers: new Headers(),
|
|
277
|
-
|
|
285
|
+
parsedBody: blob,
|
|
278
286
|
inputSchema: validStringSchema,
|
|
279
287
|
method: "POST",
|
|
280
288
|
});
|
|
@@ -290,7 +298,7 @@ describe("RequestContext", () => {
|
|
|
290
298
|
pathParams: {},
|
|
291
299
|
searchParams: new URLSearchParams(),
|
|
292
300
|
headers: new Headers(),
|
|
293
|
-
|
|
301
|
+
parsedBody: null,
|
|
294
302
|
inputSchema: validStringSchema,
|
|
295
303
|
method: "POST",
|
|
296
304
|
});
|
|
@@ -305,7 +313,7 @@ describe("RequestContext", () => {
|
|
|
305
313
|
pathParams: {},
|
|
306
314
|
searchParams: new URLSearchParams(),
|
|
307
315
|
headers: new Headers(),
|
|
308
|
-
|
|
316
|
+
parsedBody: undefined,
|
|
309
317
|
inputSchema: validStringSchema,
|
|
310
318
|
method: "POST",
|
|
311
319
|
});
|
|
@@ -324,7 +332,7 @@ describe("RequestContext", () => {
|
|
|
324
332
|
headers: new Headers(),
|
|
325
333
|
inputSchema: validStringSchema,
|
|
326
334
|
method: "POST",
|
|
327
|
-
|
|
335
|
+
parsedBody: undefined,
|
|
328
336
|
});
|
|
329
337
|
|
|
330
338
|
// We can't directly access the private field, but we can test the behavior
|
|
@@ -340,7 +348,7 @@ describe("RequestContext", () => {
|
|
|
340
348
|
inputSchema: validStringSchema,
|
|
341
349
|
shouldValidateInput: true,
|
|
342
350
|
method: "POST",
|
|
343
|
-
|
|
351
|
+
parsedBody: undefined,
|
|
344
352
|
});
|
|
345
353
|
|
|
346
354
|
expect(ctx.input).toBeDefined();
|
|
@@ -355,7 +363,7 @@ describe("RequestContext", () => {
|
|
|
355
363
|
inputSchema: validStringSchema,
|
|
356
364
|
shouldValidateInput: false,
|
|
357
365
|
method: "POST",
|
|
358
|
-
|
|
366
|
+
parsedBody: undefined,
|
|
359
367
|
});
|
|
360
368
|
|
|
361
369
|
expect(ctx.input).toBeDefined();
|
|
@@ -406,7 +414,7 @@ describe("RequestContext", () => {
|
|
|
406
414
|
searchParams: new URLSearchParams(),
|
|
407
415
|
headers: new Headers(),
|
|
408
416
|
method: "POST",
|
|
409
|
-
|
|
417
|
+
parsedBody: undefined,
|
|
410
418
|
});
|
|
411
419
|
|
|
412
420
|
expect(ctx.pathParams).toEqual({});
|
|
@@ -419,7 +427,7 @@ describe("RequestContext", () => {
|
|
|
419
427
|
searchParams: new URLSearchParams(),
|
|
420
428
|
headers: new Headers(),
|
|
421
429
|
method: "POST",
|
|
422
|
-
|
|
430
|
+
parsedBody: undefined,
|
|
423
431
|
});
|
|
424
432
|
|
|
425
433
|
expect(ctx.query.toString()).toBe("");
|
|
@@ -433,7 +441,7 @@ describe("RequestContext", () => {
|
|
|
433
441
|
searchParams,
|
|
434
442
|
headers: new Headers(),
|
|
435
443
|
method: "POST",
|
|
436
|
-
|
|
444
|
+
parsedBody: undefined,
|
|
437
445
|
});
|
|
438
446
|
|
|
439
447
|
expect(ctx.query.get("key")).toBe("value");
|
|
@@ -19,7 +19,8 @@ export class RequestInputContext<
|
|
|
19
19
|
readonly #pathParams: ExtractPathParams<TPath>;
|
|
20
20
|
readonly #searchParams: URLSearchParams;
|
|
21
21
|
readonly #headers: Headers;
|
|
22
|
-
readonly #body:
|
|
22
|
+
readonly #body: string | undefined;
|
|
23
|
+
readonly #parsedBody: RequestBodyType;
|
|
23
24
|
readonly #inputSchema: TInputSchema | undefined;
|
|
24
25
|
readonly #shouldValidateInput: boolean;
|
|
25
26
|
|
|
@@ -28,9 +29,9 @@ export class RequestInputContext<
|
|
|
28
29
|
method: string;
|
|
29
30
|
pathParams: ExtractPathParams<TPath>;
|
|
30
31
|
searchParams: URLSearchParams;
|
|
32
|
+
parsedBody: RequestBodyType;
|
|
33
|
+
rawBody?: string;
|
|
31
34
|
headers: Headers;
|
|
32
|
-
body: RequestBodyType;
|
|
33
|
-
|
|
34
35
|
request?: Request;
|
|
35
36
|
inputSchema?: TInputSchema;
|
|
36
37
|
shouldValidateInput?: boolean;
|
|
@@ -40,7 +41,8 @@ export class RequestInputContext<
|
|
|
40
41
|
this.#pathParams = config.pathParams;
|
|
41
42
|
this.#searchParams = config.searchParams;
|
|
42
43
|
this.#headers = config.headers;
|
|
43
|
-
this.#body = config.
|
|
44
|
+
this.#body = config.rawBody;
|
|
45
|
+
this.#parsedBody = config.parsedBody;
|
|
44
46
|
this.#inputSchema = config.inputSchema;
|
|
45
47
|
this.#shouldValidateInput = config.shouldValidateInput ?? true;
|
|
46
48
|
}
|
|
@@ -59,6 +61,7 @@ export class RequestInputContext<
|
|
|
59
61
|
inputSchema?: TInputSchema;
|
|
60
62
|
shouldValidateInput?: boolean;
|
|
61
63
|
state: MutableRequestState;
|
|
64
|
+
rawBody?: string;
|
|
62
65
|
}): Promise<RequestInputContext<TPath, TInputSchema>> {
|
|
63
66
|
// Use the mutable state (potentially modified by middleware)
|
|
64
67
|
return new RequestInputContext({
|
|
@@ -67,7 +70,8 @@ export class RequestInputContext<
|
|
|
67
70
|
pathParams: config.state.pathParams as ExtractPathParams<TPath>,
|
|
68
71
|
searchParams: config.state.searchParams,
|
|
69
72
|
headers: config.state.headers,
|
|
70
|
-
|
|
73
|
+
parsedBody: config.state.body,
|
|
74
|
+
rawBody: config.rawBody,
|
|
71
75
|
inputSchema: config.inputSchema,
|
|
72
76
|
shouldValidateInput: config.shouldValidateInput,
|
|
73
77
|
});
|
|
@@ -104,7 +108,7 @@ export class RequestInputContext<
|
|
|
104
108
|
pathParams: config.pathParams,
|
|
105
109
|
searchParams: config.searchParams ?? new URLSearchParams(),
|
|
106
110
|
headers: config.headers ?? new Headers(),
|
|
107
|
-
|
|
111
|
+
parsedBody: "body" in config ? config.body : undefined,
|
|
108
112
|
inputSchema: "inputSchema" in config ? config.inputSchema : undefined,
|
|
109
113
|
shouldValidateInput: false, // No input validation in SSR context
|
|
110
114
|
});
|
|
@@ -144,13 +148,11 @@ export class RequestInputContext<
|
|
|
144
148
|
get headers(): Headers {
|
|
145
149
|
return this.#headers;
|
|
146
150
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
* @internal
|
|
150
|
-
*/
|
|
151
|
-
get rawBody(): RequestBodyType {
|
|
151
|
+
|
|
152
|
+
get rawBody(): string | undefined {
|
|
152
153
|
return this.#body;
|
|
153
154
|
}
|
|
155
|
+
|
|
154
156
|
/**
|
|
155
157
|
* Input validation context (only if inputSchema is defined)
|
|
156
158
|
* @remarks `InputContext`
|
|
@@ -175,7 +177,7 @@ export class RequestInputContext<
|
|
|
175
177
|
valid: async () => {
|
|
176
178
|
if (!this.#shouldValidateInput) {
|
|
177
179
|
// In SSR context, return the body directly without validation
|
|
178
|
-
return this.#
|
|
180
|
+
return this.#parsedBody;
|
|
179
181
|
}
|
|
180
182
|
|
|
181
183
|
return this.#validateInput();
|
|
@@ -191,11 +193,11 @@ export class RequestInputContext<
|
|
|
191
193
|
throw new Error("No input schema defined for this route");
|
|
192
194
|
}
|
|
193
195
|
|
|
194
|
-
if (this.#
|
|
196
|
+
if (this.#parsedBody instanceof FormData || this.#parsedBody instanceof Blob) {
|
|
195
197
|
throw new Error("Schema validation is only supported for JSON data, not FormData or Blob");
|
|
196
198
|
}
|
|
197
199
|
|
|
198
|
-
const result = await this.#inputSchema["~standard"].validate(this.#
|
|
200
|
+
const result = await this.#inputSchema["~standard"].validate(this.#parsedBody);
|
|
199
201
|
|
|
200
202
|
if (result.issues) {
|
|
201
203
|
throw new FragnoApiValidationError("Validation failed", result.issues);
|
|
@@ -628,4 +628,255 @@ describe("Request Middleware", () => {
|
|
|
628
628
|
custom: "middleware-value",
|
|
629
629
|
});
|
|
630
630
|
});
|
|
631
|
+
|
|
632
|
+
test("ifMatchesRoute properly awaits async handlers", async () => {
|
|
633
|
+
const fragment = defineFragment("test-lib");
|
|
634
|
+
|
|
635
|
+
const routes = [
|
|
636
|
+
defineRoute({
|
|
637
|
+
method: "POST",
|
|
638
|
+
path: "/users",
|
|
639
|
+
inputSchema: z.object({ name: z.string() }),
|
|
640
|
+
outputSchema: z.object({ id: z.number(), name: z.string(), verified: z.boolean() }),
|
|
641
|
+
handler: async ({ input }, { json }) => {
|
|
642
|
+
const body = await input.valid();
|
|
643
|
+
return json({
|
|
644
|
+
id: 1,
|
|
645
|
+
name: body.name,
|
|
646
|
+
verified: false,
|
|
647
|
+
});
|
|
648
|
+
},
|
|
649
|
+
}),
|
|
650
|
+
] as const;
|
|
651
|
+
|
|
652
|
+
let asyncOperationCompleted = false;
|
|
653
|
+
|
|
654
|
+
const instance = createFragment(fragment, {}, routes, {
|
|
655
|
+
mountRoute: "/api",
|
|
656
|
+
}).withMiddleware(async ({ ifMatchesRoute }) => {
|
|
657
|
+
const result = await ifMatchesRoute("POST", "/users", async ({ input }) => {
|
|
658
|
+
// Simulate async operation (e.g., database lookup)
|
|
659
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
660
|
+
const body = await input.valid();
|
|
661
|
+
|
|
662
|
+
// Verify the async operation completed
|
|
663
|
+
asyncOperationCompleted = true;
|
|
664
|
+
|
|
665
|
+
// Check if user exists
|
|
666
|
+
if (body.name === "existing-user") {
|
|
667
|
+
return new Response(
|
|
668
|
+
JSON.stringify({
|
|
669
|
+
message: "User already exists",
|
|
670
|
+
code: "USER_EXISTS",
|
|
671
|
+
}),
|
|
672
|
+
{ status: 409, headers: { "Content-Type": "application/json" } },
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return undefined;
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
return result;
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// Test with existing user
|
|
683
|
+
const existingUserReq = new Request("http://localhost/api/users", {
|
|
684
|
+
method: "POST",
|
|
685
|
+
headers: { "Content-Type": "application/json" },
|
|
686
|
+
body: JSON.stringify({ name: "existing-user" }),
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
const existingUserRes = await instance.handler(existingUserReq);
|
|
690
|
+
expect(asyncOperationCompleted).toBe(true);
|
|
691
|
+
expect(existingUserRes.status).toBe(409);
|
|
692
|
+
expect(await existingUserRes.json()).toEqual({
|
|
693
|
+
message: "User already exists",
|
|
694
|
+
code: "USER_EXISTS",
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// Reset flag
|
|
698
|
+
asyncOperationCompleted = false;
|
|
699
|
+
|
|
700
|
+
// Test with new user
|
|
701
|
+
const newUserReq = new Request("http://localhost/api/users", {
|
|
702
|
+
method: "POST",
|
|
703
|
+
headers: { "Content-Type": "application/json" },
|
|
704
|
+
body: JSON.stringify({ name: "new-user" }),
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
const newUserRes = await instance.handler(newUserReq);
|
|
708
|
+
expect(asyncOperationCompleted).toBe(true);
|
|
709
|
+
expect(newUserRes.status).toBe(200);
|
|
710
|
+
expect(await newUserRes.json()).toEqual({
|
|
711
|
+
id: 1,
|
|
712
|
+
name: "new-user",
|
|
713
|
+
verified: false,
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("ifMatchesRoute handles async errors properly", async () => {
|
|
718
|
+
const fragment = defineFragment("test-lib");
|
|
719
|
+
|
|
720
|
+
const routes = [
|
|
721
|
+
defineRoute({
|
|
722
|
+
method: "GET",
|
|
723
|
+
path: "/data",
|
|
724
|
+
outputSchema: z.object({ data: z.string() }),
|
|
725
|
+
handler: async (_, { json }) => {
|
|
726
|
+
return json({ data: "test" });
|
|
727
|
+
},
|
|
728
|
+
}),
|
|
729
|
+
] as const;
|
|
730
|
+
|
|
731
|
+
const instance = createFragment(fragment, {}, routes, {
|
|
732
|
+
mountRoute: "/api",
|
|
733
|
+
}).withMiddleware(async ({ ifMatchesRoute }) => {
|
|
734
|
+
const result = await ifMatchesRoute("GET", "/data", async (_, { error }) => {
|
|
735
|
+
// Simulate async operation that fails
|
|
736
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
737
|
+
|
|
738
|
+
// Simulate an error condition (e.g., database unavailable)
|
|
739
|
+
return error(
|
|
740
|
+
{
|
|
741
|
+
message: "Service temporarily unavailable",
|
|
742
|
+
code: "SERVICE_UNAVAILABLE",
|
|
743
|
+
},
|
|
744
|
+
503,
|
|
745
|
+
);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
return result;
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
const req = new Request("http://localhost/api/data", {
|
|
752
|
+
method: "GET",
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
const res = await instance.handler(req);
|
|
756
|
+
expect(res.status).toBe(503);
|
|
757
|
+
expect(await res.json()).toEqual({
|
|
758
|
+
message: "Service temporarily unavailable",
|
|
759
|
+
code: "SERVICE_UNAVAILABLE",
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("ifMatchesRoute with async body modification", async () => {
|
|
764
|
+
const fragment = defineFragment("test-lib");
|
|
765
|
+
|
|
766
|
+
const routes = [
|
|
767
|
+
defineRoute({
|
|
768
|
+
method: "POST",
|
|
769
|
+
path: "/posts",
|
|
770
|
+
inputSchema: z.object({ title: z.string(), content: z.string() }),
|
|
771
|
+
outputSchema: z.object({
|
|
772
|
+
title: z.string(),
|
|
773
|
+
content: z.string(),
|
|
774
|
+
slug: z.string(),
|
|
775
|
+
createdAt: z.number(),
|
|
776
|
+
}),
|
|
777
|
+
handler: async ({ input }, { json }) => {
|
|
778
|
+
const body = await input.valid();
|
|
779
|
+
return json({
|
|
780
|
+
title: body.title,
|
|
781
|
+
content: body.content,
|
|
782
|
+
slug: body.title.toLowerCase().replace(/\s+/g, "-"),
|
|
783
|
+
createdAt: Date.now(),
|
|
784
|
+
});
|
|
785
|
+
},
|
|
786
|
+
}),
|
|
787
|
+
] as const;
|
|
788
|
+
|
|
789
|
+
const instance = createFragment(fragment, {}, routes, {
|
|
790
|
+
mountRoute: "/api",
|
|
791
|
+
}).withMiddleware(async ({ ifMatchesRoute, requestState }) => {
|
|
792
|
+
const result = await ifMatchesRoute("POST", "/posts", async ({ input }) => {
|
|
793
|
+
// Simulate async operation to enrich the body (e.g., fetching user data)
|
|
794
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
795
|
+
|
|
796
|
+
const body = await input.valid();
|
|
797
|
+
|
|
798
|
+
// Enrich the body with additional data
|
|
799
|
+
requestState.setBody({
|
|
800
|
+
...body,
|
|
801
|
+
content: `${body.content}\n\n[Enhanced by middleware]`,
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
return undefined;
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
return result;
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
const req = new Request("http://localhost/api/posts", {
|
|
811
|
+
method: "POST",
|
|
812
|
+
headers: { "Content-Type": "application/json" },
|
|
813
|
+
body: JSON.stringify({
|
|
814
|
+
title: "Test Post",
|
|
815
|
+
content: "Original content",
|
|
816
|
+
}),
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
const res = await instance.handler(req);
|
|
820
|
+
expect(res.status).toBe(200);
|
|
821
|
+
|
|
822
|
+
const responseBody = await res.json();
|
|
823
|
+
expect(responseBody).toMatchObject({
|
|
824
|
+
title: "Test Post",
|
|
825
|
+
content: "Original content\n\n[Enhanced by middleware]",
|
|
826
|
+
slug: "test-post",
|
|
827
|
+
});
|
|
828
|
+
expect(responseBody.createdAt).toBeTypeOf("number");
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
test("multiple async operations in ifMatchesRoute complete in order", async () => {
|
|
832
|
+
const fragment = defineFragment("test-lib");
|
|
833
|
+
|
|
834
|
+
const routes = [
|
|
835
|
+
defineRoute({
|
|
836
|
+
method: "GET",
|
|
837
|
+
path: "/status",
|
|
838
|
+
outputSchema: z.object({ message: z.string() }),
|
|
839
|
+
handler: async (_, { json }) => {
|
|
840
|
+
return json({ message: "OK" });
|
|
841
|
+
},
|
|
842
|
+
}),
|
|
843
|
+
] as const;
|
|
844
|
+
|
|
845
|
+
const executionOrder: string[] = [];
|
|
846
|
+
|
|
847
|
+
const instance = createFragment(fragment, {}, routes, {
|
|
848
|
+
mountRoute: "/api",
|
|
849
|
+
}).withMiddleware(async ({ ifMatchesRoute }) => {
|
|
850
|
+
executionOrder.push("start");
|
|
851
|
+
|
|
852
|
+
const result = await ifMatchesRoute("GET", "/status", async () => {
|
|
853
|
+
executionOrder.push("before-first-async");
|
|
854
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
855
|
+
executionOrder.push("after-first-async");
|
|
856
|
+
|
|
857
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
858
|
+
executionOrder.push("after-second-async");
|
|
859
|
+
|
|
860
|
+
return undefined;
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
executionOrder.push("end");
|
|
864
|
+
|
|
865
|
+
return result;
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
const req = new Request("http://localhost/api/status", {
|
|
869
|
+
method: "GET",
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
const res = await instance.handler(req);
|
|
873
|
+
expect(res.status).toBe(200);
|
|
874
|
+
expect(executionOrder).toEqual([
|
|
875
|
+
"start",
|
|
876
|
+
"before-first-async",
|
|
877
|
+
"after-first-async",
|
|
878
|
+
"after-second-async",
|
|
879
|
+
"end",
|
|
880
|
+
]);
|
|
881
|
+
});
|
|
631
882
|
});
|
|
@@ -143,6 +143,6 @@ export class RequestMiddlewareInputContext<const TRoutes extends readonly AnyFra
|
|
|
143
143
|
const outputContext = new RequestOutputContext(this.#route.outputSchema);
|
|
144
144
|
|
|
145
145
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
146
|
-
return (handler as any)(inputContext, outputContext);
|
|
146
|
+
return await (handler as any)(inputContext, outputContext);
|
|
147
147
|
};
|
|
148
148
|
}
|
package/src/test/test.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import { createFragmentForTest } from "./test";
|
|
3
3
|
import { defineFragment } from "../api/fragment-builder";
|
|
4
|
-
import { defineRoute } from "../api/route";
|
|
4
|
+
import { defineRoute, defineRoutes } from "../api/route";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
|
|
7
7
|
describe("createFragmentForTest", () => {
|
|
@@ -317,6 +317,61 @@ describe("fragment.handler", () => {
|
|
|
317
317
|
}
|
|
318
318
|
});
|
|
319
319
|
|
|
320
|
+
it("should handle route factory created with defineRoutes", async () => {
|
|
321
|
+
const fragment = defineFragment<{ apiKey: string }>("test").withServices(() => ({
|
|
322
|
+
getGreeting: (name: string) => `Hello, ${name}!`,
|
|
323
|
+
getCount: () => 42,
|
|
324
|
+
}));
|
|
325
|
+
|
|
326
|
+
const testFragment = createFragmentForTest(fragment, {
|
|
327
|
+
config: { apiKey: "test-key" },
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
type Config = { apiKey: string };
|
|
331
|
+
type Deps = {};
|
|
332
|
+
type Services = { getGreeting: (name: string) => string; getCount: () => number };
|
|
333
|
+
|
|
334
|
+
const routeFactory = defineRoutes<Config, Deps, Services>().create(({ services }) => [
|
|
335
|
+
defineRoute({
|
|
336
|
+
method: "GET",
|
|
337
|
+
path: "/greeting/:name",
|
|
338
|
+
outputSchema: z.object({ message: z.string() }),
|
|
339
|
+
handler: async ({ pathParams }, { json }) => {
|
|
340
|
+
return json({ message: services.getGreeting(pathParams.name) });
|
|
341
|
+
},
|
|
342
|
+
}),
|
|
343
|
+
defineRoute({
|
|
344
|
+
method: "GET",
|
|
345
|
+
path: "/count",
|
|
346
|
+
outputSchema: z.object({ count: z.number() }),
|
|
347
|
+
handler: async (_ctx, { json }) => {
|
|
348
|
+
return json({ count: services.getCount() });
|
|
349
|
+
},
|
|
350
|
+
}),
|
|
351
|
+
]);
|
|
352
|
+
|
|
353
|
+
const routes = [routeFactory] as const;
|
|
354
|
+
const [greetingRoute, countRoute] = testFragment.initRoutes(routes);
|
|
355
|
+
|
|
356
|
+
// Test first route
|
|
357
|
+
const greetingResponse = await testFragment.handler(greetingRoute, {
|
|
358
|
+
pathParams: { name: "World" },
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
expect(greetingResponse.type).toBe("json");
|
|
362
|
+
if (greetingResponse.type === "json") {
|
|
363
|
+
expect(greetingResponse.data).toEqual({ message: "Hello, World!" });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Test second route
|
|
367
|
+
const countResponse = await testFragment.handler(countRoute);
|
|
368
|
+
|
|
369
|
+
expect(countResponse.type).toBe("json");
|
|
370
|
+
if (countResponse.type === "json") {
|
|
371
|
+
expect(countResponse.data).toEqual({ count: 42 });
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
320
375
|
it("should handle path parameters", async () => {
|
|
321
376
|
const fragment = defineFragment<{}>("test");
|
|
322
377
|
const testFragment = createFragmentForTest(fragment, {
|
package/src/test/test.ts
CHANGED