@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,437 @@
|
|
|
1
|
+
import { test, expect, describe } from "vitest";
|
|
2
|
+
import { RequestInputContext } from "./request-input-context";
|
|
3
|
+
import { FragnoApiValidationError } from "./api";
|
|
4
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
5
|
+
|
|
6
|
+
// Mock schema implementations for testing
|
|
7
|
+
const createMockSchema = (shouldPass: boolean, returnValue?: unknown): StandardSchemaV1 => ({
|
|
8
|
+
"~standard": {
|
|
9
|
+
version: 1,
|
|
10
|
+
vendor: "test",
|
|
11
|
+
validate: async (value: unknown) => {
|
|
12
|
+
if (shouldPass) {
|
|
13
|
+
return { value: returnValue ?? value };
|
|
14
|
+
} else {
|
|
15
|
+
return {
|
|
16
|
+
issues: [
|
|
17
|
+
{
|
|
18
|
+
kind: "validation",
|
|
19
|
+
type: "string",
|
|
20
|
+
input: value,
|
|
21
|
+
expected: "string",
|
|
22
|
+
received: typeof value,
|
|
23
|
+
message: "Expected string",
|
|
24
|
+
path: [],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const validStringSchema = createMockSchema(true, "validated-string");
|
|
34
|
+
const invalidSchema = createMockSchema(false);
|
|
35
|
+
|
|
36
|
+
describe("RequestContext", () => {
|
|
37
|
+
test("Should be able to destructure RequestContext class instance", () => {
|
|
38
|
+
const ctx = new RequestInputContext({
|
|
39
|
+
method: "GET",
|
|
40
|
+
path: "/",
|
|
41
|
+
pathParams: {},
|
|
42
|
+
searchParams: new URLSearchParams(),
|
|
43
|
+
body: undefined,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const { path, pathParams, query: searchParams, input, rawBody: body } = ctx;
|
|
47
|
+
|
|
48
|
+
expect(path).toBe("/");
|
|
49
|
+
expect(pathParams).toEqual({});
|
|
50
|
+
expect(searchParams).toBeInstanceOf(URLSearchParams);
|
|
51
|
+
expect(input).toBeUndefined();
|
|
52
|
+
expect(body).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("Should support body in constructor", () => {
|
|
56
|
+
const jsonBody = { test: "data" };
|
|
57
|
+
const ctx = new RequestInputContext({
|
|
58
|
+
method: "POST",
|
|
59
|
+
path: "/api/test",
|
|
60
|
+
pathParams: {},
|
|
61
|
+
searchParams: new URLSearchParams(),
|
|
62
|
+
body: jsonBody,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(ctx.rawBody).toEqual(jsonBody);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("Should support FormData body", () => {
|
|
69
|
+
const formData = new FormData();
|
|
70
|
+
formData.append("key", "value");
|
|
71
|
+
|
|
72
|
+
const ctx = new RequestInputContext({
|
|
73
|
+
path: "/api/form",
|
|
74
|
+
pathParams: {},
|
|
75
|
+
searchParams: new URLSearchParams(),
|
|
76
|
+
body: formData,
|
|
77
|
+
method: "POST",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(ctx.rawBody).toBe(formData);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("Should support Blob body", () => {
|
|
84
|
+
const blob = new Blob(["test content"], { type: "text/plain" });
|
|
85
|
+
|
|
86
|
+
const ctx = new RequestInputContext({
|
|
87
|
+
path: "/api/upload",
|
|
88
|
+
pathParams: {},
|
|
89
|
+
searchParams: new URLSearchParams(),
|
|
90
|
+
body: blob,
|
|
91
|
+
method: "POST",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(ctx.rawBody).toBe(blob);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("Should create RequestContext with fromRequest static method", async () => {
|
|
98
|
+
const request = new Request("https://example.com/api/test", {
|
|
99
|
+
method: "POST",
|
|
100
|
+
body: JSON.stringify({ test: "data" }),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const bodyData = { test: "data" };
|
|
104
|
+
const ctx = await RequestInputContext.fromRequest({
|
|
105
|
+
request,
|
|
106
|
+
method: "POST",
|
|
107
|
+
path: "/api/test",
|
|
108
|
+
pathParams: {},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(ctx.path).toBe("/api/test");
|
|
112
|
+
expect(ctx.rawBody).toEqual(bodyData);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("Should create RequestContext with fromSSRContext static method", () => {
|
|
116
|
+
const bodyData = { ssr: "data" };
|
|
117
|
+
const ctx = RequestInputContext.fromSSRContext({
|
|
118
|
+
method: "POST",
|
|
119
|
+
path: "/api/ssr",
|
|
120
|
+
pathParams: {},
|
|
121
|
+
body: bodyData,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(ctx.path).toBe("/api/ssr");
|
|
125
|
+
expect(ctx.rawBody).toEqual(bodyData);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("Input handling", () => {
|
|
129
|
+
test("Should return undefined input when no input schema is provided", () => {
|
|
130
|
+
const ctx = new RequestInputContext({
|
|
131
|
+
path: "/test",
|
|
132
|
+
pathParams: {},
|
|
133
|
+
searchParams: new URLSearchParams(),
|
|
134
|
+
body: { test: "data" },
|
|
135
|
+
method: "POST",
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(ctx.input).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("Should return input object when input schema is provided", () => {
|
|
142
|
+
const ctx = new RequestInputContext({
|
|
143
|
+
path: "/test",
|
|
144
|
+
pathParams: {},
|
|
145
|
+
searchParams: new URLSearchParams(),
|
|
146
|
+
body: { test: "data" },
|
|
147
|
+
inputSchema: validStringSchema,
|
|
148
|
+
method: "POST",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(ctx.input).toBeDefined();
|
|
152
|
+
expect(ctx.input?.schema).toBe(validStringSchema);
|
|
153
|
+
expect(typeof ctx.input.valid).toBe("function");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("Should validate input successfully with valid data", async () => {
|
|
157
|
+
const ctx = new RequestInputContext({
|
|
158
|
+
path: "/test",
|
|
159
|
+
pathParams: {},
|
|
160
|
+
searchParams: new URLSearchParams(),
|
|
161
|
+
body: "test string",
|
|
162
|
+
inputSchema: validStringSchema,
|
|
163
|
+
method: "POST",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const result = await ctx.input?.valid();
|
|
167
|
+
expect(result).toBe("validated-string");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("Should throw validation error with invalid data", async () => {
|
|
171
|
+
const ctx = new RequestInputContext({
|
|
172
|
+
path: "/test",
|
|
173
|
+
pathParams: {},
|
|
174
|
+
searchParams: new URLSearchParams(),
|
|
175
|
+
body: 123, // Invalid for string schema
|
|
176
|
+
inputSchema: invalidSchema,
|
|
177
|
+
method: "POST",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await expect(ctx.input.valid()).rejects.toThrow(FragnoApiValidationError);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("Should throw validation error with detailed issues", async () => {
|
|
184
|
+
const ctx = new RequestInputContext({
|
|
185
|
+
path: "/test",
|
|
186
|
+
pathParams: {},
|
|
187
|
+
searchParams: new URLSearchParams(),
|
|
188
|
+
body: 123,
|
|
189
|
+
inputSchema: invalidSchema,
|
|
190
|
+
method: "POST",
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
await ctx.input?.valid();
|
|
195
|
+
expect.fail("Should have thrown validation error");
|
|
196
|
+
} catch (error) {
|
|
197
|
+
expect(error).toBeInstanceOf(FragnoApiValidationError);
|
|
198
|
+
const validationError = error as FragnoApiValidationError;
|
|
199
|
+
expect(validationError.issues).toHaveLength(1);
|
|
200
|
+
expect(validationError.issues[0]).toMatchObject({
|
|
201
|
+
kind: "validation",
|
|
202
|
+
type: "string",
|
|
203
|
+
input: 123,
|
|
204
|
+
expected: "string",
|
|
205
|
+
received: "number",
|
|
206
|
+
message: "Expected string",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("Should skip validation when shouldValidateInput is false", async () => {
|
|
212
|
+
const ctx = new RequestInputContext({
|
|
213
|
+
path: "/test",
|
|
214
|
+
pathParams: {},
|
|
215
|
+
searchParams: new URLSearchParams(),
|
|
216
|
+
body: 123,
|
|
217
|
+
inputSchema: invalidSchema,
|
|
218
|
+
shouldValidateInput: false,
|
|
219
|
+
method: "POST",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Should return the raw body without validation when validation is disabled
|
|
223
|
+
const result = await ctx.input?.valid();
|
|
224
|
+
expect(result).toBe(123);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("Should throw error when trying to validate FormData", async () => {
|
|
228
|
+
const formData = new FormData();
|
|
229
|
+
formData.append("key", "value");
|
|
230
|
+
|
|
231
|
+
const ctx = new RequestInputContext({
|
|
232
|
+
path: "/test",
|
|
233
|
+
pathParams: {},
|
|
234
|
+
searchParams: new URLSearchParams(),
|
|
235
|
+
body: formData,
|
|
236
|
+
inputSchema: validStringSchema,
|
|
237
|
+
method: "POST",
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
await expect(ctx.input.valid()).rejects.toThrow(
|
|
241
|
+
"Schema validation is only supported for JSON data, not FormData or Blob",
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("Should throw error when trying to validate Blob", async () => {
|
|
246
|
+
const blob = new Blob(["test content"], { type: "text/plain" });
|
|
247
|
+
|
|
248
|
+
const ctx = new RequestInputContext({
|
|
249
|
+
path: "/test",
|
|
250
|
+
pathParams: {},
|
|
251
|
+
searchParams: new URLSearchParams(),
|
|
252
|
+
body: blob,
|
|
253
|
+
inputSchema: validStringSchema,
|
|
254
|
+
method: "POST",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await expect(ctx.input.valid()).rejects.toThrow(
|
|
258
|
+
"Schema validation is only supported for JSON data, not FormData or Blob",
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("Should handle null body", async () => {
|
|
263
|
+
const ctx = new RequestInputContext({
|
|
264
|
+
path: "/test",
|
|
265
|
+
pathParams: {},
|
|
266
|
+
searchParams: new URLSearchParams(),
|
|
267
|
+
body: null,
|
|
268
|
+
inputSchema: validStringSchema,
|
|
269
|
+
method: "POST",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const result = await ctx.input.valid();
|
|
273
|
+
expect(result).toBe("validated-string");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("Should handle undefined body", async () => {
|
|
277
|
+
const ctx = new RequestInputContext({
|
|
278
|
+
path: "/test",
|
|
279
|
+
pathParams: {},
|
|
280
|
+
searchParams: new URLSearchParams(),
|
|
281
|
+
body: undefined,
|
|
282
|
+
inputSchema: validStringSchema,
|
|
283
|
+
method: "POST",
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const result = await ctx.input.valid();
|
|
287
|
+
expect(result).toBe("validated-string");
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("Validation configuration", () => {
|
|
292
|
+
test("Should default shouldValidateInput to true", () => {
|
|
293
|
+
const ctx = new RequestInputContext({
|
|
294
|
+
path: "/test",
|
|
295
|
+
pathParams: {},
|
|
296
|
+
searchParams: new URLSearchParams(),
|
|
297
|
+
inputSchema: validStringSchema,
|
|
298
|
+
method: "POST",
|
|
299
|
+
body: undefined,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// We can't directly access the private field, but we can test the behavior
|
|
303
|
+
expect(ctx.input).toBeDefined();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("Should respect explicit shouldValidateInput true", () => {
|
|
307
|
+
const ctx = new RequestInputContext({
|
|
308
|
+
path: "/test",
|
|
309
|
+
pathParams: {},
|
|
310
|
+
searchParams: new URLSearchParams(),
|
|
311
|
+
inputSchema: validStringSchema,
|
|
312
|
+
shouldValidateInput: true,
|
|
313
|
+
method: "POST",
|
|
314
|
+
body: undefined,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(ctx.input).toBeDefined();
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("Should respect shouldValidateInput false", () => {
|
|
321
|
+
const ctx = new RequestInputContext({
|
|
322
|
+
path: "/test",
|
|
323
|
+
pathParams: {},
|
|
324
|
+
searchParams: new URLSearchParams(),
|
|
325
|
+
inputSchema: validStringSchema,
|
|
326
|
+
shouldValidateInput: false,
|
|
327
|
+
method: "POST",
|
|
328
|
+
body: undefined,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(ctx.input).toBeDefined();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("Should disable validation in SSR context by default", () => {
|
|
335
|
+
const ctx = RequestInputContext.fromSSRContext({
|
|
336
|
+
path: "/test",
|
|
337
|
+
pathParams: {},
|
|
338
|
+
// inputSchema is not available in SSR context, but validation is disabled
|
|
339
|
+
method: "POST",
|
|
340
|
+
body: undefined,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// This tests that shouldValidateInput is set to false in SSR context
|
|
344
|
+
expect(ctx.input).toBeUndefined();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("Should pass through shouldValidateInput from fromRequest", async () => {
|
|
348
|
+
const request = new Request("https://example.com/api/test");
|
|
349
|
+
const ctx = await RequestInputContext.fromRequest({
|
|
350
|
+
request,
|
|
351
|
+
path: "/test",
|
|
352
|
+
pathParams: {},
|
|
353
|
+
inputSchema: validStringSchema,
|
|
354
|
+
shouldValidateInput: false,
|
|
355
|
+
method: "POST",
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
expect(ctx.input).toBeDefined();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
describe("Edge cases and error conditions", () => {
|
|
363
|
+
test("Should handle empty path params", () => {
|
|
364
|
+
const ctx = new RequestInputContext({
|
|
365
|
+
path: "/test",
|
|
366
|
+
pathParams: {},
|
|
367
|
+
searchParams: new URLSearchParams(),
|
|
368
|
+
method: "POST",
|
|
369
|
+
body: undefined,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
expect(ctx.pathParams).toEqual({});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("Should handle empty search params", () => {
|
|
376
|
+
const ctx = new RequestInputContext({
|
|
377
|
+
path: "/test",
|
|
378
|
+
pathParams: {},
|
|
379
|
+
searchParams: new URLSearchParams(),
|
|
380
|
+
method: "POST",
|
|
381
|
+
body: undefined,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
expect(ctx.query.toString()).toBe("");
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("Should handle search params with values", () => {
|
|
388
|
+
const searchParams = new URLSearchParams("?key=value&another=test");
|
|
389
|
+
const ctx = new RequestInputContext({
|
|
390
|
+
path: "/test",
|
|
391
|
+
pathParams: {},
|
|
392
|
+
searchParams,
|
|
393
|
+
method: "POST",
|
|
394
|
+
body: undefined,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
expect(ctx.query.get("key")).toBe("value");
|
|
398
|
+
expect(ctx.query.get("another")).toBe("test");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("Should extract search params from request URL in fromRequest", async () => {
|
|
402
|
+
const request = new Request("https://example.com/api/test?param=value");
|
|
403
|
+
const ctx = await RequestInputContext.fromRequest({
|
|
404
|
+
request,
|
|
405
|
+
path: "/test",
|
|
406
|
+
pathParams: {},
|
|
407
|
+
method: "POST",
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
expect(ctx.query.get("param")).toBe("value");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("Should use default empty search params in fromSSRContext when not provided", () => {
|
|
414
|
+
const ctx = RequestInputContext.fromSSRContext({
|
|
415
|
+
path: "/test",
|
|
416
|
+
pathParams: {},
|
|
417
|
+
method: "POST",
|
|
418
|
+
body: undefined,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
expect(ctx.query.toString()).toBe("");
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("Should use provided search params in fromSSRContext", () => {
|
|
425
|
+
const searchParams = new URLSearchParams("?ssr=true");
|
|
426
|
+
const ctx = RequestInputContext.fromSSRContext({
|
|
427
|
+
path: "/test",
|
|
428
|
+
pathParams: {},
|
|
429
|
+
searchParams,
|
|
430
|
+
method: "POST",
|
|
431
|
+
body: undefined,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
expect(ctx.query.get("ssr")).toBe("true");
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import type { ExtractPathParams } from "./internal/path";
|
|
3
|
+
import { FragnoApiValidationError, type HTTPMethod } from "./api";
|
|
4
|
+
|
|
5
|
+
export type RequestBodyType =
|
|
6
|
+
| unknown // JSON
|
|
7
|
+
| FormData
|
|
8
|
+
| Blob
|
|
9
|
+
| null
|
|
10
|
+
| undefined;
|
|
11
|
+
|
|
12
|
+
export class RequestInputContext<
|
|
13
|
+
TPath extends string = string,
|
|
14
|
+
TInputSchema extends StandardSchemaV1 | undefined = undefined,
|
|
15
|
+
> {
|
|
16
|
+
readonly #path: TPath;
|
|
17
|
+
readonly #method: string;
|
|
18
|
+
readonly #pathParams: ExtractPathParams<TPath>;
|
|
19
|
+
readonly #searchParams: URLSearchParams;
|
|
20
|
+
readonly #body: RequestBodyType;
|
|
21
|
+
readonly #inputSchema: TInputSchema | undefined;
|
|
22
|
+
readonly #shouldValidateInput: boolean;
|
|
23
|
+
|
|
24
|
+
constructor(config: {
|
|
25
|
+
path: TPath;
|
|
26
|
+
method: string;
|
|
27
|
+
pathParams: ExtractPathParams<TPath>;
|
|
28
|
+
searchParams: URLSearchParams;
|
|
29
|
+
body: RequestBodyType;
|
|
30
|
+
|
|
31
|
+
request?: Request;
|
|
32
|
+
inputSchema?: TInputSchema;
|
|
33
|
+
shouldValidateInput?: boolean;
|
|
34
|
+
}) {
|
|
35
|
+
this.#path = config.path;
|
|
36
|
+
this.#method = config.method;
|
|
37
|
+
this.#pathParams = config.pathParams;
|
|
38
|
+
this.#searchParams = config.searchParams;
|
|
39
|
+
this.#body = config.body;
|
|
40
|
+
this.#inputSchema = config.inputSchema;
|
|
41
|
+
this.#shouldValidateInput = config.shouldValidateInput ?? true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a RequestContext from a Request object for server-side handling
|
|
46
|
+
*/
|
|
47
|
+
static async fromRequest<
|
|
48
|
+
TPath extends string,
|
|
49
|
+
TInputSchema extends StandardSchemaV1 | undefined = undefined,
|
|
50
|
+
>(config: {
|
|
51
|
+
request: Request;
|
|
52
|
+
method: string;
|
|
53
|
+
path: TPath;
|
|
54
|
+
pathParams: ExtractPathParams<TPath>;
|
|
55
|
+
inputSchema?: TInputSchema;
|
|
56
|
+
shouldValidateInput?: boolean;
|
|
57
|
+
}): Promise<RequestInputContext<TPath, TInputSchema>> {
|
|
58
|
+
const url = new URL(config.request.url);
|
|
59
|
+
|
|
60
|
+
// Clone the request to avoid consuming the body stream
|
|
61
|
+
// TODO: Probably we should just cache the result instead
|
|
62
|
+
const request = config.request.clone();
|
|
63
|
+
|
|
64
|
+
// TODO: Support other body types other than json
|
|
65
|
+
const json = request.body instanceof ReadableStream ? await request.json() : undefined;
|
|
66
|
+
|
|
67
|
+
return new RequestInputContext({
|
|
68
|
+
method: config.method,
|
|
69
|
+
path: config.path,
|
|
70
|
+
pathParams: config.pathParams,
|
|
71
|
+
searchParams: url.searchParams,
|
|
72
|
+
body: json,
|
|
73
|
+
inputSchema: config.inputSchema,
|
|
74
|
+
shouldValidateInput: config.shouldValidateInput,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a RequestContext for server-side rendering contexts (no Request object)
|
|
80
|
+
*/
|
|
81
|
+
static fromSSRContext<
|
|
82
|
+
TPath extends string,
|
|
83
|
+
TInputSchema extends StandardSchemaV1 | undefined = undefined,
|
|
84
|
+
>(
|
|
85
|
+
config:
|
|
86
|
+
| {
|
|
87
|
+
method: "GET";
|
|
88
|
+
path: TPath;
|
|
89
|
+
pathParams: ExtractPathParams<TPath>;
|
|
90
|
+
searchParams?: URLSearchParams;
|
|
91
|
+
}
|
|
92
|
+
| {
|
|
93
|
+
method: Exclude<HTTPMethod, "GET">;
|
|
94
|
+
path: TPath;
|
|
95
|
+
pathParams: ExtractPathParams<TPath>;
|
|
96
|
+
searchParams?: URLSearchParams;
|
|
97
|
+
body: RequestBodyType;
|
|
98
|
+
inputSchema?: TInputSchema;
|
|
99
|
+
},
|
|
100
|
+
): RequestInputContext<TPath, TInputSchema> {
|
|
101
|
+
return new RequestInputContext({
|
|
102
|
+
method: config.method,
|
|
103
|
+
path: config.path,
|
|
104
|
+
pathParams: config.pathParams,
|
|
105
|
+
searchParams: config.searchParams ?? new URLSearchParams(),
|
|
106
|
+
body: "body" in config ? config.body : undefined,
|
|
107
|
+
inputSchema: "inputSchema" in config ? config.inputSchema : undefined,
|
|
108
|
+
shouldValidateInput: false, // No input validation in SSR context
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// TODO(Wilco): We should support reading/modifying headers here.
|
|
113
|
+
/**
|
|
114
|
+
* The HTTP method as string (e.g., `GET`, `POST`)
|
|
115
|
+
*/
|
|
116
|
+
get method(): string {
|
|
117
|
+
return this.#method;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The matched route path (e.g., `/users/:id`)
|
|
121
|
+
* @remarks `string`
|
|
122
|
+
*/
|
|
123
|
+
get path(): TPath {
|
|
124
|
+
return this.#path;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Extracted path parameters as object (e.g., `{ id: '123' }`)
|
|
128
|
+
* @remarks `Record<string, string>`
|
|
129
|
+
*/
|
|
130
|
+
get pathParams(): ExtractPathParams<TPath> {
|
|
131
|
+
return this.#pathParams;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams) object for query parameters
|
|
135
|
+
* @remarks `URLSearchParams`
|
|
136
|
+
*/
|
|
137
|
+
get query(): URLSearchParams {
|
|
138
|
+
return this.#searchParams;
|
|
139
|
+
}
|
|
140
|
+
// TODO: Should probably remove this
|
|
141
|
+
/**
|
|
142
|
+
* @internal
|
|
143
|
+
*/
|
|
144
|
+
get rawBody(): RequestBodyType {
|
|
145
|
+
return this.#body;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Input validation context (only if inputSchema is defined)
|
|
149
|
+
* @remarks `InputContext`
|
|
150
|
+
*/
|
|
151
|
+
get input(): TInputSchema extends undefined
|
|
152
|
+
? undefined
|
|
153
|
+
: {
|
|
154
|
+
schema: TInputSchema;
|
|
155
|
+
valid: () => Promise<
|
|
156
|
+
TInputSchema extends StandardSchemaV1
|
|
157
|
+
? StandardSchemaV1.InferOutput<TInputSchema>
|
|
158
|
+
: unknown
|
|
159
|
+
>;
|
|
160
|
+
} {
|
|
161
|
+
if (!this.#inputSchema) {
|
|
162
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
163
|
+
return undefined as any;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
schema: this.#inputSchema,
|
|
168
|
+
valid: async () => {
|
|
169
|
+
if (!this.#shouldValidateInput) {
|
|
170
|
+
// In SSR context, return the body directly without validation
|
|
171
|
+
return this.#body;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return this.#validateInput();
|
|
175
|
+
},
|
|
176
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
177
|
+
} as any;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async #validateInput(): Promise<
|
|
181
|
+
TInputSchema extends StandardSchemaV1 ? StandardSchemaV1.InferOutput<TInputSchema> : never
|
|
182
|
+
> {
|
|
183
|
+
if (!this.#inputSchema) {
|
|
184
|
+
throw new Error("No input schema defined for this route");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (this.#body instanceof FormData || this.#body instanceof Blob) {
|
|
188
|
+
throw new Error("Schema validation is only supported for JSON data, not FormData or Blob");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const result = await this.#inputSchema["~standard"].validate(this.#body);
|
|
192
|
+
|
|
193
|
+
if (result.issues) {
|
|
194
|
+
throw new FragnoApiValidationError("Validation failed", result.issues);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return result.value as TInputSchema extends StandardSchemaV1
|
|
198
|
+
? StandardSchemaV1.InferOutput<TInputSchema>
|
|
199
|
+
: never;
|
|
200
|
+
}
|
|
201
|
+
}
|