@mandujs/core 0.9.2 → 0.9.3
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/package.json +1 -1
- package/src/client/index.ts +2 -1
- package/src/contract/client.test.ts +308 -0
- package/src/contract/client.ts +345 -0
- package/src/contract/handler.ts +270 -0
- package/src/contract/index.ts +137 -1
- package/src/contract/infer.test.ts +346 -0
- package/src/contract/types.ts +83 -0
- package/src/filling/filling.ts +5 -1
- package/src/filling/index.ts +1 -1
- package/src/index.ts +75 -0
package/package.json
CHANGED
package/src/client/index.ts
CHANGED
|
@@ -103,8 +103,9 @@ import { Link, NavLink } from "./Link";
|
|
|
103
103
|
/**
|
|
104
104
|
* Mandu Client namespace
|
|
105
105
|
* v0.8.0: Hydration은 자동으로 처리됨 (generateRuntimeSource에서 생성)
|
|
106
|
+
* Note: Use `ManduClient` to avoid conflict with other Mandu exports
|
|
106
107
|
*/
|
|
107
|
-
export const
|
|
108
|
+
export const ManduClient = {
|
|
108
109
|
/**
|
|
109
110
|
* Create an island component
|
|
110
111
|
* @see island
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Contract Client Tests
|
|
3
|
+
*
|
|
4
|
+
* 클라이언트 타입 추론 및 기능 테스트
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, mock } from "bun:test";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { Mandu, createClient, contractFetch } from "./index";
|
|
10
|
+
|
|
11
|
+
// === Test Contract ===
|
|
12
|
+
const testContract = Mandu.contract({
|
|
13
|
+
description: "Test API",
|
|
14
|
+
tags: ["test"],
|
|
15
|
+
request: {
|
|
16
|
+
GET: {
|
|
17
|
+
query: z.object({
|
|
18
|
+
page: z.coerce.number().default(1),
|
|
19
|
+
search: z.string().optional(),
|
|
20
|
+
}),
|
|
21
|
+
},
|
|
22
|
+
POST: {
|
|
23
|
+
body: z.object({
|
|
24
|
+
name: z.string(),
|
|
25
|
+
email: z.string().email(),
|
|
26
|
+
}),
|
|
27
|
+
},
|
|
28
|
+
PUT: {
|
|
29
|
+
params: z.object({
|
|
30
|
+
id: z.string(),
|
|
31
|
+
}),
|
|
32
|
+
body: z.object({
|
|
33
|
+
name: z.string().optional(),
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
DELETE: {
|
|
37
|
+
params: z.object({
|
|
38
|
+
id: z.string(),
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
response: {
|
|
43
|
+
200: z.object({
|
|
44
|
+
data: z.array(
|
|
45
|
+
z.object({
|
|
46
|
+
id: z.string(),
|
|
47
|
+
name: z.string(),
|
|
48
|
+
})
|
|
49
|
+
),
|
|
50
|
+
total: z.number(),
|
|
51
|
+
}),
|
|
52
|
+
201: z.object({
|
|
53
|
+
data: z.object({
|
|
54
|
+
id: z.string(),
|
|
55
|
+
name: z.string(),
|
|
56
|
+
email: z.string(),
|
|
57
|
+
}),
|
|
58
|
+
}),
|
|
59
|
+
204: z.undefined(),
|
|
60
|
+
404: z.object({
|
|
61
|
+
error: z.string(),
|
|
62
|
+
}),
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("Contract Client", () => {
|
|
67
|
+
it("should create a client with all HTTP methods", () => {
|
|
68
|
+
const client = createClient(testContract, {
|
|
69
|
+
baseUrl: "http://localhost:3000/api/test",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(client.GET).toBeDefined();
|
|
73
|
+
expect(client.POST).toBeDefined();
|
|
74
|
+
expect(client.PUT).toBeDefined();
|
|
75
|
+
expect(client.DELETE).toBeDefined();
|
|
76
|
+
expect(typeof client.GET).toBe("function");
|
|
77
|
+
expect(typeof client.POST).toBe("function");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should build query string correctly", async () => {
|
|
81
|
+
let capturedUrl = "";
|
|
82
|
+
|
|
83
|
+
const mockFetch = mock(async (url: string, _options: RequestInit) => {
|
|
84
|
+
capturedUrl = url;
|
|
85
|
+
return new Response(JSON.stringify({ data: [], total: 0 }), {
|
|
86
|
+
status: 200,
|
|
87
|
+
headers: { "Content-Type": "application/json" },
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const client = createClient(testContract, {
|
|
92
|
+
baseUrl: "http://localhost:3000/api/test",
|
|
93
|
+
fetch: mockFetch as unknown as typeof fetch,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await client.GET({ query: { page: 2, search: "hello" } });
|
|
97
|
+
|
|
98
|
+
expect(capturedUrl).toContain("page=2");
|
|
99
|
+
expect(capturedUrl).toContain("search=hello");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should send JSON body for POST requests", async () => {
|
|
103
|
+
let capturedBody = "";
|
|
104
|
+
let capturedContentType = "";
|
|
105
|
+
|
|
106
|
+
const mockFetch = mock(async (_url: string, options: RequestInit) => {
|
|
107
|
+
capturedBody = options.body as string;
|
|
108
|
+
capturedContentType =
|
|
109
|
+
(options.headers as Record<string, string>)["Content-Type"] || "";
|
|
110
|
+
return new Response(
|
|
111
|
+
JSON.stringify({
|
|
112
|
+
data: { id: "1", name: "Test", email: "test@example.com" },
|
|
113
|
+
}),
|
|
114
|
+
{
|
|
115
|
+
status: 201,
|
|
116
|
+
headers: { "Content-Type": "application/json" },
|
|
117
|
+
}
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const client = createClient(testContract, {
|
|
122
|
+
baseUrl: "http://localhost:3000/api/test",
|
|
123
|
+
fetch: mockFetch as unknown as typeof fetch,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await client.POST({ body: { name: "Test", email: "test@example.com" } });
|
|
127
|
+
|
|
128
|
+
expect(capturedContentType).toBe("application/json");
|
|
129
|
+
expect(JSON.parse(capturedBody)).toEqual({
|
|
130
|
+
name: "Test",
|
|
131
|
+
email: "test@example.com",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should parse JSON response", async () => {
|
|
136
|
+
const mockData = {
|
|
137
|
+
data: [
|
|
138
|
+
{ id: "1", name: "User 1" },
|
|
139
|
+
{ id: "2", name: "User 2" },
|
|
140
|
+
],
|
|
141
|
+
total: 2,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const mockFetch = mock(async () => {
|
|
145
|
+
return new Response(JSON.stringify(mockData), {
|
|
146
|
+
status: 200,
|
|
147
|
+
headers: { "Content-Type": "application/json" },
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const client = createClient(testContract, {
|
|
152
|
+
baseUrl: "http://localhost:3000/api/test",
|
|
153
|
+
fetch: mockFetch as unknown as typeof fetch,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const result = await client.GET({ query: { page: 1 } });
|
|
157
|
+
|
|
158
|
+
expect(result.status).toBe(200);
|
|
159
|
+
expect(result.ok).toBe(true);
|
|
160
|
+
expect(result.data).toEqual(mockData);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("should include default headers", async () => {
|
|
164
|
+
let capturedHeaders: Record<string, string> = {};
|
|
165
|
+
|
|
166
|
+
const mockFetch = mock(async (_url: string, options: RequestInit) => {
|
|
167
|
+
capturedHeaders = options.headers as Record<string, string>;
|
|
168
|
+
return new Response(JSON.stringify({ data: [], total: 0 }), {
|
|
169
|
+
status: 200,
|
|
170
|
+
headers: { "Content-Type": "application/json" },
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const client = createClient(testContract, {
|
|
175
|
+
baseUrl: "http://localhost:3000/api/test",
|
|
176
|
+
headers: {
|
|
177
|
+
Authorization: "Bearer token123",
|
|
178
|
+
"X-Custom-Header": "custom-value",
|
|
179
|
+
},
|
|
180
|
+
fetch: mockFetch as unknown as typeof fetch,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
await client.GET();
|
|
184
|
+
|
|
185
|
+
expect(capturedHeaders["Authorization"]).toBe("Bearer token123");
|
|
186
|
+
expect(capturedHeaders["X-Custom-Header"]).toBe("custom-value");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("should allow per-request headers", async () => {
|
|
190
|
+
let capturedHeaders: Record<string, string> = {};
|
|
191
|
+
|
|
192
|
+
const mockFetch = mock(async (_url: string, options: RequestInit) => {
|
|
193
|
+
capturedHeaders = options.headers as Record<string, string>;
|
|
194
|
+
return new Response(JSON.stringify({ data: [], total: 0 }), {
|
|
195
|
+
status: 200,
|
|
196
|
+
headers: { "Content-Type": "application/json" },
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const client = createClient(testContract, {
|
|
201
|
+
baseUrl: "http://localhost:3000/api/test",
|
|
202
|
+
headers: { "X-Default": "default" },
|
|
203
|
+
fetch: mockFetch as unknown as typeof fetch,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await client.GET({
|
|
207
|
+
headers: { "X-Custom": "per-request" },
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(capturedHeaders["X-Default"]).toBe("default");
|
|
211
|
+
expect(capturedHeaders["X-Custom"]).toBe("per-request");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("contractFetch", () => {
|
|
216
|
+
it("should make type-safe fetch call", async () => {
|
|
217
|
+
const mockFetch = mock(async () => {
|
|
218
|
+
return new Response(JSON.stringify({ data: [], total: 0 }), {
|
|
219
|
+
status: 200,
|
|
220
|
+
headers: { "Content-Type": "application/json" },
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const result = await contractFetch(
|
|
225
|
+
testContract,
|
|
226
|
+
"GET",
|
|
227
|
+
"http://localhost:3000/api/test",
|
|
228
|
+
{ query: { page: 1 } },
|
|
229
|
+
{ fetch: mockFetch as unknown as typeof fetch }
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(result.status).toBe(200);
|
|
233
|
+
expect(result.ok).toBe(true);
|
|
234
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("should handle path parameters", async () => {
|
|
238
|
+
let capturedUrl = "";
|
|
239
|
+
|
|
240
|
+
const mockFetch = mock(async (url: string) => {
|
|
241
|
+
capturedUrl = url;
|
|
242
|
+
return new Response(JSON.stringify({ data: [], total: 0 }), {
|
|
243
|
+
status: 200,
|
|
244
|
+
headers: { "Content-Type": "application/json" },
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await contractFetch(
|
|
249
|
+
testContract,
|
|
250
|
+
"PUT",
|
|
251
|
+
"http://localhost:3000/api/test/:id",
|
|
252
|
+
{ params: { id: "123" }, body: { name: "Updated" } },
|
|
253
|
+
{ fetch: mockFetch as unknown as typeof fetch }
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(capturedUrl).toBe("http://localhost:3000/api/test/123");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("Mandu.client", () => {
|
|
261
|
+
it("should be accessible via Mandu namespace", () => {
|
|
262
|
+
expect(Mandu.client).toBe(createClient);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("should work via Mandu namespace", async () => {
|
|
266
|
+
const mockFetch = mock(async () => {
|
|
267
|
+
return new Response(JSON.stringify({ data: [], total: 0 }), {
|
|
268
|
+
status: 200,
|
|
269
|
+
headers: { "Content-Type": "application/json" },
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const client = Mandu.client(testContract, {
|
|
274
|
+
baseUrl: "http://localhost:3000/api/test",
|
|
275
|
+
fetch: mockFetch as unknown as typeof fetch,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const result = await client.GET({ query: { page: 1 } });
|
|
279
|
+
|
|
280
|
+
expect(result.status).toBe(200);
|
|
281
|
+
expect(result.data).toEqual({ data: [], total: 0 });
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("Mandu.fetch", () => {
|
|
286
|
+
it("should be accessible via Mandu namespace", () => {
|
|
287
|
+
expect(Mandu.fetch).toBe(contractFetch);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("Type Safety (Compile-time)", () => {
|
|
292
|
+
it("should enforce query types", () => {
|
|
293
|
+
// This test verifies that the type system is working
|
|
294
|
+
// If the types are wrong, this won't compile
|
|
295
|
+
const client = createClient(testContract, {
|
|
296
|
+
baseUrl: "http://localhost:3000/api/test",
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// These are valid calls (would compile)
|
|
300
|
+
const _validGet = () => client.GET({ query: { page: 1 } });
|
|
301
|
+
const _validPost = () =>
|
|
302
|
+
client.POST({ body: { name: "Test", email: "test@example.com" } });
|
|
303
|
+
|
|
304
|
+
// Type-level assertions
|
|
305
|
+
expect(typeof client.GET).toBe("function");
|
|
306
|
+
expect(typeof client.POST).toBe("function");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Contract Client
|
|
3
|
+
* Contract 기반 타입 안전 클라이언트
|
|
4
|
+
*
|
|
5
|
+
* tRPC/Elysia Eden 패턴 채택:
|
|
6
|
+
* - Contract에서 클라이언트 타입 자동 추론
|
|
7
|
+
* - 타입 안전 fetch 호출
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { z } from "zod";
|
|
11
|
+
import type {
|
|
12
|
+
ContractSchema,
|
|
13
|
+
ContractMethod,
|
|
14
|
+
MethodRequestSchema,
|
|
15
|
+
} from "./schema";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Client options for making requests
|
|
19
|
+
*/
|
|
20
|
+
export interface ClientOptions {
|
|
21
|
+
/** Base URL for API requests */
|
|
22
|
+
baseUrl: string;
|
|
23
|
+
/** Default headers for all requests */
|
|
24
|
+
headers?: Record<string, string>;
|
|
25
|
+
/** Custom fetch function (for SSR or testing) */
|
|
26
|
+
fetch?: typeof fetch;
|
|
27
|
+
/** Request timeout in milliseconds */
|
|
28
|
+
timeout?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Request options for a specific call
|
|
33
|
+
*/
|
|
34
|
+
export interface RequestOptions<
|
|
35
|
+
TQuery = unknown,
|
|
36
|
+
TBody = unknown,
|
|
37
|
+
TParams = unknown,
|
|
38
|
+
THeaders = Record<string, string>,
|
|
39
|
+
> {
|
|
40
|
+
query?: TQuery;
|
|
41
|
+
body?: TBody;
|
|
42
|
+
params?: TParams;
|
|
43
|
+
headers?: THeaders;
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Client response wrapper
|
|
49
|
+
*/
|
|
50
|
+
export interface ClientResponse<T> {
|
|
51
|
+
data: T;
|
|
52
|
+
status: number;
|
|
53
|
+
headers: Headers;
|
|
54
|
+
ok: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Infer request options from method schema
|
|
59
|
+
*/
|
|
60
|
+
type InferRequestOptions<T extends MethodRequestSchema | undefined> =
|
|
61
|
+
T extends MethodRequestSchema
|
|
62
|
+
? RequestOptions<
|
|
63
|
+
T["query"] extends z.ZodTypeAny ? z.input<T["query"]> : undefined,
|
|
64
|
+
T["body"] extends z.ZodTypeAny ? z.input<T["body"]> : undefined,
|
|
65
|
+
T["params"] extends z.ZodTypeAny ? z.input<T["params"]> : undefined,
|
|
66
|
+
T["headers"] extends z.ZodTypeAny
|
|
67
|
+
? z.input<T["headers"]>
|
|
68
|
+
: Record<string, string>
|
|
69
|
+
>
|
|
70
|
+
: RequestOptions<undefined, undefined, undefined, Record<string, string>>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Infer success response from contract
|
|
74
|
+
*/
|
|
75
|
+
type InferSuccessResponse<TResponse extends ContractSchema["response"]> =
|
|
76
|
+
TResponse[200] extends z.ZodTypeAny
|
|
77
|
+
? z.infer<TResponse[200]>
|
|
78
|
+
: TResponse[201] extends z.ZodTypeAny
|
|
79
|
+
? z.infer<TResponse[201]>
|
|
80
|
+
: unknown;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Contract client method
|
|
84
|
+
*/
|
|
85
|
+
export type ContractClientMethod<
|
|
86
|
+
T extends MethodRequestSchema | undefined,
|
|
87
|
+
TResponse extends ContractSchema["response"],
|
|
88
|
+
> = (
|
|
89
|
+
options?: InferRequestOptions<T>
|
|
90
|
+
) => Promise<ClientResponse<InferSuccessResponse<TResponse>>>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Contract client interface
|
|
94
|
+
*/
|
|
95
|
+
export type ContractClient<T extends ContractSchema> = {
|
|
96
|
+
[M in Extract<keyof T["request"], ContractMethod>]: ContractClientMethod<
|
|
97
|
+
T["request"][M] extends MethodRequestSchema ? T["request"][M] : undefined,
|
|
98
|
+
T["response"]
|
|
99
|
+
>;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build query string from object
|
|
104
|
+
*/
|
|
105
|
+
function buildQueryString(query: Record<string, unknown> | undefined): string {
|
|
106
|
+
if (!query) return "";
|
|
107
|
+
|
|
108
|
+
const params = new URLSearchParams();
|
|
109
|
+
for (const [key, value] of Object.entries(query)) {
|
|
110
|
+
if (value !== undefined && value !== null) {
|
|
111
|
+
if (Array.isArray(value)) {
|
|
112
|
+
for (const v of value) {
|
|
113
|
+
params.append(key, String(v));
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
params.append(key, String(value));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const str = params.toString();
|
|
122
|
+
return str ? `?${str}` : "";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Replace path parameters in URL
|
|
127
|
+
*/
|
|
128
|
+
function replacePathParams(
|
|
129
|
+
path: string,
|
|
130
|
+
params: Record<string, unknown> | undefined
|
|
131
|
+
): string {
|
|
132
|
+
if (!params) return path;
|
|
133
|
+
|
|
134
|
+
let result = path;
|
|
135
|
+
for (const [key, value] of Object.entries(params)) {
|
|
136
|
+
result = result.replace(`:${key}`, encodeURIComponent(String(value)));
|
|
137
|
+
result = result.replace(`[${key}]`, encodeURIComponent(String(value)));
|
|
138
|
+
}
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Create a type-safe client from a contract
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```typescript
|
|
147
|
+
* const userContract = Mandu.contract({
|
|
148
|
+
* request: {
|
|
149
|
+
* GET: { query: z.object({ page: z.number() }) },
|
|
150
|
+
* POST: { body: z.object({ name: z.string() }) },
|
|
151
|
+
* },
|
|
152
|
+
* response: {
|
|
153
|
+
* 200: z.object({ users: z.array(UserSchema) }),
|
|
154
|
+
* 201: z.object({ user: UserSchema }),
|
|
155
|
+
* },
|
|
156
|
+
* });
|
|
157
|
+
*
|
|
158
|
+
* const client = createClient(userContract, {
|
|
159
|
+
* baseUrl: "http://localhost:3000/api/users",
|
|
160
|
+
* });
|
|
161
|
+
*
|
|
162
|
+
* // Type-safe calls
|
|
163
|
+
* const users = await client.GET({ query: { page: 1 } });
|
|
164
|
+
* // users.data is typed as { users: User[] }
|
|
165
|
+
*
|
|
166
|
+
* const newUser = await client.POST({ body: { name: "Alice" } });
|
|
167
|
+
* // newUser.data is typed as { user: User }
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export function createClient<T extends ContractSchema>(
|
|
171
|
+
_contract: T,
|
|
172
|
+
options: ClientOptions
|
|
173
|
+
): ContractClient<T> {
|
|
174
|
+
const {
|
|
175
|
+
baseUrl,
|
|
176
|
+
headers: defaultHeaders = {},
|
|
177
|
+
fetch: customFetch = fetch,
|
|
178
|
+
timeout = 30000,
|
|
179
|
+
} = options;
|
|
180
|
+
|
|
181
|
+
const methods: ContractMethod[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
|
|
182
|
+
|
|
183
|
+
const client = {} as ContractClient<T>;
|
|
184
|
+
|
|
185
|
+
for (const method of methods) {
|
|
186
|
+
// @ts-expect-error - Dynamic method assignment
|
|
187
|
+
client[method] = async (
|
|
188
|
+
requestOptions: RequestOptions = {}
|
|
189
|
+
): Promise<ClientResponse<unknown>> => {
|
|
190
|
+
const { query, body, params, headers = {}, signal } = requestOptions;
|
|
191
|
+
|
|
192
|
+
// Build URL
|
|
193
|
+
let url = replacePathParams(baseUrl, params as Record<string, unknown>);
|
|
194
|
+
url += buildQueryString(query as Record<string, unknown>);
|
|
195
|
+
|
|
196
|
+
// Build request options
|
|
197
|
+
const fetchOptions: RequestInit = {
|
|
198
|
+
method,
|
|
199
|
+
headers: {
|
|
200
|
+
...defaultHeaders,
|
|
201
|
+
...headers,
|
|
202
|
+
},
|
|
203
|
+
signal,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Add body for non-GET methods
|
|
207
|
+
if (body && method !== "GET") {
|
|
208
|
+
fetchOptions.headers = {
|
|
209
|
+
...fetchOptions.headers,
|
|
210
|
+
"Content-Type": "application/json",
|
|
211
|
+
};
|
|
212
|
+
fetchOptions.body = JSON.stringify(body);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Add timeout
|
|
216
|
+
const controller = new AbortController();
|
|
217
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
218
|
+
|
|
219
|
+
if (!signal) {
|
|
220
|
+
fetchOptions.signal = controller.signal;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const response = await customFetch(url, fetchOptions);
|
|
225
|
+
clearTimeout(timeoutId);
|
|
226
|
+
|
|
227
|
+
let data: unknown;
|
|
228
|
+
const contentType = response.headers.get("content-type") || "";
|
|
229
|
+
|
|
230
|
+
if (contentType.includes("application/json")) {
|
|
231
|
+
data = await response.json();
|
|
232
|
+
} else {
|
|
233
|
+
data = await response.text();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
data,
|
|
238
|
+
status: response.status,
|
|
239
|
+
headers: response.headers,
|
|
240
|
+
ok: response.ok,
|
|
241
|
+
};
|
|
242
|
+
} catch (error) {
|
|
243
|
+
clearTimeout(timeoutId);
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return client;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Type-safe fetch wrapper for a single endpoint
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* ```typescript
|
|
257
|
+
* const result = await contractFetch(userContract, "GET", "/api/users", {
|
|
258
|
+
* query: { page: 1, limit: 10 },
|
|
259
|
+
* });
|
|
260
|
+
* // result.data is typed based on contract response
|
|
261
|
+
* ```
|
|
262
|
+
*/
|
|
263
|
+
export async function contractFetch<
|
|
264
|
+
T extends ContractSchema,
|
|
265
|
+
M extends Extract<keyof T["request"], ContractMethod>,
|
|
266
|
+
>(
|
|
267
|
+
_contract: T,
|
|
268
|
+
method: M,
|
|
269
|
+
url: string,
|
|
270
|
+
options: InferRequestOptions<
|
|
271
|
+
T["request"][M] extends MethodRequestSchema ? T["request"][M] : undefined
|
|
272
|
+
> = {} as InferRequestOptions<
|
|
273
|
+
T["request"][M] extends MethodRequestSchema ? T["request"][M] : undefined
|
|
274
|
+
>,
|
|
275
|
+
clientOptions: Partial<ClientOptions> = {}
|
|
276
|
+
): Promise<ClientResponse<InferSuccessResponse<T["response"]>>> {
|
|
277
|
+
const {
|
|
278
|
+
query,
|
|
279
|
+
body,
|
|
280
|
+
params,
|
|
281
|
+
headers = {},
|
|
282
|
+
signal,
|
|
283
|
+
} = options as RequestOptions;
|
|
284
|
+
|
|
285
|
+
const {
|
|
286
|
+
headers: defaultHeaders = {},
|
|
287
|
+
fetch: customFetch = fetch,
|
|
288
|
+
timeout = 30000,
|
|
289
|
+
} = clientOptions;
|
|
290
|
+
|
|
291
|
+
// Build URL
|
|
292
|
+
let finalUrl = replacePathParams(url, params as Record<string, unknown>);
|
|
293
|
+
finalUrl += buildQueryString(query as Record<string, unknown>);
|
|
294
|
+
|
|
295
|
+
// Build request options
|
|
296
|
+
const fetchOptions: RequestInit = {
|
|
297
|
+
method,
|
|
298
|
+
headers: {
|
|
299
|
+
...defaultHeaders,
|
|
300
|
+
...(headers as Record<string, string>),
|
|
301
|
+
},
|
|
302
|
+
signal,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// Add body for non-GET methods
|
|
306
|
+
if (body && method !== "GET") {
|
|
307
|
+
fetchOptions.headers = {
|
|
308
|
+
...fetchOptions.headers,
|
|
309
|
+
"Content-Type": "application/json",
|
|
310
|
+
};
|
|
311
|
+
fetchOptions.body = JSON.stringify(body);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Add timeout
|
|
315
|
+
const controller = new AbortController();
|
|
316
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
317
|
+
|
|
318
|
+
if (!signal) {
|
|
319
|
+
fetchOptions.signal = controller.signal;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const response = await customFetch(finalUrl, fetchOptions);
|
|
324
|
+
clearTimeout(timeoutId);
|
|
325
|
+
|
|
326
|
+
let data: unknown;
|
|
327
|
+
const contentType = response.headers.get("content-type") || "";
|
|
328
|
+
|
|
329
|
+
if (contentType.includes("application/json")) {
|
|
330
|
+
data = await response.json();
|
|
331
|
+
} else {
|
|
332
|
+
data = await response.text();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
data: data as InferSuccessResponse<T["response"]>,
|
|
337
|
+
status: response.status,
|
|
338
|
+
headers: response.headers,
|
|
339
|
+
ok: response.ok,
|
|
340
|
+
};
|
|
341
|
+
} catch (error) {
|
|
342
|
+
clearTimeout(timeoutId);
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
}
|