Skip to content

Commit 828e99b

Browse files
committed
Release api-core 0.0.3
1 parent 608c0c5 commit 828e99b

10 files changed

Lines changed: 160 additions & 12 deletions

File tree

.github/workflows/release.yml

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ jobs:
6767
run: bun run build
6868

6969
- name: Verify tag matches package version
70+
id: release
7071
run: |
7172
TAG="${GITHUB_REF_NAME}"
7273
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
@@ -77,6 +78,20 @@ jobs:
7778
echo "Tag $TAG does not match package.json version $VERSION" >&2
7879
exit 1
7980
fi
81+
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
82+
83+
- name: Generate and verify release notes
84+
env:
85+
GH_TOKEN: ${{ github.token }}
86+
TAG: ${{ steps.release.outputs.tag }}
87+
run: |
88+
gh api "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
89+
-f tag_name="$TAG" \
90+
--jq '.body // ""' > release-notes.md
91+
if ! grep -q '[^[:space:]]' release-notes.md; then
92+
echo "GitHub generated empty release notes for ${TAG}" >&2
93+
exit 1
94+
fi
8095
8196
- name: Verify package contents
8297
run: npm pack --dry-run --json
@@ -87,13 +102,10 @@ jobs:
87102
- name: Create GitHub release
88103
env:
89104
GH_TOKEN: ${{ github.token }}
105+
TAG: ${{ steps.release.outputs.tag }}
90106
run: |
91-
TAG="${GITHUB_REF_NAME}"
92-
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
93-
TAG="${{ github.event.inputs.tag }}"
94-
fi
95107
if gh release view "$TAG" >/dev/null 2>&1; then
96-
echo "GitHub release $TAG already exists"
108+
gh release edit "$TAG" --title "$TAG" --notes-file release-notes.md
97109
else
98-
gh release create "$TAG" --verify-tag --generate-notes
110+
gh release create "$TAG" --verify-tag --title "$TAG" --notes-file release-notes.md
99111
fi

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ their internal HTTP layer consistent and testable.
2222
- Built-in auth, cache, logger, rate-limit, retry, and timeout plugins.
2323
- Fetch transport with JSON bodies, raw string bodies, abort signals, and
2424
timeout handling.
25+
- Response parsing for JSON, text, and binary payloads.
2526
- Query string support for primitives and repeated array values.
2627
- ESM and CommonJS builds with TypeScript declarations.
2728

@@ -168,6 +169,16 @@ const games = await client.post<Game[]>(
168169
);
169170
```
170171

172+
Binary responses can stay on the shared request path by selecting an explicit
173+
response type:
174+
175+
```ts
176+
const bytes = await client.post<ArrayBuffer>("/games.pb", query, {
177+
headers: { accept: "application/octet-stream" },
178+
responseType: "arrayBuffer",
179+
});
180+
```
181+
171182
### Response Metadata
172183

173184
Use `requestWithResponse` when a wrapper needs more than the parsed body:

docs/guides/rest-requests.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ await client.get("/movies", {
4949
| `signal` | Caller-provided abort signal. Composes with timeout handling. |
5050
| `cacheKey` | Explicit key for cache plugins. |
5151
| `tags` | Labels plugins can use for cache invalidation or metrics. |
52+
| `responseType` | Override response parsing with `json`, `text`, `arrayBuffer`, or `blob`. Defaults to `auto`. |
53+
| `errorResponseType` | Override non-2xx body parsing. Defaults to `auto`. |
5254

5355
## Response Metadata
5456

@@ -86,6 +88,19 @@ const games = await client.post<Game[]>(
8688
);
8789
```
8890

91+
## Binary Responses
92+
93+
Use `responseType: "arrayBuffer"` for endpoints that return binary payloads
94+
while still going through retries, plugins, timeouts, and the configured
95+
transport.
96+
97+
```ts
98+
const bytes = await client.post<ArrayBuffer>("/games.pb", "fields id;", {
99+
headers: { accept: "application/octet-stream" },
100+
responseType: "arrayBuffer",
101+
});
102+
```
103+
89104
## Custom Fetch Or Transport
90105

91106
Use `fetch` when you only need to swap the fetch implementation:

docs/reference/client.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ class MyApiClient extends BaseHttpClient {
3939
| `init()` | Initialize plugins. Called lazily before the first request. |
4040
| `dispose()` | Run plugin cleanup hooks. |
4141

42+
## Response Parsing
43+
44+
Requests parse JSON automatically when the response content type is JSON and
45+
fall back to text for other bodies. Pass `responseType` to override that:
46+
47+
```ts
48+
await client.get<string>("/robots.txt", { responseType: "text" });
49+
await client.post<ArrayBuffer>("/games.pb", query, {
50+
responseType: "arrayBuffer",
51+
});
52+
```
53+
4254
## `ApiResponse<T>`
4355

4456
Returned by `requestWithResponse`.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@api-wrappers/api-core",
3-
"version": "0.0.2",
3+
"version": "0.0.3",
44
"description": "Shared HTTP client runtime for the api-wrappers organisation. Provides request orchestration, a plugin lifecycle, transport abstraction, and built-in cache/retry/logger plugins.",
55
"repository": {
66
"type": "git",

src/__tests__/client.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,49 @@ describe("BaseHttpClient", () => {
9494
expect(result.meta.source).toBe("test");
9595
});
9696

97+
it("returns binary bodies when responseType is arrayBuffer", async () => {
98+
const bytes = new Uint8Array([8, 1, 18, 4]);
99+
const client = new BaseHttpClient({
100+
baseUrl: "https://api.test",
101+
transport: makeTransport(
102+
async () =>
103+
new Response(bytes, {
104+
headers: { "content-type": "application/octet-stream" },
105+
}),
106+
),
107+
});
108+
109+
const result = await client.post<ArrayBuffer>("/games.pb", "fields id;", {
110+
headers: { accept: "application/octet-stream" },
111+
responseType: "arrayBuffer",
112+
});
113+
114+
expect([...new Uint8Array(result)]).toEqual([...bytes]);
115+
});
116+
117+
it("keeps non-ok response parsing automatic for binary requests", async () => {
118+
const client = new BaseHttpClient({
119+
baseUrl: "https://api.test",
120+
transport: makeTransport(
121+
async () =>
122+
new Response(JSON.stringify({ message: "bad token" }), {
123+
status: 401,
124+
headers: { "content-type": "application/json" },
125+
}),
126+
),
127+
});
128+
129+
try {
130+
await client.post<ArrayBuffer>("/games.pb", "fields id;", {
131+
responseType: "arrayBuffer",
132+
});
133+
throw new Error("Expected request to fail");
134+
} catch (err) {
135+
expect(err).toBeInstanceOf(ApiError);
136+
expect((err as ApiError).responseBody).toEqual({ message: "bad token" });
137+
}
138+
});
139+
97140
it("throws ApiError on non-ok response", async () => {
98141
const client = new BaseHttpClient({
99142
baseUrl: "https://api.test",

src/__tests__/transport.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { describe, expect, it } from "bun:test";
22
import type { RequestContext } from "../context/RequestContext";
33
import { TimeoutError } from "../errors/TimeoutError";
4-
import { createFetchTransport } from "../transport/fetchTransport";
4+
import {
5+
createFetchTransport,
6+
fetchTransport,
7+
} from "../transport/fetchTransport";
58

69
function makeCtx(overrides: Partial<RequestContext> = {}): RequestContext {
710
return {
@@ -47,6 +50,24 @@ describe("fetchTransport", () => {
4750
expect(captured?.body).toBe('{"query":"query { Viewer { id } }"}');
4851
});
4952

53+
it("resolves the default global fetch at execution time", async () => {
54+
const originalFetch = globalThis.fetch;
55+
let called = false;
56+
57+
try {
58+
globalThis.fetch = (async () => {
59+
called = true;
60+
return new Response("{}");
61+
}) as typeof fetch;
62+
63+
await fetchTransport.execute(makeCtx({ body: undefined, method: "GET" }));
64+
65+
expect(called).toBe(true);
66+
} finally {
67+
globalThis.fetch = originalFetch;
68+
}
69+
});
70+
5071
it("throws TimeoutError when its own timeout aborts the request", async () => {
5172
const transport = createFetchTransport(
5273
() =>

src/client/BaseHttpClient.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { sleep } from "../utils/sleep";
1616
import type { ClientConfig } from "./types";
1717

1818
/** Per-request options passed to {@link BaseHttpClient.request} and the convenience methods. */
19+
export type ResponseType = "auto" | "json" | "text" | "arrayBuffer" | "blob";
20+
1921
export interface RequestOptions {
2022
/** HTTP method. Defaults to `"GET"`. */
2123
method?: HttpMethod;
@@ -50,6 +52,16 @@ export interface RequestOptions {
5052
* these for cache invalidation, metrics grouping, or filtering.
5153
*/
5254
tags?: string[];
55+
/**
56+
* Controls how the response body is parsed. Defaults to content-type based
57+
* parsing: JSON responses become objects, everything else becomes text.
58+
*/
59+
responseType?: ResponseType;
60+
/**
61+
* Optional response parser for non-2xx bodies. Defaults to `"auto"` so APIs
62+
* that return binary success payloads can still surface text/JSON errors.
63+
*/
64+
errorResponseType?: ResponseType;
5365
}
5466

5567
export interface ApiResponse<T = unknown> {
@@ -241,7 +253,12 @@ export class BaseHttpClient {
241253
}
242254
}
243255

244-
const parsedBody = await parseBody(rawResponse);
256+
const parsedBody = await parseBody(
257+
rawResponse,
258+
rawResponse.ok
259+
? options.responseType
260+
: (options.errorResponseType ?? "auto"),
261+
);
245262

246263
let resCtx: ResponseContext = {
247264
request: ctx,
@@ -438,13 +455,22 @@ export class BaseHttpClient {
438455

439456
// ─── Helpers ──────────────────────────────────────────────────────────────────
440457

441-
async function parseBody(response: Response): Promise<unknown> {
458+
async function parseBody(
459+
response: Response,
460+
responseType: ResponseType = "auto",
461+
): Promise<unknown> {
462+
if (responseType === "arrayBuffer") return response.arrayBuffer();
463+
if (responseType === "blob") return response.blob();
464+
442465
if (response.status === 204 || response.status === 205) return undefined;
443466
if (response.headers.get("content-length") === "0") return undefined;
444467

445468
const text = await response.text();
469+
if (responseType === "text") return text;
446470
if (!text) return undefined;
447471

472+
if (responseType === "json") return JSON.parse(text);
473+
448474
const contentType = response.headers.get("content-type") ?? "";
449475
if (contentType.includes("application/json")) {
450476
return JSON.parse(text);

src/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@
3333

3434
// ─── Client ───────────────────────────────────────────────────────────────────
3535

36-
export type { ApiResponse, RequestOptions } from "./client/BaseHttpClient";
36+
export type {
37+
ApiResponse,
38+
RequestOptions,
39+
ResponseType,
40+
} from "./client/BaseHttpClient";
3741
export { BaseHttpClient } from "./client/BaseHttpClient";
3842
export { createClient } from "./client/createClient";
3943
export type {

src/transport/fetchTransport.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ import { buildUrl } from "../utils/buildUrl";
44
import { isPlainObject } from "../utils/isPlainObject";
55
import type { Transport } from "./types";
66

7+
const defaultFetch: typeof globalThis.fetch = (input, init) => {
8+
return globalThis.fetch(input, init);
9+
};
10+
711
/**
812
* Creates a {@link Transport} backed by the provided `fetch` function.
913
* Use this when you need a polyfill or a custom fetch interceptor:
@@ -16,7 +20,7 @@ import type { Transport } from "./types";
1620
* ```
1721
*/
1822
export function createFetchTransport(
19-
fetchFn: typeof globalThis.fetch = globalThis.fetch,
23+
fetchFn: typeof globalThis.fetch = defaultFetch,
2024
): Transport {
2125
return {
2226
async execute(ctx: RequestContext): Promise<Response> {

0 commit comments

Comments
 (0)