Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ import ky from 'https://esm.sh/ky';

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).

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.
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`.

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

Expand Down
17 changes: 14 additions & 3 deletions source/core/Ky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,9 +265,13 @@ export class Ky {
return response[type]();
}

const text = response.status === 204 ? '' : await response.text();
const text = await response.text();
if (text === '') {
return schema === undefined ? undefined : validateJsonWithSchema(undefined, schema);
if (schema !== undefined) {
return validateJsonWithSchema(undefined, schema);
}

return JSON.parse(text);
}

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

if (this.#options.parseJson) {
response.json = async () => this.#options.parseJson!(await response.text(), {request, response});
response.json = async () => {
const text = await response.text();
if (text === '') {
return JSON.parse(text);
}

return this.#options.parseJson!(text, {request, response});
};
}

return response;
Expand Down
4 changes: 2 additions & 2 deletions source/types/ResponsePromise.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
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.
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`.
*/
import {type KyResponse} from './response.js';
import type {StandardSchemaV1, StandardSchemaV1InferOutput} from './standard-schema.js';
Expand Down Expand Up @@ -44,7 +44,7 @@ export type ResponsePromise<T = unknown> = {
const result2 = await ky<Result>(…).json();
```
*/
<JsonType = T>(): Promise<JsonType | undefined>;
<JsonType = T>(): Promise<JsonType>;

/**
Get the response body as JSON and validate it with a Standard Schema.
Expand Down
103 changes: 86 additions & 17 deletions test/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ test('.json() when response is chunked', async t => {

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

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

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

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

t.is(responseJson, undefined);
await t.throwsAsync(promise, {
message: /Unexpected end of JSON input/,
});
});

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

const responseJson = await ky(server.url).json();

t.is(responseJson, undefined);
await t.throwsAsync(ky(server.url).json(), {
message: /Unexpected end of JSON input/,
});
});

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

let parseJsonCalled = false;
const result = await ky(server.url, {
parseJson(text) {
await t.throwsAsync(ky(server.url, {
parseJson() {
parseJsonCalled = true;
return JSON.parse(text);
return undefined;
},
}).json();
}).json(), {
message: /Unexpected end of JSON input/,
});

t.is(result, undefined);
t.false(parseJsonCalled);
});

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

let parseJsonCalled = false;
const result = await ky(server.url, {
parseJson(text) {
await t.throwsAsync(ky(server.url, {
parseJson() {
parseJsonCalled = true;
return JSON.parse(text);
return undefined;
},
}).json();
}).json(), {
message: /Unexpected end of JSON input/,
});

t.is(result, undefined);
t.false(parseJsonCalled);
});

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

test('.json(schema) with empty body does not call parseJson before validation', async t => {
const server = await createHttpTestServer(t);
server.get('/', (_request, response) => {
response.end();
});

let parseJsonCalled = false;
const schema = createSchema<string>(value => ({
value: value === undefined ? 'empty:undefined' : 'non-empty',
}));

const responseJson = await ky.get(server.url, {
parseJson(text) {
parseJsonCalled = true;
return JSON.parse(text);
},
}).json(schema);

t.false(parseJsonCalled);
t.is(responseJson, 'empty:undefined');
});

test('.json(schema) with 204 response does not call parseJson before validation', async t => {
const server = await createHttpTestServer(t);
server.get('/', (_request, response) => {
response.status(204).end();
});

let parseJsonCalled = false;
const schema = createSchema<string>(value => ({
value: value === undefined ? 'empty:undefined' : 'non-empty',
}));

const responseJson = await ky.get(server.url, {
parseJson(text) {
parseJsonCalled = true;
return JSON.parse(text);
},
}).json(schema);

t.false(parseJsonCalled);
t.is(responseJson, 'empty:undefined');
});

test('.json(schema) with invalid JSON body throws parse error before validation', async t => {
const server = await createHttpTestServer(t);
server.get('/', (_request, response) => {
Expand Down Expand Up @@ -2144,6 +2192,27 @@ test('parseJson option with response.json()', async t => {
});
});

test('parseJson option with response.json() does not run on empty body', async t => {
const server = await createHttpTestServer(t);
server.get('/', (_request, response) => {
response.end();
});

let parseJsonCalled = false;
const response = await ky.get(server.url, {
parseJson() {
parseJsonCalled = true;
return undefined;
},
});

await t.throwsAsync(response.json(), {
message: /Unexpected end of JSON input/,
});

t.false(parseJsonCalled);
});

test('parseJson option with promise.json() shortcut', async t => {
const json = {hello: 'world'};

Expand Down