Skip to content

Commit e6b89bf

Browse files
feat: parseJson(text, ctx) — request/response context for routing parsers
Inspired by ky [PR #849](sindresorhus/ky#849). The parseJson option now receives an optional second argument with the request and response, so a single shared parser can route on URL or content-type: \`\`\`ts createMisina({ parseJson: (text, ctx) => { if (ctx?.response.headers.get('x-format') === 'bigint') { return JSON.parse(text, bigintReviver) } return JSON.parse(text) }, }) \`\`\` The previous one-arg signature still works — the second arg is optional and the existing tests (which pass a one-arg callback) continue to pass. ### test/init-isolation.test.ts Two new tests covering ky [PR #861](sindresorhus/ky#861 spirit: per-request init hook isolation. - Concurrent requests don't share a counter through options.headers. - A defaults.headers value mutated inside init doesn't compound across later requests. Both pass — verifies that resolveOptions() builds a fresh per-request options object each time, so default state isn't leaked or compounded. ### test/parsejson-context.test.ts Two new tests for the new signature: - ctx?.request.url and ctx?.response.headers.get(...) are observable. - The original one-arg form keeps working. 117/117 tests pass; lint, typecheck, build, bundle budget all clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fcf36b2 commit e6b89bf

5 files changed

Lines changed: 130 additions & 6 deletions

File tree

src/_body.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,9 @@ const JSON_RE = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(;.+)?$/i
9595
export async function parseResponseBody(
9696
response: Response,
9797
method: string,
98-
parseJson: (text: string) => unknown,
98+
parseJson: (text: string, ctx?: { request: Request; response: Response }) => unknown,
9999
responseType?: "json" | "text" | "arrayBuffer" | "blob" | "stream",
100+
request?: Request,
100101
): Promise<unknown> {
101102
if (isBodylessResponse(response, method)) {
102103
if (responseType === "text") return ""
@@ -115,7 +116,7 @@ export async function parseResponseBody(
115116
if (responseType === "json" || JSON_RE.test(ct)) {
116117
const text = await response.text()
117118
if (text === "") return undefined
118-
return parseJson(text)
119+
return parseJson(text, request ? { request, response } : undefined)
119120
}
120121
if (ct.startsWith("text/")) return response.text()
121122
return response.arrayBuffer()

src/misina.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export function createMisina(defaults: MisinaOptions = {}): Misina {
147147
options.method,
148148
options.parseJson,
149149
options.responseType,
150+
ctx.request,
150151
)
151152
const httpError = new HTTPError(response, ctx.request, data)
152153
ctx.error = httpError
@@ -200,6 +201,7 @@ export function createMisina(defaults: MisinaOptions = {}): Misina {
200201
options.method,
201202
options.parseJson,
202203
options.responseType,
204+
ctx.request,
203205
)
204206

205207
if (options.validateResponse) {
@@ -334,11 +336,15 @@ function resolveOptions(
334336
redirectAllowDowngrade: init.redirectAllowDowngrade ?? defaults.redirectAllowDowngrade ?? false,
335337
throwHttpErrors: init.throwHttpErrors ?? defaults.throwHttpErrors ?? true,
336338
validateResponse: init.validateResponse ?? defaults.validateResponse,
337-
parseJson: init.parseJson ?? defaults.parseJson ?? JSON.parse,
339+
parseJson: init.parseJson ?? defaults.parseJson ?? defaultParseJson,
338340
stringifyJson: init.stringifyJson ?? defaults.stringifyJson ?? JSON.stringify,
339341
}
340342
}
341343

344+
function defaultParseJson(text: string): unknown {
345+
return JSON.parse(text)
346+
}
347+
342348
function mergeHeaders(
343349
a: Record<string, string> | undefined,
344350
b: Record<string, string> | undefined,

src/types.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,12 @@ export interface MisinaOptions {
165165
redirectMaxCount?: number
166166
/** Allow https → http redirect. Default: false. */
167167
redirectAllowDowngrade?: boolean
168-
/** Custom JSON parser. Default: JSON.parse. */
169-
parseJson?: (text: string) => unknown
168+
/**
169+
* Custom JSON parser. Default: JSON.parse. Optional context (request +
170+
* response) lets advanced parsers route on URL or content-type
171+
* (matches ky [PR #849](https://github.com/sindresorhus/ky/pull/849)).
172+
*/
173+
parseJson?: (text: string, ctx?: { request: Request; response: Response }) => unknown
170174
/** Custom JSON serializer for request bodies. Default: JSON.stringify. */
171175
stringifyJson?: (value: unknown) => string
172176
/** How arrays in `query` are serialized. Default: 'repeat'. */
@@ -259,7 +263,7 @@ export interface MisinaResolvedOptions {
259263
response: Response
260264
}) => boolean | Error)
261265
| undefined
262-
parseJson: (text: string) => unknown
266+
parseJson: (text: string, ctx?: { request: Request; response: Response }) => unknown
263267
stringifyJson: (value: unknown) => string
264268
}
265269

test/init-isolation.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from "vitest"
2+
import { createMisina } from "../src/index.ts"
3+
import mockDriverFactory, { getMockApi } from "../src/driver/mock.ts"
4+
5+
function jsonResponse(body: unknown): Response {
6+
return new Response(JSON.stringify(body), {
7+
headers: { "content-type": "application/json" },
8+
})
9+
}
10+
11+
describe("init hook — per-request isolation (ky #861)", () => {
12+
it("mutating headers in init does not leak to a sibling concurrent request", async () => {
13+
const driver = mockDriverFactory({ response: jsonResponse({}) })
14+
15+
let counter = 0
16+
const m = createMisina({
17+
driver,
18+
retry: 0,
19+
hooks: {
20+
init: (options) => {
21+
// Each request increments and writes its own value. If options
22+
// were shared, both requests would see the same final value.
23+
counter++
24+
options.headers["x-counter"] = String(counter)
25+
},
26+
},
27+
})
28+
29+
await Promise.all([m.get("https://api.test/a"), m.get("https://api.test/b")])
30+
31+
const calls = getMockApi(driver)?.calls
32+
expect(calls).toHaveLength(2)
33+
const counterA = calls?.[0]?.headers["x-counter"]
34+
const counterB = calls?.[1]?.headers["x-counter"]
35+
// Each request gets its own value; they shouldn't be identical and
36+
// should be 1 and 2 in some order.
37+
expect(new Set([counterA, counterB])).toEqual(new Set(["1", "2"]))
38+
})
39+
40+
it("mutating defaults.headers from init does not affect future requests", async () => {
41+
const driver = mockDriverFactory({ response: jsonResponse({}) })
42+
43+
const m = createMisina({
44+
driver,
45+
retry: 0,
46+
headers: { "x-base": "first" },
47+
hooks: {
48+
init: (options) => {
49+
// If options.headers shared the defaults reference, this would
50+
// mutate the *defaults* and persist across calls.
51+
options.headers["x-base"] = "mutated"
52+
},
53+
},
54+
})
55+
56+
await m.get("https://api.test/")
57+
await m.get("https://api.test/")
58+
59+
const calls = getMockApi(driver)?.calls
60+
// Both requests see 'mutated' because init runs each time, but the
61+
// mutation should NOT compound (e.g. become 'mutatedmutated').
62+
expect(calls?.[0]?.headers["x-base"]).toBe("mutated")
63+
expect(calls?.[1]?.headers["x-base"]).toBe("mutated")
64+
})
65+
})

test/parsejson-context.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, it } from "vitest"
2+
import { createMisina } from "../src/index.ts"
3+
import mockDriverFactory from "../src/driver/mock.ts"
4+
5+
describe("parseJson context (ky #849)", () => {
6+
it("optional ctx receives request and response for routing decisions", async () => {
7+
const driver = mockDriverFactory({
8+
response: new Response(JSON.stringify({ raw: 1 }), {
9+
headers: { "content-type": "application/json", "x-route": "v2" },
10+
}),
11+
})
12+
13+
let observedUrl: string | undefined
14+
let observedHeader: string | null | undefined
15+
16+
const m = createMisina({
17+
driver,
18+
retry: 0,
19+
parseJson: (text, ctx) => {
20+
observedUrl = ctx?.request.url
21+
observedHeader = ctx?.response.headers.get("x-route")
22+
return JSON.parse(text)
23+
},
24+
})
25+
26+
await m.get("https://api.test/items")
27+
28+
expect(observedUrl).toBe("https://api.test/items")
29+
expect(observedHeader).toBe("v2")
30+
})
31+
32+
it("works without context (backward-compatible)", async () => {
33+
const driver = mockDriverFactory({
34+
response: new Response('{"a":1}', {
35+
headers: { "content-type": "application/json" },
36+
}),
37+
})
38+
39+
const m = createMisina({
40+
driver,
41+
retry: 0,
42+
parseJson: (text) => JSON.parse(text),
43+
})
44+
45+
const res = await m.get<{ a: number }>("https://api.test/")
46+
expect(res.data).toEqual({ a: 1 })
47+
})
48+
})

0 commit comments

Comments
 (0)