RestEndpoint
RestEndpoints
are for HTTP based protocols like REST.
RestEndpoint
extends Endpoint
Interface
- RestEndpoint
- Endpoint
interface RestGenerics {
readonly path: string;
readonly schema?: Schema | undefined;
readonly method?: string;
readonly body?: any;
readonly searchParams?: any;
readonly paginationField?: string;
process?(value: any, ...args: any): 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 paginationField?: string;
readonly signal: AbortSignal | undefined;
url(...args: Parameters<F>): string;
searchToString(searchParams: Record<string, any>): string;
getRequestInit(
this: any,
body?: RequestInit['body'] | Record<string, unknown>,
): Promise<RequestInit> | RequestInit;
getHeaders(headers: HeadersInit): Promise<HeadersInit> | HeadersInit;
/* Perform/process fetch */
fetchResponse(input: RequestInfo, init: RequestInit): Promise<Response>;
parseResponse(response: Response): Promise<any>;
process(value: any, ...args: Parameters<F>): any;
testKey(key: string): boolean;
}
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;
testKey(key: string): boolean;
}
Usage
All options are supported as arguments to the constructor, extend, and as overrides when using inheritance
Simplest retrieval
const getTodo = new RestEndpoint({
path: '/todos/:id',
});
const todo = await getTodo({ id: 1 });
Configuration sharing
Use RestEndpoint.extend() instead of {...getTodo}
(Object spread)
const updateTodo = getTodo.extend({ method: 'PUT' });
Managing state
export class Todo extends Entity { id = ''; title = ''; completed = false; } export const getTodo = new RestEndpoint({ urlPrefix: 'https://jsonplaceholder.typicode.com', path: '/todos/:id', schema: Todo, }); export const updateTodo = getTodo.extend({ method: 'PUT' });
Using a Schema enables automatic data consistency without the need to hurt performance with refetching.
Typing
import { Comment } from './Comment'; const getComments = new RestEndpoint({ path: '/posts/:postId/comments', schema: new schema.Collection([Comment]), searchParams: {} as { sortBy?: 'votes' | 'recent' } | undefined, }); // Hover your mouse over 'comments' to see its type const comments = useSuspense(getComments, { postId: '5', sortBy: 'votes', }); const ctrl = useController(); const createComment = async data => ctrl.fetch(getComments.push, { postId: '5' }, data);
Resolution/Return
schema determines the return value when used with data-binding hooks like useSuspense, useDLE, useCache or when used with Controller.fetch
import { Todo } from './Todo'; const getTodo = new RestEndpoint({ path: '/', schema: Todo }); // Hover your mouse over 'todo' to see its type const todo = useSuspense(getTodo); async () => { const ctrl = useController(); const todo2 = await ctrl.fetch(getTodo); };
process determines the resolution value when the endpoint is called directly. For
RestEndpoints
without a schema, it also determines the return type of hooks and Controller.fetch.
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 using inheritance or .extend().
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),
await this.getRequestInit(body),
)
.then(response => this.parseResponse(response))
.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
+ '?' + searchToString(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 ?
).
Implementation
import { getUrlBase, getUrlTokens } from '@rest-hooks/rest';
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}?${this.searchToString(searchParams)}`;
}
return `${this.urlPrefix}${urlBase}`;
}
searchToString(searchParams): string
Constructs the searchParams component of url.
By default uses the standard URLSearchParams global.
searchParams (aka queryParams) are sorted to maintain determinism.
Implementation
searchToString(searchParams) {
const params = new URLSearchParams(searchParams);
params.sort();
return params.toString();
}
Using qs
library
To encode complex objects in the searchParams, you can use the qs library.
import { RestEndpoint, RestGenerics } from '@data-client/rest';
import qs from 'qs';
class QSEndpoint<O extends RestGenerics = any> extends RestEndpoint<O> {
searchToString(searchParams) {
return qs.stringify(searchParams);
}
}
import QSEndpoint from './QSEndpoint'; const getFoo = new QSEndpoint({ path: '/foo', searchParams: {} as { a: Record<string, string> }, }); getFoo({ a: { b: 'c' } });
GET /foo?a%5Bb%5D=c
Content-Type: application/json
path: string
Uses path-to-regexp 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 :
, ?
, +
, *
, {
, or }
const getSite = new RestEndpoint({ path: 'https\\://site.com/:slug', }); getSite({ slug: 'first' });
Types are inferred automatically from path
.
Additional parameters can be specified with searchParams and body.
searchParams
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 });
GET https://site.com/cool?isReact=true
Content-Type: application/json
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.
This is only used by endpoings with a method that uses body: 'POST', 'PUT', 'PATCH'.
const updateSite = new RestEndpoint({ path: 'https\\://site.com/:slug', method: 'POST', body: {} as { url: string }, }); updateSite({ slug: 'cool' }, { url: '/' });
POST https://site.com/cool
Content-Type: application/json
Body: { "url": "/" }
paginationField
If specified, will add getPage method on the RestEndpoint
. Pagination guide. Schema
must also contain a Collection.
urlPrefix: string = ''
Prepends this to the compiled path
Inheritance defaults
export class MyEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
// this allows us to override the prefix in production environments, with a dev fallback
urlPrefix = process.env.API_SERVER ?? 'http://localhost:8000';
}
Learn more about inheritance patterns for RestEndpoint
Instance overrides
export const getTicker = new RestEndpoint({
urlPrefix: 'https://api.exchange.coinbase.com',
path: '/products/:product_id/ticker',
schema: Ticker,
});
Dynamic prefix
For a dynamic prefix, try overriding the url() method instead:
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. Setting
sideEffect
explicitly will override this behavior, allowing for non-standard API designs.
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.
(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
import { RestEndpoint, RestGenerics } from '@data-client/rest'; export default class AuthdEndpoint< O extends RestGenerics = any, > extends RestEndpoint<O> { async getRequestInit(body) { return { ...(await 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
Don't use hooks here. If you need to use hooks, try using hookifyResource
import { RestEndpoint, RestGenerics } from '@data-client/rest'; 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(input, init) call. When
response.ok is not true
(like 404),
will throw a NetworkError.
parseResponse(response): Promise
Takes the Response and parses via .text() or .json() depending
on 'content-type' header having 'json' (e.g., application/json
).
If status
is 204, resolves as null
.
Override this to handle other response types like arrayBuffer
process(value, ...args): any
Perform any transforms with the parsed result. Defaults to identity function (do nothing).
The return type of process can be used to set the return type of the endpoint fetch:
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; }
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; };
Endpoint Lifecycle
schema?: Schema
- Global data consistency and performance with DRY state: where to expect Entities
- Functions to deserialize fields
- Race condition handling
- Validation
import { Entity, RestEndpoint } from '@data-client/rest';
class User extends Entity {
id = '';
username = '';
}
const getUser = new RestEndpoint({
path: '/users/:id',
schema: User,
});
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 />, Controller.expireAll(), and Controller.invalidateAll().
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() or useLive() to have an effect.
getOptimisticResponse: (snap, ...args) => expectedResponse
When provided, any fetches with this endpoint will behave as though the expectedResponse
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.
import { resource } from '@data-client/rest'; import { Post } from './Post'; export { Post }; export const PostResource = resource({ path: '/posts/:id', searchParams: {} as { userId?: string | number } | undefined, schema: Post, }).extend('vote', { path: '/posts/:id/vote', method: 'POST', body: undefined, schema: Post, getOptimisticResponse(snapshot, { id }) { const post = snapshot.get(Post, { id }); if (!post) throw snapshot.abort; return { id, votes: post.votes + 1, }; }, });
update()
(normalizedResponseOfThis, ...args) =>
({ [endpointKey]: (normalizedResponseOfEndpointToUpdate) => updatedNormalizedResponse) })
Try using Collections instead.
They are much easier to use and more robust!
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;
},
});
extend(options): RestEndpoint
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
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
getPage
An endpoint to retrieve the next page using paginationField as the searchParameter key. Schema must also contain a Collection
const getTodos = new RestEndpoint({
path: '/todos',
schema: Todo,
paginationField: 'page',
});
const todos = useSuspense(getTodos);
return (
<PaginatedList
items={todos}
fetchNextPage={() =>
// fetches url `/todos?page=${nextPage}`
ctrl.fetch(TodoResource.getList.getPage, { page: nextPage })
}
/>
);
See pagination guide for more info.
paginated(paginationfield)
Creates a new endpoint with an extra paginationfield
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');
Schema must also contain a Collection
paginated(removeCursor)
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
.
Schema must also contain a Collection
Inheritance
Make sure you use RestGenerics
to keep types working.
import { RestEndpoint, type RestGenerics } from '@data-client/rest';
class GithubEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
urlPrefix = 'https://api.github.com';
getHeaders(headers: HeadersInit): HeadersInit {
return {
...headers,
'Access-Token': getAuth(),
};
}
}