Skip to content

Commit 4ba8eed

Browse files
committed
Add interceptors support to APIClient for request and response customization
1 parent 7ab8b38 commit 4ba8eed

File tree

3 files changed

+325
-2
lines changed

3 files changed

+325
-2
lines changed

README.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,70 @@ class CustomAPIClient extends APIClient {
7676
let client = new CustomAPIClient();
7777
```
7878

79+
### Interceptos
80+
81+
You can add interceptors to the `APIClient` instance to customize the request and response handling.
82+
83+
```ts
84+
let client = new APIClient("https://api.example.com");
85+
86+
client.on("before", async (request) => {
87+
// Add a custom header to the request
88+
request.headers.set("X-Custom-Header", "value");
89+
90+
return request;
91+
});
92+
93+
client.on("after", async (request, response) => {
94+
if (response.status === 401) {
95+
// Handle unauthorized error
96+
throw new Error("Unauthorized");
97+
}
98+
99+
return response;
100+
});
101+
```
102+
103+
You can also remove interceptors using the `off` method.
104+
105+
```ts
106+
async function beforeInterceptor(request) {
107+
// Add a custom header to the request
108+
request.headers.set("X-Custom-Header", "value");
109+
110+
return request;
111+
}
112+
113+
let client = new APIClient("https://api.example.com");
114+
115+
client.on("before", beforeInterceptor); // Add the interceptor
116+
client.off("before", beforeInterceptor); // Remove the interceptor
117+
```
118+
119+
The sub-class interceptors run before the instance interceptors.
120+
121+
```ts
122+
class CustomAPIClient extends APIClient {
123+
async before(request: Request) {
124+
// Add a custom header to the request
125+
request.headers.set("X-Custom-Header", "1");
126+
127+
return request;
128+
}
129+
}
130+
131+
let client = new CustomAPIClient("https://api.example.com");
132+
133+
client.on("before", async (request) => {
134+
// Add a custom header to the request
135+
request.headers.set("X-Custom-Header", "2");
136+
137+
return request;
138+
});
139+
```
140+
141+
Here the `X-Custom-Header` will be set to `2` in the request because the instance interceptor overrides the header set by the sub-class interceptor.
142+
79143
### Testing
80144

81145
You can easily test your API calls using the `APIClient` with [msw](https://mswjs.io/) to mock the API responses.
@@ -97,6 +161,65 @@ let response = await client.get("/users/1");
97161
let data = await response.json(); // { id: 1, name: "John Doe" }
98162
```
99163

164+
### Error Handling
165+
166+
The `APIClient` class never throws an error except for network errors. You can handle API errors by checking the response status code.
167+
168+
```ts
169+
let client = new APIClient("https://api.example.com");
170+
171+
try {
172+
let response = await client.get("/users/1");
173+
if (response.status === 401) {
174+
// Handle unauthorized error
175+
throw new Error("Unauthorized");
176+
}
177+
// Handle other errors or success
178+
} catch (error) {
179+
// Handle network error
180+
console.error(error);
181+
}
182+
```
183+
184+
Alternatively, you can use the `after` interceptor to handle common API errors.
185+
186+
```ts
187+
class CustomAPIClient extends APIClient {
188+
async after(request: Request, response: Response) {
189+
if (response.status === 401) {
190+
// Handle unauthorized error
191+
throw new Error("Unauthorized");
192+
}
193+
194+
return response;
195+
}
196+
}
197+
```
198+
199+
Or you can use the `on` method to add an interceptor to handle common API errors.
200+
201+
```ts
202+
let client = new APIClient("https://api.example.com");
203+
204+
client.on("after", async (request, response) => {
205+
if (response.status === 401) {
206+
// Handle unauthorized error
207+
throw new Error("Unauthorized");
208+
}
209+
210+
return response;
211+
});
212+
```
213+
214+
### Timeout
215+
216+
You can set a timeout for the API requests using the `signal` option.
217+
218+
```ts
219+
let client = new APIClient("https://api.example.com");
220+
await client.get("/users/1", { signal: AbortSignal.timeout(5000) });
221+
```
222+
100223
## License
101224

102225
See [LICENSE](./LICENSE)

src/index.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,145 @@ describe(APIClient.name, () => {
114114
expect(client.fetch("/path")).rejects.toThrow("Bad request");
115115
});
116116
});
117+
118+
describe("Instance Interceptor", () => {
119+
test("can attach before interceptor to instance", async () => {
120+
let randomValue = crypto.randomUUID();
121+
122+
let client = new APIClient(new URL("https://example.com"));
123+
124+
client.on("before", async (request) => {
125+
request.headers.set("X-Custom", randomValue);
126+
return request;
127+
});
128+
129+
server.resetHandlers(
130+
http.all("https://example.com/path", ({ request }) => {
131+
return HttpResponse.text(request.headers.get("X-Custom"));
132+
}),
133+
);
134+
135+
let response = await client.get("/path");
136+
expect(await response.text()).toBe(randomValue);
137+
});
138+
139+
test("can attach after interceptor to instance", async () => {
140+
let client = new APIClient(new URL("https://example.com"));
141+
142+
client.on("after", async (_, response) => {
143+
if (response.status === 400) throw new Error("Bad request");
144+
return response;
145+
});
146+
147+
server.resetHandlers(
148+
http.all("https://example.com/path", () => {
149+
return HttpResponse.text("Bad request", { status: 400 });
150+
}),
151+
);
152+
153+
expect(client.fetch("/path")).rejects.toThrow("Bad request");
154+
});
155+
156+
test("can attach multiple before interceptors to instance", async () => {
157+
let client = new APIClient(new URL("https://example.com"));
158+
159+
client.on("before", async (request) => {
160+
request.headers.set("X-Custom", "First");
161+
return request;
162+
});
163+
164+
client.on("before", async (request) => {
165+
request.headers.set("X-Custom", "Second");
166+
return request;
167+
});
168+
169+
server.resetHandlers(
170+
http.all("https://example.com/path", ({ request }) => {
171+
return HttpResponse.text(request.headers.get("X-Custom"));
172+
}),
173+
);
174+
175+
let response = await client.get("/path");
176+
expect(await response.text()).toBe("Second");
177+
});
178+
179+
test("can attach multiple after interceptors to instance", async () => {
180+
let client = new APIClient(new URL("https://example.com"));
181+
182+
client.on("after", async (_, response) => {
183+
if (response.status === 400) throw new Error("Bad request");
184+
return response;
185+
});
186+
187+
client.on("after", async (_, response) => {
188+
if (response.status === 401) throw new Error("Unauthorized");
189+
return response;
190+
});
191+
192+
server.resetHandlers(
193+
http.all("https://example.com/path", () => {
194+
return HttpResponse.text("Unauthorized", { status: 401 });
195+
}),
196+
);
197+
198+
expect(client.fetch("/path")).rejects.toThrow("Unauthorized");
199+
});
200+
201+
test("sub-class before interceptos runs before instance interceptors", async () => {
202+
class CustomClient extends APIClient {
203+
constructor() {
204+
super(new URL("https://example.com"));
205+
}
206+
207+
async before(request: Request) {
208+
request.headers.set("X-Custom", "Sub-Class");
209+
return request;
210+
}
211+
}
212+
213+
let client = new CustomClient();
214+
215+
client.on("before", async (request) => {
216+
request.headers.set("X-Custom", "Instance");
217+
return request;
218+
});
219+
220+
server.resetHandlers(
221+
http.all("https://example.com/path", ({ request }) => {
222+
return HttpResponse.text(request.headers.get("X-Custom"));
223+
}),
224+
);
225+
226+
let response = await client.get("/path");
227+
expect(await response.text()).toBe("Instance");
228+
});
229+
230+
test("sub-class after interceptos runs before instance interceptors", async () => {
231+
class CustomClient extends APIClient {
232+
constructor() {
233+
super(new URL("https://example.com"));
234+
}
235+
236+
async after(_: Request, response: Response) {
237+
if (response.status === 400) throw new Error("Bad request");
238+
return response;
239+
}
240+
}
241+
242+
let client = new CustomClient();
243+
244+
client.on("after", async (_, response) => {
245+
if (response.status === 400) throw new TypeError("Bad request");
246+
return response;
247+
});
248+
249+
server.resetHandlers(
250+
http.all("https://example.com/path", () => {
251+
return HttpResponse.text("Bad request", { status: 400 });
252+
}),
253+
);
254+
255+
expect(client.fetch("/path")).rejects.toThrow(Error);
256+
});
257+
});
117258
});

src/index.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
* }
2222
*/
2323
export class APIClient {
24+
private beforeListeners = new Set<APIClient.BeforeListenerFunction>();
25+
private afterListeners = new Set<APIClient.AfterListenerFunction>();
26+
2427
/** The base URL for all API requests. Must be defined in subclasses. */
2528
protected readonly baseURL: URL;
2629

@@ -62,9 +65,26 @@ export class APIClient {
6265
*/
6366
async fetch(path: string, init?: RequestInit) {
6467
let url = new URL(path, this.baseURL);
65-
let request = await this.before(new Request(url.toString(), init));
68+
69+
let request = new Request(url.toString(), init);
70+
request = await this.before(request);
71+
72+
if (this.beforeListeners.size > 0) {
73+
for (let listener of this.beforeListeners) {
74+
request = await listener(request);
75+
}
76+
}
77+
6678
let response = await fetch(request);
67-
return await this.after(request, response);
79+
response = await this.after(request, response);
80+
81+
if (this.afterListeners.size > 0) {
82+
for (let listener of this.afterListeners) {
83+
response = await listener(request, response);
84+
}
85+
}
86+
87+
return response;
6888
}
6989

7090
/**
@@ -121,4 +141,43 @@ export class APIClient {
121141
delete(path: string, init?: Omit<RequestInit, "method">) {
122142
return this.fetch(path, { ...init, method: "DELETE" });
123143
}
144+
145+
public on(event: "before", listener: APIClient.BeforeListenerFunction): this;
146+
public on(event: "after", listener: APIClient.AfterListenerFunction): this;
147+
public on(event: "before" | "after", listener: APIClient.ListenerFunction) {
148+
if (event === "before") {
149+
this.beforeListeners.add(listener as APIClient.BeforeListenerFunction);
150+
}
151+
152+
if (event === "after") {
153+
this.afterListeners.add(listener as APIClient.AfterListenerFunction);
154+
}
155+
156+
return this;
157+
}
158+
159+
public off(event: "before", listener: APIClient.BeforeListenerFunction): this;
160+
public off(event: "after", listener: APIClient.AfterListenerFunction): this;
161+
public off(event: "before" | "after", listener: APIClient.ListenerFunction) {
162+
if (event === "before") {
163+
this.beforeListeners.delete(listener as APIClient.BeforeListenerFunction);
164+
}
165+
166+
if (event === "after") {
167+
this.afterListeners.delete(listener as APIClient.AfterListenerFunction);
168+
}
169+
170+
return this;
171+
}
172+
}
173+
174+
export namespace APIClient {
175+
export type BeforeListenerFunction = (request: Request) => Promise<Request>;
176+
177+
export type AfterListenerFunction = (
178+
request: Request,
179+
response: Response,
180+
) => Promise<Response>;
181+
182+
export type ListenerFunction = BeforeListenerFunction | AfterListenerFunction;
124183
}

0 commit comments

Comments
 (0)