Resource
Resources 是一组 RestEndpoints 的集合,它们通过共享同一个 schema
来操作共同的数据
用法
export class Todo extends Entity {
id = '';
title = '';
completed = false;
static key = 'Todo';
}
const TodoResource = resource({
urlPrefix: 'https://jsonplaceholder.typicode.com',
path: '/todos/:id',
schema: Todo,
});
const todo = useSuspense(TodoResource.get, { id: '5' });
const todos = useSuspense(TodoResource.getList);
controller.fetch(TodoResource.getList.push, {
title: 'finish installing reactive data client',
});
controller.fetch(
TodoResource.update,
{ id: '5' },
{ ...todo, completed: true },
);
controller.fetch(
TodoResource.partialUpdate,
{ id: '5' },
{ completed: true },
);
controller.fetch(TodoResource.delete, { id: '5' });
参数
{
path: string;
schema: Schema;
urlPrefix?: string;
body?: any;
searchParams?: any;
paginationField?: string;
optimistic?: boolean;
Endpoint?: typeof RestEndpoint;
Collection?: typeof Collection;
} & EndpointExtraOptions
path
传给单条目 endpoint 的 RestEndpoint.path。使用 path-to-regexp v8 语法——关于可选参数、通配符、带引号的名称和转义的完整说明,请参阅 RestEndpoint.path。
创建类(getList.push/getList.unshift)和 getList 会移除最后一个 :param 或 *wildcard token。
const PostResource = resource({
schema: Post,
path: '/:group/posts/:id',
});
// GET /react/posts/abc
PostResource.get({ group: 'react', id: 'abc' });
// PATCH /react/posts/abc
PostResource.partialUpdate({ group: 'react', id: 'abc' }, { title: 'This new title' });
// GET /react/posts
PostResource.getList({ group: 'react' });
可选参数使用 {} 语法:
const PostResource = resource({
schema: Post,
path: '/:group/posts{/:id}',
});
PostResource.get({ group: 'react', id: 'abc' });
PostResource.getList({ group: 'react' });
也支持将通配符参数作为最后一个 token:
const FileResource = resource({
schema: File,
path: '/repos/:owner/*path',
});
// GET /repos/john/src/index.ts
FileResource.get({ owner: 'john', path: ['src', 'index.ts'] });
// GET /repos/john
FileResource.getList({ owner: 'john' });
schema
传给 RestEndpoint.schema,表示单个条目。它通常是一个 Entity 或 Union。
- getList 使用该 schema 的 Array Collection。
- delete 使用该 schema 的 Invalidate。
urlPrefix
searchParams
传给 getList 和 getList.push 的 RestEndpoint.searchParams
body
传给 getList.push、update 和 partialUpdate 的 RestEndpoint.body
paginationField
如果指定,会在 Resource 上添加 Resource.getList.getPage 方法。
nonFilterArgumentKeys
透传给 getList schema 的 Collection.nonFilterArgumentKeys 选项。
const PostResource = resource({
path: '/:group/posts/:id',
searchParams: {} as { orderBy?: string; author?: string },
schema: Post,
nonFilterArgumentKeys: ['orderBy'],
});
也支持 RegExp 和函数形式:
resource({
path: '/:group/posts/:id',
searchParams: {} as { orderBy?: string; author?: string },
schema: Post,
nonFilterArgumentKeys: /orderBy/,
});
optimistic
设为 true 会让所有变更 endpoint 都变为乐观的,使 UI
立即更新,甚至无需等待请求完成。
Endpoint
用于构造各个成员的类。
import { RestEndpoint } from '@data-client/rest';
export default class AuthdEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
async getRequestInit(body: any): Promise<RequestInit> {
return {
...(await super.getRequestInit(body)),
credentials: 'same-origin',
};
}
}
const TodoResource = resource({
path: '/todos/:id',
schema: Todo,
Endpoint: AuthdEndpoint,
});
Collection
用于构造 getList schema 的 Collection 类。当你需要在 nonFilterArgumentKeys 之外进一步自定义 collection 的行为时(例如更改 move 的合并逻辑),请使用它。
import { resource, Collection, unshift } from '@data-client/rest';
class MyCollection<
S extends any[] | PolymorphicInterface = any,
Parent extends any[] = [urlParams: any, body?: any],
> extends Collection<S, Parent> {
constructor(schema: S) {
super(schema);
// prepend moved items instead of appending
this.move = this.moveWith(unshift);
}
}
const TodoResource = resource({
path: '/todos/:id',
searchParams: {} as { userId?: string; orderBy?: string } | undefined,
schema: Todo,
Collection: MyCollection,
});
EndpointExtraOptions
包括 dataExpiryLength、errorExpiryLength、errorPolicy、invalidIfStale 和 pollFrequency
成员
这些成员提供了 REST API 中常见的标准 CRUD endpoint。你可以根据自己的 API 随意自定义或添加新的 endpoint。
const PostResource = resource({
schema: Post,
path: '/:group/posts/:id',
searchParams: {} as { author?: string },
paginationField: 'page',
});
| 名称 | 方法 | 参数 | Schema |
|---|---|---|---|
| get | GET | [{group: string; id: string}] | Post |
| getList | GET | [{group: string; author?: string}] | Collection([Post]) |
| getList.push | POST | [{group: string; author?: string}, Partial<Post>] | Collection([Post]).push |
| getList.unshift | POST | [{group: string; author?: string}, Partial<Post>] | Collection([Post]).unshift |
| getList.getPage | GET | [{group: string; author?: string; page: string}] | Collection([Post]).addWith |
| getList.move | PATCH | [{group: string; id: string }, Partial<Post>] | Collection([Post]).move |
| update | PUT | [{group: string; id: string }, Partial<Post>] | Post |
| partialUpdate | PATCH | [{group: string; id: string }, Partial<Post>] | Post |
| delete | DELETE | [{group: string; id: string }] | Invalidate(Post) |
get
获取单个 Entity。
import { resource } from '@data-client/rest'; import Post from './Post'; export const PostResource = resource({ schema: Post, path: '/:group/posts/:id', searchParams: {} as { author?: string }, });
import { PostResource } from './Resource'; PostResource.get({ group: 'react', id: '1', });
GET /react/posts/1
content-type: application/json
{
"id": "1",
"group": "react",
"title": "this post",
"author": "clara"
}
通常与 useSuspense()、Controller.invalidate、Controller.expireAll 一起使用
getList
获取 Entity 列表。
import { resource } from '@data-client/rest'; import Post from './Post'; export const PostResource = resource({ schema: Post, path: '/:group/posts/:id', searchParams: {} as { author?: string }, });
import { PostResource } from './Resource'; PostResource.getList({ group: 'react', author: 'clara', });
GET /react/posts?author=clara
content-type: application/json
[
{
"id": "1",
"group": "react",
"title": "this post",
"author": "clara"
}
]
| 字段 | 值 |
|---|---|
| method | 'GET' |
| path | removeLastArg(path) |
| searchParams | searchParams |
| paginationField | paginationField |
| schema | new Collection([schema]) |
resource({ path: '/:first/:second' }).getList.path === '/:first';
resource({ path: '/:first' }).getList.path === '/';
resource({ path: '/:owner/*path' }).getList.path === '/:owner';
通常与 useSuspense()、Controller.invalidate、Controller.expireAll 一起使用
getList.push
RestEndpoint.push 会创建一个新的 Entity,并将其添加到 getList 的末尾。如果想放在开头,请改用 getList.unshift。将数组作为 body 传入即可一次创建多个。
import { resource } from '@data-client/rest'; import Post from './Post'; export const PostResource = resource({ schema: Post, path: '/:group/posts/:id', searchParams: {} as { author?: string }, });
import { PostResource } from './Resource'; PostResource.getList.push( { group: 'react', author: 'clara' }, { title: 'winning' }, );
POST /react/posts?author=clara
content-type: application/json
Body: {"title":"winning"}
{
"id": "2",
"group": "react",
"title": "winning",
"author": "clara"
}
| 字段 | 值 |
|---|---|
| method | 'POST' |
| path | removeLastArg(path) |
| searchParams | searchParams |
| body | body |
| schema | getList.schema.push |
通常与 Controller.fetch 一起使用
getList.unshift
RestEndpoint.unshift 会创建一个新的 Entity,并将其添加到 getList 的开头。
import { resource } from '@data-client/rest'; import Post from './Post'; export const PostResource = resource({ schema: Post, path: '/:group/posts/:id', searchParams: {} as { author?: string }, });
import { PostResource } from './Resource'; PostResource.getList.unshift( { group: 'react', author: 'clara' }, { title: 'winning' }, );
POST /react/posts?author=clara
content-type: application/json
Body: {"title":"winning"}
{
"id": "2",
"group": "react",
"title": "winning",
"author": "clara"
}
| 字段 | 值 |
|---|---|
| method | 'POST' |
| path | removeLastArg(path) |
| searchParams | searchParams |
| body | body |
| schema | getList.schema.unshift |
通常与 Controller.fetch 一起使用
getList.getPage
RestEndpoint.getPage 会获取另一页数据并追加到 getList 中,同时确保没有重复项。
只有指定了 paginationField 时才会提供此成员。
import { resource } from '@data-client/rest'; import Post from './Post'; export const PostResource = resource({ schema: Post, path: '/:group/posts/:id', searchParams: {} as { author?: string }, paginationField: 'page', });
import { PostResource } from './Resource'; PostResource.getList.getPage({ group: 'react', author: 'clara', page: 2, });
GET /react/posts?author=clara&page=2
content-type: application/json
[
{
"id": "5",
"group": "react",
"title": "second page",
"author": "clara"
}
]
| 字段 | 值 |
|---|---|
| method | 'GET' |
| path | removeLastArg(path) |
| searchParams | searchParams |
| paginationField | paginationField |
| schema | getList.schema.addWith |
args: PathToArgs(shortenPath(path)) & searchParams & \{ [paginationField]: string | number \}
通常与 Controller.fetch 一起使用
getList.move
RestEndpoint.move 会在 Collections 之间移动 Entity:将其从与旧状态匹配的 collection 中移除,并添加到与 body 中新值相匹配的 collection 中。
import { resource } from '@data-client/rest'; import Post from './Post'; export const PostResource = resource({ schema: Post, path: '/:group/posts/:id', searchParams: {} as { author?: string }, });
import { PostResource } from './Resource'; PostResource.getList.move( { group: 'react', id: '1' }, { group: 'vue' }, );
PATCH /react/posts/1
content-type: application/json
Body: {"group":"vue"}
{
"id": "1",
"group": "vue",
"title": "this post",
"author": "clara"
}
| 字段 | 值 |
|---|---|
| method | 'PATCH' |
| path | path |
| body | body |
| schema | getList.schema.move |
通常与 Controller.fetch 一起使用
update
更新一个 Entity。
import { resource } from '@data-client/rest'; import Post from './Post'; export const PostResource = resource({ schema: Post, path: '/:group/posts/:id', searchParams: {} as { author?: string }, });
import { PostResource } from './Resource'; PostResource.update( { group: 'react', id: '1' }, { title: 'updated title', author: 'clara' }, );
PUT /react/posts/1
content-type: application/json
Body: {"title":"updated title","author":"clara"}
{
"id": "1",
"group": "react",
"title": "updated title",
"author": "clara"
}
通常与 Controller.fetch 一起使用
partialUpdate
更新 Entity 的部分字段。
import { resource } from '@data-client/rest'; import Post from './Post'; export const PostResource = resource({ schema: Post, path: '/:group/posts/:id', searchParams: {} as { author?: string }, });
import { PostResource } from './Resource'; PostResource.partialUpdate( { group: 'react', id: '1' }, { title: 'updated title' }, );
PATCH /react/posts/1
content-type: application/json
Body: {"title":"updated title"}
{
"id": "1",
"group": "react",
"title": "updated title",
"author": "clara"
}
通常与 Controller.fetch 一起使用
delete
删除一个 Entity。
import { resource } from '@data-client/rest'; import Post from './Post'; export const PostResource = resource({ schema: Post, path: '/:group/posts/:id', searchParams: {} as { author?: string }, });
import { PostResource } from './Resource'; PostResource.delete({ group: 'react', id: '1' });
DELETE /react/posts/1
content-type: application/json
{
"id": "1"
}
| 字段 | 值 |
|---|---|
| method | 'DELETE' |
| path | path |
| schema | new Invalidate(schema) |
| process | |
通常与 Controller.fetch 一起使用
响应
{ "id": "xyz" }
响应应当是字符串形式的 pk(例如 'xyz'),或者是一个包含计算
Entity.pk 所需成员的对象(例如 {id: 'xyz'})。
如果没有提供响应,process 的实现会尝试使用作为对象发送的 url 参数来计算
Entity.pk。这样,只要使用的是标准参数,即使没有响应,默认实现也依然可以正常工作。
这使得 Invalidate 能够将该 Entity 从 Entity 表中移除
extend()
resource 提供了一个很好的起点,但 endpoint 往往还需要进一步自定义。
extend() 是多态的,有三种形式:
函数形式(用于获取 BaseResource/super)
这种形式最灵活,但也最冗长。
export const IssueResource= resource({
path: '/repos/:owner/:repo/issues/:number',
schema: Issue,
pollFrequency: 60000,
searchParams: {} as IssueFilters | undefined,
}).extend(BaseResource => ({
search: BaseResource.getList.extend({
path: '/search/issues?{q=:q}%20repo\\::owner/:repo{&page=:page}',
schema: {
results: {
incompleteResults: false,
items: BaseIssueResource.getList.schema.results,
totalCount: 0,
},
link: '',
},
})
)});
批量扩展已有成员
这种形式只适用于已有的成员。
export const CommentResource = resource({
path: '/repos/:owner/:repo/issues/comments/:id',
schema: Comment,
}).extend({
getList: { path: '/repos/:owner/:repo/issues/:number/comments' },
update: { body: { body: '' } },
});
添加新成员
这种形式一次只能添加一个 endpoint。
export const UserResource = createGithubResource({
path: '/users/:login',
schema: User,
}).extend('current', {
path: '/user',
schema: User,
});
Github CommentResource
探索 github-app 示例
函数继承模式
要复用与 Resource 定义相关的代码,你可以创建自己的函数来调用 resource()。这与基于类的继承效果类似,而且还有一个额外的好处:允许完全覆盖类型。
import {
resource,
RestEndpoint,
Collection,
type EndpointExtraOptions,
type RestGenerics,
type ResourceGenerics,
type ResourceOptions,
} from '@data-client/rest';
export class AuthdEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
urlPrefix = process.env.API_SERVER ?? 'http://localhost:8000';
async getRequestInit(body: any): Promise<RequestInit> {
return {
...(await super.getRequestInit(body)),
credentials: 'same-origin',
};
}
}
export function myResource<O extends ResourceGenerics = any>({
schema,
Endpoint = AuthdEndpoint,
...extraOptions
}: Readonly<O> & ResourceOptions) {
return resource({
Endpoint,
schema,
...extraOptions,
}).extend({
getList: {
schema: {
results: new Collection([schema]),
total: 0,
limit: 0,
skip: 0,
},
},
});
}
GraphQL + REST Hybrid
当你的 API 同时提供 REST 和 GraphQL endpoint 时,可以在同一个 resource 中混合使用它们。使用 Entity.process() 来规范化不同形态的响应。
import { GQLEndpoint } from '@data-client/graphql';
import { Entity, resource } from '@data-client/rest';
const gql = new GQLEndpoint('https://api.myservice.com/graphql');
export class Repository extends Entity {
id = '';
name = '';
owner = { login: '' };
stargazersCount = 0;
forksCount = 0;
pk() {
return `${this.owner.login}/${this.name}`;
}
static key = 'Repository';
}
/** Normalizes GraphQL response shape to match REST Entity */
export class GqlRepository extends Repository {
static process(input: any, parent: any, key: string | undefined) {
// GraphQL uses different field names than REST
if ('stargazerCount' in input) {
return {
...input,
stargazersCount: input.stargazerCount,
forksCount: input.forkCount,
};
}
return input;
}
}
export const RepositoryResource = resource({
path: '/repos/:owner/:repo',
schema: Repository,
}).extend(base => ({
// REST endpoint for single repo
get: base.get,
// GraphQL endpoint for user's pinned repos
getByPinned: gql.query(
(v: { login: string }) => `query ($login: String!) {
user(login: $login) {
pinnedItems(first: 6, types: REPOSITORY) {
nodes {
... on Repository {
id
name
owner { login }
stargazerCount
forkCount
}
}
}
}
}`,
{ user: { pinnedItems: { nodes: [GqlRepository] } } },
),
}));
Github 示例
探索 github-app 示例