tkrotoff/fetch

View on GitHub
src/Http.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
import { entriesToObject } from './utils/entriesToObject';
import { HttpError } from './HttpError';

/**
 * `Promise<`[`Response`](https://fetch.spec.whatwg.org/#response)`>` with added methods from [`Body`](https://fetch.spec.whatwg.org/#body-mixin).
 */
// https://github.com/microsoft/TypeScript/blob/v4.7.4/lib/lib.dom.d.ts#L2503-L2507
export type ResponsePromiseWithBodyMethods = Promise<Response> &
  Pick<Body, 'arrayBuffer' | 'blob' | 'formData' | /*'json' |*/ 'text'> & {
    // FIXME https://github.com/microsoft/TypeScript/issues/26188
    json(): Promise<unknown>;
  };

const arrayBufferMimeType = '*/*';
const blobMimeType = '*/*';
const formDataMimeType = 'multipart/form-data';
export const jsonMimeType = 'application/json';
const textMimeType = 'text/*';

export function isJSONResponse(response: Response) {
  const contentType = response.headers.get('content-type') ?? '';
  return contentType.includes(jsonMimeType);
}

function extendResponsePromiseWithBodyMethods(
  responsePromise: ResponsePromiseWithBodyMethods,
  headers: Headers
) {
  function setAcceptHeader(mimeType: string) {
    headers.set('accept', headers.get('accept') ?? mimeType);
  }

  /* eslint-disable no-param-reassign */

  responsePromise.arrayBuffer = async () => {
    setAcceptHeader(arrayBufferMimeType);
    const response = await responsePromise;
    return response.arrayBuffer();
  };

  responsePromise.blob = async () => {
    setAcceptHeader(blobMimeType);
    const response = await responsePromise;
    return response.blob();
  };

  responsePromise.formData = async () => {
    setAcceptHeader(formDataMimeType);
    const response = await responsePromise;
    return response.formData();
  };

  responsePromise.json = async () => {
    setAcceptHeader(jsonMimeType);
    const response = await responsePromise;
    if (isJSONResponse(response)) {
      return response.json();
    }
    return response.text();
  };

  responsePromise.text = async () => {
    setAcceptHeader(textMimeType);
    const response = await responsePromise;
    return response.text();
  };

  /* eslint-enable no-param-reassign */
}

export type Init = Omit<RequestInit, 'method' | 'body'>;

export type Config = { init: Init };

export const defaults: Config = {
  init: {}
};

type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';

// Can throw:
// - HttpError if !response.ok
// - TypeError if request blocked (DevTools or CORS) or network timeout (net::ERR_TIMED_OUT):
//   - Firefox 68: "TypeError: "NetworkError when attempting to fetch resource.""
//   - Chrome 76: "TypeError: Failed to fetch"
// - DOMException if request aborted
function request<T extends BodyInit>(
  input: RequestInfo | URL,
  headers: Headers,
  init: Omit<Init, 'headers'> | undefined,
  method: Method,
  body?: T
) {
  async function _fetch() {
    // Have to wait for headers to be modified inside extendResponsePromiseWithBodyMethods
    await Promise.resolve();

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

    const res = await fetch(req);

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

    return res;
  }

  const responsePromise = _fetch() as ResponsePromiseWithBodyMethods;
  extendResponsePromiseWithBodyMethods(responsePromise, headers);

  return responsePromise;
}

function getHeaders(init?: Init) {
  // We don't know if defaults.init.headers and init.headers are JSON or Headers instances
  // thus we have to make the conversion
  const defaultInitHeaders = entriesToObject(new Headers(defaults.init.headers));
  const initHeaders = entriesToObject(new Headers(init?.headers));
  return new Headers({ ...defaultInitHeaders, ...initHeaders });
}

function getJSONHeaders(init?: Init) {
  const headers = getHeaders(init);
  headers.set('content-type', jsonMimeType);
  return headers;
}

/**
 * Performs a HTTP `GET` request.
 */
export function get(input: RequestInfo | URL, init?: Init) {
  return request(input, getHeaders(init), init, 'GET');
}

/**
 * Performs a HTTP `POST` request.
 *
 * @see {@link postJSON()}
 */
export function post<T extends BodyInit>(input: RequestInfo | URL, body?: T, init?: Init) {
  return request(input, getHeaders(init), init, 'POST', body);
}

/**
 * Performs a HTTP `POST` request with a JSON body.
 *
 * @see {@link post()}
 */
// Should be named postJson or postJSON?
// - JS uses JSON.parse(), JSON.stringify(), toJSON()
// - Deno uses [JSON](https://github.com/denoland/deno/blob/v1.5.3/cli/dts/lib.webworker.d.ts#L387) and [Json](https://github.com/denoland/deno/blob/v1.5.3/cli/dts/lib.webworker.d.ts#L260)
//
// 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 postJSON<T extends object>(input: RequestInfo | URL, body: T, init?: Init) {
  return request(input, getJSONHeaders(init), init, 'POST', JSON.stringify(body));
}
// No need to have postFormData() and friends: the browser already sets the proper request content type
// Something like "content-type: multipart/form-data; boundary=----WebKitFormBoundaryl8VQ0sfwUpJEWna3"

/**
 * Performs a HTTP `PUT` request.
 *
 * @see {@link putJSON()}
 */
export function put<T extends BodyInit>(input: RequestInfo | URL, body?: T, init?: Init) {
  return request(input, getHeaders(init), init, 'PUT', body);
}

/**
 * Performs a HTTP `PUT` request with a JSON body.
 *
 * @see {@link put()}
 */
export function putJSON<T extends object>(input: RequestInfo | URL, body: T, init?: Init) {
  return request(input, getJSONHeaders(init), init, 'PUT', JSON.stringify(body));
}

/**
 * Performs a HTTP `PATCH` request.
 *
 * @see {@link patchJSON()}
 */
export function patch<T extends BodyInit>(input: RequestInfo | URL, body?: T, init?: Init) {
  return request(input, getHeaders(init), init, 'PATCH', body);
}

/**
 * Performs a HTTP `PATCH` request with a JSON body.
 *
 * @see {@link patch()}
 */
export function patchJSON<T extends object>(input: RequestInfo | URL, body: T, init?: Init) {
  return request(input, getJSONHeaders(init), init, 'PATCH', JSON.stringify(body));
}

/**
 * Performs a HTTP `DELETE` request.
 */
// Cannot be named delete :-/
export function del(input: RequestInfo | URL, init?: Init) {
  return request(input, getHeaders(init), init, 'DELETE');
}