RestEndpoint
RestEndpoints
are for HTTP based protocols like REST.
- RestEndpoint
- Endpoint
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;
}
class Endpoint<F extends (...args: any) => Promise<any>> {
constructor(fetchFunction: F, options: EndpointOptions);
key(...args: Parameters<F>): string;
readonly sideEffect?: true;
readonly schema?: Schema;
/** Default data expiry length, will fall back to NetworkManager default if not defined */
readonly dataExpiryLength?: number;
/** Default error expiry length, will fall back to NetworkManager default if not defined */
readonly errorExpiryLength?: number;
/** Poll with at least this frequency in miliseconds */
readonly pollFrequency?: number;
/** Marks cached resources as invalid if they are stale */
readonly invalidIfStale?: boolean;
/** Enables optimistic updates for this request - uses return value as assumed network response */
readonly getOptimisticResponse?: (
snap: SnapshotInterface,
...args: Parameters<F>
) => ResolveType<F>;
/** Determines whether to throw or fallback to */
readonly errorPolicy?: (error: any) => 'soft' | undefined;
}
RestEndpoint
extends Endpoint
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
const get = new RestEndpoint({ path: '/', schema: Todo });
// todo is Todo
const todo = useSuspense(get);
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 get = new RestEndpoint({
path: '/',
process(value): TodoInterface {
return value;
},
});
// todo is TodoInterface
const todo = await get();
const todo2 = await controller.fetch(get);
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 get = new RestEndpoint({ path: '/' });
get();
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.
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.
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 });
Fetch Lifecycle
RestEndpoint adds to Endpoint by providing customizations for a provided fetch method.
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' });
Types are inferred automatically from path
.
body
can be used to set a second argument for mutation endpoints. The actual value is not
used in any way so it does not matter.
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
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
.
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.
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
getHeaders(headers: HeadersInit): HeadersInit
Called by getRequestInit to determine HTTP Headers
This is often useful for authentication
Don't use hooks here.
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.
The return type of process can be used to set the return type of the endpoint fetch:
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;
}
// title is string
const title = (await getTodo({ id })).title;
schema?: Schema
- Global data consistency and performance with DRY state: where to expect Entities
- Classes to deserialize fields
- Race condition handling
- Validation
- Expiry
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.
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.
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() 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.
update(normalizedResponseOfThis, ...args) => ({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) })
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:
const createUser = new RestEndpoint({
path: '/user',
method: 'POST',
schema: User,
update: (newUserId: string) => ({
[userList.key()]: (users = []) => [newUserId, ...users],
}),
});
More updates:
const allusers = useSuspense(userList);
const adminUsers = useSuspense(userList, { admin: true });
The endpoint below ensures the new user shows up immediately in the usages above.
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
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,
],
}),
}),
};
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(),
};
},
});
paginated(removeCursor): args
function paginated<E, A extends any[]>(
this: E,
removeCursor: (...args: A) => readonly [...Parameters<E>],
): PaginationEndpoint<E, A>;
Extends an endpoint whose schema contains an Array and creates a new endpoint that will append the items it finds into the list from the first endpoint. See Infinite Scrolling Pagination for more info.
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
.
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(),
};
}
}