Skip to main content

RestEndpoint

RestEndpoints are for HTTP based protocols like REST.

extends

RestEndpoint extends Endpoint

Interface
interface RestGenerics {
readonly path: string;
readonly schema?: Schema | undefined;
readonly method?: string;
readonly body?: any;
}

export class RestEndpoint<O extends RestGenerics = any> extends Endpoint {
/* Prepare fetch */
readonly path: string;
readonly urlPrefix: string;
readonly requestInit: RequestInit;
readonly method: string;
readonly signal: AbortSignal | undefined;
url(...args: Parameters<F>): string;
getRequestInit(
this: any,
body?: RequestInit['body'] | Record<string, unknown>,
): RequestInit;
getHeaders(headers: HeadersInit): HeadersInit;

/* Perform/process fetch */
fetchResponse(input: RequestInfo, init: RequestInit): Promise<Response>;
parseResponse(response: Response): Promise<any>;
process(value: any, ...args: Parameters<F>): any;
}

Usage​

All options are supported as arguments to the constructor, extend, and as overrides when using inheritance

Simplest retrieval​

const getTodo = new RestEndpoint({
path: 'https\\://jsonplaceholder.typicode.com/todos/:id',
});

Managing state​

export class Todo extends Entity {
id = '';
title = '';
completed = false;
pk() {
return this.id;
}
}
const getTodo = new RestEndpoint({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos/:id',
schema: Todo,
});
const updateTodo = new RestEndpoint({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos/:id',
method: 'PUT',
schema: Todo,
});

Using a Schema enables automatic data consistency without the need to hurt performance with refetching.

Typing​

Resolution/Return​

schema determines the return value when used with data-binding hooks like useSuspense, useDLE or useCache

export class Todo extends Entity {
id = '';
title = '';
completed = false;
pk() {
return this.id;
}
}
import { Todo } from './Todo';
const getTodo = new RestEndpoint({ path: '/', schema: Todo });
// Hover your mouse over 'todo' to see its type
const todo = useSuspense(getTodo);

process determines the resolution value when the endpoint is called directly or when use with Controller.fetch. Otherwise this will be 'any' to ensure compatibility.

interface TodoInterface {
title: string;
completed: boolean;
}
const getTodo = new RestEndpoint({
path: '/',
process(value): TodoInterface {
return value;
},
});
async () => {
// todo is TodoInterface
const todo = await getTodo();
const ctrl = useController();
const todo2 = await ctrl.fetch(getTodo);
};

Function Parameters​

path used to construct the url determines the type of the first argument. If it has no patterns, then the 'first' argument is skipped.

const getRoot = new RestEndpoint({ path: '/' });
getRoot();
const getById = new RestEndpoint({ path: '/:id' });
// both number and string types work as they are serialized into strings to construct the url
getById({ id: 5 });
getById({ id: '5' });

method determines whether there is a second argument to be sent as the body.

export const update = new RestEndpoint({ path: '/:id', method: 'PUT' });
update({ id: 5 }, { title: 'updated', completed: true });

However, this is typed as 'any' so it won't catch typos.

body can be used to type the argument after the url parameters. It is only used for typing so the value sent does not matter. undefined value can be used to 'disable' the second argument.

export const update = new RestEndpoint({
path: '/:id',
method: 'PUT',
body: {} as TodoInterface,
});
update({ id: 5 }, { title: 'updated', completed: true });
// `undefined` disables 'body' argument
const rpc = new RestEndpoint({
path: '/:id',
method: 'PUT',
body: undefined,
});
rpc({ id: 5 });

searchParams can be used in a similar way to body to specify types extra parameters, used for the GET searchParams/queryParams in a url().

const getUsers = new RestEndpoint({
path: '/:group/user/:id',
searchParams: {} as { isAdmin?: boolean; sort: 'asc' | 'desc' },
});
getList.url({ group: 'big', id: '5', sort: 'asc' }) === '/big/user/5?sort=asc';
getList.url({ group: 'big', id: '5', sort: 'desc', isAdmin: true }) ===
'/big/user/5?isAdmin=true&sort=asc';

Fetch Lifecycle​

RestEndpoint adds to Endpoint by providing customizations for a provided fetch method.

fetch implementation for RestEndpoint
function fetch(...args) {
const urlParams = this.#hasBody && args.length < 2 ? {} : args[0] || {};
const body = this.#hasBody ? args[args.length - 1] : undefined;
return this.fetchResponse(this.url(urlParams), this.getRequestInit(body))
.then(this.parseResponse)
.then(res => this.process(res, ...args));
}

Prepare Fetch​

Members double as options (second constructor arg). While none are required, the first few have defaults.

url(params): string​

urlPrefix + path template + '?' + searchParams

url() uses the params to fill in the path template. Any unused params members are then used as searchParams (aka 'GET' params - the stuff after ?).

searchParams (aka queryParams) are sorted to maintain determinism.

Implementation
url(urlParams = {}) {
const urlBase = getUrlBase(this.path)(urlParams);
const tokens = getUrlTokens(this.path);
const searchParams = {};
Object.keys(urlParams).forEach(k => {
if (!tokens.has(k)) {
searchParams[k] = urlParams[k];
}
});
if (Object.keys(searchParams).length) {
return `${this.urlPrefix}${urlBase}?${paramsToString(searchParams)}`;
}
return `${this.urlPrefix}${urlBase}`;
}

path: string​

Uses path-to-regex to build urls using the parameters passed. This also informs the types so they are properly enforced.

: prefixed words are key names. Both strings and numbers are accepted as options.

const getThing = new RestEndpoint({ path: '/:group/things/:id' });
getThing({ group: 'first', id: 77 });

? to indicate optional parameters

const optional = new RestEndpoint({ path: '/:group/things/:number?' });
optional({ group: 'first' });
optional({ group: 'first', number: 'fifty' });

\\ to escape special characters like : or ?

const getSite = new RestEndpoint({ path: 'https\\://site.com/:slug' });
getSite({ slug: 'first' });
info

Types are inferred automatically from path.

Additional parameters can be specified with searchParams and body.

searchParams: { [key:string]: string|number|boolean }​

searchParams can be to specify types extra parameters, used for the GET searchParams/queryParams in a url().

The actual value is not used in any way - this only determines typing.

const getReactSite = new RestEndpoint({
path: 'https\\://site.com/:slug',
searchParams: {} as { isReact: boolean },
});
getReactSite({ slug: 'cool', isReact: true });
getReactSite.url({ slug: 'cool', isReact: true }) ===
'https://site.com/cool?isReact=true';

body​

body can be used to set a second argument for mutation endpoints. The actual value is not used in any way - this only determines typing.

const updateSite = new RestEndpoint({
path: 'https\\://site.com/:slug',
body: {} as { url: string },
});
updateSite({ slug: 'cool' }, { url: 'https://resthooks.io/' });

urlPrefix: string = ''​

Prepends this to the compiled path

tip

For a dynamic prefix, try overriding the url() method:

const getTodo = new RestEndpoint({
path: '/todo/:id',
url(...args) {
return dynamicPrefix() + super.url(...args);
},
});

method: string = 'GET'​

Method is part of the HTTP protocol. REST protocols use these to indicate the type of operation. Because of this RestEndpoint uses this to inform sideEffect and whether the endpoint should use a body payload.

GET is 'readonly', other methods imply sideEffects.

GET and DELETE both default to no body.

How method affects function Parameters

method only influences parameters in the RestEndpoint constructor and not .extend(). This allows non-standard method-body combinations.

body will default to any. You can always set body explicitly to take full control. undefined can be used to indicate there is no body.

(id: string, myPayload: Record<string, unknown>) => {
const standardCreate = new RestEndpoint({
path: '/:id',
method: 'POST',
});
standardCreate({ id }, myPayload);
const nonStandardEndpoint = new RestEndpoint({
path: '/:id',
method: 'POST',
body: undefined,
});
// no second 'body' argument, because body was set to 'undefined'
nonStandardEndpoint({ id });
};

getRequestInit(body): RequestInit​

Prepares RequestInit used in fetch. This is sent to fetchResponse

async

Import from @rest-hooks/rest/next to get the next version, which is async

import { RestEndpoint, RestGenerics } from '@rest-hooks/rest/next';
export default class AuthdEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
async getRequestInit(body) {
return {
...super.getRequestInit(body),
method: await getMethod(),
};
}
}
async function getMethod() {
return 'GET';
}

getHeaders(headers: HeadersInit): HeadersInit​

Called by getRequestInit to determine HTTP Headers

This is often useful for authentication

caution

Don't use hooks here.

async

Import from @rest-hooks/rest/next to get the next version, which is async

import { RestEndpoint, RestGenerics } from '@rest-hooks/rest/next';
export default class AuthdEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
async getHeaders(headers: HeadersInit) {
return {
...headers,
'Access-Token': await getAuthToken(),
};
}
}
async function getAuthToken() {
return 'example';
}

Handle fetch​

fetchResponse(input, init): Promise​

Performs the fetch call

parseResponse(response): Promise​

Takes the Response and parses via .text() or .json()

process(value, ...args): any​

Perform any transforms with the parsed result. Defaults to identity function.

tip

The return type of process can be used to set the return type of the endpoint fetch:

▶getTodo.ts
export const getTodo = new RestEndpoint({
path: '/todos/:id',
// The identity function is the default value; so we aren't changing any runtime behavior
process(value): TodoInterface {
return value;
},
});
interface TodoInterface {
id: string;
title: string;
completed: boolean;
}
▶useTodo.ts
import { getTodo } from './getTodo';
async (id: string) => {
// hover title to see it is a string
// see TS autocomplete by deleting `.title` and retyping the `.`
const title = (await getTodo({ id })).title;
};

schema?: Schema​

Declarative data lifecycle

import { Entity, RestEndpoint } from '@rest-hooks/rest';

class User extends Entity {
readonly id: string = '';
readonly username: string = '';

pk() {
return this.id;
}
}

const getUser = new RestEndpoint({
path: '/users/:id',
schema: User,
});

Endpoint Life-Cycles​

These are inherited from Endpoint

dataExpiryLength?: number​

Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager.

Learn more about expiry time

errorExpiryLength?: number​

Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager.

errorPolicy?: (error: any) => 'soft' | undefined​

'soft' will use stale data (if exists) in case of error; undefined or not providing option will result in error.

Learn more about errorPolicy

errorPolicy(error) {
return error.status >= 500 ? 'soft' : undefined;
}

invalidIfStale: boolean​

Indicates stale data should be considered unusable and thus not be returned from the cache. This means that useSuspense() will suspend when data is stale even if it already exists in cache.

pollFrequency: number​

Frequency in millisecond to poll at. Requires using useSubscription() or useLive() to have an effect.

getOptimisticResponse: (snap, ...args) => fakePayload​

When provided, any fetches with this endpoint will behave as though the fakePayload return value from this function was a succesful network response. When the actual fetch completes (regardless of failure or success), the optimistic update will be replaced with the actual network response.

Optimistic update guide

update(normalizedResponseOfThis, ...args) => ({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) })​

UpdateType.ts
type UpdateFunction<
Source extends EndpointInterface,
Updaters extends Record<string, any> = Record<string, any>,
> = (
source: ResultEntry<Source>,
...args: Parameters<Source>
) => { [K in keyof Updaters]: (result: Updaters[K]) => Updaters[K] };

Simplest case:

userEndpoint.ts
const createUser = new RestEndpoint({
path: '/user',
method: 'POST',
schema: User,
update: (newUserId: string) => ({
[userList.key()]: (users = []) => [newUserId, ...users],
}),
});

More updates:

Component.tsx
const allusers = useSuspense(userList);
const adminUsers = useSuspense(userList, { admin: true });

The endpoint below ensures the new user shows up immediately in the usages above.

userEndpoint.ts
const createUser = new RestEndpoint({
path: '/user',
method: 'POST',
schema: User,
update: (newUserId, newUser) => {
const updates = {
[userList.key()]: (users = []) => [newUserId, ...users],
];
if (newUser.isAdmin) {
updates[userList.key({ admin: true })] = (users = []) => [newUserId, ...users];
}
return updates;
},
});

This is usage with a createResource

TodoResource.ts
import { Entity, createResource } from '@rest-hooks/rest';

export class Todo extends Entity {
readonly id: number = 0;
readonly userId: number = 0;
readonly title: string = '';
readonly completed: boolean = false;

pk() {
return `${this.id}`;
}
}

// We declare BaseTodoResource before TodoResource to prevent recursive type definitions
const BaseTodoResource = createResource({
path: 'https://jsonplaceholder.typicode.com/todos/:id',
schema: Todo,
});
export const TodoResource = {
...BaseTodoResource,
create: BaseTodoResource.create.extend({
update: (newResourceId: string) => ({
[todoList.key({})]: (resourceIds: string[] = []) => [
...resourceIds,
newResourceId,
],
}),
}),
};

key(urlParams): string​

Serializes the parameters. This is used to build a lookup key in global stores.

Default:

`${this.method} ${this.url(urlParams)}`;

testKey(key): boolean​

Returns true if the provided (fetch) key matches this endpoint.

This is used for mock interceptors with with <MockResolver />

extend(options): Endpoint​

Can be used to further customize the endpoint definition

const getUser = new RestEndpoint({ path: '/users/:id' });

const UserDetailNormalized = getUser.extend({
schema: User,
getHeaders(headers: HeadersInit): HeadersInit {
return {
...headers,
'Access-Token': getAuth(),
};
},
});

Specialized extenders​

paginated(cursorField): Endpoint​

Creates a new endpoint with an extra cursorField string that will be used to find the specific page, to append to this endpoint. See Infinite Scrolling Pagination for more info.

const getNextPage = getList.paginated('cursor');

paginated(removeCursor): Endpoint​

function paginated<E, A extends any[]>(
this: E,
removeCursor: (...args: A) => readonly [...Parameters<E>],
): PaginationEndpoint<E, A>;

The function form allows any argument processing. This is the equivalent of sending cursor string like above.

const getNextPage = getList.paginated(
({ cursor, ...rest }: { cursor: string | number }) =>
(Object.keys(rest).length ? [rest] : []) as any,
);

removeCusor is a function that takes the arguments sent in fetch of getNextPage and returns the arguments to update getList.

push​

This is a convenience to place newly created Entities at the end of a Collection.

When this RestEndpoint's schema contains a Collection, this returned a new RestEndpoint with its parents properties, but with method: 'POST' and schema: Collection.push

unshift​

This is a convenience to place newly created Entities at the start of a Collection.

When this RestEndpoint's schema contains a Collection, this returned a new RestEndpoint with its parents properties, but with method: 'POST' and schema: Collection.push

assign​

This is a convenience to add newly created Entities to a Values Collection.

When this RestEndpoint's schema contains a Collection, this returned a new RestEndpoint with its parents properties, but with method: 'POST' and schema: Collection.push

Inheritance​

Make sure you use RestGenerics to keep types working.

import { RestEndpoint, RestGenerics } from '@rest-hooks/rest';

class GithubEndpoint<O extends RestGenerics = any> extends RestEndpoint<O> {
urlPrefix = 'https://api.github.com';

getHeaders(headers: HeadersInit): HeadersInit {
return {
...headers,
'Access-Token': getAuth(),
};
}
}