RestEndpoint
Los RestEndpoints son para protocolos basados en HTTP como REST.
RestEndpoint extiende Endpoint
Interfaz
- RestEndpoint
- Endpoint
interface RestGenerics {
readonly path: string;
readonly schema?: Schema | undefined;
readonly method?: string;
readonly body?: any;
readonly searchParams?: any;
readonly paginationField?: string;
readonly content?: 'json' | 'blob' | 'text' | 'arrayBuffer' | 'stream';
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 content?: 'json' | 'blob' | 'text' | 'arrayBuffer' | 'stream';
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 milliseconds */
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;
}
Uso
Todas las opciones se admiten como argumentos del constructor, de extend y como sobrescrituras al usar herencia
La obtención más simple
const getTodo = new RestEndpoint({
path: '/todos/:id',
});
const todo = await getTodo({ id: 1 });
Compartir configuración
Usa RestEndpoint.extend() en lugar de {...getTodo} (Object spread)
const updateTodo = getTodo.extend({ method: 'PUT' });
Gestionar el estado
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' });
Usar un Schema habilita la consistencia automática de los datos sin necesidad de perjudicar el rendimiento con volver a obtener los datos.
Tipado
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);
Resolución/Retorno
schema determina el valor de retorno cuando se usa con hooks de enlace de datos como useSuspense, useDLE, useCache o cuando se usa con 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 determina el valor de resolución cuando el endpoint se llama directamente. En los
RestEndpoints sin schema, también determina el tipo de retorno de hooks y de 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); };
Parámetros de la función
path, que se usa para construir la url, determina el tipo del primer argumento. Si no tiene patrones, se omite el 'primer' argumento.
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 determina si hay un segundo argumento que se envía como body.
export const update = new RestEndpoint({ path: '/:id', method: 'PUT', }); update({ id: 5 }, { title: 'updated', completed: true });
Sin embargo, este se tipa como 'any', por lo que no detectará errores tipográficos.
body se puede usar para tipar el argumento que sigue a los parámetros de la url. Solo se usa para el tipado, así que el
valor enviado no importa. El valor undefined se puede usar para 'deshabilitar' el segundo argumento.
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 se puede usar de forma similar a body para especificar los tipos de parámetros adicionales, usados
para los searchParams/queryParams del GET en un url().
const getUsers = new RestEndpoint({
path: '/:group/user/:id',
searchParams: {} as { isAdmin?: boolean; sort: 'asc' | 'desc' },
});
getUsers.url({ group: 'big', id: '5', sort: 'asc' }) ===
'/big/user/5?sort=asc';
getUsers.url({
group: 'big',
id: '5',
sort: 'desc',
isAdmin: true,
}) === '/big/user/5?isAdmin=true&sort=desc';
Ciclo de vida del fetch
RestEndpoint amplía Endpoint al ofrecer personalizaciones para un método fetch proporcionado mediante herencia o .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));
}
Preparar el fetch
Los miembros funcionan también como opciones (segundo argumento del constructor). Aunque ninguno es obligatorio, los primeros tienen valores por defecto.
url(params): string
urlPrefix + path template + '?' + searchToString(searchParams)
url() usa los params para rellenar la plantilla de path. Los miembros de params que no se usen se emplean después
como searchParams (también llamados params 'GET', lo que va después de ?).
Implementación
import { getUrlBase, getUrlTokens } from '@data-client/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
Construye el componente searchParams de la url.
Por defecto usa el global estándar URLSearchParams.
Los searchParams (también llamados queryParams) se ordenan para mantener el determinismo.
Implementación
searchToString(searchParams) {
const params = new URLSearchParams(searchParams);
params.sort();
return params.toString();
}
Usar la librería qs
Para codificar objetos complejos en los searchParams, puedes usar la librería qs.
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
Usa path-to-regexp v8 para construir urls con los parámetros pasados. Esto también define los tipos, de modo que se apliquen correctamente.
Parámetros
Las palabras con prefijo : son nombres de parámetros. Se aceptan tanto strings como números como valores,
ya que se serializan en el string de la url.
const getThing = new RestEndpoint({ path: '/:group/things/:id' }); getThing({ group: 'first', id: 77 });
Parámetros opcionales
Envuelve el segmento opcional (incluido su prefijo) en {} para hacerlo opcional.
El tipo de los parámetros opcionales pasa a ser string | number | undefined.
const optional = new RestEndpoint({ path: '/:group/things{/:number}', }); optional({ group: 'first' }); optional({ group: 'first', number: 'fifty' });
Se pueden encadenar varios segmentos opcionales con distintos prefijos:
const ep = new RestEndpoint({
path: '{/:attr1}{-:attr2}{-:attr3}',
});
ep({ attr1: 'hi' });
ep({ attr2: 'hi' });
ep({ attr1: 'hi', attr3: 'ho' });
Comodines (parámetros repetidos)
*name coincide con uno o más segmentos de la ruta. Envuélvelo en {} para que coincida con cero o más (opcional).
Los parámetros comodín se tipan como string[] (arrays), ya que representan varios segmentos de la ruta.
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
Nombres de parámetros entre comillas
Los nombres de parámetros deben ser identificadores válidos de JavaScript. Los nombres que contienen caracteres especiales
como - o . deben ir entre comillas dobles:
const ep = new RestEndpoint({ path: '/:"with-dash"/:"my.param"' });
ep({ 'with-dash': 'hello', 'my.param': 'world' });
Escapar caracteres especiales
Los caracteres {}()*: y \\ son especiales en path-to-regexp y deben escaparse con \\ cuando se usan como literales.
const getSite = new RestEndpoint({ path: 'https\\://site.com/:slug', }); getSite({ slug: 'first' });
? y + no son especiales en path-to-regexp v8 y no necesitan escaparse.
Esto significa que los query strings se pueden incrustar en la ruta sin escapar ?:
const search = new RestEndpoint({
path: '/search?{q=:q}{&page=:page}',
});
search({ q: 'test', page: 1 });
// URL: /search?q=test&page=1
Los tipos se infieren automáticamente a partir de path.
Se pueden especificar parámetros adicionales con searchParams y body.
searchParams
searchParams se puede usar para especificar los tipos de parámetros adicionales, usados para los searchParams/queryParams del GET en un url().
El valor real no se usa de ninguna manera; esto solo determina el tipado.
import { RestEndpoint } from '@data-client/rest'; 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 se puede usar para definir un segundo argumento en endpoints de mutación. El valor real no se
usa de ninguna manera; esto solo determina el tipado.
Solo lo usan los endpoints con un método que utiliza body: 'POST', 'PUT', 'PATCH'.
import { RestEndpoint } from '@data-client/rest'; 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
Si se especifica, agregará el método getPage al RestEndpoint. Guía de paginación. El schema
también debe contener una Collection.
urlPrefix: string = ''
Antepone esto al path compilado
Valores por defecto mediante herencia
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';
}
Más información sobre los patrones de herencia para RestEndpoint
Sobrescrituras por instancia
export const getTicker = new RestEndpoint({
urlPrefix: 'https://api.exchange.coinbase.com',
path: '/products/:product_id/ticker',
schema: Ticker,
});
Prefijo dinámico
Para un prefijo dinámico, prueba mejor sobrescribir el método url():
const getTodo = new RestEndpoint({
path: '/todo/:id',
url(...args) {
return dynamicPrefix() + super.url(...args);
},
});
method: string = 'GET'
El método es parte del protocolo HTTP.
Los protocolos REST lo usan para indicar el tipo de operación. Por eso RestEndpoint lo usa
para determinar sideEffect y si el endpoint debe usar un payload body. Establecer
sideEffect explícitamente sobrescribirá este comportamiento, lo que permite diseños de API no estándar.
GET es 'de solo lectura'; los demás métodos implican sideEffects.
GET y DELETE no tienen body por defecto.
method solo influye en los parámetros del constructor de RestEndpoint y no en .extend().
Esto permite combinaciones no estándar de método y body.
body será any por defecto. Siempre puedes establecer body explícitamente para tener el control total. Se puede usar undefined
para indicar que no hay 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
Prepara el RequestInit que se usa en el fetch. Se envía a fetchResponse
Un body que sea un objeto plano o un array se codifica como JSON, con un encabezado Content-Type: application/json, a menos que
requestInit o getHeaders establezcan uno. Cualquier otro body, como FormData, Blob,
URLSearchParams o un string, se pasa a fetch() tal cual.
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
Lo llama getRequestInit para determinar los encabezados HTTP
Esto suele ser útil para la autenticación
No uses hooks aquí. Si necesitas usar hooks, prueba con 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'; }
Manejar el fetch
fetchResponse(input, init): Promise
Realiza la llamada fetch(input, init). Cuando
response.ok no es true (como en un 404),
lanza un NetworkError.
content
Controla cómo se interpreta el cuerpo de la Response.
Cuando se establece, el tipo de retorno se infiere automáticamente y schema queda restringido a undefined
para los tipos de contenido que no son JSON.
| Valor | Se interpreta con | Tipo de retorno |
|---|---|---|
'json' | response.json() | any |
'blob' | response.blob() | Blob |
'text' | response.text() | string |
'arrayBuffer' | response.arrayBuffer() | ArrayBuffer |
'stream' | response.body | ReadableStream<Uint8Array> |
| sin establecer | Detección automática según el encabezado Content-Type | any |
Cuando content no está establecido, parseResponse detecta automáticamente el tipo de respuesta a partir del
encabezado Content-Type: los tipos JSON llaman a .json(), los tipos binarios (imágenes, application/octet-stream,
PDFs, etc.) llaman a .blob() y los tipos de texto llaman a .text().
Descargas de archivos
Para descargar archivos, establece content: 'blob'. El tipo de retorno es Blob y schema debe ser
undefined (los datos binarios no se pueden normalizar). Usa dataExpiryLength: 0 para evitar guardar en caché
blobs grandes en memoria.
const downloadFile = new RestEndpoint({
path: '/files/:id/download',
content: 'blob',
dataExpiryLength: 0,
});
Para extraer el nombre del archivo del encabezado Content-Disposition, sobrescribe parseResponse:
const downloadFile = new RestEndpoint({
path: '/files/:id/download',
content: 'blob',
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;
},
});
Consulta la guía de descarga de archivos para ver el uso completo con el disparador de descarga del navegador.
parseResponse(response): Promise
Toma la Response e interpreta el cuerpo.
Cuando content está establecido, controla directamente la interpretación. En caso contrario, se ejecuta la detección automática
según el encabezado Content-Type:
los tipos JSON llaman a .json(), los tipos
binarios llaman a .blob() y los tipos
de texto llaman a .text().
Si status es 204, se resuelve como null.
Sobrescríbelo para casos avanzados, como extraer los encabezados junto con el cuerpo.
process(value, ...args): any
Aplica cualquier transformación al resultado ya interpretado. Por defecto es la función identidad (no hace nada).
args son los argumentos con los que se llamó al endpoint. Se tipan a partir de path,
searchParams y body del endpoint, incluidos los definidos en la misma llamada a extend().
const getUser = new RestEndpoint({ path: '/users/:id' });
const getUserWithId = getUser.extend({
process(value, params) {
// params is { id: string | number }
return { ...value, id: `${params.id}` };
},
});
El tipo de retorno de process se puede usar para establecer el tipo de retorno del fetch del endpoint:
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; };
Ciclo de vida del Endpoint
schema?: Schema
Ciclo de vida declarativo de los datos
- Consistencia global de los datos y rendimiento con un estado DRY: dónde esperar Entities
- Funciones para deserializar campos
- Manejo de condiciones de carrera
- Validación
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
Serializa los parámetros. Se usa para construir una clave de búsqueda en stores globales.
Por defecto:
`${this.method} ${this.url(urlParams)}`;
testKey(key): boolean
Devuelve true si la key (de fetch) proporcionada coincide con este endpoint.
Se usa para los interceptors de mock con <MockResolver />, Controller.expireAll(), and Controller.invalidateAll().
dataExpiryLength?: number
Tiempo de vida personalizado en la caché de los datos del recurso obtenido. Reemplazará el valor establecido en NetworkManager.
Más información sobre el tiempo de caducidad
errorExpiryLength?: number
Tiempo de vida personalizado de los errores del recurso obtenido. Reemplazará el valor establecido en NetworkManager.
errorPolicy?: (error: any) => 'soft' | undefined
'soft' usará los datos obsoletos (si existen) en caso de error; undefined o no proporcionar la opción provocará un error.
Más información sobre errorPolicy
errorPolicy(error) {
return error.status >= 500 ? 'soft' : undefined;
}
invalidIfStale: boolean
Indica que los datos obsoletos deben considerarse inutilizables y, por tanto, no devolverse desde la caché. Esto significa que useSuspense() se suspenderá cuando los datos estén obsoletos aunque ya existan en la caché.
pollFrequency: number
Frecuencia en milisegundos con la que se realiza el sondeo. Requiere usar useSubscription() o useLive() para tener efecto.
getOptimisticResponse: (snap, ...args) => expectedResponse
Cuando se proporciona, cualquier fetch con este endpoint se comportará como si el valor de retorno expectedResponse
de esta función fuera una respuesta de red exitosa. Cuando el fetch real se completa (ya sea
con fallo o con éxito), la actualización optimista se reemplaza por la respuesta de red real.
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) })
Prueba a usar Collections en su lugar.
¡Son mucho más fáciles de usar y más robustas!
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] };
El caso más sencillo:
const createUser = new RestEndpoint({
path: '/user',
method: 'POST',
schema: User,
update: (newUserId: string) => ({
[userList.key()]: (users = []) => [newUserId, ...users],
}),
});
Más actualizaciones:
const allusers = useSuspense(userList);
const adminUsers = useSuspense(userList, { admin: true });
El endpoint siguiente garantiza que el nuevo usuario aparezca de inmediato en los usos anteriores.
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
Se puede usar para personalizar aún más la definición del endpoint
const getUser = new RestEndpoint({ path: '/users/:id' });
const UserDetailNormalized = getUser.extend({
schema: User,
getHeaders(headers: HeadersInit): HeadersInit {
return {
...headers,
'Access-Token': getAuth(),
};
},
});
Extensores especializados
Estos accesores de conveniencia crean nuevos endpoints para operaciones comunes de Collection.
Solo funcionan cuando el schema del RestEndpoint contiene una Collection.
push
Crea un endpoint POST que coloca las Entities recién creadas al final de una Collection.
Devuelve un nuevo RestEndpoint con method: 'POST' y schema: Collection.push
import { RestEndpoint, Collection } from '@data-client/rest';
import { useController } from '@data-client/react';
import { Todo } from './resources';
const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: new Collection([Todo]),
});
const ctrl = useController();
// POST /todos - adds new Todo to the end of the list
const newTodo = await ctrl.fetch(
getTodos.push,
{ userId: '1' },
{ title: 'Buy groceries' },
);
import { resource } from '@data-client/rest';
import { useController } from '@data-client/react';
import { User } from './resources';
const UserResource = resource({
path: '/groups/:group/users/:id',
schema: User,
});
const ctrl = useController();
// POST /groups/five/users - adds new User to the end of the list
const newUser = await ctrl.fetch(
UserResource.getList.push,
{ group: 'five' },
);
// Send an array to create several at once; they're added to the end in order
await ctrl.fetch(
UserResource.getList.push,
{ group: 'five' },
[{ username: 'ana' }, { username: 'bo' }],
);
unshift
Crea un endpoint POST que coloca las Entities recién creadas al inicio de una Collection.
Devuelve un nuevo RestEndpoint con method: 'POST' y schema: Collection.unshift
import { RestEndpoint, Collection } from '@data-client/rest';
import { useController } from '@data-client/react';
import { Todo } from './resources';
const getTodos = new RestEndpoint({
path: '/todos',
searchParams: {} as { userId?: string },
schema: new Collection([Todo]),
});
const ctrl = useController();
// POST /todos - adds new Todo to the beginning of the list
const newTodo = await ctrl.fetch(
getTodos.unshift,
{ userId: '1' },
{ title: 'Urgent task' },
);
import { resource } from '@data-client/rest';
import { useController } from '@data-client/react';
import { User } from './resources';
const UserResource = resource({
path: '/groups/:group/users/:id',
schema: User,
});
const ctrl = useController();
// POST /groups/five/users - adds new User to the start of the list
const newUser = await ctrl.fetch(
UserResource.getList.unshift,
{ group: 'five' },
);
assign
Crea un endpoint POST que fusiona Entities en una Collection de Values.
Devuelve un nuevo RestEndpoint con method: 'POST' y schema: Collection.assign
import { RestEndpoint, Collection, Values } from '@data-client/rest';
import { useController } from '@data-client/react';
import { Stats } from './resources';
const getStats = new RestEndpoint({
path: '/products/stats',
schema: new Collection(new Values(Stats)),
});
const ctrl = useController();
// 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 },
});
import { resource, Collection, Values } from '@data-client/rest';
import { useController } from '@data-client/react';
import { Stats } from './resources';
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)),
},
});
const ctrl = useController();
// POST /products/stats - add/update entries
await ctrl.fetch(StatsResource.getList.assign, {
'BTC-USD': { product_id: 'BTC-USD', volume: 1000 },
});
remove
Crea un endpoint PATCH que elimina Entities de una Collection y las actualiza con la respuesta.
Devuelve un nuevo RestEndpoint con method: 'PATCH' y schema: Collection.remove
import { RestEndpoint, Collection } from '@data-client/rest';
import { useController } from '@data-client/react';
import { Todo } from './resources';
const getTodos = new RestEndpoint({
path: '/todos',
schema: new Collection([Todo]),
});
const ctrl = useController();
// PATCH /todos - removes Todo from collection AND updates the entity
await ctrl.fetch(getTodos.remove, { id: '123', completed: true });
import { resource } from '@data-client/rest';
import { useController } from '@data-client/react';
import { User } from './resources';
const UserResource = resource({
path: '/groups/:group/users/:id',
schema: User,
});
const ctrl = useController();
// 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' },
);
Para usar el schema remove con un endpoint distinto (por ejemplo, DELETE):
const deleteAndRemove = MyResource.delete.extend({
schema: MyResource.getList.schema.remove,
});
move
Crea un endpoint PATCH que mueve Entities entre Collections. Elimina de las colecciones que coinciden con el estado actual de la entidad y agrega a las colecciones que coinciden con los nuevos valores (del body o último argumento).
Devuelve un nuevo RestEndpoint con method: 'PATCH' y schema: Collection.move
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> ); }
El filtro de eliminación se basa en los valores existentes de la entidad en el store. El filtro de adición se basa en los valores combinados de la entidad (existentes + body). Esto usa la misma lógica de createCollectionFilter que push/remove.
import { resource } from '@data-client/rest';
import { useController } from '@data-client/react';
import { User } from './resources';
const UserResource = resource({
path: '/groups/:group/users/:id',
schema: User,
});
const ctrl = useController();
// 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
Un endpoint para obtener la página siguiente usando paginationField como clave del searchParameter. El schema también debe contener una Collection
const getTodos = new RestEndpoint({
path: '/todos',
schema: Todo,
paginationField: 'page',
});
const todos = useSuspense(getTodos);
const ctrl = useController();
return (
<PaginatedList
items={todos}
fetchNextPage={() =>
// fetches url `/todos?page=${nextPage}`
ctrl.fetch(getTodos.getPage, { page: nextPage })
}
/>
);
Consulta la guía de paginación para más información.
paginated(paginationfield)
Crea un nuevo endpoint con un string paginationfield adicional que se usará para encontrar la página
específica que se agregará a este endpoint. Consulta Paginación con scroll infinito para más información.
const getNextPage = getList.paginated('cursor');
El schema también debe contener una Collection
paginated(removeCursor)
function paginated<E, A extends any[]>(
this: E,
removeCursor: (...args: A) => readonly [...Parameters<E>],
): PaginationEndpoint<E, A>;
La forma de función permite cualquier procesamiento de argumentos. Es el equivalente a enviar el string cursor como arriba.
const getNextPage = getList.paginated(
({ cursor, ...rest }: { cursor: string | number }) =>
(Object.keys(rest).length ? [rest] : []) as any,
);
removeCusor es una función que toma los argumentos enviados en el fetch de getNextPage y devuelve
los argumentos para actualizar getList.
El schema también debe contener una Collection
Herencia
Asegúrate de usar RestGenerics para que los tipos sigan funcionando.
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(),
};
}
}