Skip to content

Commit 3713ce8

Browse files
authored
Add request/response context to parseJson option (#849)
1 parent 1d15eb6 commit 3713ce8

5 files changed

Lines changed: 161 additions & 6 deletions

File tree

readme.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,9 +734,12 @@ Default: `JSON.parse()`
734734
735735
User-defined JSON-parsing function.
736736
737+
The function receives the response text as the first argument and a context object as the second argument containing the `request` ([`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)) and `response` ([`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)).
738+
737739
Use-cases:
738740
1. Parse JSON via the [`bourne` package](https://github.com/hapijs/bourne) to protect from prototype pollution.
739741
2. Parse JSON with [`reviver` option of `JSON.parse()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse).
742+
3. Log or handle JSON parse errors with request context.
740743
741744
```js
742745
import ky from 'ky';
@@ -747,6 +750,17 @@ const json = await ky('https://example.com', {
747750
}).json();
748751
```
749752
753+
```js
754+
import ky from 'ky';
755+
756+
const json = await ky('https://example.com', {
757+
parseJson: (text, {request, response}) => {
758+
console.log(`Parsing JSON from ${request.url} (status: ${response.status})`);
759+
return JSON.parse(text);
760+
}
761+
}).json();
762+
```
763+
750764
##### stringifyJson
751765
752766
Type: `Function`\

source/core/Ky.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ export class Ky {
244244
}
245245

246246
const jsonValue = options.parseJson
247-
? await options.parseJson(text)
247+
? await options.parseJson(text, {request: ky.request, response})
248248
: JSON.parse(text);
249249

250250
return schema === undefined ? jsonValue : validateJsonWithSchema(jsonValue, schema);
@@ -491,7 +491,7 @@ export class Ky {
491491

492492
#decorateResponse(response: Response): Response {
493493
if (this.#options.parseJson) {
494-
response.json = async () => this.#options.parseJson!(await response.text());
494+
response.json = async () => this.#options.parseJson!(await response.text(), {request: this.request, response});
495495
}
496496

497497
return response;
@@ -511,7 +511,7 @@ export class Ky {
511511
return text;
512512
}
513513

514-
return this.#parseJson(text, errorDataTimeout);
514+
return this.#parseJson(text, response, errorDataTimeout);
515515
}
516516

517517
#isJsonContentType(contentType: string): boolean {
@@ -584,11 +584,14 @@ export class Ky {
584584
return result;
585585
}
586586

587-
async #parseJson(text: string, timeoutMs: number): Promise<unknown> {
587+
async #parseJson(text: string, response: Response, timeoutMs: number): Promise<unknown> {
588588
let timeoutId: ReturnType<typeof setTimeout> | undefined;
589589
try {
590590
return await Promise.race([
591-
Promise.resolve().then(() => (this.#options.parseJson ?? JSON.parse)(text)),
591+
Promise.resolve().then(() => this.#options.parseJson
592+
? this.#options.parseJson(text, {request: this.request, response})
593+
: JSON.parse(text),
594+
),
592595
new Promise<undefined>(resolve => {
593596
timeoutId = setTimeout(() => {
594597
resolve(undefined);

source/types/options.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,12 @@ export type KyOptions = {
4141
/**
4242
User-defined JSON-parsing function.
4343
44+
The function receives the response text as the first argument and a context object as the second argument containing the `request` and `response`.
45+
4446
Use-cases:
4547
1. Parse JSON via the [`bourne` package](https://github.com/hapijs/bourne) to protect from prototype pollution.
4648
2. Parse JSON with [`reviver` option of `JSON.parse()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse).
49+
3. Log or handle JSON parse errors with request context.
4750
4851
@default JSON.parse()
4952
@@ -56,8 +59,21 @@ export type KyOptions = {
5659
parseJson: text => bourne(text)
5760
}).json();
5861
```
62+
63+
@example
64+
```
65+
import ky from 'ky';
66+
67+
const json = await ky('https://example.com', {
68+
parseJson: (text, {request, response}) => {
69+
console.log(`Parsing JSON from ${request.url} (status: ${response.status})`);
70+
return JSON.parse(text);
71+
}
72+
}).json();
73+
```
5974
*/
60-
parseJson?: (text: string) => unknown;
75+
// `options` is intentionally not included in the context to avoid exposing Ky internals through a parsing callback. `request`/`response` already provide the metadata needed for logging.
76+
parseJson?: (text: string, context: {request: Request; response: Response}) => unknown;
6177

6278
/**
6379
User-defined JSON-stringifying function.

test/http-error.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,49 @@ test('HTTPError#data respects parseJson option', async t => {
126126
t.deepEqual(error?.data, {value: 1, custom: true});
127127
});
128128

129+
test('HTTPError#data parseJson receives context', async t => {
130+
const server = await createHttpTestServer(t);
131+
const body = {value: 1};
132+
server.get('/', (_request, response) => {
133+
response.status(400).json(body);
134+
});
135+
136+
const error = await t.throwsAsync<HTTPError>(ky.get(server.url, {
137+
parseJson(text, {request, response}) {
138+
t.true(request instanceof Request);
139+
t.true(response instanceof Response);
140+
t.is(response.status, 400);
141+
t.true(request.url.includes(server.url));
142+
const data = JSON.parse(text) as Record<string, unknown>;
143+
data.custom = true;
144+
return data;
145+
},
146+
}));
147+
t.deepEqual(error?.data, {value: 1, custom: true});
148+
});
149+
150+
test('HTTPError#data async parseJson receives context', async t => {
151+
const server = await createHttpTestServer(t);
152+
const body = {value: 1};
153+
server.get('/', (_request, response) => {
154+
response.status(400).json(body);
155+
});
156+
157+
const error = await t.throwsAsync<HTTPError>(ky.get(server.url, {
158+
async parseJson(text, {request, response}) {
159+
t.true(request instanceof Request);
160+
t.true(response instanceof Response);
161+
t.is(response.status, 400);
162+
t.true(request.url.includes(server.url));
163+
await Promise.resolve();
164+
const data = JSON.parse(text) as Record<string, unknown>;
165+
data.custom = true;
166+
return data;
167+
},
168+
}));
169+
t.deepEqual(error?.data, {value: 1, custom: true});
170+
});
171+
129172
test('HTTPError#data awaits async parseJson option', async t => {
130173
const server = await createHttpTestServer(t);
131174
const body = {value: 1};

test/main.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1864,6 +1864,85 @@ test('parseJson option errors are thrown before .json(schema) validation', async
18641864
t.false(isSchemaCalled());
18651865
});
18661866

1867+
test('parseJson option receives context via .json() shortcut', async t => {
1868+
const json = {hello: 'world'};
1869+
1870+
const server = await createHttpTestServer(t);
1871+
server.get('/', (_request, response) => {
1872+
response.json(json);
1873+
});
1874+
1875+
const responseJson = await ky
1876+
.get(server.url, {
1877+
parseJson(text, {request, response}) {
1878+
t.true(request instanceof Request);
1879+
t.true(response instanceof Response);
1880+
t.is(response.status, 200);
1881+
t.true(request.url.includes(server.url));
1882+
return JSON.parse(text);
1883+
},
1884+
})
1885+
.json();
1886+
1887+
t.deepEqual(responseJson, json);
1888+
});
1889+
1890+
test('parseJson option receives context via response.json()', async t => {
1891+
const json = {hello: 'world'};
1892+
1893+
const server = await createHttpTestServer(t);
1894+
server.get('/', (_request, response) => {
1895+
response.json(json);
1896+
});
1897+
1898+
const response = await ky.get(server.url, {
1899+
parseJson(text, {request, response}) {
1900+
t.true(request instanceof Request);
1901+
t.true(response instanceof Response);
1902+
t.is(response.status, 200);
1903+
t.true(request.url.includes(server.url));
1904+
return JSON.parse(text);
1905+
},
1906+
});
1907+
1908+
const responseJson = await response.json();
1909+
t.deepEqual(responseJson, json);
1910+
});
1911+
1912+
test('parseJson option receives context after retry', async t => {
1913+
let requestCount = 0;
1914+
const statuses: number[] = [];
1915+
1916+
const server = await createHttpTestServer(t);
1917+
server.get('/', (_request, response) => {
1918+
requestCount++;
1919+
if (requestCount === 1) {
1920+
response.status(500).json({error: 'fail'});
1921+
return;
1922+
}
1923+
1924+
response.json({hello: 'world'});
1925+
});
1926+
1927+
const responseJson = await ky
1928+
.get(server.url, {
1929+
retry: 1,
1930+
parseJson(text, {request, response}) {
1931+
t.true(request instanceof Request);
1932+
t.true(response instanceof Response);
1933+
t.true(request.url.includes(server.url));
1934+
statuses.push(response.status);
1935+
return JSON.parse(text);
1936+
},
1937+
})
1938+
.json();
1939+
1940+
t.deepEqual(responseJson, {hello: 'world'});
1941+
t.is(requestCount, 2);
1942+
// ParseJson is called for the error response (HTTPError#data) and the success response
1943+
t.deepEqual(statuses, [500, 200]);
1944+
});
1945+
18671946
test('stringifyJson option with request.json()', async t => {
18681947
const server = await createHttpTestServer(t, {bodyParser: false});
18691948

0 commit comments

Comments
 (0)