Skip to content

Latest commit

 

History

History
1260 lines (946 loc) · 32.7 KB

File metadata and controls

1260 lines (946 loc) · 32.7 KB
title RestEndpoint - Strongly typed path-based HTTP API definitions
sidebar_label RestEndpoint
description Strongly typed path-based extensible HTTP API definitions.

import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; import TypeScriptEditor from '@site/src/components/TypeScriptEditor'; import EndpointPlayground from '@site/src/components/HTTP/EndpointPlayground'; import Grid from '@site/src/components/Grid'; import Link from '@docusaurus/Link'; import HooksPlayground from '@site/src/components/HooksPlayground';

RestEndpoint

RestEndpoints are for HTTP based protocols like REST.

:::info extends

RestEndpoint extends Endpoint

:::

Interface

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

export class Comment extends Entity {
  id = '';
  title = '';
  body = '';
  postId = '';

  static key = 'Comment';
}
import { Comment } from './Comment';

const getComments = new RestEndpoint({
  path: '/posts/:postId/comments',
  schema: new 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;

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

import Lifecycle from '../diagrams/_restendpoint_lifecycle.mdx';

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 {#url}

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 {#searchToString}

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) {
    // highlight-next-line
    return qs.stringify(searchParams);
  }
}

<EndpointPlayground input="/foo?a%5Bb%5D=c" init={{method: 'GET', headers: {'Content-Type': 'application/json'}}}>

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

path: string {#path}

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

Parameters

: prefixed words are parameter names. Both strings and numbers are accepted as values, since they are serialized into the url string.

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

Optional parameters

Wrap the optional segment (including its prefix) in {} to make it optional. The type of optional parameters becomes string | number | undefined.

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

Multiple optional segments can be chained with different prefixes:

const ep = new RestEndpoint({
  path: '{/:attr1}{-:attr2}{-:attr3}',
});

ep({ attr1: 'hi' });
ep({ attr2: 'hi' });
ep({ attr1: 'hi', attr3: 'ho' });

Wildcards (repeating parameters)

*name matches one-or-more path segments. Wrap in {} to make it zero-or-more (optional). Wildcard parameters are typed as string[] (arrays), since they represent multiple path segments.

const files = new RestEndpoint({ path: '/files/*path' });
files({ path: ['documents', 'reports', 'q4'] });
// URL: /files/documents/reports/q4

const optionalFiles = new RestEndpoint({ path: '/files{/*path}' });
optionalFiles({});
// URL: /files
optionalFiles({ path: ['documents'] });
// URL: /files/documents

Quoted parameter names

Parameter names must be valid JavaScript identifiers. Names containing special characters like - or . must be quoted with double quotes:

const ep = new RestEndpoint({ path: '/:"with-dash"/:"my.param"' });
ep({ 'with-dash': 'hello', 'my.param': 'world' });

Escaping special characters

Characters {}()*: and \\ are special in path-to-regexp and must be escaped with \\ when used as literals.

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

? and + are not special in path-to-regexp v8 and do not need escaping. This means query strings can be embedded in the path without escaping ?:

const search = new RestEndpoint({
  path: '/search?{q=:q}{&page=:page}',
});
search({ q: 'test', page: 1 });
// URL: /search?q=test&page=1

:::info

Types are inferred automatically from path.

Additional parameters can be specified with searchParams and body.

:::

searchParams {#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.

<EndpointPlayground input="https://site.com/cool?isReact=true" init={{method: 'GET', headers: {'Content-Type': 'application/json'}}}>

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

getReactSite({ slug: 'cool', isReact: true });

body {#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'.

<EndpointPlayground input="https://site.com/cool" init={{method: 'POST', body: '{ "url": "/" }', headers: {'Content-Type': 'application/json'}}}>

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 = '' {#urlPrefix}

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

:::tip

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}

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.

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

(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 {#getRequestInit}

Prepares RequestInit used in fetch. This is sent to fetchResponse

:::tip async

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 {#getHeaders}

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

:::

:::tip 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 {#fetchResponse}

Performs the fetch(input, init) call. When response.ok is not true (like 404), will throw a NetworkError.

parseResponse(response): Promise {#parseResponse}

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 blob or arrayBuffer.

File downloads {#file-download}

For binary responses like file downloads, override parseResponse to use response.blob(). Set schema: undefined since binary data is not normalizable. Use dataExpiryLength: 0 to avoid caching large blobs in memory.

const downloadFile = new RestEndpoint({
  path: '/files/:id/download',
  schema: undefined,
  dataExpiryLength: 0,
  parseResponse(response) {
    return response.blob();
  },
  process(blob): { blob: Blob; filename: string } {
    return { blob, filename: 'download' };
  },
});

To extract the filename from the Content-Disposition header, override both parseResponse and process:

const downloadFile = new RestEndpoint({
  path: '/files/:id/download',
  schema: undefined,
  dataExpiryLength: 0,
  async parseResponse(response) {
    const blob = await response.blob();
    const disposition = response.headers.get('Content-Disposition');
    const filename =
      disposition?.match(/filename="?(.+?)"?$/)?.[1] ?? 'download';
    return { blob, filename };
  },
  process(value): { blob: Blob; filename: string } {
    return value;
  },
});

See file download guide for complete usage with browser download trigger.

process(value, ...args): any {#process}

Perform any transforms with the parsed result. Defaults to identity function (do nothing).

:::tip

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 {#schema}

Declarative data lifecycle

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 {#key}

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

Default:

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

testKey(key): boolean {#testKey}

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

This is used for mock interceptors with with <MockResolver />, Controller.expireAll(), and Controller.invalidateAll().

import EndpointLifecycle from './_EndpointLifecycle.mdx';

extend(options): RestEndpoint {#extend}

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

These convenience accessors create new endpoints for common Collection operations. They only work when the RestEndpoint's schema contains a Collection.

push

Creates a POST endpoint that places newly created Entities at the end of a Collection.

Returns a new RestEndpoint with method: 'POST' and schema: Collection.push

const getTodos = new RestEndpoint({
  path: '/todos',
  searchParams: {} as { userId?: string },
  schema: new Collection([Todo]),
});

// POST /todos - adds new Todo to the end of the list
const newTodo = await ctrl.fetch(
  getTodos.push,
  { userId: '1' },
  { title: 'Buy groceries' },
);
const UserResource = resource({
  path: '/groups/:group/users/:id',
  schema: User,
});

// POST /groups/five/users - adds new User to the end of the list
const newUser = await ctrl.fetch(
  UserResource.getList.push,
  { group: 'five' },
  { username: 'newuser', email: 'new@example.com' },
);

unshift

Creates a POST endpoint that places newly created Entities at the start of a Collection.

Returns a new RestEndpoint with method: 'POST' and schema: Collection.unshift

const getTodos = new RestEndpoint({
  path: '/todos',
  searchParams: {} as { userId?: string },
  schema: new Collection([Todo]),
});

// POST /todos - adds new Todo to the beginning of the list
const newTodo = await ctrl.fetch(
  getTodos.unshift,
  { userId: '1' },
  { title: 'Urgent task' },
);
const UserResource = resource({
  path: '/groups/:group/users/:id',
  schema: User,
});

// POST /groups/five/users - adds new User to the start of the list
const newUser = await ctrl.fetch(
  UserResource.getList.unshift,
  { group: 'five' },
  { username: 'priorityuser', email: 'priority@example.com' },
);

assign

Creates a POST endpoint that merges Entities into a Values Collection.

Returns a new RestEndpoint with method: 'POST' and schema: Collection.assign

const getStats = new RestEndpoint({
  path: '/products/stats',
  schema: new Collection(new Values(Stats)),
});

// POST /products/stats - add/update entries in the Values collection
await ctrl.fetch(getStats.assign, {
  'BTC-USD': { product_id: 'BTC-USD', volume: 1000 },
  'ETH-USD': { product_id: 'ETH-USD', volume: 500 },
});
const StatsResource = resource({
  urlPrefix: 'https://api.exchange.example.com',
  path: '/products/:product_id/stats',
  schema: Stats,
}).extend({
  getList: {
    path: '/products/stats',
    schema: new Collection(new Values(Stats)),
  },
});

// POST /products/stats - add/update entries
await ctrl.fetch(StatsResource.getList.assign, {
  'BTC-USD': { product_id: 'BTC-USD', volume: 1000 },
});

remove

Creates a PATCH endpoint that removes Entities from a Collection and updates them with the response.

Returns a new RestEndpoint with method: 'PATCH' and schema: Collection.remove

const getTodos = new RestEndpoint({
  path: '/todos',
  schema: new Collection([Todo]),
});

// PATCH /todos - removes Todo from collection AND updates the entity
await ctrl.fetch(getTodos.remove, {}, { id: '123', completed: true });
const UserResource = resource({
  path: '/groups/:group/users/:id',
  schema: User,
});

// PATCH /groups/five/users - removes user from 'five' group list
// AND updates the user entity with response data (e.g., new group)
await ctrl.fetch(
  UserResource.getList.remove,
  { group: 'five' },
  { id: '2', group: 'newgroup' },
);

To use the remove schema with a different endpoint (e.g., DELETE):

const deleteAndRemove = MyResource.delete.extend({
  schema: MyResource.getList.schema.remove,
});

move

Creates a PATCH endpoint that moves Entities between Collections. It removes from collections matching the entity's existing state and adds to collections matching the new values (from the body/last arg).

Returns a new RestEndpoint with method: 'PATCH' and schema: Collection.move

import { kanbanFixtures, getInitialInterceptorData } from '@site/src/fixtures/kanban';

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

export class Task extends Entity {
  id = '';
  title = '';
  status = 'backlog';
  pk() { return this.id; }
  static key = 'Task';
}
export const TaskResource = resource({
  path: '/tasks/:id',
  searchParams: {} as { status: string },
  schema: Task,
  optimistic: true,
});
import { useController } from '@data-client/react';
import { TaskResource, type Task } from './TaskResource';

export default function TaskCard({ task }: { task: Task }) {
  const handleMove = () => ctrl.fetch(
    TaskResource.getList.move,
    { id: task.id },
    { id: task.id, status: task.status === 'backlog' ? 'in-progress' : 'backlog' },
  );
  const ctrl = useController();
  return (
    <div className="listItem">
      <span style={{ flex: 1 }}>{task.title}</span>
      <button onClick={handleMove}>
        {task.status === 'backlog' ? '\u25bc' : '\u25b2'}
      </button>
    </div>
  );
}
import { useSuspense } from '@data-client/react';
import { TaskResource } from './TaskResource';
import TaskCard from './TaskCard';

function TaskBoard() {
  const backlog = useSuspense(TaskResource.getList, { status: 'backlog' });
  const inProgress = useSuspense(TaskResource.getList, { status: 'in-progress' });
  return (
    <div>
      <div className="boardColumn">
        <h4>Backlog</h4>
        {backlog.map(task => <TaskCard key={task.pk()} task={task} />)}
      </div>
      <div className="boardColumn">
        <h4>Active</h4>
        {inProgress.map(task => <TaskCard key={task.pk()} task={task} />)}
      </div>
    </div>
  );
}
render(<TaskBoard />);

The remove filter is based on the entity's existing values in the store. The add filter is based on the merged entity values (existing + body). This uses the same createCollectionFilter logic as push/remove.

const UserResource = resource({
  path: '/groups/:group/users/:id',
  schema: User,
});

// PATCH /groups/five/users/5 - moves user 5 from 'five' group to 'ten' group
await ctrl.fetch(
  UserResource.getList.move,
  { group: 'five', id: '2' },
  { id: '2', group: 'ten' },
);

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) {#paginated}

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) {#paginated-function}

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