@zimic/fetch
Version:
Next-gen, TypeScript-first fetch-like API client
617 lines (601 loc) • 21.2 kB
text/typescript
import { HttpSchemaPath, HttpSchemaMethod, LiteralHttpSchemaPathFromNonLiteral, HttpSchema } from '@zimic/http';
import { PossiblePromise, RequiredByKey } from '@zimic/utils/types';
import FetchResponseError from '../errors/FetchResponseError';
import { FetchRequest, FetchRequestConstructor, FetchRequestInit, FetchResponse } from './requests';
/**
* The input to fetch a resource, either a path, a URL, or a {@link FetchRequest request}.
*
* @see {@link https://github.com/zimicjs/zimic/wiki/api‐zimic‐fetch#fetch `fetch` API reference}
*/
export type FetchInput<
Schema extends HttpSchema,
Method extends HttpSchemaMethod<Schema>,
Path extends HttpSchemaPath.NonLiteral<Schema, Method>,
> = Path | URL | FetchRequest<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>>;
/**
* A fetch instance typed with an HTTP schema, closely compatible with the
* {@link https://developer.mozilla.org/docs/Web/API/Fetch_API native Fetch API}. All requests and responses are typed by
* default with the schema, including methods, paths, status codes, parameters, and bodies.
*
* Requests sent by the fetch instance have their URL automatically prefixed with the base URL of the instance. Default
* options are also applied to the requests, if present in the instance.
*
* @example
* import { type HttpSchema } from '@zimic/http';
* import { createFetch } from '@zimic/fetch';
*
* interface User {
* id: string;
* username: string;
* }
*
* type Schema = HttpSchema<{
* '/users': {
* GET: {
* request: {
* searchParams: { query?: string };
* };
* response: {
* 200: { body: User[] };
* 404: { body: { message: string } };
* 500: { body: { message: string } };
* };
* };
* };
* }>;
*
* const fetch = createFetch<Schema>({
* baseURL: 'http://localhost:3000',
* headers: { 'accept-language': 'en' },
* });
*
* const response = await fetch('/users', {
* method: 'GET',
* searchParams: { query: 'u' },
* });
*
* if (response.status === 404) {
* return null; // User not found
* }
*
* if (!response.ok) {
* throw response.error;
* }
*
* const users = await response.json();
* return users; // User[]
*
* @param input The resource to fetch, either a path, a URL, or a {@link FetchRequest request}. If a path is provided, it
* is automatically prefixed with the base URL of the fetch instance when the request is sent. If a URL or a request
* is provided, it is used as is.
* @param init The request options. If a path or a URL is provided as the first argument, this argument is required and
* should contain at least the method of the request. If the first argument is a {@link FetchRequest request}, this
* argument is optional.
* @returns A promise that resolves to the response to the request.
* @see {@link https://github.com/zimicjs/zimic/wiki/api‐zimic‐fetch#fetch `fetch` API reference}
* @see {@link https://developer.mozilla.org/docs/Web/API/Fetch_API}
* @see {@link https://developer.mozilla.org/docs/Web/API/Request}
* @see {@link https://developer.mozilla.org/docs/Web/API/RequestInit}
* @see {@link https://developer.mozilla.org/docs/Web/API/Response}
*/
export interface Fetch<Schema extends HttpSchema> extends Pick<FetchOptions<Schema>, 'onRequest' | 'onResponse'> {
<
Method extends HttpSchemaMethod<Schema>,
Path extends HttpSchemaPath.NonLiteral<Schema, Method>,
Redirect extends RequestRedirect = 'follow',
>(
input: Path | URL,
init: FetchRequestInit<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>, Redirect>,
): Promise<FetchResponse<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>, false, Redirect>>;
<
Method extends HttpSchemaMethod<Schema>,
Path extends HttpSchemaPath.NonLiteral<Schema, Method>,
Redirect extends RequestRedirect = 'follow',
>(
input: FetchRequest<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>>,
init?: FetchRequestInit<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>, Redirect>,
): Promise<FetchResponse<Schema, Method, LiteralHttpSchemaPathFromNonLiteral<Schema, Method, Path>, false, Redirect>>;
/**
* The default options for each request sent by the fetch instance. The available options are the same as the
* {@link https://developer.mozilla.org/docs/Web/API/RequestInit `RequestInit`} options, plus `baseURL`.
*
* @example
* import { type HttpSchema } from '@zimic/http';
* import { createFetch } from '@zimic/fetch';
*
* interface Post {
* id: string;
* title: string;
* }
*
* type Schema = HttpSchema<{
* '/posts': {
* POST: {
* request: {
* headers: { 'content-type': 'application/json' };
* body: { title: string };
* };
* response: {
* 201: { body: Post };
* };
* };
* };
* }>;
*
* const fetch = createFetch<Schema>({
* baseURL: 'http://localhost:3000',
* headers: { 'accept-language': 'en' },
* });
*
* // Set the authorization header for all requests
* const { accessToken } = await authenticate();
*
* fetch.defaults.headers.authorization = `Bearer ${accessToken}`;
* console.log(fetch.defaults.headers);
*
* const response = await fetch('/posts', {
* method: 'POST',
* headers: { 'content-type': 'application/json' },
* body: JSON.stringify({ title: 'My post' }),
* });
*
* const post = await response.json(); // Post
*/
defaults: FetchDefaults;
/**
* A loosely-typed version of {@link Fetch `fetch`}. This can be useful to make requests with fewer type constraints,
* such as in {@link onRequest `onRequest`} and {@link onResponse `onResponse`} listeners.
*
* @example
* import { type HttpSchema } from '@zimic/http';
* import { createFetch } from '@zimic/fetch';
*
* interface User {
* id: string;
* username: string;
* }
*
* type Schema = HttpSchema<{
* '/auth/login': {
* POST: {
* request: {
* headers: { 'content-type': 'application/json' };
* body: { username: string; password: string };
* };
* response: {
* 201: { body: { accessToken: string } };
* };
* };
* };
*
* '/auth/refresh': {
* POST: {
* response: {
* 201: { body: { accessToken: string } };
* };
* };
* };
*
* '/users': {
* GET: {
* request: {
* headers: { authorization: string };
* };
* response: {
* 200: { body: User[] };
* 401: { body: { message: string } };
* 403: { body: { message: string } };
* };
* };
* };
* }>;
*
* const fetch = createFetch<Schema>({
* baseURL,
*
* async onResponse(response) {
* if (response.status === 401) {
* const body = await response.clone().json();
*
* if (body.message === 'Access token expired') {
* // Refresh the access token
* const refreshResponse = await this('/auth/refresh', { method: 'POST' });
* const { accessToken } = await refreshResponse.json();
*
* // Clone the original request and update its headers
* const updatedRequest = response.request.clone();
* updatedRequest.headers.set('authorization', `Bearer ${accessToken}`);
*
* // Retry the original request with the updated headers
* return this.loose(updatedRequest);
* }
* }
*
* return response;
* },
* });
*
* // Authenticate to your service before requests
* const loginRequest = await fetch('/auth/login', {
* method: 'POST',
* headers: { 'content-type': 'application/json' },
* body: JSON.stringify({ username: 'me', password: 'password' }),
* });
* const { accessToken } = await loginRequest.json();
*
* // Set the authorization header for all requests
* fetch.defaults.headers.authorization = `Bearer ${accessToken}`;
*
* const request = await fetch('/users', {
* method: 'GET',
* searchParams: { query: 'u' },
* });
*
* const users = await request.json(); // User[]
*
* @param input The resource to fetch, either a path, a URL, or a {@link FetchRequest request}. If a path is provided,
* it is automatically prefixed with the base URL of the fetch instance when the request is sent. If a URL or a
* request is provided, it is used as is.
* @param init The request options. If a path or a URL is provided as the first argument, this argument is required
* and should contain at least the method of the request. If the first argument is a {@link FetchRequest request},
* this argument is optional.
* @returns A promise that resolves to the response to the request.
* @see {@link https://github.com/zimicjs/zimic/wiki/api‐zimic‐fetch#fetchloose `fetch.loose` API reference}
* @see {@link https://developer.mozilla.org/docs/Web/API/Fetch_API}
* @see {@link https://developer.mozilla.org/docs/Web/API/Request}
* @see {@link https://developer.mozilla.org/docs/Web/API/RequestInit}
* @see {@link https://developer.mozilla.org/docs/Web/API/Response}
*/
loose: Fetch.Loose;
/**
* A constructor for creating
* {@link https://github.com/zimicjs/zimic/wiki/api‐zimic‐fetch#fetchrequest-1 `FetchRequest`}, closely compatible with
* the native {@link https://developer.mozilla.org/docs/Web/API/Request Request} constructor.
*
* @example
* import { type HttpSchema } from '@zimic/http';
* import { createFetch } from '@zimic/fetch';
*
* interface User {
* id: string;
* username: string;
* }
*
* type Schema = HttpSchema<{
* '/users': {
* POST: {
* request: {
* headers: { 'content-type': 'application/json' };
* body: { username: string };
* };
* response: {
* 201: { body: User };
* };
* };
* };
* }>;
*
* const fetch = createFetch<Schema>({
* baseURL: 'http://localhost:3000',
* });
*
* const request = new fetch.Request('/users', {
* method: 'POST',
* headers: { 'content-type': 'application/json' },
* body: JSON.stringify({ username: 'me' }),
* });
*
* console.log(request); // FetchRequest<Schema, 'POST', '/users'>
* console.log(request.path); // '/users'
*
* @see {@link https://github.com/zimicjs/zimic/wiki/api‐zimic‐fetch#fetchrequest-1 `FetchRequest`}
*/
Request: FetchRequestConstructor<Schema>;
/**
* A type guard that checks if a request is a {@link FetchRequest}, was created by the fetch instance, and has a
* specific method and path. This is useful to narrow down the type of a request before using it.
*
* @example
* import { type HttpSchema } from '@zimic/http';
* import { createFetch } from '@zimic/fetch';
*
* interface User {
* id: string;
* username: string;
* }
*
* type Schema = HttpSchema<{
* '/users': {
* POST: {
* request: {
* headers: { 'content-type': 'application/json' };
* body: { username: string };
* };
* response: {
* 201: { body: User };
* };
* };
* };
* }>;
*
* const fetch = createFetch<Schema>({
* baseURL: 'http://localhost:3000',
* });
*
* const request = new fetch.Request('/users', {
* method: 'POST',
* headers: { 'content-type': 'application/json' },
* body: JSON.stringify({ username: 'me' }),
* });
*
* if (fetch.isRequest(request, 'POST', '/users')) {
* // request is a FetchRequest<Schema, 'POST', '/users'>
*
* const contentType = request.headers.get('content-type'); // 'application/json'
* const body = await request.json(); // { username: string }
* }
*
* @param request The request to check.
* @param method The method to check.
* @param path The path to check.
* @returns `true` if the request was created by the fetch instance and has the specified method and path; `false`
* otherwise.
*/
isRequest: <Method extends HttpSchemaMethod<Schema>, Path extends HttpSchemaPath.Literal<Schema, Method>>(
request: unknown,
method: Method,
path: Path,
) => request is FetchRequest<Schema, Method, Path>;
/**
* A type guard that checks if a response is a {@link FetchResponse}, was received by the fetch instance, and has a
* specific method and path. This is useful to narrow down the type of a response before using it.
*
* @example
* import { type HttpSchema } from '@zimic/http';
* import { createFetch } from '@zimic/fetch';
*
* interface User {
* id: string;
* username: string;
* }
*
* type Schema = HttpSchema<{
* '/users': {
* GET: {
* request: {
* searchParams: { query?: string };
* };
* response: {
* 200: { body: User[] };
* };
* };
* };
* }>;
*
* const fetch = createFetch<Schema>({
* baseURL: 'http://localhost:3000',
* });
*
* const response = await fetch('/users', {
* method: 'GET',
* searchParams: { query: 'u' },
* });
*
* if (fetch.isResponse(response, 'GET', '/users')) {
* // response is a FetchResponse<Schema, 'GET', '/users'>
*
* const users = await response.json(); // User[]
* }
*
* @param response The response to check.
* @param method The method to check.
* @param path The path to check.
* @returns `true` if the response was received by the fetch instance and has the specified method and path; `false`
* otherwise.
*/
isResponse: <Method extends HttpSchemaMethod<Schema>, Path extends HttpSchemaPath.Literal<Schema, Method>>(
response: unknown,
method: Method,
path: Path,
) => response is FetchResponse<Schema, Method, Path>;
/**
* A type guard that checks if an error is a {@link FetchResponseError} related to a {@link FetchResponse response}
* received by the fetch instance with a specific method and path. This is useful to narrow down the type of an error
* before handling it.
*
* @example
* import { type HttpSchema } from '@zimic/http';
* import { createFetch } from '@zimic/fetch';
*
* interface User {
* id: string;
* username: string;
* }
*
* type Schema = HttpSchema<{
* '/users': {
* GET: {
* request: {
* searchParams: { query?: string };
* };
* response: {
* 200: { body: User[] };
* 400: { body: { message: string } };
* 500: { body: { message: string } };
* };
* };
* };
* }>;
*
* const fetch = createFetch<Schema>({
* baseURL: 'http://localhost:3000',
* });
*
* try {
* const response = await fetch('/users', {
* method: 'GET',
* searchParams: { query: 'u' },
* });
*
* if (!response.ok) {
* throw response.error; // FetchResponseError<Schema, 'GET', '/users'>
* }
* } catch (error) {
* if (fetch.isResponseError(error, 'GET', '/users')) {
* // error is a FetchResponseError<Schema, 'GET', '/users'>
*
* const status = error.response.status; // 400 | 500
* const { message } = await error.response.json(); // { message: string }
*
* console.error('Could not fetch users:', { status, message });
* }
* }
*
* @param error The error to check.
* @param method The method to check.
* @param path The path to check.
* @returns `true` if the error is a response error received by the fetch instance and has the specified method and
* path; `false` otherwise.
*/
isResponseError: <Method extends HttpSchemaMethod<Schema>, Path extends HttpSchemaPath.Literal<Schema, Method>>(
error: unknown,
method: Method,
path: Path,
) => error is FetchResponseError<Schema, Method, Path>;
}
export namespace Fetch {
/** A loosely-typed version of {@link Fetch `fetch`}. This can be useful to make requests with fewer type constraints, */
export type Loose = (
input: string | URL | FetchRequest.Loose,
init?: FetchRequestInit.Loose,
) => Promise<FetchResponse.Loose>;
}
/**
* The options to create a {@link https://github.com/zimicjs/zimic/wiki/api‐zimic‐fetch#fetch fetch instance}.
*
* @see {@link https://github.com/zimicjs/zimic/wiki/api‐zimic‐fetch#createfetch `createFetch(options)` API reference}
*/
export interface FetchOptions<Schema extends HttpSchema> extends Omit<FetchRequestInit.Defaults, 'method'> {
/**
* A listener function that is called for each request. It can modify the requests before they are sent.
*
* @example
* import { createFetch } from '@zimic/fetch';
* import { type HttpSchema } from '@zimic/http';
*
* interface User {
* id: string;
* username: string;
* }
*
* type Schema = HttpSchema<{
* '/users': {
* GET: {
* request: {
* searchParams: { page?: number; limit?: number };
* };
* response: {
* 200: { body: User[] };
* };
* };
* };
* }>;
*
* const fetch = createFetch<Schema>({
* baseURL: 'http://localhost:80',
*
* onRequest(request) {
* if (this.isRequest(request, 'GET', '/users')) {
* const url = new URL(request.url);
* url.searchParams.append('limit', '10');
*
* const updatedRequest = new Request(url, request);
* return updatedRequest;
* }
*
* return request;
* },
* });
*
* @param request The original request.
* @returns The request to be sent. It can be the original request or a modified version of it.
* @this {Fetch<Schema>} The fetch instance that is sending the request.
*/
onRequest?: (this: Fetch<Schema>, request: FetchRequest.Loose) => PossiblePromise<Request>;
/**
* A listener function that is called after each response is received. It can modify the responses before they are
* returned to the fetch caller.
*
* @example
* import { type HttpSchema } from '@zimic/http';
* import { createFetch } from '@zimic/fetch';
*
* interface User {
* id: string;
* username: string;
* }
*
* type Schema = HttpSchema<{
* '/users': {
* GET: {
* response: {
* 200: {
* headers: { 'content-encoding'?: string };
* body: User[];
* };
* };
* };
* };
* }>;
*
* const fetch = createFetch<Schema>({
* baseURL: 'http://localhost:80',
*
* onResponse(response) {
* if (this.isResponse(response, 'GET', '/users')) {
* console.log(response.headers.get('content-encoding'));
* }
* return response;
* },
* });
*
* @param response The original response.
* @returns The response to be returned.
* @this {Fetch<Schema>} The fetch instance that received the response.
*/
onResponse?: (this: Fetch<Schema>, response: FetchResponse.Loose) => PossiblePromise<Response>;
}
/**
* The default options for each request sent by the fetch instance.
*
* @see {@link https://github.com/zimicjs/zimic/wiki/api‐zimic‐fetch#fetchdefaults `fetch.defaults` API reference}
*/
export type FetchDefaults = RequiredByKey<FetchRequestInit.Defaults, 'headers' | 'searchParams'>;
/**
* Infers the schema of a {@link https://github.com/zimicjs/zimic/wiki/api‐zimic‐fetch#fetch fetch instance}.
*
* @example
* import { type HttpSchema } from '@zimic/http';
* import { createFetch, InferFetchSchema } from '@zimic/fetch';
*
* const fetch = createFetch<{
* '/users': {
* GET: {
* response: { 200: { body: User[] } };
* };
* };
* }>({
* baseURL: 'http://localhost:3000',
* });
*
* type Schema = InferFetchSchema<typeof fetch>;
* // {
* // '/users': {
* // GET: {
* // response: { 200: { body: User[] } };
* // };
* // };
*
* @see {@link https://github.com/zimicjs/zimic/wiki/api‐zimic‐fetch#fetch `fetch` API reference}
*/
export type InferFetchSchema<FetchInstance> = FetchInstance extends Fetch<infer Schema> ? Schema : never;