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;
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;
}

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} (spread)

const updateTodo = getTodo.extend({ method: 'PUT' });

Managing state

export class Todo extends Entity {
  id = '';
  title = '';
  completed = false;
  pk() {
    return this.id;
  }
}

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

export class Comment extends Entity {
  id = '';
  title = '';
  body = '';
  postId = '';
  pk() {
    return this.id;
  }
  static key = 'Comment';
}
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

export class Todo extends Entity {
  id = '';
  title = '';
  completed = false;
  pk() {
    return this.id;
  }
  static key = 'Todo';
}
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().

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),
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 { RestEndpoint, RestGenerics } from '@data-client/rest';
import qs from 'qs';

export default 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' } });
Request
GET /foo?a%5Bb%5D=c
Content-Type: application/json

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

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 });
Request
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: '/' });

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

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 { RestEndpoint, RestGenerics } from '@data-client/rest';

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

warning

Don't use hooks here. If you need to use hooks, try using hookifyResource

async
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 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 '@data-client/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.

import { Entity } from '@data-client/rest';

export class Post extends Entity {
  id = 0;
  userId = 0;
  title = '';
  body = '';
  votes = 0;

  pk() {
    return this.id?.toString();
  }
  static key = 'Post';

  get img() {
    return `//placekitten.com/96/72?image=${this.id % 16}`;
  }
}
import { RestEndpoint, createResource } from '@data-client/rest';
import { Post } from './Post';

export { Post };

export const PostResource = createResource({
  path: '/posts/:id',
  schema: Post,
}).extend(Base => ({
  vote: new RestEndpoint({
    path: '/posts/:id/vote',
    method: 'POST',
    body: undefined,
    schema: Post,
    getOptimisticResponse(snapshot, { id }) {
      const { data } = snapshot.getResponse(Base.get, { id });
      if (!data) throw snapshot.abort;
      return {
        id,
        votes: data.votes + 1,
      };
    },
  }),
}));
import { useController } from '@data-client/react';
import { PostResource, type Post } from './PostResource';

export default function PostItem({ post }: { post: Post }) {
  const ctrl = useController();
  const handleVote = () => {
    ctrl.fetch(PostResource.vote, { id: post.id });
  };
  return (
    <div>
      <div className="voteBlock">
        <small className="vote">
          <button className="up" onClick={handleVote}>
            &nbsp;
          </button>
          {post.votes}
        </small>
        <img src={post.img} width="70" height="52" />
      </div>
      <div>
        <h4>{post.title}</h4>
        <p>{post.body}</p>
      </div>
    </div>
  );
}
import { Query, schema } from '@data-client/rest';
import { Post } from './PostResource';

const queryTotalVotes = new Query(
  new schema.All(Post),
  (posts, { userId } = {}) => {
    if (userId !== undefined)
      posts = posts.filter(post => post.userId === userId);
    return posts.reduce((total, post) => total + post.votes, 0);
  },
);

export default function TotalVotes({ userId }: { userId: number }) {
  const totalVotes = useCache(queryTotalVotes, { userId });
  return (
    <center>
      <small>{totalVotes} votes total</small>
    </center>
  );
}
import { useSuspense } from '@data-client/react';
import { PostResource } from './PostResource';
import PostItem from './PostItem';
import TotalVotes from './TotalVotes';

function PostList() {
  const userId = 2;
  const posts = useSuspense(PostResource.getList, { userId });
  return (
    <div>
      {posts.map(post => (
        <PostItem key={post.pk()} post={post} />
      ))}
      <TotalVotes userId={userId} />
    </div>
  );
}
render(<PostList />);
🔴 Live Preview
Store
Optimistic update guide

update()

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

Try using Collections instead.

They are much easier to use and more robust!

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;
},
});

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().

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

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): Endpoint

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): 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.

Schema must also contain a Collection

Inheritance

Make sure you use RestGenerics to keep types working.

import { RestEndpoint, 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(),
};
}
}