@fragno-dev/core 0.1.11 → 0.2.2
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 +87 -69
- package/CHANGELOG.md +79 -0
- package/dist/api/api.d.ts +21 -2
- package/dist/api/api.d.ts.map +1 -1
- package/dist/api/api.js +2 -1
- package/dist/api/api.js.map +1 -1
- package/dist/api/bind-services.d.ts +0 -1
- package/dist/api/bind-services.d.ts.map +1 -1
- package/dist/api/bind-services.js.map +1 -1
- package/dist/api/error.d.ts.map +1 -1
- package/dist/api/error.js.map +1 -1
- package/dist/api/fragment-definition-builder.d.ts +32 -40
- package/dist/api/fragment-definition-builder.d.ts.map +1 -1
- package/dist/api/fragment-definition-builder.js +15 -21
- package/dist/api/fragment-definition-builder.js.map +1 -1
- package/dist/api/fragment-instantiator.d.ts +51 -30
- package/dist/api/fragment-instantiator.d.ts.map +1 -1
- package/dist/api/fragment-instantiator.js +201 -52
- package/dist/api/fragment-instantiator.js.map +1 -1
- package/dist/api/request-context-storage.d.ts +4 -0
- package/dist/api/request-context-storage.d.ts.map +1 -1
- package/dist/api/request-context-storage.js +6 -0
- package/dist/api/request-context-storage.js.map +1 -1
- package/dist/api/request-input-context.d.ts +57 -1
- package/dist/api/request-input-context.d.ts.map +1 -1
- package/dist/api/request-input-context.js +67 -0
- package/dist/api/request-input-context.js.map +1 -1
- package/dist/api/request-middleware.d.ts +2 -2
- package/dist/api/request-middleware.d.ts.map +1 -1
- package/dist/api/request-middleware.js.map +1 -1
- package/dist/api/request-output-context.d.ts +1 -1
- package/dist/api/request-output-context.d.ts.map +1 -1
- package/dist/api/request-output-context.js.map +1 -1
- package/dist/api/route-caller.d.ts +30 -0
- package/dist/api/route-caller.d.ts.map +1 -0
- package/dist/api/route-caller.js +63 -0
- package/dist/api/route-caller.js.map +1 -0
- package/dist/api/route-handler-input-options.d.ts.map +1 -1
- package/dist/api/route.d.ts +8 -8
- package/dist/api/route.d.ts.map +1 -1
- package/dist/api/route.js.map +1 -1
- package/dist/api/shared-types.d.ts.map +1 -1
- package/dist/client/client-error.d.ts.map +1 -1
- package/dist/client/client-error.js.map +1 -1
- package/dist/client/client.d.ts +90 -50
- package/dist/client/client.d.ts.map +1 -1
- package/dist/client/client.js +128 -16
- package/dist/client/client.js.map +1 -1
- package/dist/client/client.svelte.d.ts +6 -5
- package/dist/client/client.svelte.d.ts.map +1 -1
- package/dist/client/client.svelte.js +10 -2
- package/dist/client/client.svelte.js.map +1 -1
- package/dist/client/internal/ndjson-streaming.js.map +1 -1
- package/dist/client/react.d.ts +5 -4
- package/dist/client/react.d.ts.map +1 -1
- package/dist/client/react.js +104 -12
- package/dist/client/react.js.map +1 -1
- package/dist/client/solid.d.ts +7 -5
- package/dist/client/solid.d.ts.map +1 -1
- package/dist/client/solid.js +23 -9
- package/dist/client/solid.js.map +1 -1
- package/dist/client/vanilla.d.ts +16 -4
- package/dist/client/vanilla.d.ts.map +1 -1
- package/dist/client/vanilla.js +21 -1
- package/dist/client/vanilla.js.map +1 -1
- package/dist/client/vue.d.ts +10 -4
- package/dist/client/vue.d.ts.map +1 -1
- package/dist/client/vue.js +24 -1
- package/dist/client/vue.js.map +1 -1
- package/dist/id.d.ts +2 -0
- package/dist/id.js +3 -0
- package/dist/internal/cuid.d.ts +16 -0
- package/dist/internal/cuid.d.ts.map +1 -0
- package/dist/internal/cuid.js +82 -0
- package/dist/internal/cuid.js.map +1 -0
- package/dist/internal/trace-context.d.ts +23 -0
- package/dist/internal/trace-context.d.ts.map +1 -0
- package/dist/internal/trace-context.js +14 -0
- package/dist/internal/trace-context.js.map +1 -0
- package/dist/mod-client.d.ts +7 -20
- package/dist/mod-client.d.ts.map +1 -1
- package/dist/mod-client.js +25 -13
- package/dist/mod-client.js.map +1 -1
- package/dist/mod.d.ts +8 -6
- package/dist/mod.js +3 -1
- package/dist/runtime.d.ts +15 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +33 -0
- package/dist/runtime.js.map +1 -0
- package/dist/test/test.d.ts +6 -6
- package/dist/test/test.d.ts.map +1 -1
- package/dist/test/test.js.map +1 -1
- package/dist/util/ssr.js.map +1 -1
- package/package.json +42 -52
- package/src/api/api.test.ts +3 -1
- package/src/api/api.ts +28 -0
- package/src/api/bind-services.ts +0 -5
- package/src/api/error.ts +1 -0
- package/src/api/fragment-definition-builder.extend.test.ts +2 -1
- package/src/api/fragment-definition-builder.test.ts +2 -1
- package/src/api/fragment-definition-builder.ts +56 -112
- package/src/api/fragment-instantiator.test.ts +311 -166
- package/src/api/fragment-instantiator.ts +470 -131
- package/src/api/fragment-services.test.ts +1 -0
- package/src/api/internal/path-runtime.test.ts +8 -0
- package/src/api/internal/path-type.test.ts +3 -1
- package/src/api/internal/route.test.ts +1 -0
- package/src/api/request-context-storage.ts +7 -0
- package/src/api/request-input-context.test.ts +156 -2
- package/src/api/request-input-context.ts +87 -1
- package/src/api/request-middleware.test.ts +43 -2
- package/src/api/request-middleware.ts +4 -3
- package/src/api/request-output-context.test.ts +3 -1
- package/src/api/request-output-context.ts +2 -1
- package/src/api/route-caller.test.ts +195 -0
- package/src/api/route-caller.ts +167 -0
- package/src/api/route-handler-input-options.ts +2 -1
- package/src/api/route.test.ts +4 -2
- package/src/api/route.ts +9 -3
- package/src/api/shared-types.ts +2 -1
- package/src/client/client-builder.test.ts +4 -2
- package/src/client/client-error.test.ts +2 -1
- package/src/client/client-error.ts +1 -1
- package/src/client/client-types.test.ts +19 -5
- package/src/client/client.ssr.test.ts +6 -4
- package/src/client/client.svelte.test.ts +18 -9
- package/src/client/client.svelte.ts +38 -13
- package/src/client/client.test.ts +244 -10
- package/src/client/client.ts +473 -148
- package/src/client/internal/ndjson-streaming.test.ts +6 -3
- package/src/client/internal/ndjson-streaming.ts +1 -0
- package/src/client/react.test.ts +176 -6
- package/src/client/react.ts +226 -31
- package/src/client/solid.test.ts +29 -5
- package/src/client/solid.ts +60 -22
- package/src/client/vanilla.test.ts +148 -6
- package/src/client/vanilla.ts +63 -9
- package/src/client/vue.test.ts +397 -8
- package/src/client/vue.ts +74 -4
- package/src/id.ts +1 -0
- package/src/internal/cuid.test.ts +164 -0
- package/src/internal/cuid.ts +133 -0
- package/src/internal/trace-context.ts +35 -0
- package/src/mod-client.ts +55 -9
- package/src/mod.ts +9 -3
- package/src/runtime.ts +48 -0
- package/src/test/test.test.ts +4 -2
- package/src/test/test.ts +14 -7
- package/src/util/async.test.ts +1 -0
- package/src/util/content-type.test.ts +1 -0
- package/src/util/nanostores.test.ts +3 -1
- package/src/util/ssr.ts +1 -0
- package/tsconfig.json +1 -1
- package/tsdown.config.ts +2 -0
- package/vitest.config.ts +2 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
2
3
|
import { buildPath, extractPathParams, matchPathParams } from "./path";
|
|
3
4
|
|
|
4
5
|
describe("extractPathParams (runtime names)", () => {
|
|
@@ -77,6 +78,13 @@ describe("matchPathParams (runtime extraction)", () => {
|
|
|
77
78
|
expect(matchPathParams("/users/:id", "/users/123/")).toEqual({ id: "123" });
|
|
78
79
|
});
|
|
79
80
|
|
|
81
|
+
test("URL decodes named params", () => {
|
|
82
|
+
expect(matchPathParams("/users/:name", "/users/a%20b")).toEqual({ name: "a b" });
|
|
83
|
+
expect(matchPathParams("/files/:path", "/files/folder%2Fsubfolder")).toEqual({
|
|
84
|
+
path: "folder/subfolder",
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
80
88
|
test("pattern longer than path fills empty strings for remaining params", () => {
|
|
81
89
|
// Remaining ":id" becomes empty string
|
|
82
90
|
expect(matchPathParams("/users/:id", "/users")).toEqual({ id: "" });
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { test, expect, expectTypeOf } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
4
|
+
|
|
2
5
|
import type {
|
|
3
6
|
ExtractPathParams,
|
|
4
7
|
ExtractPathParamNames,
|
|
@@ -9,7 +12,6 @@ import type {
|
|
|
9
12
|
MaybeExtractPathParamsOrWiden,
|
|
10
13
|
QueryParamsHint,
|
|
11
14
|
} from "./path";
|
|
12
|
-
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
13
15
|
|
|
14
16
|
// Type-only tests using expectTypeOf from vitest
|
|
15
17
|
test("ExtractPathParams type tests", () => {
|
|
@@ -28,6 +28,13 @@ export class RequestContextStorage<TRequestStorage> {
|
|
|
28
28
|
return this.#storage.run(data, callback);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Check whether a store is currently active.
|
|
33
|
+
*/
|
|
34
|
+
hasStore(): boolean {
|
|
35
|
+
return this.#storage.getStore() !== undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
/**
|
|
32
39
|
* Get the current stored data from AsyncLocalStorage.
|
|
33
40
|
* @throws an error if called outside of a run() callback.
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { test, expect, describe } from "vitest";
|
|
2
|
-
|
|
3
|
-
import { FragnoApiValidationError } from "./api";
|
|
2
|
+
|
|
4
3
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
4
|
+
|
|
5
|
+
import { FragnoApiValidationError } from "./api";
|
|
5
6
|
import { MutableRequestState } from "./mutable-request-state";
|
|
7
|
+
import { RequestInputContext } from "./request-input-context";
|
|
6
8
|
|
|
7
9
|
// Mock schema implementations for testing
|
|
8
10
|
const createMockSchema = (shouldPass: boolean, returnValue?: unknown): StandardSchemaV1 => ({
|
|
@@ -292,6 +294,29 @@ describe("RequestContext", () => {
|
|
|
292
294
|
);
|
|
293
295
|
});
|
|
294
296
|
|
|
297
|
+
test("Should throw error when trying to validate ReadableStream", async () => {
|
|
298
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
299
|
+
start(controller) {
|
|
300
|
+
controller.enqueue(new TextEncoder().encode("stream body"));
|
|
301
|
+
controller.close();
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
const ctx = new RequestInputContext({
|
|
306
|
+
path: "/test",
|
|
307
|
+
pathParams: {},
|
|
308
|
+
searchParams: new URLSearchParams(),
|
|
309
|
+
headers: new Headers(),
|
|
310
|
+
parsedBody: stream,
|
|
311
|
+
inputSchema: validStringSchema,
|
|
312
|
+
method: "POST",
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
await expect(ctx.input.valid()).rejects.toThrow(
|
|
316
|
+
"Schema validation is only supported for JSON data, not FormData, Blob, or ReadableStream",
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
295
320
|
test("Should handle null body", async () => {
|
|
296
321
|
const ctx = new RequestInputContext({
|
|
297
322
|
path: "/test",
|
|
@@ -493,4 +518,133 @@ describe("RequestContext", () => {
|
|
|
493
518
|
expect(ctx.query.get("ssr")).toBe("true");
|
|
494
519
|
});
|
|
495
520
|
});
|
|
521
|
+
|
|
522
|
+
describe("FormData handling", () => {
|
|
523
|
+
test("formData() should return FormData when body is FormData", () => {
|
|
524
|
+
const formData = new FormData();
|
|
525
|
+
formData.append("file", new Blob(["test"]), "test.txt");
|
|
526
|
+
formData.append("description", "A test file");
|
|
527
|
+
|
|
528
|
+
const ctx = new RequestInputContext({
|
|
529
|
+
path: "/upload",
|
|
530
|
+
pathParams: {},
|
|
531
|
+
searchParams: new URLSearchParams(),
|
|
532
|
+
headers: new Headers(),
|
|
533
|
+
parsedBody: formData,
|
|
534
|
+
method: "POST",
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const result = ctx.formData();
|
|
538
|
+
expect(result).toBe(formData);
|
|
539
|
+
expect(result.get("description")).toBe("A test file");
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("formData() should throw when body is not FormData", () => {
|
|
543
|
+
const ctx = new RequestInputContext({
|
|
544
|
+
path: "/upload",
|
|
545
|
+
pathParams: {},
|
|
546
|
+
searchParams: new URLSearchParams(),
|
|
547
|
+
headers: new Headers(),
|
|
548
|
+
parsedBody: { key: "value" },
|
|
549
|
+
method: "POST",
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
expect(() => ctx.formData()).toThrow(
|
|
553
|
+
"Request body is not FormData. Ensure the request was sent with Content-Type: multipart/form-data.",
|
|
554
|
+
);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("formData() should throw when body is undefined", () => {
|
|
558
|
+
const ctx = new RequestInputContext({
|
|
559
|
+
path: "/upload",
|
|
560
|
+
pathParams: {},
|
|
561
|
+
searchParams: new URLSearchParams(),
|
|
562
|
+
headers: new Headers(),
|
|
563
|
+
parsedBody: undefined,
|
|
564
|
+
method: "POST",
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
expect(() => ctx.formData()).toThrow(
|
|
568
|
+
"Request body is not FormData. Ensure the request was sent with Content-Type: multipart/form-data.",
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("isFormData() should return true when body is FormData", () => {
|
|
573
|
+
const formData = new FormData();
|
|
574
|
+
formData.append("key", "value");
|
|
575
|
+
|
|
576
|
+
const ctx = new RequestInputContext({
|
|
577
|
+
path: "/upload",
|
|
578
|
+
pathParams: {},
|
|
579
|
+
searchParams: new URLSearchParams(),
|
|
580
|
+
headers: new Headers(),
|
|
581
|
+
parsedBody: formData,
|
|
582
|
+
method: "POST",
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
expect(ctx.isFormData()).toBe(true);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("isFormData() should return false when body is JSON", () => {
|
|
589
|
+
const ctx = new RequestInputContext({
|
|
590
|
+
path: "/upload",
|
|
591
|
+
pathParams: {},
|
|
592
|
+
searchParams: new URLSearchParams(),
|
|
593
|
+
headers: new Headers(),
|
|
594
|
+
parsedBody: { key: "value" },
|
|
595
|
+
method: "POST",
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
expect(ctx.isFormData()).toBe(false);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("isFormData() should return false when body is undefined", () => {
|
|
602
|
+
const ctx = new RequestInputContext({
|
|
603
|
+
path: "/upload",
|
|
604
|
+
pathParams: {},
|
|
605
|
+
searchParams: new URLSearchParams(),
|
|
606
|
+
headers: new Headers(),
|
|
607
|
+
parsedBody: undefined,
|
|
608
|
+
method: "POST",
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
expect(ctx.isFormData()).toBe(false);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("isFormData() should return false when body is Blob", () => {
|
|
615
|
+
const blob = new Blob(["test content"], { type: "text/plain" });
|
|
616
|
+
|
|
617
|
+
const ctx = new RequestInputContext({
|
|
618
|
+
path: "/upload",
|
|
619
|
+
pathParams: {},
|
|
620
|
+
searchParams: new URLSearchParams(),
|
|
621
|
+
headers: new Headers(),
|
|
622
|
+
parsedBody: blob,
|
|
623
|
+
method: "POST",
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
expect(ctx.isFormData()).toBe(false);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("Can use isFormData() to conditionally access formData()", () => {
|
|
630
|
+
const formData = new FormData();
|
|
631
|
+
formData.append("file", new Blob(["test"]), "test.txt");
|
|
632
|
+
|
|
633
|
+
const ctx = new RequestInputContext({
|
|
634
|
+
path: "/upload",
|
|
635
|
+
pathParams: {},
|
|
636
|
+
searchParams: new URLSearchParams(),
|
|
637
|
+
headers: new Headers(),
|
|
638
|
+
parsedBody: formData,
|
|
639
|
+
method: "POST",
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
if (ctx.isFormData()) {
|
|
643
|
+
const result = ctx.formData();
|
|
644
|
+
expect(result.get("file")).toBeInstanceOf(Blob);
|
|
645
|
+
} else {
|
|
646
|
+
expect.fail("Should have detected FormData");
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
});
|
|
496
650
|
});
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
import { FragnoApiValidationError, type HTTPMethod } from "./api";
|
|
4
|
+
import type { ExtractPathParams } from "./internal/path";
|
|
4
5
|
import type { MutableRequestState } from "./mutable-request-state";
|
|
5
6
|
|
|
6
7
|
export type RequestBodyType =
|
|
7
8
|
| unknown // JSON
|
|
8
9
|
| FormData
|
|
9
10
|
| Blob
|
|
11
|
+
| ReadableStream<Uint8Array>
|
|
10
12
|
| null
|
|
11
13
|
| undefined;
|
|
12
14
|
|
|
@@ -153,6 +155,84 @@ export class RequestInputContext<
|
|
|
153
155
|
return this.#body;
|
|
154
156
|
}
|
|
155
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Access the request body as FormData.
|
|
160
|
+
*
|
|
161
|
+
* Use this method when handling file uploads or multipart form submissions.
|
|
162
|
+
* The request must have been sent with Content-Type: multipart/form-data.
|
|
163
|
+
*
|
|
164
|
+
* @throws Error if the request body is not FormData
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```typescript
|
|
168
|
+
* defineRoute({
|
|
169
|
+
* method: "POST",
|
|
170
|
+
* path: "/upload",
|
|
171
|
+
* async handler(ctx, res) {
|
|
172
|
+
* const formData = ctx.formData();
|
|
173
|
+
* const file = formData.get("file") as File;
|
|
174
|
+
* const description = formData.get("description") as string;
|
|
175
|
+
* // ... process file
|
|
176
|
+
* }
|
|
177
|
+
* });
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
formData(): FormData {
|
|
181
|
+
if (!(this.#parsedBody instanceof FormData)) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
"Request body is not FormData. Ensure the request was sent with Content-Type: multipart/form-data.",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return this.#parsedBody;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Check if the request body is FormData.
|
|
191
|
+
*
|
|
192
|
+
* Useful for routes that accept both JSON and FormData payloads.
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* ```typescript
|
|
196
|
+
* defineRoute({
|
|
197
|
+
* method: "POST",
|
|
198
|
+
* path: "/upload",
|
|
199
|
+
* async handler(ctx, res) {
|
|
200
|
+
* if (ctx.isFormData()) {
|
|
201
|
+
* const formData = ctx.formData();
|
|
202
|
+
* // handle file upload
|
|
203
|
+
* } else {
|
|
204
|
+
* const json = await ctx.input.valid();
|
|
205
|
+
* // handle JSON payload
|
|
206
|
+
* }
|
|
207
|
+
* }
|
|
208
|
+
* });
|
|
209
|
+
* ```
|
|
210
|
+
*/
|
|
211
|
+
isFormData(): boolean {
|
|
212
|
+
return this.#parsedBody instanceof FormData;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Access the request body as a ReadableStream (application/octet-stream).
|
|
217
|
+
*
|
|
218
|
+
* @throws Error if the request body is not a ReadableStream
|
|
219
|
+
*/
|
|
220
|
+
bodyStream(): ReadableStream<Uint8Array> {
|
|
221
|
+
if (!(this.#parsedBody instanceof ReadableStream)) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
"Request body is not a ReadableStream. Ensure the request was sent with Content-Type: application/octet-stream.",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return this.#parsedBody;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Check if the request body is a ReadableStream.
|
|
231
|
+
*/
|
|
232
|
+
isBodyStream(): boolean {
|
|
233
|
+
return this.#parsedBody instanceof ReadableStream;
|
|
234
|
+
}
|
|
235
|
+
|
|
156
236
|
/**
|
|
157
237
|
* Input validation context (only if inputSchema is defined)
|
|
158
238
|
* @remarks `InputContext`
|
|
@@ -197,6 +277,12 @@ export class RequestInputContext<
|
|
|
197
277
|
throw new Error("Schema validation is only supported for JSON data, not FormData or Blob");
|
|
198
278
|
}
|
|
199
279
|
|
|
280
|
+
if (this.#parsedBody instanceof ReadableStream) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
"Schema validation is only supported for JSON data, not FormData, Blob, or ReadableStream",
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
200
286
|
const result = await this.#inputSchema["~standard"].validate(this.#parsedBody);
|
|
201
287
|
|
|
202
288
|
if (result.issues) {
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { test, expect, describe, expectTypeOf } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import { FragnoApiValidationError } from "./error";
|
|
2
6
|
import { defineFragment } from "./fragment-definition-builder";
|
|
3
7
|
import { instantiate } from "./fragment-instantiator";
|
|
4
8
|
import { defineRoute } from "./route";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
import { FragnoApiValidationError } from "./error";
|
|
7
9
|
|
|
8
10
|
describe("Request Middleware", () => {
|
|
9
11
|
test("middleware can intercept and return early", async () => {
|
|
@@ -276,6 +278,45 @@ describe("Request Middleware", () => {
|
|
|
276
278
|
expect(middlewareCalled).toBe(true);
|
|
277
279
|
});
|
|
278
280
|
|
|
281
|
+
test("ifMatchesRoute - supports internal routes", async () => {
|
|
282
|
+
const config = {};
|
|
283
|
+
|
|
284
|
+
const internalRoutes = [
|
|
285
|
+
defineRoute({
|
|
286
|
+
method: "GET",
|
|
287
|
+
path: "/status",
|
|
288
|
+
handler: async (_input, { json }) => {
|
|
289
|
+
return json({ ok: true });
|
|
290
|
+
},
|
|
291
|
+
}),
|
|
292
|
+
] as const;
|
|
293
|
+
|
|
294
|
+
const definition = defineFragment<typeof config>("test-lib")
|
|
295
|
+
.withInternalRoutes(internalRoutes)
|
|
296
|
+
.build();
|
|
297
|
+
|
|
298
|
+
const instance = instantiate(definition)
|
|
299
|
+
.withConfig(config)
|
|
300
|
+
.withOptions({
|
|
301
|
+
mountRoute: "/api",
|
|
302
|
+
})
|
|
303
|
+
.build()
|
|
304
|
+
.withMiddleware(async ({ ifMatchesRoute }) => {
|
|
305
|
+
return await ifMatchesRoute("GET", "/_internal/status", async ({ path }, { json }) => {
|
|
306
|
+
expectTypeOf(path).toEqualTypeOf<"/_internal/status">();
|
|
307
|
+
return json({ ok: false }, 418);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const req = new Request("http://localhost/api/_internal/status", {
|
|
312
|
+
method: "GET",
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const res = await instance.handler(req);
|
|
316
|
+
expect(res.status).toBe(418);
|
|
317
|
+
expect(await res.json()).toEqual({ ok: false });
|
|
318
|
+
});
|
|
319
|
+
|
|
279
320
|
test("only one middleware is supported", async () => {
|
|
280
321
|
const config = {};
|
|
281
322
|
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
|
|
2
3
|
import type { ExtractRouteByPath, ExtractRoutePath } from "../client/client";
|
|
3
4
|
import type { HTTPMethod } from "./api";
|
|
4
5
|
import type { ExtractPathParams } from "./internal/path";
|
|
5
|
-
import
|
|
6
|
+
import { MutableRequestState } from "./mutable-request-state";
|
|
6
7
|
import { RequestInputContext } from "./request-input-context";
|
|
7
8
|
import { OutputContext, RequestOutputContext } from "./request-output-context";
|
|
8
|
-
import {
|
|
9
|
+
import type { AnyFragnoRouteConfig } from "./route";
|
|
9
10
|
|
|
10
11
|
export type FragnoMiddlewareCallback<
|
|
11
12
|
TRoutes extends readonly AnyFragnoRouteConfig[],
|
|
@@ -113,7 +114,7 @@ export class RequestMiddlewareInputContext<const TRoutes extends readonly AnyFra
|
|
|
113
114
|
// Defined as a field so that `this` reference stays in tact when destructuring
|
|
114
115
|
ifMatchesRoute = async <
|
|
115
116
|
const TMethod extends HTTPMethod,
|
|
116
|
-
const TPath extends ExtractRoutePath<TRoutes>,
|
|
117
|
+
const TPath extends ExtractRoutePath<TRoutes, TMethod>,
|
|
117
118
|
const TRoute extends ExtractRouteByPath<TRoutes, TPath, TMethod> = ExtractRouteByPath<
|
|
118
119
|
TRoutes,
|
|
119
120
|
TPath,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { test, expect, describe, vi } from "vitest";
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
4
|
+
|
|
4
5
|
import { ResponseStream } from "./internal/response-stream";
|
|
6
|
+
import { RequestOutputContext } from "./request-output-context";
|
|
5
7
|
|
|
6
8
|
// Mock schema implementations for testing
|
|
7
9
|
const createMockSchema = (shouldPass: boolean, returnValue?: unknown): StandardSchemaV1 => ({
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
|
|
2
3
|
import type { ContentlessStatusCode, StatusCode } from "../http/http-status";
|
|
3
|
-
import { ResponseStream } from "./internal/response-stream";
|
|
4
4
|
import type { InferOrUnknown } from "../util/types-util";
|
|
5
|
+
import { ResponseStream } from "./internal/response-stream";
|
|
5
6
|
|
|
6
7
|
export type ResponseData = string | ArrayBuffer | ReadableStream | Uint8Array<ArrayBuffer>;
|
|
7
8
|
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, expect, expectTypeOf, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import type { FragnoResponse } from "./fragno-response";
|
|
6
|
+
import { defineRoute } from "./route";
|
|
7
|
+
import { createRouteCaller } from "./route-caller";
|
|
8
|
+
|
|
9
|
+
describe("createRouteCaller", () => {
|
|
10
|
+
test("replaces inherited content-type with application/json for object bodies", async () => {
|
|
11
|
+
let capturedRequest: Request | null = null;
|
|
12
|
+
|
|
13
|
+
const callRoute = createRouteCaller<{
|
|
14
|
+
callRoute: (
|
|
15
|
+
method: "POST",
|
|
16
|
+
path: "/test",
|
|
17
|
+
input: { body: { hello: string } },
|
|
18
|
+
) => Promise<unknown>;
|
|
19
|
+
}>({
|
|
20
|
+
baseUrl: "https://example.com/app",
|
|
21
|
+
baseHeaders: {
|
|
22
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
23
|
+
},
|
|
24
|
+
fetch: async (request) => {
|
|
25
|
+
capturedRequest = request;
|
|
26
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
27
|
+
headers: { "content-type": "application/json" },
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await callRoute("POST", "/test", {
|
|
33
|
+
body: { hello: "world" },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!capturedRequest) {
|
|
37
|
+
throw new Error("Expected fetch to receive a request.");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const request = capturedRequest as Request;
|
|
41
|
+
expect(request.headers.get("content-type")).toBe("application/json");
|
|
42
|
+
expect(await request.text()).toBe('{"hello":"world"}');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("keeps an explicit application/octet-stream content-type for binary bodies", async () => {
|
|
46
|
+
let capturedRequest: Request | null = null;
|
|
47
|
+
|
|
48
|
+
const callRoute = createRouteCaller<{
|
|
49
|
+
callRoute: (
|
|
50
|
+
method: "POST",
|
|
51
|
+
path: "/binary",
|
|
52
|
+
input: { body: ArrayBuffer; headers: HeadersInit },
|
|
53
|
+
) => Promise<unknown>;
|
|
54
|
+
}>({
|
|
55
|
+
baseUrl: "https://example.com/app",
|
|
56
|
+
baseHeaders: {
|
|
57
|
+
"content-type": "multipart/form-data; boundary=---original",
|
|
58
|
+
},
|
|
59
|
+
fetch: async (request) => {
|
|
60
|
+
capturedRequest = request;
|
|
61
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
62
|
+
headers: { "content-type": "application/json" },
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await callRoute("POST", "/binary", {
|
|
68
|
+
body: new Uint8Array([1, 2, 3]).buffer,
|
|
69
|
+
headers: {
|
|
70
|
+
"content-type": "application/octet-stream",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
if (!capturedRequest) {
|
|
75
|
+
throw new Error("Expected fetch to receive a request.");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const request = capturedRequest as Request;
|
|
79
|
+
expect(request.headers.get("content-type")).toBe("application/octet-stream");
|
|
80
|
+
expect(new Uint8Array(await request.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3]));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("infers route-specific inputs and outputs from fragment routes", async () => {
|
|
84
|
+
const routes = [
|
|
85
|
+
defineRoute({
|
|
86
|
+
method: "GET",
|
|
87
|
+
path: "/threads",
|
|
88
|
+
outputSchema: z.object({
|
|
89
|
+
threads: z.array(z.object({ id: z.string() })),
|
|
90
|
+
hasNextPage: z.boolean(),
|
|
91
|
+
}),
|
|
92
|
+
handler: async (_ctx, { json }) =>
|
|
93
|
+
json({ threads: [{ id: "thread-1" }], hasNextPage: false }),
|
|
94
|
+
}),
|
|
95
|
+
defineRoute({
|
|
96
|
+
method: "GET",
|
|
97
|
+
path: "/threads/:threadId/messages",
|
|
98
|
+
outputSchema: z.object({
|
|
99
|
+
messages: z.array(z.object({ id: z.string(), threadId: z.string() })),
|
|
100
|
+
cursor: z.string().optional(),
|
|
101
|
+
hasNextPage: z.boolean(),
|
|
102
|
+
}),
|
|
103
|
+
handler: async ({ pathParams, query }, { json }) =>
|
|
104
|
+
json({
|
|
105
|
+
messages: [{ id: "message-1", threadId: pathParams.threadId }],
|
|
106
|
+
cursor: query.get("cursor") ?? undefined,
|
|
107
|
+
hasNextPage: false,
|
|
108
|
+
}),
|
|
109
|
+
}),
|
|
110
|
+
defineRoute({
|
|
111
|
+
method: "POST",
|
|
112
|
+
path: "/threads/:threadId/reply",
|
|
113
|
+
inputSchema: z.object({ text: z.string() }),
|
|
114
|
+
outputSchema: z.object({ ok: z.boolean() }),
|
|
115
|
+
handler: async (_ctx, { json }) => json({ ok: true }),
|
|
116
|
+
}),
|
|
117
|
+
] as const;
|
|
118
|
+
|
|
119
|
+
type FakeFragment = {
|
|
120
|
+
routes: typeof routes;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const callRoute = createRouteCaller<FakeFragment>({
|
|
124
|
+
baseUrl: "https://example.com/app",
|
|
125
|
+
mountRoute: "/api",
|
|
126
|
+
fetch: async (request) => {
|
|
127
|
+
const url = new URL(request.url);
|
|
128
|
+
|
|
129
|
+
if (url.pathname === "/api/threads") {
|
|
130
|
+
return Response.json({
|
|
131
|
+
threads: [{ id: "thread-1" }],
|
|
132
|
+
hasNextPage: false,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (url.pathname === "/api/threads/thread-1/messages") {
|
|
137
|
+
return Response.json({
|
|
138
|
+
messages: [{ id: "message-1", threadId: "thread-1" }],
|
|
139
|
+
cursor: url.searchParams.get("cursor") ?? undefined,
|
|
140
|
+
hasNextPage: false,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (url.pathname === "/api/threads/thread-1/reply") {
|
|
145
|
+
const body = (await request.json()) as { text: string };
|
|
146
|
+
return Response.json({ ok: body.text === "hello" });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return Response.json({ message: "Not found", code: "NOT_FOUND" }, { status: 404 });
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const listResponse = await callRoute("GET", "/threads", {
|
|
154
|
+
query: { cursor: "cursor-1" },
|
|
155
|
+
});
|
|
156
|
+
const messagesResponse = await callRoute("GET", "/threads/:threadId/messages", {
|
|
157
|
+
pathParams: { threadId: "thread-1" },
|
|
158
|
+
query: { cursor: "cursor-1" },
|
|
159
|
+
});
|
|
160
|
+
const replyResponse = await callRoute("POST", "/threads/:threadId/reply", {
|
|
161
|
+
pathParams: { threadId: "thread-1" },
|
|
162
|
+
body: { text: "hello" },
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expectTypeOf<Extract<typeof listResponse, { type: "json" }>["data"]>().toEqualTypeOf<{
|
|
166
|
+
threads: { id: string }[];
|
|
167
|
+
hasNextPage: boolean;
|
|
168
|
+
}>();
|
|
169
|
+
expectTypeOf<Extract<typeof messagesResponse, { type: "json" }>["data"]>().toEqualTypeOf<{
|
|
170
|
+
messages: { id: string; threadId: string }[];
|
|
171
|
+
cursor?: string | undefined;
|
|
172
|
+
hasNextPage: boolean;
|
|
173
|
+
}>();
|
|
174
|
+
expectTypeOf<Extract<typeof replyResponse, { type: "json" }>["data"]>().toEqualTypeOf<{
|
|
175
|
+
ok: boolean;
|
|
176
|
+
}>();
|
|
177
|
+
expectTypeOf<typeof listResponse>().toExtend<FragnoResponse<unknown>>();
|
|
178
|
+
|
|
179
|
+
expect(listResponse.type).toBe("json");
|
|
180
|
+
if (listResponse.type === "json") {
|
|
181
|
+
expect(listResponse.data.threads[0]?.id).toBe("thread-1");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
expect(messagesResponse.type).toBe("json");
|
|
185
|
+
if (messagesResponse.type === "json") {
|
|
186
|
+
expect(messagesResponse.data.messages[0]?.threadId).toBe("thread-1");
|
|
187
|
+
expect(messagesResponse.data.cursor).toBe("cursor-1");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
expect(replyResponse.type).toBe("json");
|
|
191
|
+
if (replyResponse.type === "json") {
|
|
192
|
+
expect(replyResponse.data.ok).toBe(true);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|