Skip to content

Commit

Permalink
Add JSON generics to ky() and HTTPError (#619)
Browse files Browse the repository at this point in the history
  • Loading branch information
sholladay authored Aug 17, 2024
1 parent a7204c4 commit f76c7cd
Show file tree
Hide file tree
Showing 9 changed files with 78 additions and 29 deletions.
42 changes: 36 additions & 6 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ It's just a tiny package with no dependencies.
- URL prefix option
- Instances with custom defaults
- Hooks
- TypeScript niceties (e.g. `.json()` resolves to `unknown`, not `any`; `.json<T>()` can be used too)
- TypeScript niceties (e.g. `.json()` supports generics and defaults to `unknown`, not `any`)

## Install

Expand Down Expand Up @@ -120,13 +120,33 @@ import ky from 'https://esm.sh/ky';

### ky(input, options?)

The `input` and `options` are the same as [`fetch`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch), with some exceptions:

- The `credentials` option is `same-origin` by default, which is the default in the spec too, but not all browsers have caught up yet.
- Adds some more options. See below.
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 an empty string if body is empty or the response status is `204` instead of throwing a parse error due to an empty body.

```js
import ky from 'ky';

const user = await ky('/api/user').json();

console.log(user);
```

⌨️ **TypeScript:** Accepts an optional [type parameter](https://www.typescriptlang.org/docs/handbook/2/generics.html), which defaults to [`unknown`](https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown), and is passed through to the return type of `.json()`.

```ts
import ky from 'ky';

// user1 is unknown
const user1 = await ky('/api/users/1').json();
// user2 is a User
const user2 = await ky<User>('/api/users/2').json();
// user3 is a User
const user3 = await ky('/api/users/3').json<User>();

console.log([user1, user2, user3]);
```

### ky.get(input, options?)
### ky.post(input, options?)
### ky.put(input, options?)
Expand All @@ -136,13 +156,21 @@ Returns a [`Response` object](https://developer.mozilla.org/en-US/docs/Web/API/R

Sets `options.method` to the method name and makes a request.

⌨️ **TypeScript:** Accepts an optional type parameter for use with JSON responses (see [`ky()`](#kyinput-options)).

#### input

Type: `string` | `URL` | `Request`

Same as [`fetch` input](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request#input).

When using a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) instance as `input`, any URL altering options (such as `prefixUrl`) will be ignored.

#### options

Type: `object`

In addition to all the [`fetch` options](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options), it supports these options:
Same as [`fetch` options](https://developer.mozilla.org/en-US/docs/Web/API/fetch#options), plus the following additional options:

##### method

Expand Down Expand Up @@ -597,6 +625,8 @@ try {
}
```

⌨️ **TypeScript:** Accepts an optional [type parameter](https://www.typescriptlang.org/docs/handbook/2/generics.html), which defaults to [`unknown`](https://www.typescriptlang.org/docs/handbook/2/functions.html#unknown), and is passed through to the return type of `error.response.json()`.

### TimeoutError

The error thrown when the request times out. It has a `request` property with the [`Request` object](https://developer.mozilla.org/en-US/docs/Web/API/Request).
Expand Down
2 changes: 1 addition & 1 deletion source/core/Ky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class Ky {
ky._decorateResponse(response);

if (!response.ok && ky._options.throwHttpErrors) {
let error = new HTTPError(response, ky.request, (ky._options as unknown) as NormalizedOptions);
let error = new HTTPError(response, ky.request, ky._options as NormalizedOptions);

for (const hook of ky._options.hooks.beforeError) {
// eslint-disable-next-line no-await-in-loop
Expand Down
5 changes: 2 additions & 3 deletions source/errors/HTTPError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import type {NormalizedOptions} from '../types/options.js';
import type {KyRequest} from '../types/request.js';
import type {KyResponse} from '../types/response.js';

// eslint-lint-disable-next-line @typescript-eslint/naming-convention
export class HTTPError extends Error {
public response: KyResponse;
export class HTTPError<T = unknown> extends Error {
public response: KyResponse<T>;
public request: KyRequest;
public options: NormalizedOptions;

Expand Down
10 changes: 6 additions & 4 deletions source/types/ResponsePromise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ Returns a `Response` object with `Body` methods added for convenience. So you ca
*/
import {type KyResponse} from './response.js';

export type ResponsePromise = {
export type ResponsePromise<T = unknown> = {
arrayBuffer: () => Promise<ArrayBuffer>;

blob: () => Promise<Blob>;
Expand All @@ -30,10 +30,12 @@ export type ResponsePromise = {
value: number;
}
const result = await ky(…).json<Result>();
const result1 = await ky(…).json<Result>();
// or
const result2 = await ky<Result>(…).json();
```
*/
json: <T = unknown>() => Promise<T>;
json: <J = T>() => Promise<J>;

text: () => Promise<string>;
} & Promise<KyResponse>;
} & Promise<KyResponse<T>>;
12 changes: 6 additions & 6 deletions source/types/ky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,47 +19,47 @@ export type KyInstance = {
//=> `{data: '🦄'}`
```
*/
(url: Input, options?: Options): ResponsePromise;
<T>(url: Input, options?: Options): ResponsePromise<T>;

/**
Fetch the given `url` using the option `{method: 'get'}`.
@param url - `Request` object, `URL` object, or URL string.
@returns A promise with `Body` methods added.
*/
get: (url: Input, options?: Options) => ResponsePromise;
get: <T>(url: Input, options?: Options) => ResponsePromise<T>;

/**
Fetch the given `url` using the option `{method: 'post'}`.
@param url - `Request` object, `URL` object, or URL string.
@returns A promise with `Body` methods added.
*/
post: (url: Input, options?: Options) => ResponsePromise;
post: <T>(url: Input, options?: Options) => ResponsePromise<T>;

/**
Fetch the given `url` using the option `{method: 'put'}`.
@param url - `Request` object, `URL` object, or URL string.
@returns A promise with `Body` methods added.
*/
put: (url: Input, options?: Options) => ResponsePromise;
put: <T>(url: Input, options?: Options) => ResponsePromise<T>;

/**
Fetch the given `url` using the option `{method: 'delete'}`.
@param url - `Request` object, `URL` object, or URL string.
@returns A promise with `Body` methods added.
*/
delete: (url: Input, options?: Options) => ResponsePromise;
delete: <T>(url: Input, options?: Options) => ResponsePromise<T>;

/**
Fetch the given `url` using the option `{method: 'patch'}`.
@param url - `Request` object, `URL` object, or URL string.
@returns A promise with `Body` methods added.
*/
patch: (url: Input, options?: Options) => ResponsePromise;
patch: <T>(url: Input, options?: Options) => ResponsePromise<T>;

/**
Fetch the given `url` using the option `{method: 'head'}`.
Expand Down
4 changes: 2 additions & 2 deletions source/types/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,6 @@ type CombinedRequestInit = globalThis.RequestInit & UndiciRequestInit;

export type RequestInitRegistry = {[K in keyof CombinedRequestInit]-?: true};

export type KyRequest = {
json: <T = unknown>() => Promise<T>;
export type KyRequest<T = unknown> = {
json: <J = T>() => Promise<J>;
} & Request;
4 changes: 2 additions & 2 deletions source/types/response.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export type KyResponse = {
json: <T = unknown>() => Promise<T>;
export type KyResponse<T = unknown> = {
json: <J = T >() => Promise<J>;
} & Response;
22 changes: 19 additions & 3 deletions test/http-error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import test from 'ava';
import {expectTypeOf} from 'expect-type';
import {HTTPError} from '../source/index.js';
import {type Mutable} from '../source/utils/types.js';

Expand All @@ -14,7 +15,7 @@ function createFakeResponse({status, statusText}: {status?: number; statusText?:

test('HTTPError handles undefined response.statusText', t => {
const status = 500;
// @ts-expect-error missing Request
// @ts-expect-error missing options
const error = new HTTPError(
// This simulates the case where a browser Response object does
// not define statusText, such as IE, Safari, etc.
Expand All @@ -27,7 +28,7 @@ test('HTTPError handles undefined response.statusText', t => {
});

test('HTTPError handles undefined response.status', t => {
// @ts-expect-error missing Request
// @ts-expect-error missing options
const error = new HTTPError(
// This simulates a catastrophic case where some unexpected
// response object was sent to HTTPError.
Expand All @@ -39,7 +40,7 @@ test('HTTPError handles undefined response.status', t => {
});

test('HTTPError handles a response.status of 0', t => {
// @ts-expect-error missing Request
// @ts-expect-error missing options
const error = new HTTPError(
// Apparently, it's possible to get a response status of 0.
createFakeResponse({statusText: undefined, status: 0}),
Expand All @@ -48,3 +49,18 @@ test('HTTPError handles a response.status of 0', t => {

t.is(error.message, 'Request failed with status code 0: GET invalid:foo');
});

test('HTTPError provides response.json()', async t => {
// @ts-expect-error missing options
const error = new HTTPError<{foo: 'bar'}>(
new Response(JSON.stringify({foo: 'bar'})),
new Request('invalid:foo'),
);

const responseJson = await error.response.json();

expectTypeOf(responseJson).toEqualTypeOf<{foo: 'bar'}>();

t.true(error.response instanceof Response);
t.deepEqual(responseJson, {foo: 'bar'});
});
6 changes: 4 additions & 2 deletions test/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,9 @@ test('.json() when response is chunked', async t => {
response.end(']');
});

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

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

t.deepEqual(responseJson, ['one', 'two']);

Expand Down Expand Up @@ -831,7 +833,7 @@ test('parseJson option with response.json()', async t => {

const responseJson = await response.json<{hello: string; extra: string}>();

expectTypeOf(responseJson).toMatchTypeOf({hello: 'world', extra: 'extraValue'});
expectTypeOf(responseJson).toEqualTypeOf({hello: 'world', extra: 'extraValue'});

t.deepEqual(responseJson, {
...json,
Expand Down

0 comments on commit f76c7cd

Please sign in to comment.