Skip to content

Commit

Permalink
Add request property to HttpError
Browse files Browse the repository at this point in the history
  • Loading branch information
tkrotoff committed Jul 6, 2023
1 parent 4137bb5 commit 4de7a4d
Show file tree
Hide file tree
Showing 9 changed files with 76 additions and 46 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const config = {
'no-underscore-dangle': 'off',
'no-plusplus': 'off',
'spaced-comment': 'off',
'lines-between-class-members': 'off',
camelcase: 'off',

'import/no-extraneous-dependencies': 'off',
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Check [examples/web](examples/web)

### HttpError

@tkrotoff/fetch throws [`HttpError`](src/HttpError.ts) with a [`response`](https://fetch.spec.whatwg.org/#response) property when the HTTP status code is < `200` or >= `300`.
@tkrotoff/fetch throws [`HttpError`](src/HttpError.ts) with [`response`](https://fetch.spec.whatwg.org/#response) and [`request`](https://fetch.spec.whatwg.org/#request) properties when the HTTP status code is < `200` or >= `300`.

### Test utilities

Expand Down
43 changes: 20 additions & 23 deletions src/Http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { defaults, del, get, patch, patchJSON, post, postJSON, put, putJSON } fr
const path = '/';

test('defaults.init', async () => {
const url = 'http://localhost';
const url = 'http://localhost/';

const spy = jest
.spyOn(globalThis, 'fetch')
Expand All @@ -17,12 +17,11 @@ test('defaults.init', async () => {
// Should use defaults.init
await get(url).text();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenLastCalledWith(url, {
headers: expect.any(Headers),
method: 'GET'
});
let headers = entriesToObject(spy.mock.calls[0][1]!.headers as Headers);
expect(headers).toEqual({ accept: 'text/*' });
expect(spy).toHaveBeenLastCalledWith(expect.any(Request));
let req = spy.mock.calls[0][0] as Request;
expect(req.method).toEqual('GET');
expect(req.url).toEqual(url);
expect(entriesToObject(req.headers)).toEqual({ accept: 'text/*' });

// What happens when defaults.init is modified?
const originalInit = { ...defaults.init };
Expand All @@ -35,27 +34,25 @@ test('defaults.init', async () => {
spy.mockClear();
await get(url).text();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenLastCalledWith(url, {
mode: 'cors',
credentials: 'include',
headers: expect.any(Headers),
method: 'GET'
});
headers = entriesToObject(spy.mock.calls[0][1]!.headers as Headers);
expect(headers).toEqual({ accept: 'text/*', test1: 'true' });
expect(spy).toHaveBeenLastCalledWith(expect.any(Request));
req = spy.mock.calls[0][0] as Request;
expect(req.method).toEqual('GET');
expect(req.url).toEqual(url);
expect(req.mode).toEqual('cors');
expect(req.credentials).toEqual('include');
expect(entriesToObject(req.headers)).toEqual({ accept: 'text/*', test1: 'true' });

// Should not overwrite defaults.init.headers
spy.mockClear();
await get(url, { mode: 'no-cors', credentials: 'omit', headers: { test2: 'true' } }).text();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenLastCalledWith(url, {
mode: 'no-cors',
credentials: 'omit',
headers: expect.any(Headers),
method: 'GET'
});
headers = entriesToObject(spy.mock.calls[0][1]!.headers as Headers);
expect(headers).toEqual({ accept: 'text/*', test1: 'true', test2: 'true' });
expect(spy).toHaveBeenLastCalledWith(expect.any(Request));
req = spy.mock.calls[0][0] as Request;
expect(req.method).toEqual('GET');
expect(req.url).toEqual(url);
expect(req.mode).toEqual('no-cors');
expect(req.credentials).toEqual('omit');
expect(entriesToObject(req.headers)).toEqual({ accept: 'text/*', test1: 'true', test2: 'true' });

defaults.init = originalInit;
expect(defaults.init).toEqual({});
Expand Down
8 changes: 5 additions & 3 deletions src/Http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,19 @@ function request<T extends BodyInit>(
// Have to wait for headers to be modified inside extendResponsePromiseWithBodyMethods
await Promise.resolve();

const response = await fetch(input, {
const req = new Request(input, {
...defaults.init,
...init,
headers,
method,
body
});

if (!response.ok) throw new HttpError(response);
const res = await fetch(req);

return response;
if (!res.ok) throw new HttpError(req, res);

return res;
}

const responsePromise = _fetch() as ResponsePromiseWithBodyMethods;
Expand Down
17 changes: 13 additions & 4 deletions src/HttpError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { HttpError } from './HttpError';
const path = '/';

test('HttpError with statusText (HTTP/1.1)', async () => {
expect.assertions(6);
expect.assertions(8);

const server = createTestServer({ silenceErrors: true });

Expand All @@ -20,10 +20,12 @@ test('HttpError with statusText (HTTP/1.1)', async () => {
await get(url).text();
} catch (e) {
assert(e instanceof HttpError);
const { name, message, response } = e;
const { name, message, request, response } = e;
/* eslint-disable jest/no-conditional-expect */
expect(name).toEqual('HttpError');
expect(message).toEqual('Not Found');
expect(request.method).toEqual('GET');
expect(request.url).toContain('https://127.0.0.1:');
expect(response.status).toEqual(404);
expect(response.statusText).toEqual('Not Found');
expect(response.headers.get('content-type')).toEqual('application/json; charset=utf-8');
Expand Down Expand Up @@ -51,26 +53,33 @@ test('HttpError without statusText because of HTTP/2', async () => {

// With statusText
let e = new HttpError(
undefined!,
new Response(JSON.stringify(body), { status: 404, statusText: 'Not Found' })
);
expect(e.name).toEqual('HttpError');
expect(e.message).toEqual('Not Found');
expect(e.request).toEqual(undefined);
expect(e.response.status).toEqual(404);
expect(e.response.statusText).toEqual('Not Found');
expect(await e.response.json()).toEqual(body);

// Without statusText
e = new HttpError(new Response(JSON.stringify(body), { status: 404 }));
e = new HttpError(undefined!, new Response(JSON.stringify(body), { status: 404 }));
expect(e.name).toEqual('HttpError');
expect(e.message).toEqual('404');
expect(e.request).toEqual(undefined);
expect(e.response.status).toEqual(404);
expect(e.response.statusText).toEqual('');
expect(await e.response.json()).toEqual(body);

// With empty statusText
e = new HttpError(new Response(JSON.stringify(body), { status: 404, statusText: '' }));
e = new HttpError(
undefined!,
new Response(JSON.stringify(body), { status: 404, statusText: '' })
);
expect(e.name).toEqual('HttpError');
expect(e.message).toEqual('404');
expect(e.request).toEqual(undefined);
expect(e.response.status).toEqual(404);
expect(e.response.statusText).toEqual('');
expect(await e.response.json()).toEqual(body);
Expand Down
7 changes: 6 additions & 1 deletion src/HttpError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@
// - Node.js uses [http](https://nodejs.org/en/docs/guides/anatomy-of-an-http-transaction/)
// - Deno uses [Http](https://github.com/denoland/deno/blob/v1.5.3/cli/rt/01_errors.js#L116)
export class HttpError extends Error {
/**
* Undefined when using {@link createHttpError()} or {@link createResponsePromise()}.
*/
request: Request;
response: Response;

constructor(response: Response) {
constructor(request: Request, response: Response) {
const { status, statusText } = response;

super(
Expand All @@ -19,6 +23,7 @@ export class HttpError extends Error {
);

this.name = 'HttpError';
this.request = request;
this.response = response;
}
}
34 changes: 24 additions & 10 deletions src/createHttpError.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,10 @@ test('Response.error()', async () => {

test('200 OK', async () => {
{
const { name, message, response } = createHttpError('body', 200, 'OK');
const { name, message, request, response } = createHttpError('body', 200, 'OK');
expect(name).toEqual('HttpError');
expect(message).toEqual('OK');
expect(request).toEqual(undefined);
checkBody(response.body);
expect(response.bodyUsed).toEqual(false);
expect(entriesToObject(response.headers)).toEqual({
Expand All @@ -158,9 +159,10 @@ test('200 OK', async () => {
}

{
const { name, message, response } = createJSONHttpError({ body: true }, 200, 'OK');
const { name, message, request, response } = createJSONHttpError({ body: true }, 200, 'OK');
expect(name).toEqual('HttpError');
expect(message).toEqual('OK');
expect(request).toEqual(undefined);
checkBody(response.body);
expect(response.bodyUsed).toEqual(false);
expect(entriesToObject(response.headers)).toEqual({
Expand All @@ -180,9 +182,10 @@ test('200 OK', async () => {

test('204 No Content', async () => {
{
const { name, message, response } = createHttpError(undefined, 204, 'No Content');
const { name, message, request, response } = createHttpError(undefined, 204, 'No Content');
expect(name).toEqual('HttpError');
expect(message).toEqual('No Content');
expect(request).toEqual(undefined);
expect(response.body).toEqual(process.env.FETCH === 'whatwg-fetch' ? undefined : null);
expect(response.bodyUsed).toEqual(false);
expect(entriesToObject(response.headers)).toEqual({});
Expand Down Expand Up @@ -218,9 +221,10 @@ test('204 No Content', async () => {

test('404 Not Found', async () => {
{
const { name, message, response } = createHttpError('error', 404, 'Not Found');
const { name, message, request, response } = createHttpError('error', 404, 'Not Found');
expect(name).toEqual('HttpError');
expect(message).toEqual('Not Found');
expect(request).toEqual(undefined);
checkBody(response.body);
expect(response.bodyUsed).toEqual(false);
expect(entriesToObject(response.headers)).toEqual({
Expand All @@ -238,9 +242,14 @@ test('404 Not Found', async () => {
}

{
const { name, message, response } = createJSONHttpError({ error: 404 }, 404, 'Not Found');
const { name, message, request, response } = createJSONHttpError(
{ error: 404 },
404,
'Not Found'
);
expect(name).toEqual('HttpError');
expect(message).toEqual('Not Found');
expect(request).toEqual(undefined);
checkBody(response.body);
expect(response.bodyUsed).toEqual(false);
expect(entriesToObject(response.headers)).toEqual({
Expand All @@ -260,9 +269,10 @@ test('404 Not Found', async () => {

test('no statusText', async () => {
{
const { name, message, response } = createHttpError('body', 200);
const { name, message, request, response } = createHttpError('body', 200);
expect(name).toEqual('HttpError');
expect(message).toEqual('200');
expect(request).toEqual(undefined);
checkBody(response.body);
expect(response.bodyUsed).toEqual(false);
expect(entriesToObject(response.headers)).toEqual({
Expand All @@ -280,9 +290,10 @@ test('no statusText', async () => {
}

{
const { name, message, response } = createJSONHttpError({ body: true }, 200);
const { name, message, request, response } = createJSONHttpError({ body: true }, 200);
expect(name).toEqual('HttpError');
expect(message).toEqual('200');
expect(request).toEqual(undefined);
checkBody(response.body);
expect(response.bodyUsed).toEqual(false);
expect(entriesToObject(response.headers)).toEqual({
Expand Down Expand Up @@ -339,9 +350,10 @@ test('status 0', async () => {
test('no status', async () => {
{
// @ts-ignore
const { name, message, response } = createHttpError('body');
const { name, message, request, response } = createHttpError('body');
expect(name).toEqual('HttpError');
expect(message).toEqual('200');
expect(request).toEqual(undefined);
checkBody(response.body);
expect(response.bodyUsed).toEqual(false);
expect(entriesToObject(response.headers)).toEqual({
Expand All @@ -360,9 +372,10 @@ test('no status', async () => {

{
// @ts-ignore
const { name, message, response } = createJSONHttpError({ body: true });
const { name, message, request, response } = createJSONHttpError({ body: true });
expect(name).toEqual('HttpError');
expect(message).toEqual('200');
expect(request).toEqual(undefined);
checkBody(response.body);
expect(response.bodyUsed).toEqual(false);
expect(entriesToObject(response.headers)).toEqual({
Expand All @@ -382,9 +395,10 @@ test('no status', async () => {

test('no params', async () => {
// @ts-ignore
const { name, message, response } = createHttpError();
const { name, message, request, response } = createHttpError();
expect(name).toEqual('HttpError');
expect(message).toEqual('200');
expect(request).toEqual(undefined);
expect(response.body).toEqual(process.env.FETCH === 'whatwg-fetch' ? undefined : null);
expect(response.bodyUsed).toEqual(false);
expect(entriesToObject(response.headers)).toEqual({});
Expand Down
6 changes: 4 additions & 2 deletions src/createHttpError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { jsonMimeType } from './Http';
import { HttpError } from './HttpError';

/**
* Creates a {@link HttpError}.
* Creates a {@link HttpError} with the given response and an undefined request.
*
* @see {@link createJSONHttpError()}
*/
export function createHttpError(body: BodyInit | undefined, status: number, statusText?: string) {
return new HttpError(
undefined!,
new Response(body, {
status,
statusText
Expand All @@ -16,14 +17,15 @@ export function createHttpError(body: BodyInit | undefined, status: number, stat
}

/**
* Creates a {@link HttpError} with a JSON {@link Response} body.
* Creates a {@link HttpError} with a JSON {@link Response} response body and an undefined request.
*
* @see {@link createHttpError()}
*/
// Record<string, unknown> is compatible with "type" not with "interface": "Index signature is missing in type 'MyInterface'"
// Best alternative is object, why? https://stackoverflow.com/a/58143592
export function createJSONHttpError(body: object, status: number, statusText?: string) {
return new HttpError(
undefined!,
// FIXME Replace with [Response.json()](https://twitter.com/lcasdev/status/1564598435772342272)
new Response(JSON.stringify(body), {
status,
Expand Down
4 changes: 2 additions & 2 deletions src/createResponsePromise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ function extendResponsePromiseWithBodyMethods(
// We already reject just below (body method) and
// we don't want the "root" responsePromise rejection to be unhandled
});
reject(new HttpError(response));
reject(new HttpError(undefined!, response));
}
});
});
Expand Down Expand Up @@ -58,7 +58,7 @@ export function createResponsePromise(body?: BodyInit, init?: ResponseInit) {
} else {
// Let's call this the "root" responsePromise rejection
// Will be silently caught if we throw inside a body method, see extendResponsePromiseWithBodyMethods
reject(new HttpError(response));
reject(new HttpError(undefined!, response));
}
}) as ResponsePromiseWithBodyMethods;

Expand Down

0 comments on commit 4de7a4d

Please sign in to comment.