@typokit/client 0.1.4

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.
@@ -0,0 +1,109 @@
1
+ import type { HttpMethod, RouteContract } from "@typokit/types";
2
+ import { AppError } from "@typokit/errors";
3
+ /** Extract param names from a path pattern like "/users/:id/posts/:postId" */
4
+ type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}` ? {
5
+ [K in Param | keyof ExtractParams<Rest>]: string;
6
+ } : T extends `${string}:${infer Param}` ? {
7
+ [K in Param]: string;
8
+ } : Record<string, never>;
9
+ /** A single route definition binding a method + path to a contract */
10
+ export interface RouteDefinition<TMethod extends HttpMethod = HttpMethod, TContract extends RouteContract = RouteContract> {
11
+ method: TMethod;
12
+ contract: TContract;
13
+ }
14
+ /** Map of path patterns to their route definitions per method */
15
+ export type RouteMap = Record<string, Partial<Record<HttpMethod, RouteContract<any, any, any, any>>>>;
16
+ /** Interceptor function that can modify the request before it is sent */
17
+ export type RequestInterceptor = (request: RequestInit & {
18
+ url: string;
19
+ }) => (RequestInit & {
20
+ url: string;
21
+ }) | Promise<RequestInit & {
22
+ url: string;
23
+ }>;
24
+ /** Options for creating a client */
25
+ export interface ClientOptions {
26
+ baseUrl: string;
27
+ headers?: Record<string, string>;
28
+ interceptors?: RequestInterceptor[];
29
+ }
30
+ /** Options for individual requests */
31
+ export interface RequestOptions<TQuery = void, TBody = void> {
32
+ params?: Record<string, string>;
33
+ query?: TQuery extends void ? never : TQuery;
34
+ body?: TBody extends void ? never : TBody;
35
+ headers?: Record<string, string>;
36
+ }
37
+ /** Error thrown when an API call returns a non-OK status */
38
+ export declare class ClientError extends AppError {
39
+ readonly response: {
40
+ status: number;
41
+ body: unknown;
42
+ };
43
+ constructor(response: {
44
+ status: number;
45
+ body: unknown;
46
+ });
47
+ }
48
+ /** Extract routes of a given method from a RouteMap */
49
+ type RoutesForMethod<TRoutes extends RouteMap, M extends HttpMethod> = {
50
+ [P in keyof TRoutes]: M extends keyof TRoutes[P] ? P : never;
51
+ }[keyof TRoutes] & string;
52
+ /** Get the contract type for a path + method */
53
+ type ContractFor<TRoutes extends RouteMap, P extends string, M extends HttpMethod> = P extends keyof TRoutes ? M extends keyof TRoutes[P] ? TRoutes[P][M] extends RouteContract<any, any, any, any> ? TRoutes[P][M] : never : never : never;
54
+ /** Build the options type for a given contract + path */
55
+ type MethodRequestOptions<TContract extends RouteContract<any, any, any, any>, _TPath extends string> = TContract extends RouteContract<infer TParams, infer TQuery, infer TBody, infer _TResponse> ? TParams extends void ? TQuery extends void ? TBody extends void ? {
56
+ headers?: Record<string, string>;
57
+ } | undefined : {
58
+ body: TBody;
59
+ headers?: Record<string, string>;
60
+ } : TBody extends void ? {
61
+ query: TQuery;
62
+ headers?: Record<string, string>;
63
+ } : {
64
+ query: TQuery;
65
+ body: TBody;
66
+ headers?: Record<string, string>;
67
+ } : TQuery extends void ? TBody extends void ? {
68
+ params: TParams & Record<string, string>;
69
+ headers?: Record<string, string>;
70
+ } : {
71
+ params: TParams & Record<string, string>;
72
+ body: TBody;
73
+ headers?: Record<string, string>;
74
+ } : TBody extends void ? {
75
+ params: TParams & Record<string, string>;
76
+ query: TQuery;
77
+ headers?: Record<string, string>;
78
+ } : {
79
+ params: TParams & Record<string, string>;
80
+ query: TQuery;
81
+ body: TBody;
82
+ headers?: Record<string, string>;
83
+ } : never;
84
+ /** Extract the response type from a contract */
85
+ type ResponseFor<TContract extends RouteContract<any, any, any, any>> = TContract extends RouteContract<infer _P, infer _Q, infer _B, infer TResponse> ? TResponse : never;
86
+ /** Type-safe API client */
87
+ export interface TypeSafeClient<TRoutes extends RouteMap> {
88
+ get<P extends RoutesForMethod<TRoutes, "GET">>(path: P, options?: MethodRequestOptions<ContractFor<TRoutes, P, "GET">, P>): Promise<ResponseFor<ContractFor<TRoutes, P, "GET">>>;
89
+ post<P extends RoutesForMethod<TRoutes, "POST">>(path: P, options?: MethodRequestOptions<ContractFor<TRoutes, P, "POST">, P>): Promise<ResponseFor<ContractFor<TRoutes, P, "POST">>>;
90
+ put<P extends RoutesForMethod<TRoutes, "PUT">>(path: P, options?: MethodRequestOptions<ContractFor<TRoutes, P, "PUT">, P>): Promise<ResponseFor<ContractFor<TRoutes, P, "PUT">>>;
91
+ patch<P extends RoutesForMethod<TRoutes, "PATCH">>(path: P, options?: MethodRequestOptions<ContractFor<TRoutes, P, "PATCH">, P>): Promise<ResponseFor<ContractFor<TRoutes, P, "PATCH">>>;
92
+ delete<P extends RoutesForMethod<TRoutes, "DELETE">>(path: P, options?: MethodRequestOptions<ContractFor<TRoutes, P, "DELETE">, P>): Promise<ResponseFor<ContractFor<TRoutes, P, "DELETE">>>;
93
+ }
94
+ /**
95
+ * Create a type-safe API client.
96
+ *
97
+ * @example
98
+ * ```ts
99
+ * type MyRoutes = {
100
+ * "/users": { GET: RouteContract<void, { page?: number }, void, User[]> };
101
+ * "/users/:id": { GET: RouteContract<{ id: string }, void, void, User> };
102
+ * };
103
+ * const client = createClient<MyRoutes>({ baseUrl: "http://localhost:3000" });
104
+ * const users = await client.get("/users", { query: { page: 1 } });
105
+ * ```
106
+ */
107
+ export declare function createClient<TRoutes extends RouteMap>(options: ClientOptions): TypeSafeClient<TRoutes>;
108
+ export type { ExtractParams };
109
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,114 @@
1
+ // @typokit/client — Type-Safe Fetch Client
2
+ import { AppError, createAppError } from "@typokit/errors";
3
+ // ─── Client Error ───────────────────────────────────────────
4
+ /** Error thrown when an API call returns a non-OK status */
5
+ export class ClientError extends AppError {
6
+ response;
7
+ constructor(response) {
8
+ super("CLIENT_ERROR", response.status, `Request failed with status ${response.status}`);
9
+ this.response = response;
10
+ this.name = "ClientError";
11
+ Object.setPrototypeOf(this, new.target.prototype);
12
+ }
13
+ }
14
+ // ─── Implementation ─────────────────────────────────────────
15
+ /** Substitute path parameters into a URL pattern */
16
+ function buildUrl(baseUrl, path, params, query) {
17
+ let resolvedPath = path;
18
+ if (params) {
19
+ for (const [key, value] of Object.entries(params)) {
20
+ resolvedPath = resolvedPath.replace(`:${key}`, encodeURIComponent(value));
21
+ }
22
+ }
23
+ const url = new URL(resolvedPath, baseUrl);
24
+ if (query) {
25
+ for (const [key, value] of Object.entries(query)) {
26
+ if (value !== undefined && value !== null) {
27
+ if (Array.isArray(value)) {
28
+ for (const item of value) {
29
+ url.searchParams.append(key, String(item));
30
+ }
31
+ }
32
+ else {
33
+ url.searchParams.set(key, String(value));
34
+ }
35
+ }
36
+ }
37
+ }
38
+ return url.toString();
39
+ }
40
+ /** Apply interceptors sequentially */
41
+ async function applyInterceptors(request, interceptors) {
42
+ let current = request;
43
+ for (const interceptor of interceptors) {
44
+ current = await interceptor(current);
45
+ }
46
+ return current;
47
+ }
48
+ /** Parse response body, throwing a typed error on non-OK status */
49
+ async function handleResponse(response) {
50
+ let body;
51
+ const contentType = response.headers.get("content-type") ?? "";
52
+ if (contentType.includes("application/json")) {
53
+ body = await response.json();
54
+ }
55
+ else {
56
+ body = await response.text();
57
+ }
58
+ if (!response.ok) {
59
+ // Try to parse as ErrorResponse and throw a typed error
60
+ if (body &&
61
+ typeof body === "object" &&
62
+ "error" in body &&
63
+ typeof body.error === "object") {
64
+ const errBody = body.error;
65
+ throw createAppError(response.status, errBody.code ?? "UNKNOWN_ERROR", errBody.message ?? `Request failed with status ${response.status}`, errBody.details);
66
+ }
67
+ throw new ClientError({ status: response.status, body });
68
+ }
69
+ return body;
70
+ }
71
+ /**
72
+ * Create a type-safe API client.
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * type MyRoutes = {
77
+ * "/users": { GET: RouteContract<void, { page?: number }, void, User[]> };
78
+ * "/users/:id": { GET: RouteContract<{ id: string }, void, void, User> };
79
+ * };
80
+ * const client = createClient<MyRoutes>({ baseUrl: "http://localhost:3000" });
81
+ * const users = await client.get("/users", { query: { page: 1 } });
82
+ * ```
83
+ */
84
+ export function createClient(options) {
85
+ const { baseUrl, headers: defaultHeaders = {}, interceptors = [] } = options;
86
+ async function request(method, path, opts) {
87
+ const url = buildUrl(baseUrl, path, opts?.params, opts?.query);
88
+ const requestHeaders = {
89
+ ...defaultHeaders,
90
+ ...(opts?.headers ?? {}),
91
+ };
92
+ if (opts?.body !== undefined) {
93
+ requestHeaders["content-type"] = "application/json";
94
+ }
95
+ let requestInit = {
96
+ url,
97
+ method,
98
+ headers: requestHeaders,
99
+ ...(opts?.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
100
+ };
101
+ requestInit = await applyInterceptors(requestInit, interceptors);
102
+ const { url: finalUrl, ...fetchOpts } = requestInit;
103
+ const response = await fetch(finalUrl, fetchOpts);
104
+ return handleResponse(response);
105
+ }
106
+ return {
107
+ get: (path, opts) => request("GET", path, opts),
108
+ post: (path, opts) => request("POST", path, opts),
109
+ put: (path, opts) => request("PUT", path, opts),
110
+ patch: (path, opts) => request("PATCH", path, opts),
111
+ delete: (path, opts) => request("DELETE", path, opts),
112
+ };
113
+ }
114
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@typokit/client",
3
+ "exports": {
4
+ ".": {
5
+ "import": "./dist/index.js",
6
+ "types": "./dist/index.d.ts"
7
+ }
8
+ },
9
+ "version": "0.1.4",
10
+ "type": "module",
11
+ "files": [
12
+ "dist",
13
+ "src"
14
+ ],
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "dependencies": {
18
+ "@typokit/errors": "0.1.4",
19
+ "@typokit/types": "0.1.4"
20
+ },
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "https://github.com/KyleBastien/typokit",
24
+ "directory": "packages/client"
25
+ },
26
+ "scripts": {
27
+ "test": "rstest run --passWithNoTests"
28
+ }
29
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,40 @@
1
+ // Ambient type declarations for fetch API (available in Node 18+, Bun, Deno, browsers)
2
+ // We don't add DOM lib or @types/node to keep the package platform-agnostic.
3
+
4
+ declare class Headers {
5
+ constructor(init?: Record<string, string>);
6
+ get(name: string): string | null;
7
+ set(name: string, value: string): void;
8
+ has(name: string): boolean;
9
+ delete(name: string): void;
10
+ forEach(callback: (value: string, key: string) => void): void;
11
+ }
12
+
13
+ declare class URL {
14
+ constructor(url: string, base?: string);
15
+ readonly searchParams: URLSearchParams;
16
+ toString(): string;
17
+ }
18
+
19
+ declare class URLSearchParams {
20
+ set(name: string, value: string): void;
21
+ append(name: string, value: string): void;
22
+ get(name: string): string | null;
23
+ toString(): string;
24
+ }
25
+
26
+ interface RequestInit {
27
+ method?: string;
28
+ headers?: Record<string, string>;
29
+ body?: string;
30
+ }
31
+
32
+ interface ResponseLike {
33
+ readonly ok: boolean;
34
+ readonly status: number;
35
+ readonly headers: Headers;
36
+ json(): Promise<unknown>;
37
+ text(): Promise<string>;
38
+ }
39
+
40
+ declare function fetch(url: string, init?: RequestInit): Promise<ResponseLike>;
@@ -0,0 +1,313 @@
1
+ // @typokit/client — Unit Tests
2
+
3
+ import { describe, it, expect } from "@rstest/core";
4
+ import { createClient, ClientError } from "./index.js";
5
+ import type { ExtractParams, RequestInterceptor } from "./index.js";
6
+ import type { RouteContract } from "@typokit/types";
7
+ import { AppError } from "@typokit/errors";
8
+
9
+ // ─── Type-level Tests ───────────────────────────────────────
10
+
11
+ // Verify ExtractParams infers correctly at the type level
12
+ type _AssertSingle =
13
+ ExtractParams<"/users/:id"> extends { id: string } ? true : never;
14
+ const _testSingle: _AssertSingle = true;
15
+
16
+ type _AssertMulti =
17
+ ExtractParams<"/users/:id/posts/:postId"> extends {
18
+ id: string;
19
+ postId: string;
20
+ }
21
+ ? true
22
+ : never;
23
+ const _testMulti: _AssertMulti = true;
24
+
25
+ type _AssertNone =
26
+ ExtractParams<"/users"> extends Record<string, never> ? true : never;
27
+ const _testNone: _AssertNone = true;
28
+
29
+ // Suppress unused variable warnings
30
+ void _testSingle;
31
+ void _testMulti;
32
+ void _testNone;
33
+
34
+ // ─── Test Route Map ─────────────────────────────────────────
35
+
36
+ interface User {
37
+ id: string;
38
+ name: string;
39
+ }
40
+
41
+ type TestRoutes = {
42
+ "/users": {
43
+ GET: RouteContract<void, { page?: number }, void, User[]>;
44
+ POST: RouteContract<void, void, { name: string }, User>;
45
+ };
46
+ "/users/:id": {
47
+ GET: RouteContract<{ id: string }, void, void, User>;
48
+ PUT: RouteContract<{ id: string }, void, { name: string }, User>;
49
+ DELETE: RouteContract<{ id: string }, void, void, void>;
50
+ };
51
+ };
52
+
53
+ // ─── Fetch Spy ──────────────────────────────────────────────
54
+
55
+ interface FetchCall {
56
+ url: string;
57
+ init?: { method?: string; headers?: Record<string, string>; body?: string };
58
+ }
59
+
60
+ let fetchCalls: FetchCall[] = [];
61
+
62
+ function mockFetch(response: {
63
+ status: number;
64
+ body: unknown;
65
+ headers?: Record<string, string>;
66
+ }): void {
67
+ fetchCalls = [];
68
+ const headerEntries = {
69
+ "content-type": "application/json",
70
+ ...(response.headers ?? {}),
71
+ };
72
+ (globalThis as Record<string, unknown>).fetch = (
73
+ url: string,
74
+ init?: RequestInit,
75
+ ) => {
76
+ fetchCalls.push({ url, init: init as FetchCall["init"] });
77
+ return Promise.resolve({
78
+ ok: response.status >= 200 && response.status < 300,
79
+ status: response.status,
80
+ headers: {
81
+ get: (name: string) =>
82
+ (headerEntries as Record<string, string>)[name.toLowerCase()] ?? null,
83
+ },
84
+ json: () => Promise.resolve(response.body),
85
+ text: () => Promise.resolve(JSON.stringify(response.body)),
86
+ });
87
+ };
88
+ }
89
+
90
+ // ─── Tests ──────────────────────────────────────────────────
91
+
92
+ describe("createClient", () => {
93
+ it("should create a client with all HTTP methods", () => {
94
+ mockFetch({ status: 200, body: [] });
95
+ const client = createClient<TestRoutes>({
96
+ baseUrl: "http://localhost:3000",
97
+ });
98
+
99
+ expect(typeof client.get).toBe("function");
100
+ expect(typeof client.post).toBe("function");
101
+ expect(typeof client.put).toBe("function");
102
+ expect(typeof client.patch).toBe("function");
103
+ expect(typeof client.delete).toBe("function");
104
+ });
105
+
106
+ it("should make a GET request with correct URL", async () => {
107
+ const users: User[] = [{ id: "1", name: "Alice" }];
108
+ mockFetch({ status: 200, body: users });
109
+
110
+ const client = createClient<TestRoutes>({
111
+ baseUrl: "http://localhost:3000",
112
+ });
113
+ const result = await client.get("/users");
114
+
115
+ expect(result).toEqual(users);
116
+ expect(fetchCalls.length).toBe(1);
117
+ expect(fetchCalls[0].url).toBe("http://localhost:3000/users");
118
+ expect(fetchCalls[0].init?.method).toBe("GET");
119
+ });
120
+
121
+ it("should substitute path parameters", async () => {
122
+ const user: User = { id: "42", name: "Bob" };
123
+ mockFetch({ status: 200, body: user });
124
+
125
+ const client = createClient<TestRoutes>({
126
+ baseUrl: "http://localhost:3000",
127
+ });
128
+ const result = await client.get("/users/:id", { params: { id: "42" } });
129
+
130
+ expect(result).toEqual(user);
131
+ expect(fetchCalls[0].url).toBe("http://localhost:3000/users/42");
132
+ expect(fetchCalls[0].init?.method).toBe("GET");
133
+ });
134
+
135
+ it("should append query parameters", async () => {
136
+ mockFetch({ status: 200, body: [] });
137
+
138
+ const client = createClient<TestRoutes>({
139
+ baseUrl: "http://localhost:3000",
140
+ });
141
+ await client.get("/users", { query: { page: 2 } });
142
+
143
+ expect(fetchCalls[0].url).toBe("http://localhost:3000/users?page=2");
144
+ });
145
+
146
+ it("should send JSON body for POST requests", async () => {
147
+ const newUser: User = { id: "3", name: "Charlie" };
148
+ mockFetch({ status: 201, body: newUser });
149
+
150
+ const client = createClient<TestRoutes>({
151
+ baseUrl: "http://localhost:3000",
152
+ });
153
+ const result = await client.post("/users", { body: { name: "Charlie" } });
154
+
155
+ expect(result).toEqual(newUser);
156
+ expect(fetchCalls[0].init?.method).toBe("POST");
157
+ expect(fetchCalls[0].init?.body).toBe(JSON.stringify({ name: "Charlie" }));
158
+ expect(fetchCalls[0].init?.headers?.["content-type"]).toBe(
159
+ "application/json",
160
+ );
161
+ });
162
+
163
+ it("should send JSON body for PUT requests", async () => {
164
+ const updated: User = { id: "42", name: "Updated" };
165
+ mockFetch({ status: 200, body: updated });
166
+
167
+ const client = createClient<TestRoutes>({
168
+ baseUrl: "http://localhost:3000",
169
+ });
170
+ const result = await client.put("/users/:id", {
171
+ params: { id: "42" },
172
+ body: { name: "Updated" },
173
+ });
174
+
175
+ expect(result).toEqual(updated);
176
+ expect(fetchCalls[0].url).toBe("http://localhost:3000/users/42");
177
+ expect(fetchCalls[0].init?.method).toBe("PUT");
178
+ });
179
+
180
+ it("should make DELETE requests", async () => {
181
+ mockFetch({ status: 200, body: null });
182
+
183
+ const client = createClient<TestRoutes>({
184
+ baseUrl: "http://localhost:3000",
185
+ });
186
+ await client.delete("/users/:id", { params: { id: "42" } });
187
+
188
+ expect(fetchCalls[0].url).toBe("http://localhost:3000/users/42");
189
+ expect(fetchCalls[0].init?.method).toBe("DELETE");
190
+ });
191
+
192
+ it("should include default headers", async () => {
193
+ mockFetch({ status: 200, body: [] });
194
+
195
+ const client = createClient<TestRoutes>({
196
+ baseUrl: "http://localhost:3000",
197
+ headers: { "x-api-key": "secret123" },
198
+ });
199
+ await client.get("/users");
200
+
201
+ expect(fetchCalls[0].init?.headers?.["x-api-key"]).toBe("secret123");
202
+ });
203
+
204
+ it("should merge request-level headers with defaults", async () => {
205
+ mockFetch({ status: 200, body: [] });
206
+
207
+ const client = createClient<TestRoutes>({
208
+ baseUrl: "http://localhost:3000",
209
+ headers: { "x-api-key": "secret123" },
210
+ });
211
+ await client.get("/users", {
212
+ query: {},
213
+ headers: { "x-request-id": "req-1" },
214
+ });
215
+
216
+ expect(fetchCalls[0].init?.headers?.["x-api-key"]).toBe("secret123");
217
+ expect(fetchCalls[0].init?.headers?.["x-request-id"]).toBe("req-1");
218
+ });
219
+ });
220
+
221
+ describe("error handling", () => {
222
+ it("should throw AppError subclass for error responses with ErrorResponse body", async () => {
223
+ mockFetch({
224
+ status: 404,
225
+ body: { error: { code: "NOT_FOUND", message: "User not found" } },
226
+ });
227
+
228
+ const client = createClient<TestRoutes>({
229
+ baseUrl: "http://localhost:3000",
230
+ });
231
+
232
+ let caught: unknown;
233
+ try {
234
+ await client.get("/users/:id", { params: { id: "999" } });
235
+ } catch (err) {
236
+ caught = err;
237
+ }
238
+
239
+ expect(caught).toBeInstanceOf(AppError);
240
+ expect((caught as AppError).status).toBe(404);
241
+ expect((caught as AppError).code).toBe("NOT_FOUND");
242
+ });
243
+
244
+ it("should throw ClientError for non-OK responses without ErrorResponse body", async () => {
245
+ mockFetch({
246
+ status: 500,
247
+ body: "Internal Server Error",
248
+ headers: { "content-type": "text/plain" },
249
+ });
250
+
251
+ const client = createClient<TestRoutes>({
252
+ baseUrl: "http://localhost:3000",
253
+ });
254
+
255
+ let caught: unknown;
256
+ try {
257
+ await client.get("/users");
258
+ } catch (err) {
259
+ caught = err;
260
+ }
261
+
262
+ expect(caught).toBeInstanceOf(ClientError);
263
+ expect((caught as ClientError).status).toBe(500);
264
+ });
265
+ });
266
+
267
+ describe("interceptors", () => {
268
+ it("should apply request interceptors in order", async () => {
269
+ mockFetch({ status: 200, body: [] });
270
+
271
+ const interceptor1: RequestInterceptor = (req) => ({
272
+ ...req,
273
+ headers: { ...(req.headers as Record<string, string>), "x-first": "1" },
274
+ });
275
+
276
+ const interceptor2: RequestInterceptor = (req) => ({
277
+ ...req,
278
+ headers: { ...(req.headers as Record<string, string>), "x-second": "2" },
279
+ });
280
+
281
+ const client = createClient<TestRoutes>({
282
+ baseUrl: "http://localhost:3000",
283
+ interceptors: [interceptor1, interceptor2],
284
+ });
285
+ await client.get("/users");
286
+
287
+ expect(fetchCalls[0].init?.headers?.["x-first"]).toBe("1");
288
+ expect(fetchCalls[0].init?.headers?.["x-second"]).toBe("2");
289
+ });
290
+
291
+ it("should support async interceptors", async () => {
292
+ mockFetch({ status: 200, body: [] });
293
+
294
+ const asyncInterceptor: RequestInterceptor = async (req) => {
295
+ await Promise.resolve();
296
+ return {
297
+ ...req,
298
+ headers: {
299
+ ...(req.headers as Record<string, string>),
300
+ authorization: "Bearer token123",
301
+ },
302
+ };
303
+ };
304
+
305
+ const client = createClient<TestRoutes>({
306
+ baseUrl: "http://localhost:3000",
307
+ interceptors: [asyncInterceptor],
308
+ });
309
+ await client.get("/users");
310
+
311
+ expect(fetchCalls[0].init?.headers?.authorization).toBe("Bearer token123");
312
+ });
313
+ });
package/src/index.ts ADDED
@@ -0,0 +1,328 @@
1
+ // @typokit/client — Type-Safe Fetch Client
2
+
3
+ import type { HttpMethod, RouteContract } from "@typokit/types";
4
+ import { AppError, createAppError } from "@typokit/errors";
5
+
6
+ // ─── Path Parameter Extraction ──────────────────────────────
7
+
8
+ /** Extract param names from a path pattern like "/users/:id/posts/:postId" */
9
+ type ExtractParams<T extends string> =
10
+ T extends `${string}:${infer Param}/${infer Rest}`
11
+ ? { [K in Param | keyof ExtractParams<Rest>]: string }
12
+ : T extends `${string}:${infer Param}`
13
+ ? { [K in Param]: string }
14
+ : Record<string, never>;
15
+
16
+ // ─── Route Definition Types ─────────────────────────────────
17
+
18
+ /** A single route definition binding a method + path to a contract */
19
+ export interface RouteDefinition<
20
+ TMethod extends HttpMethod = HttpMethod,
21
+ TContract extends RouteContract = RouteContract,
22
+ > {
23
+ method: TMethod;
24
+ contract: TContract;
25
+ }
26
+
27
+ /** Map of path patterns to their route definitions per method */
28
+ export type RouteMap = Record<
29
+ string,
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentional: generic route map accepts any contract shape
31
+ Partial<Record<HttpMethod, RouteContract<any, any, any, any>>>
32
+ >;
33
+
34
+ // ─── Client Options ─────────────────────────────────────────
35
+
36
+ /** Interceptor function that can modify the request before it is sent */
37
+ export type RequestInterceptor = (
38
+ request: RequestInit & { url: string },
39
+ ) => (RequestInit & { url: string }) | Promise<RequestInit & { url: string }>;
40
+
41
+ /** Options for creating a client */
42
+ export interface ClientOptions {
43
+ baseUrl: string;
44
+ headers?: Record<string, string>;
45
+ interceptors?: RequestInterceptor[];
46
+ }
47
+
48
+ /** Options for individual requests */
49
+ export interface RequestOptions<TQuery = void, TBody = void> {
50
+ params?: Record<string, string>;
51
+ query?: TQuery extends void ? never : TQuery;
52
+ body?: TBody extends void ? never : TBody;
53
+ headers?: Record<string, string>;
54
+ }
55
+
56
+ // ─── Client Error ───────────────────────────────────────────
57
+
58
+ /** Error thrown when an API call returns a non-OK status */
59
+ export class ClientError extends AppError {
60
+ constructor(public readonly response: { status: number; body: unknown }) {
61
+ super(
62
+ "CLIENT_ERROR",
63
+ response.status,
64
+ `Request failed with status ${response.status}`,
65
+ );
66
+ this.name = "ClientError";
67
+ Object.setPrototypeOf(this, new.target.prototype);
68
+ }
69
+ }
70
+
71
+ // ─── Type-Level Helpers ─────────────────────────────────────
72
+
73
+ /** Extract routes of a given method from a RouteMap */
74
+ type RoutesForMethod<TRoutes extends RouteMap, M extends HttpMethod> = {
75
+ [P in keyof TRoutes]: M extends keyof TRoutes[P] ? P : never;
76
+ }[keyof TRoutes] &
77
+ string;
78
+
79
+ /** Get the contract type for a path + method */
80
+ type ContractFor<
81
+ TRoutes extends RouteMap,
82
+ P extends string,
83
+ M extends HttpMethod,
84
+ > = P extends keyof TRoutes
85
+ ? M extends keyof TRoutes[P]
86
+ ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
87
+ TRoutes[P][M] extends RouteContract<any, any, any, any>
88
+ ? TRoutes[P][M]
89
+ : never
90
+ : never
91
+ : never;
92
+
93
+ /** Build the options type for a given contract + path */
94
+ type MethodRequestOptions<
95
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
96
+ TContract extends RouteContract<any, any, any, any>,
97
+ _TPath extends string,
98
+ > =
99
+ TContract extends RouteContract<
100
+ infer TParams,
101
+ infer TQuery,
102
+ infer TBody,
103
+ infer _TResponse
104
+ >
105
+ ? TParams extends void
106
+ ? TQuery extends void
107
+ ? TBody extends void
108
+ ? { headers?: Record<string, string> } | undefined
109
+ : { body: TBody; headers?: Record<string, string> }
110
+ : TBody extends void
111
+ ? { query: TQuery; headers?: Record<string, string> }
112
+ : { query: TQuery; body: TBody; headers?: Record<string, string> }
113
+ : TQuery extends void
114
+ ? TBody extends void
115
+ ? {
116
+ params: TParams & Record<string, string>;
117
+ headers?: Record<string, string>;
118
+ }
119
+ : {
120
+ params: TParams & Record<string, string>;
121
+ body: TBody;
122
+ headers?: Record<string, string>;
123
+ }
124
+ : TBody extends void
125
+ ? {
126
+ params: TParams & Record<string, string>;
127
+ query: TQuery;
128
+ headers?: Record<string, string>;
129
+ }
130
+ : {
131
+ params: TParams & Record<string, string>;
132
+ query: TQuery;
133
+ body: TBody;
134
+ headers?: Record<string, string>;
135
+ }
136
+ : never;
137
+
138
+ /** Extract the response type from a contract */
139
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
140
+ type ResponseFor<TContract extends RouteContract<any, any, any, any>> =
141
+ TContract extends RouteContract<infer _P, infer _Q, infer _B, infer TResponse>
142
+ ? TResponse
143
+ : never;
144
+
145
+ // ─── Client Interface ───────────────────────────────────────
146
+
147
+ /** Type-safe API client */
148
+ export interface TypeSafeClient<TRoutes extends RouteMap> {
149
+ get<P extends RoutesForMethod<TRoutes, "GET">>(
150
+ path: P,
151
+ options?: MethodRequestOptions<ContractFor<TRoutes, P, "GET">, P>,
152
+ ): Promise<ResponseFor<ContractFor<TRoutes, P, "GET">>>;
153
+
154
+ post<P extends RoutesForMethod<TRoutes, "POST">>(
155
+ path: P,
156
+ options?: MethodRequestOptions<ContractFor<TRoutes, P, "POST">, P>,
157
+ ): Promise<ResponseFor<ContractFor<TRoutes, P, "POST">>>;
158
+
159
+ put<P extends RoutesForMethod<TRoutes, "PUT">>(
160
+ path: P,
161
+ options?: MethodRequestOptions<ContractFor<TRoutes, P, "PUT">, P>,
162
+ ): Promise<ResponseFor<ContractFor<TRoutes, P, "PUT">>>;
163
+
164
+ patch<P extends RoutesForMethod<TRoutes, "PATCH">>(
165
+ path: P,
166
+ options?: MethodRequestOptions<ContractFor<TRoutes, P, "PATCH">, P>,
167
+ ): Promise<ResponseFor<ContractFor<TRoutes, P, "PATCH">>>;
168
+
169
+ delete<P extends RoutesForMethod<TRoutes, "DELETE">>(
170
+ path: P,
171
+ options?: MethodRequestOptions<ContractFor<TRoutes, P, "DELETE">, P>,
172
+ ): Promise<ResponseFor<ContractFor<TRoutes, P, "DELETE">>>;
173
+ }
174
+
175
+ // ─── Implementation ─────────────────────────────────────────
176
+
177
+ /** Substitute path parameters into a URL pattern */
178
+ function buildUrl(
179
+ baseUrl: string,
180
+ path: string,
181
+ params?: Record<string, string>,
182
+ query?: Record<string, unknown>,
183
+ ): string {
184
+ let resolvedPath = path;
185
+ if (params) {
186
+ for (const [key, value] of Object.entries(params)) {
187
+ resolvedPath = resolvedPath.replace(`:${key}`, encodeURIComponent(value));
188
+ }
189
+ }
190
+
191
+ const url = new URL(resolvedPath, baseUrl);
192
+
193
+ if (query) {
194
+ for (const [key, value] of Object.entries(query)) {
195
+ if (value !== undefined && value !== null) {
196
+ if (Array.isArray(value)) {
197
+ for (const item of value) {
198
+ url.searchParams.append(key, String(item));
199
+ }
200
+ } else {
201
+ url.searchParams.set(key, String(value));
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ return url.toString();
208
+ }
209
+
210
+ /** Apply interceptors sequentially */
211
+ async function applyInterceptors(
212
+ request: RequestInit & { url: string },
213
+ interceptors: RequestInterceptor[],
214
+ ): Promise<RequestInit & { url: string }> {
215
+ let current = request;
216
+ for (const interceptor of interceptors) {
217
+ current = await interceptor(current);
218
+ }
219
+ return current;
220
+ }
221
+
222
+ /** Parse response body, throwing a typed error on non-OK status */
223
+ async function handleResponse<T>(response: ResponseLike): Promise<T> {
224
+ let body: unknown;
225
+ const contentType = response.headers.get("content-type") ?? "";
226
+ if (contentType.includes("application/json")) {
227
+ body = await response.json();
228
+ } else {
229
+ body = await response.text();
230
+ }
231
+
232
+ if (!response.ok) {
233
+ // Try to parse as ErrorResponse and throw a typed error
234
+ if (
235
+ body &&
236
+ typeof body === "object" &&
237
+ "error" in body &&
238
+ typeof (body as Record<string, unknown>).error === "object"
239
+ ) {
240
+ const errBody = (
241
+ body as {
242
+ error: {
243
+ code?: string;
244
+ message?: string;
245
+ details?: Record<string, unknown>;
246
+ };
247
+ }
248
+ ).error;
249
+ throw createAppError(
250
+ response.status,
251
+ errBody.code ?? "UNKNOWN_ERROR",
252
+ errBody.message ?? `Request failed with status ${response.status}`,
253
+ errBody.details,
254
+ );
255
+ }
256
+ throw new ClientError({ status: response.status, body });
257
+ }
258
+
259
+ return body as T;
260
+ }
261
+
262
+ /**
263
+ * Create a type-safe API client.
264
+ *
265
+ * @example
266
+ * ```ts
267
+ * type MyRoutes = {
268
+ * "/users": { GET: RouteContract<void, { page?: number }, void, User[]> };
269
+ * "/users/:id": { GET: RouteContract<{ id: string }, void, void, User> };
270
+ * };
271
+ * const client = createClient<MyRoutes>({ baseUrl: "http://localhost:3000" });
272
+ * const users = await client.get("/users", { query: { page: 1 } });
273
+ * ```
274
+ */
275
+ export function createClient<TRoutes extends RouteMap>(
276
+ options: ClientOptions,
277
+ ): TypeSafeClient<TRoutes> {
278
+ const { baseUrl, headers: defaultHeaders = {}, interceptors = [] } = options;
279
+
280
+ async function request<T>(
281
+ method: HttpMethod,
282
+ path: string,
283
+ opts?: {
284
+ params?: Record<string, string>;
285
+ query?: Record<string, unknown>;
286
+ body?: unknown;
287
+ headers?: Record<string, string>;
288
+ },
289
+ ): Promise<T> {
290
+ const url = buildUrl(baseUrl, path, opts?.params, opts?.query);
291
+
292
+ const requestHeaders: Record<string, string> = {
293
+ ...defaultHeaders,
294
+ ...(opts?.headers ?? {}),
295
+ };
296
+
297
+ if (opts?.body !== undefined) {
298
+ requestHeaders["content-type"] = "application/json";
299
+ }
300
+
301
+ let requestInit: RequestInit & { url: string } = {
302
+ url,
303
+ method,
304
+ headers: requestHeaders,
305
+ ...(opts?.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
306
+ };
307
+
308
+ requestInit = await applyInterceptors(requestInit, interceptors);
309
+
310
+ const { url: finalUrl, ...fetchOpts } = requestInit;
311
+ const response = await fetch(finalUrl, fetchOpts);
312
+
313
+ return handleResponse<T>(response);
314
+ }
315
+
316
+ return {
317
+ get: (path, opts) => request("GET", path, opts as Record<string, unknown>),
318
+ post: (path, opts) =>
319
+ request("POST", path, opts as Record<string, unknown>),
320
+ put: (path, opts) => request("PUT", path, opts as Record<string, unknown>),
321
+ patch: (path, opts) =>
322
+ request("PATCH", path, opts as Record<string, unknown>),
323
+ delete: (path, opts) =>
324
+ request("DELETE", path, opts as Record<string, unknown>),
325
+ } as TypeSafeClient<TRoutes>;
326
+ }
327
+
328
+ export type { ExtractParams };