Skip to content

Commit 1af0fab

Browse files
committed
Make json() throw on empty responses
1 parent bbc98fc commit 1af0fab

File tree

4 files changed

+103
-23
lines changed

4 files changed

+103
-23
lines changed

readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ import ky from 'https://esm.sh/ky';
9191

9292
The `input` and `options` are the same as [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch), with additional `options` available (see below).
9393

94-
Returns a [`Response` object](https://developer.mozilla.org/en-US/docs/Web/API/Response) with [`Body` methods](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#body) added for convenience. So you can, for example, call `ky.get(input).json()` directly without having to await the `Response` first. When called like that, an appropriate `Accept` header will be set depending on the body method used. Unlike the `Body` methods of `window.fetch`, these will throw an `HTTPError` if the response status is not in the range of `200...299`. Also, `.json()` will return `undefined` if body is empty or the response status is `204` instead of throwing a parse error due to an empty body.
94+
Returns a [`Response` object](https://developer.mozilla.org/en-US/docs/Web/API/Response) with [`Body` methods](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch#body) added for convenience. So you can, for example, call `ky.get(input).json()` directly without having to await the `Response` first. When called like that, an appropriate `Accept` header will be set depending on the body method used. Unlike the `Body` methods of `window.fetch`, these will throw an `HTTPError` if the response status is not in the range of `200...299`. Also, `.json()` throws if the body is empty or the response status is `204`.
9595

9696
Available body shortcuts: `.json()`, `.text()`, `.formData()`, `.arrayBuffer()`, `.blob()`, and `.bytes()`. The `.bytes()` shortcut is only present when the runtime supports `Response.prototype.bytes()`.
9797

source/core/Ky.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,9 +265,13 @@ export class Ky {
265265
return response[type]();
266266
}
267267

268-
const text = response.status === 204 ? '' : await response.text();
268+
const text = await response.text();
269269
if (text === '') {
270-
return schema === undefined ? undefined : validateJsonWithSchema(undefined, schema);
270+
if (schema !== undefined) {
271+
return validateJsonWithSchema(undefined, schema);
272+
}
273+
274+
return JSON.parse(text);
271275
}
272276

273277
const jsonValue = initHookOptions.parseJson
@@ -535,7 +539,14 @@ export class Ky {
535539
const request = this.#getResponseRequest(response);
536540

537541
if (this.#options.parseJson) {
538-
response.json = async () => this.#options.parseJson!(await response.text(), {request, response});
542+
response.json = async () => {
543+
const text = await response.text();
544+
if (text === '') {
545+
return JSON.parse(text);
546+
}
547+
548+
return this.#options.parseJson!(text, {request, response});
549+
};
539550
}
540551

541552
return response;

source/types/ResponsePromise.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
Returns a `Response` object with `Body` methods added for convenience. So you can, for example, call `ky.get(input).json()` directly without having to await the `Response` first. When called like that, an appropriate `Accept` header will be set depending on the body method used. Unlike the `Body` methods of `window.fetch`, these will throw an `HTTPError` if the response status is not in the range of `200...299`. Also, `.json()` will return `undefined` if body is empty or the response status is `204` instead of throwing a parse error due to an empty body.
2+
Returns a `Response` object with `Body` methods added for convenience. So you can, for example, call `ky.get(input).json()` directly without having to await the `Response` first. When called like that, an appropriate `Accept` header will be set depending on the body method used. Unlike the `Body` methods of `window.fetch`, these will throw an `HTTPError` if the response status is not in the range of `200...299`. Also, `.json()` throws if the body is empty or the response status is `204`.
33
*/
44
import {type KyResponse} from './response.js';
55
import type {StandardSchemaV1, StandardSchemaV1InferOutput} from './standard-schema.js';
@@ -44,7 +44,7 @@ export type ResponsePromise<T = unknown> = {
4444
const result2 = await ky<Result>(…).json();
4545
```
4646
*/
47-
<JsonType = T>(): Promise<JsonType | undefined>;
47+
<JsonType = T>(): Promise<JsonType>;
4848

4949
/**
5050
Get the response body as JSON and validate it with a Standard Schema.

test/main.ts

Lines changed: 86 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ test('.json() when response is chunked', async t => {
261261

262262
const responseJson = await ky.get<['one', 'two']>(server.url).json();
263263

264-
expectTypeOf(responseJson).toEqualTypeOf<['one', 'two'] | undefined>();
264+
expectTypeOf(responseJson).toEqualTypeOf<['one', 'two']>();
265265

266266
t.deepEqual(responseJson, ['one', 'two']);
267267
});
@@ -287,10 +287,12 @@ test('.json() with empty body', async t => {
287287
response.end();
288288
});
289289

290-
const responseJson = await ky.get<{foo: string}>(server.url).json();
291-
expectTypeOf(responseJson).toEqualTypeOf<{foo: string} | undefined>();
290+
const promise = ky.get<{foo: string}>(server.url).json();
291+
expectTypeOf(promise).toEqualTypeOf<Promise<{foo: string}>>();
292292

293-
t.is(responseJson, undefined);
293+
await t.throwsAsync(promise, {
294+
message: /Unexpected end of JSON input/,
295+
});
294296
});
295297

296298
test('.json() with 204 response and empty body', async t => {
@@ -302,9 +304,9 @@ test('.json() with 204 response and empty body', async t => {
302304
response.status(204).end();
303305
});
304306

305-
const responseJson = await ky(server.url).json();
306-
307-
t.is(responseJson, undefined);
307+
await t.throwsAsync(ky(server.url).json(), {
308+
message: /Unexpected end of JSON input/,
309+
});
308310
});
309311

310312
test('.json() with 204 response does not call parseJson', async t => {
@@ -314,14 +316,15 @@ test('.json() with 204 response does not call parseJson', async t => {
314316
});
315317

316318
let parseJsonCalled = false;
317-
const result = await ky(server.url, {
318-
parseJson(text) {
319+
await t.throwsAsync(ky(server.url, {
320+
parseJson() {
319321
parseJsonCalled = true;
320-
return JSON.parse(text);
322+
return undefined;
321323
},
322-
}).json();
324+
}).json(), {
325+
message: /Unexpected end of JSON input/,
326+
});
323327

324-
t.is(result, undefined);
325328
t.false(parseJsonCalled);
326329
});
327330

@@ -332,14 +335,15 @@ test('.json() with empty body does not call parseJson', async t => {
332335
});
333336

334337
let parseJsonCalled = false;
335-
const result = await ky(server.url, {
336-
parseJson(text) {
338+
await t.throwsAsync(ky(server.url, {
339+
parseJson() {
337340
parseJsonCalled = true;
338-
return JSON.parse(text);
341+
return undefined;
339342
},
340-
}).json();
343+
}).json(), {
344+
message: /Unexpected end of JSON input/,
345+
});
341346

342-
t.is(result, undefined);
343347
t.false(parseJsonCalled);
344348
});
345349

@@ -579,6 +583,50 @@ test('.json(schema) validates 204 responses as undefined', async t => {
579583
t.deepEqual(error?.issues, issues);
580584
});
581585

586+
test('.json(schema) with empty body does not call parseJson before validation', async t => {
587+
const server = await createHttpTestServer(t);
588+
server.get('/', (_request, response) => {
589+
response.end();
590+
});
591+
592+
let parseJsonCalled = false;
593+
const schema = createSchema<string>(value => ({
594+
value: value === undefined ? 'empty:undefined' : 'non-empty',
595+
}));
596+
597+
const responseJson = await ky.get(server.url, {
598+
parseJson(text) {
599+
parseJsonCalled = true;
600+
return JSON.parse(text);
601+
},
602+
}).json(schema);
603+
604+
t.false(parseJsonCalled);
605+
t.is(responseJson, 'empty:undefined');
606+
});
607+
608+
test('.json(schema) with 204 response does not call parseJson before validation', async t => {
609+
const server = await createHttpTestServer(t);
610+
server.get('/', (_request, response) => {
611+
response.status(204).end();
612+
});
613+
614+
let parseJsonCalled = false;
615+
const schema = createSchema<string>(value => ({
616+
value: value === undefined ? 'empty:undefined' : 'non-empty',
617+
}));
618+
619+
const responseJson = await ky.get(server.url, {
620+
parseJson(text) {
621+
parseJsonCalled = true;
622+
return JSON.parse(text);
623+
},
624+
}).json(schema);
625+
626+
t.false(parseJsonCalled);
627+
t.is(responseJson, 'empty:undefined');
628+
});
629+
582630
test('.json(schema) with invalid JSON body throws parse error before validation', async t => {
583631
const server = await createHttpTestServer(t);
584632
server.get('/', (_request, response) => {
@@ -2144,6 +2192,27 @@ test('parseJson option with response.json()', async t => {
21442192
});
21452193
});
21462194

2195+
test('parseJson option with response.json() does not run on empty body', async t => {
2196+
const server = await createHttpTestServer(t);
2197+
server.get('/', (_request, response) => {
2198+
response.end();
2199+
});
2200+
2201+
let parseJsonCalled = false;
2202+
const response = await ky.get(server.url, {
2203+
parseJson() {
2204+
parseJsonCalled = true;
2205+
return undefined;
2206+
},
2207+
});
2208+
2209+
await t.throwsAsync(response.json(), {
2210+
message: /Unexpected end of JSON input/,
2211+
});
2212+
2213+
t.false(parseJsonCalled);
2214+
});
2215+
21472216
test('parseJson option with promise.json() shortcut', async t => {
21482217
const json = {hello: 'world'};
21492218

0 commit comments

Comments
 (0)