title | description |
---|---|
RestEndpoint |
Strongly typed path-based API definitions. |
import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem';
RestEndpoints
are for HTTP based protocols like REST.
<Tabs defaultValue="RestEndpoint" values={[ { label: 'RestEndpoint', value: 'RestEndpoint' }, { label: 'Endpoint', value: '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;
}
:::info extends
RestEndpoint
extends Endpoint
:::
All options are supported as arguments to the constructor, extend, and as overrides when using inheritance
const getTodo = new RestEndpoint({
path: 'https\\://jsonplaceholder.typicode.com/todos/:id',
});
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.
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);
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 });
RestEndpoint adds to Endpoint by providing customizations for a provided fetch method.
flowchart TB
URL-->response
INIT-->response
subgraph Prepare Fetch
subgraph URL
direction BT
urlPrefix-->url("url(...args)")
path-->url
end
subgraph INIT
direction BT
getHeaders("getHeaders()")-->reqinit("getRequestInit(...args)")
method-->reqinit
signal-->reqinit
end
end
subgraph Perform Fetch
response("fetchResponse()")-->parse("parseResponse()")
parse-->process("process()")
end
click url "#url"
click urlPrefix "#urlPrefix"
click path "#path"
click getHeaders "#getHeaders"
click method "#method"
click signal "https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal"
click reqinit "#getRequestInit"
click response "#fetchResponse"
click parse "#parseResponse"
click process "#process"
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));
}
Members double as options (second constructor arg). While none are required, the first few have defaults.
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}`;
}
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
.
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',
// highlight-next-line
body: {} as { url: string },
});
updateSite({ slug: 'cool' }, { url: 'https://resthooks.io/' });
:::
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 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
.
:::tip 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.
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 });
:::
Prepares RequestInit used in fetch. This is sent to fetchResponse
Called by getRequestInit to determine HTTP Headers
This is often useful for authentication
:::caution
Don't use hooks here.
:::
Performs the fetch call
Takes the Response and parses via .text() or .json()
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:
const getTodo = new RestEndpoint({
path: '/todos/:id',
// The identity function is the default value; so we aren't changing any runtime behavior
// highlight-next-line
process(value): TodoInterface {
return value;
},
});
interface TodoInterface {
id: string;
title: string;
completed: boolean;
}
// title is string
const title = (await getTodo({ id })).title;
:::
- 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,
});
These are inherited from Endpoint
Custom data cache lifetime for the fetched resource. Will override the value set in NetworkManager.
Custom data error lifetime for the fetched resource. Will override the value set in NetworkManager.
'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;
}
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.
Frequency in millisecond to poll at. Requires using useSubscription() to have an effect.
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) }) {#update}
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({
// highlight-start
update: (newResourceId: string) => ({
[todoList.key({})]: (resourceIds: string[] = []) => [
...resourceIds,
newResourceId,
],
}),
// highlight-end
}),
};
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(),
};
},
});
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
.
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(),
};
}
}