Skip to content

Commit d9e8ee7

Browse files
committed
feat: enhance RequestContext with signal and QueryParams types
fix: update RateLimitError to include response body in constructor refactor: change import path for GraphQLRequestError chore: update module name in index.ts and adjust exports feat: add Auth and RateLimit plugins with respective options fix: improve cache plugin to handle cache hits and misses feat: implement fetchTransport with body serialization and timeout handling feat: add utility functions for building URLs and resolving paths docs: create adoption guide for integrating api-core into existing wrappers docs: add plugin guide detailing plugin structure and usage test: add tests for auth and rate limit plugins test: implement transport tests for request handling and timeout scenarios feat: introduce resolveUrl utility for cleaner URL handling
1 parent 7d40740 commit d9e8ee7

31 files changed

Lines changed: 1408 additions & 900 deletions

README.md

Lines changed: 150 additions & 372 deletions
Large diffs are not rendered by default.

bun.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/adoption.md

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
# Adoption Guide
2+
3+
This guide shows how to use `@api-wrappers/api-core` inside the existing wrapper
4+
projects without changing their public APIs.
5+
6+
## Adoption Rule
7+
8+
Replace only the internal HTTP layer. Keep exported clients, endpoint classes,
9+
method names, parameter shapes, and response types unchanged.
10+
11+
## Local Dependency
12+
13+
From a wrapper project in this workspace:
14+
15+
```bash
16+
bun add ../api-core
17+
```
18+
19+
If the wrapper uses a package manager workspace later, point it at
20+
`@api-wrappers/api-core` through the workspace protocol instead.
21+
22+
## Shared Internal Client Pattern
23+
24+
Create or update the wrapper's private HTTP client so endpoint classes keep
25+
calling the same methods.
26+
27+
```ts
28+
import {
29+
BaseHttpClient,
30+
createAuthPlugin,
31+
createRetryPlugin,
32+
createTimeoutPlugin,
33+
type RequestOptions,
34+
} from "@api-wrappers/api-core";
35+
36+
export class HttpClient {
37+
private readonly core: BaseHttpClient;
38+
39+
constructor(getToken: () => Promise<string>) {
40+
this.core = new BaseHttpClient({
41+
baseUrl: "https://api.example.com/v1",
42+
plugins: [
43+
createAuthPlugin(getToken),
44+
createRetryPlugin({ maxAttempts: 3, delayMs: 300 }),
45+
createTimeoutPlugin({ timeoutMs: 30_000 }),
46+
],
47+
});
48+
}
49+
50+
get<T>(path: string, options?: RequestOptions): Promise<T> {
51+
return this.core.get<T>(path, options);
52+
}
53+
54+
post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
55+
return this.core.post<T>(path, body, options);
56+
}
57+
}
58+
```
59+
60+
## IGDB Wrapper Shape
61+
62+
IGDB needs an async Twitch token, `Client-ID`, text bodies, retry behavior, and
63+
request throttling.
64+
65+
```ts
66+
import {
67+
BaseHttpClient,
68+
createAuthPlugin,
69+
createRateLimitPlugin,
70+
createRetryPlugin,
71+
} from "@api-wrappers/api-core";
72+
73+
export class HttpClient {
74+
private readonly core: BaseHttpClient;
75+
76+
constructor(options: { clientId: string; auth: AuthManager }) {
77+
this.core = new BaseHttpClient({
78+
baseUrl: "https://api.igdb.com/v4",
79+
defaultHeaders: {
80+
"client-id": options.clientId,
81+
accept: "application/json",
82+
},
83+
plugins: [
84+
createRateLimitPlugin({ maxConcurrent: 4, minTimeMs: 250 }),
85+
createAuthPlugin(() => options.auth.getAccessToken()),
86+
createRetryPlugin({
87+
maxAttempts: 3,
88+
delayMs: 500,
89+
retriableStatusCodes: [429, 500, 502, 503, 504],
90+
}),
91+
],
92+
});
93+
}
94+
95+
request<T>(endpoint: string, body: string): Promise<T[]> {
96+
return this.core.post<T[]>(endpoint, body, {
97+
headers: { "content-type": "text/plain" },
98+
});
99+
}
100+
101+
async requestCount(endpoint: string, body: string): Promise<number> {
102+
const data = await this.core.post<{ count: number }>(`${endpoint}/count`, body, {
103+
headers: { "content-type": "text/plain" },
104+
});
105+
return data.count;
106+
}
107+
}
108+
```
109+
110+
Map `ApiError`, `RateLimitError`, and `TimeoutError` to the wrapper's existing
111+
custom errors if preserving exact error classes is required.
112+
113+
## TMDB Wrapper Shape
114+
115+
TMDB primarily needs query parameters, bearer token or API key auth, timeout,
116+
retry, and response payload errors.
117+
118+
```ts
119+
import {
120+
BaseHttpClient,
121+
createAuthPlugin,
122+
createRetryPlugin,
123+
createTimeoutPlugin,
124+
type QueryParams,
125+
} from "@api-wrappers/api-core";
126+
127+
export class API {
128+
private readonly apiKey?: string;
129+
private readonly core: BaseHttpClient;
130+
131+
constructor(auth: string | { apiKey?: string; accessToken?: string }) {
132+
const accessToken = typeof auth === "string" ? auth : auth.accessToken;
133+
this.apiKey = typeof auth === "string" ? undefined : auth.apiKey;
134+
135+
this.core = new BaseHttpClient({
136+
baseUrl: "https://api.themoviedb.org/3",
137+
defaultHeaders: { accept: "application/json" },
138+
plugins: [
139+
...(accessToken ? [createAuthPlugin(accessToken)] : []),
140+
createRetryPlugin({
141+
maxAttempts: 3,
142+
delayMs: 300,
143+
retriableStatusCodes: [429, 502, 503, 504],
144+
}),
145+
createTimeoutPlugin({ timeoutMs: 30_000 }),
146+
],
147+
});
148+
}
149+
150+
get<T>(path: string, query: QueryParams = {}): Promise<T> {
151+
return this.core.get<T>(path, {
152+
query: {
153+
...query,
154+
...(this.apiKey ? { api_key: this.apiKey } : {}),
155+
},
156+
});
157+
}
158+
}
159+
```
160+
161+
Array query values are supported directly:
162+
163+
```ts
164+
api.get("/discover/movie", { with_genres: [28, 12], page: 2 });
165+
```
166+
167+
## AniList Wrapper Shape
168+
169+
AniList is GraphQL-only. The generated SDK can continue to own typed operation
170+
shapes, or services can call `core.graphql()` directly.
171+
172+
```ts
173+
import {
174+
BaseHttpClient,
175+
createAuthPlugin,
176+
createRetryPlugin,
177+
} from "@api-wrappers/api-core";
178+
179+
const core = new BaseHttpClient({
180+
baseUrl: "https://graphql.anilist.co",
181+
plugins: [
182+
...(token ? [createAuthPlugin(token)] : []),
183+
createRetryPlugin({
184+
maxAttempts: 4,
185+
delayMs: 1_000,
186+
retriableStatusCodes: [429, 500, 502, 503, 504],
187+
}),
188+
],
189+
});
190+
191+
const data = await core.graphql<GetMediaQuery, GetMediaVariables>("/", {
192+
query: GET_MEDIA,
193+
variables: { id: 1 },
194+
});
195+
```
196+
197+
## Testing A Wrapper
198+
199+
Use a custom transport to keep wrapper tests deterministic:
200+
201+
```ts
202+
const core = new BaseHttpClient({
203+
baseUrl: "https://api.example.com",
204+
transport: {
205+
execute: async (ctx) =>
206+
new Response(JSON.stringify(fixtures[ctx.url] ?? {}), {
207+
headers: { "content-type": "application/json" },
208+
}),
209+
},
210+
});
211+
```
212+
213+
This avoids network calls while still exercising wrapper endpoints, query
214+
building, auth plugins, cache behavior, and error mapping.
215+
216+
## Adoption Checklist
217+
218+
- Install `@api-wrappers/api-core`.
219+
- Replace only the wrapper's private HTTP implementation.
220+
- Preserve existing public wrapper classes and endpoint method signatures.
221+
- Move token lookup into `createAuthPlugin`.
222+
- Move retries into `createRetryPlugin`.
223+
- Move throttling into `createRateLimitPlugin` where needed.
224+
- Keep wrapper-specific error classes by mapping core errors at the wrapper
225+
boundary if the public API already documents those classes.
226+
- Add transport-backed tests for one success response, one API error, one auth
227+
request, and one retry or rate-limit path.

0 commit comments

Comments
 (0)