@fragno-dev/core 0.1.5 → 0.1.7
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 +49 -45
- package/CHANGELOG.md +52 -0
- package/dist/api/api.d.ts +2 -2
- package/dist/api/fragment-builder.d.ts +3 -2
- package/dist/api/fragment-instantiation.d.ts +4 -3
- package/dist/api/fragment-instantiation.js +3 -3
- package/dist/api/route.d.ts +3 -0
- package/dist/api/route.js +3 -0
- package/dist/{api-B1-h7jPC.d.ts → api-BWN97TOr.d.ts} +17 -3
- package/dist/api-BWN97TOr.d.ts.map +1 -0
- package/dist/api-DngJDcmO.js.map +1 -1
- package/dist/client/client.d.ts +4 -3
- package/dist/client/client.js +3 -3
- package/dist/client/client.svelte.d.ts +3 -3
- package/dist/client/client.svelte.d.ts.map +1 -1
- package/dist/client/client.svelte.js +3 -3
- package/dist/client/react.d.ts +3 -3
- package/dist/client/react.d.ts.map +1 -1
- package/dist/client/react.js +3 -3
- package/dist/client/solid.d.ts +3 -3
- package/dist/client/solid.d.ts.map +1 -1
- package/dist/client/solid.js +3 -3
- package/dist/client/vanilla.d.ts +3 -3
- package/dist/client/vanilla.d.ts.map +1 -1
- package/dist/client/vanilla.js +3 -3
- package/dist/client/vue.d.ts +3 -3
- package/dist/client/vue.d.ts.map +1 -1
- package/dist/client/vue.js +7 -7
- package/dist/client/vue.js.map +1 -1
- package/dist/{client-YUZaNg5U.js → client-C5LsYHEI.js} +92 -11
- package/dist/client-C5LsYHEI.js.map +1 -0
- package/dist/{fragment-builder-DsqUOfJ5.d.ts → fragment-builder-MGr68GNb.d.ts} +80 -44
- package/dist/fragment-builder-MGr68GNb.d.ts.map +1 -0
- package/dist/{fragment-instantiation-Cp0K8zdS.js → fragment-instantiation-C4wvwl6V.js} +108 -3
- package/dist/fragment-instantiation-C4wvwl6V.js.map +1 -0
- package/dist/mod.d.ts +3 -2
- package/dist/mod.js +3 -3
- package/dist/{route-Dk1GyqHs.js → request-output-context-CdIjwmEN.js} +13 -24
- package/dist/request-output-context-CdIjwmEN.js.map +1 -0
- package/dist/route-Bl9Zr1Yv.d.ts +26 -0
- package/dist/route-Bl9Zr1Yv.d.ts.map +1 -0
- package/dist/route-C5Uryylh.js +21 -0
- package/dist/route-C5Uryylh.js.map +1 -0
- package/dist/test/test.d.ts +24 -70
- package/dist/test/test.d.ts.map +1 -1
- package/dist/test/test.js +27 -115
- package/dist/test/test.js.map +1 -1
- package/package.json +6 -1
- package/src/api/api.ts +1 -0
- package/src/api/fragment-instantiation.test.ts +460 -0
- package/src/api/fragment-instantiation.ts +121 -0
- package/src/api/fragno-response.ts +132 -0
- package/src/api/internal/path-type.test.ts +7 -7
- package/src/api/internal/path.ts +1 -1
- package/src/api/request-output-context.test.ts +10 -10
- package/src/api/request-output-context.ts +3 -3
- package/src/api/route-handler-input-options.ts +15 -0
- package/src/client/client-types.test.ts +4 -4
- package/src/client/client.test.ts +341 -0
- package/src/client/client.ts +96 -15
- package/src/client/internal/fetcher-merge.ts +59 -0
- package/src/test/test.test.ts +110 -165
- package/src/test/test.ts +56 -266
- package/tsdown.config.ts +1 -0
- package/dist/api-B1-h7jPC.d.ts.map +0 -1
- package/dist/client-YUZaNg5U.js.map +0 -1
- package/dist/fragment-builder-DsqUOfJ5.d.ts.map +0 -1
- package/dist/fragment-instantiation-Cp0K8zdS.js.map +0 -1
- package/dist/route-CTxjMtGZ.js +0 -10
- package/dist/route-CTxjMtGZ.js.map +0 -1
- package/dist/route-Dk1GyqHs.js.map +0 -1
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discriminated union representing all possible Fragno response types
|
|
3
|
+
*/
|
|
4
|
+
export type FragnoResponse<T> =
|
|
5
|
+
| {
|
|
6
|
+
type: "empty";
|
|
7
|
+
status: number;
|
|
8
|
+
headers: Headers;
|
|
9
|
+
}
|
|
10
|
+
| {
|
|
11
|
+
type: "error";
|
|
12
|
+
status: number;
|
|
13
|
+
headers: Headers;
|
|
14
|
+
error: { message: string; code: string };
|
|
15
|
+
}
|
|
16
|
+
| {
|
|
17
|
+
type: "json";
|
|
18
|
+
status: number;
|
|
19
|
+
headers: Headers;
|
|
20
|
+
data: T;
|
|
21
|
+
}
|
|
22
|
+
| {
|
|
23
|
+
type: "jsonStream";
|
|
24
|
+
status: number;
|
|
25
|
+
headers: Headers;
|
|
26
|
+
stream: AsyncGenerator<T extends unknown[] ? T[number] : T>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a Response object into a FragnoResponse discriminated union
|
|
31
|
+
*/
|
|
32
|
+
export async function parseFragnoResponse<T>(response: Response): Promise<FragnoResponse<T>> {
|
|
33
|
+
const status = response.status;
|
|
34
|
+
const headers = response.headers;
|
|
35
|
+
const contentType = headers.get("content-type") || "";
|
|
36
|
+
|
|
37
|
+
// Check for streaming response
|
|
38
|
+
if (contentType.includes("application/x-ndjson")) {
|
|
39
|
+
return {
|
|
40
|
+
type: "jsonStream",
|
|
41
|
+
status,
|
|
42
|
+
headers,
|
|
43
|
+
stream: parseNDJSONStream<T>(response),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Parse JSON body
|
|
48
|
+
const text = await response.text();
|
|
49
|
+
|
|
50
|
+
// Empty response
|
|
51
|
+
if (!text || text === "null") {
|
|
52
|
+
return {
|
|
53
|
+
type: "empty",
|
|
54
|
+
status,
|
|
55
|
+
headers,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const data = JSON.parse(text);
|
|
60
|
+
|
|
61
|
+
// Error response (has message and code, or error and code)
|
|
62
|
+
if (data && typeof data === "object" && "code" in data) {
|
|
63
|
+
if ("message" in data) {
|
|
64
|
+
return {
|
|
65
|
+
type: "error",
|
|
66
|
+
status,
|
|
67
|
+
headers,
|
|
68
|
+
error: { message: data.message, code: data.code },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if ("error" in data) {
|
|
72
|
+
return {
|
|
73
|
+
type: "error",
|
|
74
|
+
status,
|
|
75
|
+
headers,
|
|
76
|
+
error: { message: data.error, code: data.code },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// JSON response
|
|
82
|
+
return {
|
|
83
|
+
type: "json",
|
|
84
|
+
status,
|
|
85
|
+
headers,
|
|
86
|
+
data: data as T,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse an NDJSON stream into an async generator
|
|
92
|
+
*/
|
|
93
|
+
async function* parseNDJSONStream<T>(
|
|
94
|
+
response: Response,
|
|
95
|
+
): AsyncGenerator<T extends unknown[] ? T[number] : T> {
|
|
96
|
+
if (!response.body) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const reader = response.body.getReader();
|
|
101
|
+
const decoder = new TextDecoder();
|
|
102
|
+
let buffer = "";
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
while (true) {
|
|
106
|
+
const { done, value } = await reader.read();
|
|
107
|
+
|
|
108
|
+
if (done) {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
buffer += decoder.decode(value, { stream: true });
|
|
113
|
+
const lines = buffer.split("\n");
|
|
114
|
+
|
|
115
|
+
// Keep the last incomplete line in the buffer
|
|
116
|
+
buffer = lines.pop() || "";
|
|
117
|
+
|
|
118
|
+
for (const line of lines) {
|
|
119
|
+
if (line.trim()) {
|
|
120
|
+
yield JSON.parse(line) as T extends unknown[] ? T[number] : T;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Process any remaining data in the buffer
|
|
126
|
+
if (buffer.trim()) {
|
|
127
|
+
yield JSON.parse(buffer) as T extends unknown[] ? T[number] : T;
|
|
128
|
+
}
|
|
129
|
+
} finally {
|
|
130
|
+
reader.releaseLock();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -446,39 +446,39 @@ test("MaybeExtractPathParamsOrWiden type tests", () => {
|
|
|
446
446
|
test("QueryParamsHint type tests", () => {
|
|
447
447
|
// Basic usage with string union
|
|
448
448
|
expectTypeOf<QueryParamsHint<"page" | "limit">>().toEqualTypeOf<
|
|
449
|
-
Partial<Record<"page" | "limit", string>> & Record<string, string>
|
|
449
|
+
Partial<Record<"page" | "limit", string>> & Record<string, string | undefined>
|
|
450
450
|
>();
|
|
451
451
|
|
|
452
452
|
// Single parameter hint
|
|
453
453
|
expectTypeOf<QueryParamsHint<"search">>().toEqualTypeOf<
|
|
454
|
-
Partial<Record<"search", string>> & Record<string, string>
|
|
454
|
+
Partial<Record<"search", string>> & Record<string, string | undefined>
|
|
455
455
|
>();
|
|
456
456
|
|
|
457
457
|
// Empty hint (never) - should still allow any string keys
|
|
458
458
|
expectTypeOf<QueryParamsHint<never>>().toEqualTypeOf<
|
|
459
|
-
Partial<Record<never, string>> & Record<string, string>
|
|
459
|
+
Partial<Record<never, string>> & Record<string, string | undefined>
|
|
460
460
|
>();
|
|
461
461
|
|
|
462
462
|
// With custom value type
|
|
463
463
|
expectTypeOf<QueryParamsHint<"page" | "limit", number>>().toEqualTypeOf<
|
|
464
|
-
Partial<Record<"page" | "limit", number>> & Record<string, number>
|
|
464
|
+
Partial<Record<"page" | "limit", number>> & Record<string, number | undefined>
|
|
465
465
|
>();
|
|
466
466
|
|
|
467
467
|
// With boolean value type
|
|
468
468
|
expectTypeOf<QueryParamsHint<"enabled" | "debug", boolean>>().toEqualTypeOf<
|
|
469
|
-
Partial<Record<"enabled" | "debug", boolean>> & Record<string, boolean>
|
|
469
|
+
Partial<Record<"enabled" | "debug", boolean>> & Record<string, boolean | undefined>
|
|
470
470
|
>();
|
|
471
471
|
|
|
472
472
|
// With union value type
|
|
473
473
|
type StringOrNumber = string | number;
|
|
474
474
|
expectTypeOf<QueryParamsHint<"value", StringOrNumber>>().toEqualTypeOf<
|
|
475
|
-
Partial<Record<"value", StringOrNumber>> & Record<string, StringOrNumber>
|
|
475
|
+
Partial<Record<"value", StringOrNumber>> & Record<string, StringOrNumber | undefined>
|
|
476
476
|
>();
|
|
477
477
|
|
|
478
478
|
// With custom object type
|
|
479
479
|
type CustomType = { raw: string; parsed: boolean };
|
|
480
480
|
expectTypeOf<QueryParamsHint<"data", CustomType>>().toEqualTypeOf<
|
|
481
|
-
Partial<Record<"data", CustomType>> & Record<string, CustomType>
|
|
481
|
+
Partial<Record<"data", CustomType>> & Record<string, CustomType | undefined>
|
|
482
482
|
>();
|
|
483
483
|
});
|
|
484
484
|
|
package/src/api/internal/path.ts
CHANGED
|
@@ -124,7 +124,7 @@ export type HasPathParams<T extends string> = ExtractPathParamNames<T> extends n
|
|
|
124
124
|
export type QueryParamsHint<TQueryParameters extends string, ValueType = string> = Partial<
|
|
125
125
|
Record<TQueryParameters, ValueType>
|
|
126
126
|
> &
|
|
127
|
-
Record<string, ValueType>;
|
|
127
|
+
Record<string, ValueType | undefined>;
|
|
128
128
|
|
|
129
129
|
// Runtime utilities
|
|
130
130
|
|
|
@@ -53,8 +53,8 @@ describe("RequestOutputContext", () => {
|
|
|
53
53
|
expect(response).toBeInstanceOf(Response);
|
|
54
54
|
expect(response.status).toBe(201);
|
|
55
55
|
|
|
56
|
-
const body = await response.
|
|
57
|
-
expect(body).toBe(
|
|
56
|
+
const body = await response.text();
|
|
57
|
+
expect(body).toBe("");
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
test("Should return empty response with custom status number", async () => {
|
|
@@ -63,8 +63,8 @@ describe("RequestOutputContext", () => {
|
|
|
63
63
|
|
|
64
64
|
expect(response.status).toBe(204);
|
|
65
65
|
|
|
66
|
-
const body = await response.
|
|
67
|
-
expect(body).toBe(
|
|
66
|
+
const body = await response.text();
|
|
67
|
+
expect(body).toBe("");
|
|
68
68
|
});
|
|
69
69
|
|
|
70
70
|
test("Should return empty response with custom headers via second parameter", async () => {
|
|
@@ -75,8 +75,8 @@ describe("RequestOutputContext", () => {
|
|
|
75
75
|
expect(response.status).toBe(201);
|
|
76
76
|
expect(response.headers.get("X-Custom")).toBe("test-value");
|
|
77
77
|
|
|
78
|
-
const body = await response.
|
|
79
|
-
expect(body).toBe(
|
|
78
|
+
const body = await response.text();
|
|
79
|
+
expect(body).toBe("");
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
test("Should return empty response with status and headers via second parameter", async () => {
|
|
@@ -87,8 +87,8 @@ describe("RequestOutputContext", () => {
|
|
|
87
87
|
expect(response.status).toBe(204);
|
|
88
88
|
expect(response.headers.get("X-Custom")).toBe("test-value");
|
|
89
89
|
|
|
90
|
-
const body = await response.
|
|
91
|
-
expect(body).toBe(
|
|
90
|
+
const body = await response.text();
|
|
91
|
+
expect(body).toBe("");
|
|
92
92
|
});
|
|
93
93
|
|
|
94
94
|
test("Should return empty response with ResponseInit object", async () => {
|
|
@@ -103,8 +103,8 @@ describe("RequestOutputContext", () => {
|
|
|
103
103
|
expect(response.status).toBe(204);
|
|
104
104
|
expect(response.headers.get("X-Custom")).toBe("test-value");
|
|
105
105
|
|
|
106
|
-
const body = await response.
|
|
107
|
-
expect(body).toBe(
|
|
106
|
+
const body = await response.text();
|
|
107
|
+
expect(body).toBe("");
|
|
108
108
|
});
|
|
109
109
|
|
|
110
110
|
test("Should handle multiple headers in ResponseInit", async () => {
|
|
@@ -75,7 +75,7 @@ export abstract class OutputContext<const TOutput, const TErrorCode extends stri
|
|
|
75
75
|
|
|
76
76
|
if (typeof initOrStatus === "undefined") {
|
|
77
77
|
const mergedHeaders = mergeHeaders(defaultHeaders, headers);
|
|
78
|
-
return Response
|
|
78
|
+
return new Response(null, {
|
|
79
79
|
status: 201,
|
|
80
80
|
headers: mergedHeaders,
|
|
81
81
|
});
|
|
@@ -83,14 +83,14 @@ export abstract class OutputContext<const TOutput, const TErrorCode extends stri
|
|
|
83
83
|
|
|
84
84
|
if (typeof initOrStatus === "number") {
|
|
85
85
|
const mergedHeaders = mergeHeaders(defaultHeaders, headers);
|
|
86
|
-
return Response
|
|
86
|
+
return new Response(null, {
|
|
87
87
|
status: initOrStatus,
|
|
88
88
|
headers: mergedHeaders,
|
|
89
89
|
});
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
const mergedHeaders = mergeHeaders(defaultHeaders, initOrStatus.headers, headers);
|
|
93
|
-
return Response
|
|
93
|
+
return new Response(null, {
|
|
94
94
|
status: initOrStatus.status,
|
|
95
95
|
headers: mergedHeaders,
|
|
96
96
|
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
2
|
+
import type { ExtractPathParams } from "./internal/path";
|
|
3
|
+
import type { InferOr } from "../util/types-util";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Options for calling a route handler
|
|
7
|
+
*/
|
|
8
|
+
export type RouteHandlerInputOptions<
|
|
9
|
+
TPath extends string,
|
|
10
|
+
TInputSchema extends StandardSchemaV1 | undefined,
|
|
11
|
+
> = {
|
|
12
|
+
pathParams?: ExtractPathParams<TPath>;
|
|
13
|
+
query?: URLSearchParams | Record<string, string>;
|
|
14
|
+
headers?: Headers | Record<string, string>;
|
|
15
|
+
} & (TInputSchema extends undefined ? { body?: never } : { body: InferOr<TInputSchema, unknown> });
|
|
@@ -417,7 +417,7 @@ describe("FragnoClientMutatorData", () => {
|
|
|
417
417
|
(args?: {
|
|
418
418
|
body?: undefined;
|
|
419
419
|
path?: Record<"id", string> | undefined;
|
|
420
|
-
query?: Record<string, string>;
|
|
420
|
+
query?: Record<string, string | undefined>;
|
|
421
421
|
}) => Promise<undefined>
|
|
422
422
|
>();
|
|
423
423
|
});
|
|
@@ -437,7 +437,7 @@ describe("FragnoClientMutatorData", () => {
|
|
|
437
437
|
(args?: {
|
|
438
438
|
body?: undefined;
|
|
439
439
|
path?: Record<"id", string> | undefined;
|
|
440
|
-
query?: Record<string, string>;
|
|
440
|
+
query?: Record<string, string | undefined>;
|
|
441
441
|
}) => Promise<{
|
|
442
442
|
id: number;
|
|
443
443
|
name: string;
|
|
@@ -465,7 +465,7 @@ describe("FragnoClientMutatorData", () => {
|
|
|
465
465
|
}
|
|
466
466
|
| undefined;
|
|
467
467
|
path?: Record<"id", string> | undefined;
|
|
468
|
-
query?: Record<string, string>;
|
|
468
|
+
query?: Record<string, string | undefined>;
|
|
469
469
|
}) => Promise<undefined>
|
|
470
470
|
>();
|
|
471
471
|
});
|
|
@@ -486,7 +486,7 @@ describe("FragnoClientMutatorData", () => {
|
|
|
486
486
|
(args?: {
|
|
487
487
|
body?: undefined;
|
|
488
488
|
path?: Record<"id", string> | undefined;
|
|
489
|
-
query?: Record<"id" | "name", string>;
|
|
489
|
+
query?: Record<"id" | "name", string | undefined>;
|
|
490
490
|
}) => Promise<undefined>
|
|
491
491
|
>();
|
|
492
492
|
});
|
|
@@ -445,6 +445,83 @@ describe("hook parameter reactivity", () => {
|
|
|
445
445
|
}
|
|
446
446
|
});
|
|
447
447
|
|
|
448
|
+
test("should react to optional query parameters", async () => {
|
|
449
|
+
const testFragment = defineFragment("test-fragment");
|
|
450
|
+
const testRoutes = [
|
|
451
|
+
defineRoute({
|
|
452
|
+
method: "GET",
|
|
453
|
+
path: "/users",
|
|
454
|
+
queryParameters: ["thing"],
|
|
455
|
+
outputSchema: z.array(z.object({ id: z.number(), name: z.string(), suffix: z.string() })),
|
|
456
|
+
handler: async (_ctx, { json }) => json([{ id: 1, name: "John", suffix: "default" }]),
|
|
457
|
+
}),
|
|
458
|
+
] as const;
|
|
459
|
+
|
|
460
|
+
vi.mocked(global.fetch).mockImplementation(async (input) => {
|
|
461
|
+
assert(typeof input === "string");
|
|
462
|
+
|
|
463
|
+
const url = new URL(input);
|
|
464
|
+
const thing = url.searchParams.get("thing");
|
|
465
|
+
|
|
466
|
+
const suffix = thing ? `with-${thing}` : "without-thing";
|
|
467
|
+
|
|
468
|
+
return {
|
|
469
|
+
headers: new Headers(),
|
|
470
|
+
ok: true,
|
|
471
|
+
json: async () => [{ id: 1, name: "John", suffix }],
|
|
472
|
+
} as Response;
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const cb = createClientBuilder(testFragment, clientConfig, testRoutes);
|
|
476
|
+
const useUsers = cb.createHook("/users");
|
|
477
|
+
|
|
478
|
+
// This atom with string | undefined type should be accepted by the query parameter
|
|
479
|
+
const thingAtom = atom<string | undefined>("initial");
|
|
480
|
+
|
|
481
|
+
const store = useUsers.store({ query: { thing: thingAtom } });
|
|
482
|
+
|
|
483
|
+
const itt = createAsyncIteratorFromCallback(store.listen);
|
|
484
|
+
|
|
485
|
+
{
|
|
486
|
+
const { value } = await itt.next();
|
|
487
|
+
expect(value).toEqual({
|
|
488
|
+
loading: true,
|
|
489
|
+
promise: expect.any(Promise),
|
|
490
|
+
data: undefined,
|
|
491
|
+
error: undefined,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
{
|
|
496
|
+
const { value } = await itt.next();
|
|
497
|
+
expect(value).toEqual({
|
|
498
|
+
loading: false,
|
|
499
|
+
data: [{ id: 1, name: "John", suffix: "with-initial" }],
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Change thing to another value
|
|
504
|
+
thingAtom.set(undefined);
|
|
505
|
+
|
|
506
|
+
{
|
|
507
|
+
const { value } = await itt.next();
|
|
508
|
+
expect(value).toEqual({
|
|
509
|
+
loading: true,
|
|
510
|
+
promise: expect.any(Promise),
|
|
511
|
+
data: undefined,
|
|
512
|
+
error: undefined,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
{
|
|
517
|
+
const { value } = await itt.next();
|
|
518
|
+
expect(value).toEqual({
|
|
519
|
+
loading: false,
|
|
520
|
+
data: [{ id: 1, name: "John", suffix: "without-thing" }],
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
448
525
|
test("should react to combined path and query parameters", async () => {
|
|
449
526
|
const testFragment = defineFragment("test-fragment");
|
|
450
527
|
const testRoutes = [
|
|
@@ -1688,3 +1765,267 @@ describe("type guards", () => {
|
|
|
1688
1765
|
expect(isGetHook(Symbol("fragno-get-hook"))).toBe(false);
|
|
1689
1766
|
});
|
|
1690
1767
|
});
|
|
1768
|
+
|
|
1769
|
+
describe("Custom Fetcher Configuration", () => {
|
|
1770
|
+
const testFragment = defineFragment("test-fragment");
|
|
1771
|
+
const testRoutes = [
|
|
1772
|
+
defineRoute({
|
|
1773
|
+
method: "GET",
|
|
1774
|
+
path: "/users",
|
|
1775
|
+
outputSchema: z.array(z.object({ id: z.number(), name: z.string() })),
|
|
1776
|
+
handler: async (_ctx, { json }) => json([{ id: 1, name: "John" }]),
|
|
1777
|
+
}),
|
|
1778
|
+
defineRoute({
|
|
1779
|
+
method: "POST",
|
|
1780
|
+
path: "/users",
|
|
1781
|
+
inputSchema: z.object({ name: z.string() }),
|
|
1782
|
+
outputSchema: z.object({ id: z.number(), name: z.string() }),
|
|
1783
|
+
handler: async (_ctx, { json }) => json({ id: 2, name: "Jane" }),
|
|
1784
|
+
}),
|
|
1785
|
+
] as const;
|
|
1786
|
+
|
|
1787
|
+
const clientConfig: FragnoPublicClientConfig = {
|
|
1788
|
+
baseUrl: "http://localhost:3000",
|
|
1789
|
+
};
|
|
1790
|
+
|
|
1791
|
+
beforeEach(() => {
|
|
1792
|
+
vi.clearAllMocks();
|
|
1793
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockReset();
|
|
1794
|
+
});
|
|
1795
|
+
|
|
1796
|
+
afterEach(() => {
|
|
1797
|
+
vi.restoreAllMocks();
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
test("fragment author sets RequestInit options (credentials)", async () => {
|
|
1801
|
+
let capturedOptions: RequestInit | undefined;
|
|
1802
|
+
|
|
1803
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
|
|
1804
|
+
capturedOptions = options;
|
|
1805
|
+
return {
|
|
1806
|
+
headers: new Headers(),
|
|
1807
|
+
ok: true,
|
|
1808
|
+
json: async () => [{ id: 1, name: "John" }],
|
|
1809
|
+
} as Response;
|
|
1810
|
+
});
|
|
1811
|
+
|
|
1812
|
+
const client = createClientBuilder(testFragment, clientConfig, testRoutes, {
|
|
1813
|
+
type: "options",
|
|
1814
|
+
options: { credentials: "include" },
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
const useUsers = client.createHook("/users");
|
|
1818
|
+
await useUsers.query();
|
|
1819
|
+
|
|
1820
|
+
expect(capturedOptions).toBeDefined();
|
|
1821
|
+
expect(capturedOptions?.credentials).toBe("include");
|
|
1822
|
+
});
|
|
1823
|
+
|
|
1824
|
+
test("user overrides with their own RequestInit (deep merge)", async () => {
|
|
1825
|
+
let capturedOptions: RequestInit | undefined;
|
|
1826
|
+
|
|
1827
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
|
|
1828
|
+
capturedOptions = options;
|
|
1829
|
+
return {
|
|
1830
|
+
headers: new Headers(),
|
|
1831
|
+
ok: true,
|
|
1832
|
+
json: async () => [{ id: 1, name: "John" }],
|
|
1833
|
+
} as Response;
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
const authorConfig = { type: "options", options: { credentials: "include" } } as const;
|
|
1837
|
+
const userConfig = {
|
|
1838
|
+
...clientConfig,
|
|
1839
|
+
fetcherConfig: { type: "options", options: { mode: "cors" } } as const,
|
|
1840
|
+
};
|
|
1841
|
+
|
|
1842
|
+
const client = createClientBuilder(testFragment, userConfig, testRoutes, authorConfig);
|
|
1843
|
+
|
|
1844
|
+
const useUsers = client.createHook("/users");
|
|
1845
|
+
await useUsers.query();
|
|
1846
|
+
|
|
1847
|
+
expect(capturedOptions).toBeDefined();
|
|
1848
|
+
expect(capturedOptions?.credentials).toBe("include");
|
|
1849
|
+
expect(capturedOptions?.mode).toBe("cors");
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
test("user provides custom fetch function (takes full precedence)", async () => {
|
|
1853
|
+
const customFetch = vi.fn(async () => ({
|
|
1854
|
+
headers: new Headers(),
|
|
1855
|
+
ok: true,
|
|
1856
|
+
json: async () => [{ id: 999, name: "Custom" }],
|
|
1857
|
+
})) as unknown as typeof fetch;
|
|
1858
|
+
|
|
1859
|
+
const authorConfig = { type: "options", options: { credentials: "include" } } as const;
|
|
1860
|
+
const userConfig = {
|
|
1861
|
+
...clientConfig,
|
|
1862
|
+
fetcherConfig: { type: "function", fetcher: customFetch } as const,
|
|
1863
|
+
};
|
|
1864
|
+
|
|
1865
|
+
const client = createClientBuilder(testFragment, userConfig, testRoutes, authorConfig);
|
|
1866
|
+
|
|
1867
|
+
const useUsers = client.createHook("/users");
|
|
1868
|
+
const result = await useUsers.query();
|
|
1869
|
+
|
|
1870
|
+
expect(customFetch).toHaveBeenCalled();
|
|
1871
|
+
expect(result).toEqual([{ id: 999, name: "Custom" }]);
|
|
1872
|
+
});
|
|
1873
|
+
|
|
1874
|
+
test("author provides custom fetch, user provides RequestInit (user RequestInit used)", async () => {
|
|
1875
|
+
const authorFetch = vi.fn(async () => ({
|
|
1876
|
+
headers: new Headers(),
|
|
1877
|
+
ok: true,
|
|
1878
|
+
json: async () => [{ id: 777, name: "Author" }],
|
|
1879
|
+
})) as unknown as typeof fetch;
|
|
1880
|
+
|
|
1881
|
+
let capturedOptions: RequestInit | undefined;
|
|
1882
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
|
|
1883
|
+
capturedOptions = options;
|
|
1884
|
+
return {
|
|
1885
|
+
headers: new Headers(),
|
|
1886
|
+
ok: true,
|
|
1887
|
+
json: async () => [{ id: 1, name: "John" }],
|
|
1888
|
+
} as Response;
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
const authorConfig = { type: "function", fetcher: authorFetch } as const;
|
|
1892
|
+
const userConfig = {
|
|
1893
|
+
...clientConfig,
|
|
1894
|
+
fetcherConfig: { type: "options", options: { credentials: "include" } } as const,
|
|
1895
|
+
};
|
|
1896
|
+
|
|
1897
|
+
const client = createClientBuilder(testFragment, userConfig, testRoutes, authorConfig);
|
|
1898
|
+
|
|
1899
|
+
const useUsers = client.createHook("/users");
|
|
1900
|
+
await useUsers.query();
|
|
1901
|
+
|
|
1902
|
+
// User's RequestInit takes precedence, so global fetch should be used
|
|
1903
|
+
expect(authorFetch).not.toHaveBeenCalled();
|
|
1904
|
+
expect(global.fetch).toHaveBeenCalled();
|
|
1905
|
+
expect(capturedOptions?.credentials).toBe("include");
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
test("headers merge correctly (user headers override author headers)", async () => {
|
|
1909
|
+
let capturedOptions: RequestInit | undefined;
|
|
1910
|
+
|
|
1911
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
|
|
1912
|
+
capturedOptions = options;
|
|
1913
|
+
return {
|
|
1914
|
+
headers: new Headers(),
|
|
1915
|
+
ok: true,
|
|
1916
|
+
json: async () => [{ id: 1, name: "John" }],
|
|
1917
|
+
} as Response;
|
|
1918
|
+
});
|
|
1919
|
+
|
|
1920
|
+
const authorConfig = {
|
|
1921
|
+
type: "options",
|
|
1922
|
+
options: {
|
|
1923
|
+
headers: {
|
|
1924
|
+
"X-Author-Header": "author-value",
|
|
1925
|
+
"X-Shared-Header": "author-shared",
|
|
1926
|
+
},
|
|
1927
|
+
},
|
|
1928
|
+
} as const;
|
|
1929
|
+
|
|
1930
|
+
const userConfig = {
|
|
1931
|
+
...clientConfig,
|
|
1932
|
+
fetcherConfig: {
|
|
1933
|
+
type: "options",
|
|
1934
|
+
options: {
|
|
1935
|
+
headers: {
|
|
1936
|
+
"X-User-Header": "user-value",
|
|
1937
|
+
"X-Shared-Header": "user-shared",
|
|
1938
|
+
},
|
|
1939
|
+
},
|
|
1940
|
+
} as const,
|
|
1941
|
+
};
|
|
1942
|
+
|
|
1943
|
+
const client = createClientBuilder(testFragment, userConfig, testRoutes, authorConfig);
|
|
1944
|
+
|
|
1945
|
+
const useUsers = client.createHook("/users");
|
|
1946
|
+
await useUsers.query();
|
|
1947
|
+
|
|
1948
|
+
expect(capturedOptions).toBeDefined();
|
|
1949
|
+
const headers = new Headers(capturedOptions?.headers);
|
|
1950
|
+
expect(headers.get("X-Author-Header")).toBe("author-value");
|
|
1951
|
+
expect(headers.get("X-User-Header")).toBe("user-value");
|
|
1952
|
+
expect(headers.get("X-Shared-Header")).toBe("user-shared"); // User overrides
|
|
1953
|
+
});
|
|
1954
|
+
|
|
1955
|
+
test("custom fetcher works with mutators", async () => {
|
|
1956
|
+
let capturedOptions: RequestInit | undefined;
|
|
1957
|
+
|
|
1958
|
+
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (_url, options) => {
|
|
1959
|
+
capturedOptions = options;
|
|
1960
|
+
return {
|
|
1961
|
+
headers: new Headers(),
|
|
1962
|
+
ok: true,
|
|
1963
|
+
status: 200,
|
|
1964
|
+
json: async () => ({ id: 2, name: "Jane" }),
|
|
1965
|
+
} as Response;
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
const client = createClientBuilder(testFragment, clientConfig, testRoutes, {
|
|
1969
|
+
type: "options",
|
|
1970
|
+
options: { credentials: "include" },
|
|
1971
|
+
});
|
|
1972
|
+
|
|
1973
|
+
const mutator = client.createMutator("POST", "/users");
|
|
1974
|
+
await mutator.mutateQuery({ body: { name: "Jane" } });
|
|
1975
|
+
|
|
1976
|
+
expect(capturedOptions).toBeDefined();
|
|
1977
|
+
expect(capturedOptions?.credentials).toBe("include");
|
|
1978
|
+
expect(capturedOptions?.method).toBe("POST");
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
test("buildUrl method works correctly", () => {
|
|
1982
|
+
const client = createClientBuilder(testFragment, clientConfig, testRoutes);
|
|
1983
|
+
|
|
1984
|
+
const url1 = client.buildUrl("/users");
|
|
1985
|
+
expect(url1).toBe("http://localhost:3000/api/test-fragment/users");
|
|
1986
|
+
|
|
1987
|
+
const url2 = client.buildUrl("/users/:id", { path: { id: "123" } });
|
|
1988
|
+
expect(url2).toBe("http://localhost:3000/api/test-fragment/users/123");
|
|
1989
|
+
|
|
1990
|
+
const url3 = client.buildUrl("/users", { query: { sort: "name", order: "asc" } });
|
|
1991
|
+
expect(url3).toBe("http://localhost:3000/api/test-fragment/users?sort=name&order=asc");
|
|
1992
|
+
|
|
1993
|
+
const url4 = client.buildUrl("/users/:id", {
|
|
1994
|
+
path: { id: "456" },
|
|
1995
|
+
query: { include: "posts" },
|
|
1996
|
+
});
|
|
1997
|
+
expect(url4).toBe("http://localhost:3000/api/test-fragment/users/456?include=posts");
|
|
1998
|
+
});
|
|
1999
|
+
|
|
2000
|
+
test("getFetcher returns correct fetcher and options", () => {
|
|
2001
|
+
const customFetch = vi.fn() as unknown as typeof fetch;
|
|
2002
|
+
const client = createClientBuilder(
|
|
2003
|
+
testFragment,
|
|
2004
|
+
{
|
|
2005
|
+
...clientConfig,
|
|
2006
|
+
fetcherConfig: { type: "function", fetcher: customFetch },
|
|
2007
|
+
},
|
|
2008
|
+
testRoutes,
|
|
2009
|
+
);
|
|
2010
|
+
|
|
2011
|
+
const { fetcher, defaultOptions } = client.getFetcher();
|
|
2012
|
+
expect(fetcher).toBe(customFetch);
|
|
2013
|
+
expect(defaultOptions).toBeUndefined();
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
test("getFetcher returns default fetch and options", () => {
|
|
2017
|
+
const client = createClientBuilder(
|
|
2018
|
+
testFragment,
|
|
2019
|
+
{
|
|
2020
|
+
...clientConfig,
|
|
2021
|
+
fetcherConfig: { type: "options", options: { credentials: "include" } },
|
|
2022
|
+
},
|
|
2023
|
+
testRoutes,
|
|
2024
|
+
);
|
|
2025
|
+
|
|
2026
|
+
const { fetcher, defaultOptions } = client.getFetcher();
|
|
2027
|
+
expect(fetcher).toBe(fetch);
|
|
2028
|
+
expect(defaultOptions).toBeDefined();
|
|
2029
|
+
expect(defaultOptions?.credentials).toBe("include");
|
|
2030
|
+
});
|
|
2031
|
+
});
|