跳到主要内容

RestEndpoint

RestEndpoints 适用于 REST 等基于 HTTP 的协议。

extends

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

用法​

所有选项都可以作为构造函数和 extend 的参数传入,也可以在使用继承时作为覆盖项

最简单的获取​

const getTodo = new RestEndpoint({
path: '/todos/:id',
});
const todo = await getTodo({ id: 1 });

共享配置​

请使用 RestEndpoint.extend(),而不是 {...getTodo}(对象展开)

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

管理状态​

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

使用 Schema 可以实现自动的数据一致性,而无需通过重新获取来牺牲性能。

类型​

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

解析值/返回值​

与 useSuspense、useDLE、useCache 等数据绑定 hook 一起使用时,或与 Controller.fetch 一起使用时,返回值由 schema 决定

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

直接调用 endpoint 时,解析值由 process 决定。对于没有 schema 的 RestEndpoints,它还决定 hook 以及 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);
};

函数参数​

用于构造 url 的 path 决定了第一个参数的类型。如果其中没有任何模式,则会跳过“第一个”参数。

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 决定是否存在作为 body 发送的第二个参数。

export const update = new RestEndpoint({
  path: '/:id',
  method: 'PUT',
});
update({ id: 5 }, { title: 'updated', completed: true });

不过,它的类型是 'any',因此无法捕获拼写错误。

body 可用于为 url 参数之后的那个参数指定类型。它只用于类型,因此传入的值是什么并不重要。可以用 undefined 值来“禁用”第二个参数。

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 的用法与 body 类似,用于为额外参数指定类型,这些参数会作为 url() 中的 GET searchParams/queryParams。

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

获取生命周期​

RestEndpoint 在 Endpoint 的基础上,允许通过继承或 .extend() 对内置的 fetch 方法进行定制。

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

准备获取​

成员同时也是选项(构造函数的第二个参数)。虽然它们都不是必需的,但前几个有默认值。

url(params): string​

urlPrefix + path template + '?' + searchToString(searchParams)

url() 使用 params 填充 path 模板。之后,所有未用到的 params 成员都会被用作 searchParams(也就是“GET”参数——即 ? 后面的部分)。

实现
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​

构造 url 中的 searchParams 部分。

默认使用标准的全局 URLSearchParams。

searchParams(也称 queryParams)会被排序,以保证结果的确定性。

实现
searchToString(searchParams) {
const params = new URLSearchParams(searchParams);
params.sort();
return params.toString();
}

使用 qs 库​

要在 searchParams 中编码复杂对象,可以使用 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​

使用 path-to-regexp v8,根据传入的参数构建 url。它同时也决定了类型,从而确保类型被正确约束。

参数​

以 : 为前缀的单词是参数名。字符串和数字都可以作为参数值,因为它们会被序列化到 url 字符串中。

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

可选参数​

用 {} 包裹可选片段(包括其前缀),即可使其成为可选的。可选参数的类型会变为 string | number | undefined。

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

多个可选片段可以用不同的前缀串联起来:

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

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

通配符(重复参数)​

*name 匹配一个或多个路径片段。用 {} 包裹可使其匹配零个或多个(可选)。通配符参数的类型为 string[](数组),因为它们表示多个路径片段。

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

加引号的参数名​

参数名必须是合法的 JavaScript 标识符。包含 - 或 . 等特殊字符的名称必须用双引号括起来:

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

转义特殊字符​

字符 {}()*: 和 \\ 在 path-to-regexp 中具有特殊含义,作为字面量使用时必须用 \\ 转义。

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

? 和 + 在 path-to-regexp v8 中不是特殊字符,无需转义。这意味着可以在 path 中直接嵌入查询字符串,而无需转义 ?:

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

类型会根据 path 自动推断。

额外的参数可以通过 searchParams 和 body 来指定。

searchParams​

searchParams 可用于为额外参数指定类型,这些参数会作为 url() 中的 GET searchParams/queryParams。

其实际的值不会以任何方式被使用——它只决定类型。

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 可用于为变更类 endpoint 设置第二个参数。其实际的值不会以任何方式被使用——它只决定类型。

它只用于 method 会携带 body 的 endpoint:'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​

如果指定,会在 RestEndpoint 上添加 getPage 方法。参见分页指南。Schema 中还必须包含一个 Collection。

urlPrefix: string = ''​

将其添加到编译后的 path 之前

通过继承设置默认值​

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

进一步了解 RestEndpoint 的继承模式

实例覆盖​

export const getTicker = new RestEndpoint({
urlPrefix: 'https://api.exchange.coinbase.com',
path: '/products/:product_id/ticker',
schema: Ticker,
});

动态前缀​

提示

如需动态前缀,可以改为覆盖 url() 方法:

const getTodo = new RestEndpoint({
path: '/todo/:id',
url(...args) {
return dynamicPrefix() + super.url(...args);
},
});

method: string = 'GET'​

Method 是 HTTP 协议的一部分。 REST 协议用它来表示操作的类型。因此,RestEndpoint 会据此确定 sideEffect,以及该 endpoint 是否应使用 body 负载。显式设置 sideEffect 会覆盖这一行为,从而支持非标准的 API 设计。

GET 是“只读”的,其他 method 则意味着存在副作用。

GET 和 DELETE 默认都没有 body。

method 如何影响函数参数

method 只会影响 RestEndpoint 构造函数中的参数,而不会影响 .extend()。这使得非标准的 method 与 body 组合成为可能。

body 默认为 any。你始终可以显式设置 body 以获得完全控制。可以用 undefined 表示没有 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​

准备 fetch 中使用的 RequestInit。它会被传给 fetchResponse

普通对象或数组形式的 body 会被编码为 JSON,并附带 Content-Type: application/json header,除非 requestInit 或 getHeaders 已经设置了该 header。其他任何 body,例如 FormData、Blob、 URLSearchParams 或字符串,都会原样传给 fetch()。

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​

由 getRequestInit 调用,用于确定 HTTP Headers

这通常在认证时很有用

注意

不要在这里使用 hook。如果需要使用 hook,可以试试 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';
}

处理获取​

fetchResponse(input, init): Promise​

执行 fetch(input, init) 调用。当 response.ok 不为 true 时(例如 404),会抛出 NetworkError。

content​

控制如何解析 Response 的 body。设置后,返回类型会被自动推断;对于非 JSON 的内容类型,schema 会被限制为 undefined。

值解析方式返回类型
'json'response.json()any
'blob'response.blob()Blob
'text'response.text()string
'arrayBuffer'response.arrayBuffer()ArrayBuffer
'stream'response.bodyReadableStream<Uint8Array>
未设置根据 Content-Type header 自动检测any

未设置 content 时,parseResponse 会根据 Content-Type header 自动检测响应类型:JSON 类型调用 .json(),二进制类型(图片、application/octet-stream、 PDF 等)调用 .blob(),文本类类型则调用 .text()。

文件下载​

下载文件时,请设置 content: 'blob'。返回类型为 Blob,且 schema 必须为 undefined(二进制数据无法被规范化)。使用 dataExpiryLength: 0 可以避免在内存中缓存大型 blob。

const downloadFile = new RestEndpoint({
path: '/files/:id/download',
content: 'blob',
dataExpiryLength: 0,
});

要从 Content-Disposition header 中提取文件名,请覆盖 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;
},
});

关于结合浏览器下载触发的完整用法,请参阅文件下载指南。

parseResponse(response): Promise​

接收 Response 并解析其 body。

设置了 content 时,由它直接控制解析方式。否则,会根据 Content-Type header 自动检测: JSON 类型调用 .json(),二进制类型调用 .blob(),文本类类型调用 .text()。

如果 status 为 204,则 resolve 为 null。

对于需要同时提取 header 和 body 等高级场景,可以覆盖此方法。

process(value, ...args): any​

对解析后的结果执行任意变换。默认为恒等函数(什么也不做)。

args 是调用 endpoint 时传入的参数。它们的类型来自 endpoint 的 path、 searchParams 和 body,包括在同一次 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}` };
},
});
提示

process 的返回类型可用于设置 endpoint 获取的返回类型:

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

Endpoint 生命周期​

schema?: Schema​

声明式数据生命周期

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​

序列化参数。它用于在全局 store 中构建查找键。

默认实现:

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

testKey(key): boolean​

如果提供的(获取)key 与此 endpoint 匹配,则返回 true。

它用于 <MockResolver /> 的模拟 interceptor,以及 Controller.expireAll() 和 Controller.invalidateAll()。

dataExpiryLength?: number​

为所获取资源自定义的数据缓存有效期。会覆盖 NetworkManager 中设置的值。

进一步了解过期时间

errorExpiryLength?: number​

为所获取资源自定义的错误有效期。会覆盖 NetworkManager 中设置的值。

errorPolicy?: (error: any) => 'soft' | undefined​

'soft' 会在出错时使用过时数据(如果存在);返回 undefined 或不提供该选项时,结果将是错误。

进一步了解 errorPolicy

errorPolicy(error) {
return error.status >= 500 ? 'soft' : undefined;
}

invalidIfStale: boolean​

表示过时数据应被视为不可用,因此不会从缓存中返回。这意味着即使数据已经存在于缓存中,只要它过时了,useSuspense() 就会挂起。

pollFrequency: number​

轮询频率,单位为毫秒。需要配合 useSubscription() 或 useLive() 使用才会生效。

getOptimisticResponse: (snap, ...args) => expectedResponse​

提供该函数后,使用此 endpoint 的任何获取都会表现得如同该函数返回的 expectedResponse 是一次成功的网络响应。当实际请求完成时(无论失败还是成功),乐观更新都会被实际的网络响应替换。

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,
    };
  },
});
结果
Store▶
乐观更新指南

update()​

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

试试改用 Collections。

它们更易用,也更健壮!

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

最简单的情况:

userEndpoint.ts
const createUser = new RestEndpoint({
path: '/user',
method: 'POST',
schema: User,
update: (newUserId: string) => ({
[userList.key()]: (users = []) => [newUserId, ...users],
}),
});

更多更新:

Component.tsx
const allusers = useSuspense(userList);
const adminUsers = useSuspense(userList, { admin: true });

下面的 endpoint 确保新用户会立即出现在上述用法中。

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

extend(options): RestEndpoint​

可用于进一步定制 endpoint 的定义

const getUser = new RestEndpoint({ path: '/users/:id' });

const UserDetailNormalized = getUser.extend({
schema: User,
getHeaders(headers: HeadersInit): HeadersInit {
return {
...headers,
'Access-Token': getAuth(),
};
},
});

专用扩展器​

这些便捷访问器会为常见的 Collection 操作创建新的 endpoint。只有当 RestEndpoint 的 schema 中包含 Collection 时,它们才能使用。

push​

创建一个 POST endpoint,将新创建的 Entity 放到 Collection 的末尾。

返回一个新的 RestEndpoint,其 method 为 'POST',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' },
{ username: 'newuser', email: '[email protected]' },
);

// 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​

创建一个 POST endpoint,将新创建的 Entity 放到 Collection 的开头。

返回一个新的 RestEndpoint,其 method 为 'POST',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' },
{ username: 'priorityuser', email: '[email protected]' },
);

assign​

创建一个 POST endpoint,将 Entity 合并到 Values Collection 中。

返回一个新的 RestEndpoint,其 method 为 'POST',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​

创建一个 PATCH endpoint,从 Collection 中移除 Entity,并用响应更新它们。

返回一个新的 RestEndpoint,其 method 为 'PATCH',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' },
);

要在其他 endpoint(例如 DELETE)上使用 remove schema:

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

move​

创建一个 PATCH endpoint,在 Collections 之间移动 Entity。它会从与该 Entity 现有状态匹配的 collection 中移除它,并添加到与新值(来自 body/最后一个参数)匹配的 collection 中。

返回一个新的 RestEndpoint,其 method 为 'PATCH',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>
  );
}
结果
Store▶

移除时的过滤基于该 Entity 在 store 中的现有值。添加时的过滤基于合并后的 Entity 值(现有值 + body)。它与 push/remove 使用相同的 createCollectionFilter 逻辑。

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​

一个以 paginationField 作为 searchParameter 键来获取下一页的 endpoint。Schema 中还必须包含一个 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 })
}
/>
);

更多信息请参阅分页指南。

paginated(paginationfield)​

创建一个新的 endpoint,它带有一个额外的 paginationfield 字符串,用于查找特定的页面,并将结果追加到此 endpoint。更多信息请参阅无限滚动分页。

const getNextPage = getList.paginated('cursor');

Schema 中还必须包含一个 Collection

paginated(removeCursor)​

function paginated<E, A extends any[]>(
this: E,
removeCursor: (...args: A) => readonly [...Parameters<E>],
): PaginationEndpoint<E, A>;

函数形式允许对参数进行任意处理。这与上面传入 cursor 字符串是等价的。

const getNextPage = getList.paginated(
({ cursor, ...rest }: { cursor: string | number }) =>
(Object.keys(rest).length ? [rest] : []) as any,
);

removeCusor 是一个函数,它接收获取 getNextPage 时传入的参数,并返回用于更新 getList 的参数。

Schema 中还必须包含一个 Collection

继承​

请务必使用 RestGenerics,以保证类型正常工作。

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