@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.
- package/dist/index.d.ts +109 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +114 -0
- package/dist/index.js.map +1 -0
- package/package.json +29 -0
- package/src/env.d.ts +40 -0
- package/src/index.test.ts +313 -0
- package/src/index.ts +328 -0
package/dist/index.d.ts
ADDED
|
@@ -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":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAChE,OAAO,EAAE,QAAQ,EAAkB,MAAM,iBAAiB,CAAC;AAI3D,8EAA8E;AAC9E,KAAK,aAAa,CAAC,CAAC,SAAS,MAAM,IACjC,CAAC,SAAS,GAAG,MAAM,IAAI,MAAM,KAAK,IAAI,MAAM,IAAI,EAAE,GAC9C;KAAG,CAAC,IAAI,KAAK,GAAG,MAAM,aAAa,CAAC,IAAI,CAAC,GAAG,MAAM;CAAE,GACpD,CAAC,SAAS,GAAG,MAAM,IAAI,MAAM,KAAK,EAAE,GAClC;KAAG,CAAC,IAAI,KAAK,GAAG,MAAM;CAAE,GACxB,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;AAI9B,sEAAsE;AACtE,MAAM,WAAW,eAAe,CAC9B,OAAO,SAAS,UAAU,GAAG,UAAU,EACvC,SAAS,SAAS,aAAa,GAAG,aAAa;IAE/C,MAAM,EAAE,OAAO,CAAC;IAChB,QAAQ,EAAE,SAAS,CAAC;CACrB;AAED,iEAAiE;AACjE,MAAM,MAAM,QAAQ,GAAG,MAAM,CAC3B,MAAM,EAEN,OAAO,CAAC,MAAM,CAAC,UAAU,EAAE,aAAa,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,CAC/D,CAAC;AAIF,yEAAyE;AACzE,MAAM,MAAM,kBAAkB,GAAG,CAC/B,OAAO,EAAE,WAAW,GAAG;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,KACnC,CAAC,WAAW,GAAG;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,OAAO,CAAC,WAAW,GAAG;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC;AAE9E,oCAAoC;AACpC,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,YAAY,CAAC,EAAE,kBAAkB,EAAE,CAAC;CACrC;AAED,sCAAsC;AACtC,MAAM,WAAW,cAAc,CAAC,MAAM,GAAG,IAAI,EAAE,KAAK,GAAG,IAAI;IACzD,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChC,KAAK,CAAC,EAAE,MAAM,SAAS,IAAI,GAAG,KAAK,GAAG,MAAM,CAAC;IAC7C,IAAI,CAAC,EAAE,KAAK,SAAS,IAAI,GAAG,KAAK,GAAG,KAAK,CAAC;IAC1C,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC;AAID,4DAA4D;AAC5D,qBAAa,WAAY,SAAQ,QAAQ;aACX,QAAQ,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE;gBAA3C,QAAQ,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE;CASxE;AAID,uDAAuD;AACvD,KAAK,eAAe,CAAC,OAAO,SAAS,QAAQ,EAAE,CAAC,SAAS,UAAU,IAAI;KACpE,CAAC,IAAI,MAAM,OAAO,GAAG,CAAC,SAAS,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,KAAK;CAC7D,CAAC,MAAM,OAAO,CAAC,GACd,MAAM,CAAC;AAET,gDAAgD;AAChD,KAAK,WAAW,CACd,OAAO,SAAS,QAAQ,EACxB,CAAC,SAAS,MAAM,EAChB,CAAC,SAAS,UAAU,IAClB,CAAC,SAAS,MAAM,OAAO,GACvB,CAAC,SAAS,MAAM,OAAO,CAAC,CAAC,CAAC,GAExB,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,aAAa,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,GACrD,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GACb,KAAK,GACP,KAAK,GACP,KAAK,CAAC;AAEV,yDAAyD;AACzD,KAAK,oBAAoB,CAEvB,SAAS,SAAS,aAAa,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,EACnD,MAAM,SAAS,MAAM,IAErB,SAAS,SAAS,aAAa,CAC7B,MAAM,OAAO,EACb,MAAM,MAAM,EACZ,MAAM,KAAK,EACX,MAAM,UAAU,CACjB,GACG,OAAO,SAAS,IAAI,GAClB,MAAM,SAAS,IAAI,GACjB,KAAK,SAAS,IAAI,GAChB;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG,SAAS,GAChD;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GACnD,KAAK,SAAS,IAAI,GAChB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GACnD;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,KAAK,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GACpE,MAAM,SAAS,IAAI,GACjB,KAAK,SAAS,IAAI,GAChB;IACE,MAAM,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC,GACD;IACE,MAAM,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,IAAI,EAAE,KAAK,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC,GACH,KAAK,SAAS,IAAI,GAChB;IACE,MAAM,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC,GACD;IACE,MAAM,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,KAAK,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAClC,GACP,KAAK,CAAC;AAEZ,gDAAgD;AAEhD,KAAK,WAAW,CAAC,SAAS,SAAS,aAAa,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,IAClE,SAAS,SAAS,aAAa,CAAC,MAAM,EAAE,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,EAAE,MAAM,SAAS,CAAC,GAC1E,SAAS,GACT,KAAK,CAAC;AAIZ,2BAA2B;AAC3B,MAAM,WAAW,cAAc,CAAC,OAAO,SAAS,QAAQ;IACtD,GAAG,CAAC,CAAC,SAAS,eAAe,CAAC,OAAO,EAAE,KAAK,CAAC,EAC3C,IAAI,EAAE,CAAC,EACP,OAAO,CAAC,EAAE,oBAAoB,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC,GAChE,OAAO,CAAC,WAAW,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC;IAExD,IAAI,CAAC,CAAC,SAAS,eAAe,CAAC,OAAO,EAAE,MAAM,CAAC,EAC7C,IAAI,EAAE,CAAC,EACP,OAAO,CAAC,EAAE,oBAAoB,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC,GACjE,OAAO,CAAC,WAAW,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;IAEzD,GAAG,CAAC,CAAC,SAAS,eAAe,CAAC,OAAO,EAAE,KAAK,CAAC,EAC3C,IAAI,EAAE,CAAC,EACP,OAAO,CAAC,EAAE,oBAAoB,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,EAAE,KAAK,CAAC,EAAE,CAAC,CAAC,GAChE,OAAO,CAAC,WAAW,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC;IAExD,KAAK,CAAC,CAAC,SAAS,eAAe,CAAC,OAAO,EAAE,OAAO,CAAC,EAC/C,IAAI,EAAE,CAAC,EACP,OAAO,CAAC,EAAE,oBAAoB,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC,GAClE,OAAO,CAAC,WAAW,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;IAE1D,MAAM,CAAC,CAAC,SAAS,eAAe,CAAC,OAAO,EAAE,QAAQ,CAAC,EACjD,IAAI,EAAE,CAAC,EACP,OAAO,CAAC,EAAE,oBAAoB,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,EAAE,QAAQ,CAAC,EAAE,CAAC,CAAC,GACnE,OAAO,CAAC,WAAW,CAAC,WAAW,CAAC,OAAO,EAAE,CAAC,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC;CAC5D;AAyFD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,OAAO,SAAS,QAAQ,EACnD,OAAO,EAAE,aAAa,GACrB,cAAc,CAAC,OAAO,CAAC,CAiDzB;AAED,YAAY,EAAE,aAAa,EAAE,CAAC"}
|
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 };
|