docs: add resources-actions doc and sample (#853)

* docs: add resources-actions doc and sample

* docs: fix sample

* docs: remove built file

* feat: update readme

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
Junyi 2022-09-29 21:03:35 +08:00 committed by GitHub
parent a7e6e4716a
commit 82560b926b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 711 additions and 4 deletions

View File

@ -190,6 +190,10 @@ export default {
type: 'integer',
name: 'status'
},
{
type: 'string',
name: 'address'
},
{
type: 'belongsTo',
name: 'user'
@ -312,4 +316,4 @@ export default {
* 扩展已有的数据表的字段
* 扩展新的字段类型
我们将本章所涉及的代码放到了一个完整的示例包 [packages/samples/shop-modeling](https://github.com/nocobase/nocobase/tree/develop/packages/samples/shop-modeling) 中,可以直接在本地运行,查看效果。
我们将本章所涉及的代码放到了一个完整的示例包 [packages/samples/shop-modeling](https://github.com/nocobase/nocobase/tree/main/packages/samples/shop-modeling) 中,可以直接在本地运行,查看效果。

View File

@ -1 +1,390 @@
# Resources & Actions
# 资源与操作
在 Web 开发领域,你可能听说过 RESTful 的概念NocoBase 也借用了这个资源的概念来映射系统中的各种实体,比如数据库中的数据、文件系统中的文件或某个服务等。但 NocoBase 基于实践考虑,并未完全遵循 RESTful 的约定,而是参考 [Google Cloud API 设计指南](https://cloud.google.com/apis/design) 的规范做了一些扩展,以适应更多的场景。
## 基础概念
### Resource
目前的资源主要针对数据库表中的数据,数据库中的数据表在默认情况下都会被自动映射为资源,同时也提供了服务端的数据接口。所以在默认情况下,只要使用了 `db.collection()` 定义了数据表,就可以通过 NocoBase 的 HTTP API 访问到这个数据表的数据资源了。自动生成的资源的名称与数据库表定义的表名相同,比如 `db.collection({ name: 'users' })` 定义的数据表,对应的资源名称就是 `users`
数据型的资源还分为独立资源和关系资源:
* 独立资源:`<collection>`
* 关系资源:`<collection>.<association>`
```ts
// 等同于 app.resourcer.define()
// 定义文章资源
app.resource({
name: 'posts'
});
// 定义文章的作者资源
app.resource({
name: 'posts.user'
});
// 定义文章的评论资源
app.resource({
name: 'posts.coments'
});
```
需要自定义的情况主要针对于非数据库表类资源,比如内存中的数据、其他服务的代理接口等,以及需要对已有数据表类资源定义特定操作的情况。
例如定义一个与数据库无关的发送通知操作的资源:
```ts
app.resource({
name: 'notifications',
actions: {
async send(ctx, next) {
await someProvider.send(ctx.request.body);
next();
}
}
});
```
则在 HTTP API 中可以这样访问:
```bash
curl -X POST -d '{"title": "Hello", "to": "hello@nocobase.com"}' 'http://localhost:13000/api/notifications:send'
```
### Action
操作主要指对资源的读取和写入通常用于查阅数据、创建数据、更新数据、删除数据等。NocoBase 通过定义操作来实现对资源的访问,操作的核心其实是一个用于处理请求且兼容 Koa 的中间件函数。
```ts
// 等同于 app.resourcer.registerActions()
// 注册针对 posts 资源的 create 操作方法
app.actions({
async ['posts:create'](ctx, next) {
const postRepo = ctx.db.getRepository('posts');
await postRepo.create({
values: ctx.action.params.values
});
await next();
}
});
```
NocoBase 也内置了针对数据资源通常的 CRUD 操作,当默认提供的 CRUD 操作不满足业务场景的情况下,也可以对特定资源扩展更多的操作。比如是对内置操作额外的处理需求,或者需要设置默认参数的情况。
### Action 参数
客户端的请求到达服务端后,相关的请求参数会被按规则解析并放在请求的 `ctx.action.params` 对象上。Action 参数主要有三个来源:
1. Action 定义时默认参数
2. 客户端请求携带
3. 其他中间件预处理
在真正操作处理函数处理之前,上面这三个部分的参数会按此顺序被合并到一起,最终传入操作的执行函数中。在多个中间件中也是如此,上一个中间件处理完的参数会被继续随 `ctx` 传递到下一个中间件中。
针对内置的操作可使用的参数,可以参考 [@nocobase/actions](/api/actions) 包的内容。除自定义操作以外,客户端请求主要使用这些参数,自定义的操作可以根据业务需求扩展需要的参数。
中间件预处理主要使用 `ctx.action.mergeParams()` 方法,且根据不同的参数类型有不同的合并策略,具体也可以参考 [mergeParams()](/api/resourcer/action#mergeparams) 方法的内容。
内置 Action 的默认参数在合并时只能以 `mergeParams()` 方法针对各个参数的默认策略执行,以达到服务端进行一定操作限制的目的。例如:
```ts
app.resource({
name: 'posts',
actions: {
create: {
whitelist: ['title', 'content'],
blacklist: ['createdAt', 'createdById'],
}
}
});
```
如上定义了针对 `posts` 资源的 `create` 操作,其中 `whitelist``blacklist` 分别是针对 `values` 参数的白名单和黑名单,即只允许 `values` 参数中的 `title``content` 字段,且禁止 `values` 参数中的 `createdAt``createdById` 字段。
## 示例
我们继续之前 [数据表与字段示例](/development/guide/collections-fields#示例) 中的简单店铺场景,进一步理解资源与操作相关的概念。这里假设我们的在基于之前数据表的示例进行进一步资源和操作的定义,所以这里不再重复定义数据表的内容。
### 内置默认操作
在 [数据表与字段示例](/development/guide/collections-fields#示例) 中我们提到,只要是通过 `db.collection()` 定义的数据表,都会被自动注册为一个数据表资源,且会自动注册一些默认的操作。
对简单数据资源的默认操作:
* [`list`](/api/actions#list):查询数据表中的数据列表
* [`get`](/api/actions#get):查询数据表中的单条数据
* [`create`](/api/actions#create):对数据表创建单条数据
* [`update`](/api/actions#update):对数据表更新单条数据
* [`destroy`](/api/actions#destroy):对数据表删除单条数据
所以,只要定义了对应的数据表,我们对商品、订单等数据资源就可以直接使用这些默认操作以完成最场景的 CRUD 场景。
某些情况下,不只是简单的针对单条数据的操作时,或者默认操作的参数需要有一定控制时,我们也可以覆盖默认的操作行为。比如我们创建订单时,不应该由客户端提交 `userId` 来代表订单的归属,而是应该由服务端根据当前登录用户来确定订单归属,这时我们就可以覆盖默认的 `create` 操作。对于简单的扩展,我们直接在插件的主类中编写:
```ts
import { Plugin } from '@nocobase/server';
import actions from '@nocobase/actions';
export default class ShopPlugin extends Plugin {
async load() {
// ...
this.app.resource({
name: 'orders',
actions: {
async create(ctx, next) {
ctx.action.mergeParams({
values: {
userId: ctx.state.user.id
}
});
return actions.create(ctx, next);
}
}
});
}
}
```
这样,我们在插件加载过程中针对订单数据资源就覆盖了默认的 `create` 操作,但在修改操作参数以后仍调用了默认逻辑,无需自行编写。修改提交参数的 `mergeParams()` 方法对内置默认操作来说非常有用,我们会在后面介绍。
### 数据表资源的自定义操作
当内置操作不能满足业务需求时,我们可以通过自定义操作来扩展资源的功能。例如通常一个订单会有很多状态,如果我们对 `status` 字段的取值设计为一系列枚举值:
* `-1`:已取消
* `0`:已下单,未付款
* `1`:已付款,未发货
* `2`:已发货,未签收
* `3`:已签收,订单完成
那么我们就可以通过自定义操作来实现订单状态的变更,比如对订单进行一个发货的操作,虽然简单的情况下可以通过 `update` 操作来实现,但是如果还有支付、签收等更复杂的情况,仅使用 `update` 会造成语义不清晰且参数混乱的问题,因此我们可以通过自定义操作来实现。
首先我们增加一张发货信息表的定义,保存到 `collections/deliveries.ts`
```ts
export default {
name: 'deliveries',
fields: [
{
type: 'belongsTo',
name: 'order'
},
{
type: 'string',
name: 'provider'
},
{
type: 'string',
name: 'trackingNumber'
},
{
type: 'integer',
name: 'status'
}
]
};
```
同时对订单表也扩展一个发货信息的关联字段(`collections/orders.ts`
```ts
export default {
name: 'orders',
fields: [
// ...other fields
{
type: 'hasOne',
name: 'delivery'
}
]
};
```
然后我们在插件的主类中增加对应的操作定义:
```ts
import { Plugin } from '@nocobase/server';
export default class ShopPlugin extends Plugin {
async load() {
// ...
this.app.resource({
name: 'orders',
actions: {
async deliver(ctx, next) {
const { resourceIndex } = ctx.action.params;
const orderRepo = ctx.db.getRepository('orders');
const [order] = await orderRepo.update({
filterByTk: resourceIndex,
values: {
status: 2,
delivery: {
...ctx.action.params.values,
status: 0
}
}
});
ctx.body = order;
next();
}
}
});
}
}
```
其中Repository 是使用数据表数据仓库类,大部分进行数据读写的操作都会由此完成,详细可以参考 [Repository API](/api/database/repository) 部分。
定义好之后我们从客户端就可以通过 HTTP API 来调用“发货”这个操作了:
```bash
curl \
-X POST \
-H 'Content-Type: application/json' \
-d '{"provider": "SF", "trackingNumber": "SF1234567890"}' \
'/api/orders:deliver/<id>'
```
同样的,我们还可以定义更多类似的操作,比如支付、签收等。
### 参数合并
假设我们要提供用户查询自己的且只能查询自己的订单,同时我们需要限制用户不能查询已取消的订单,那么我们可以通过 action 的默认参数来定义:
```ts
import { Plugin } from '@nocobase/server';
export default class ShopPlugin extends Plugin {
async load() {
// ...
this.app.resource({
name: 'orders',
actions: {
// 对 list 操作的默认参数
list: {
filter: {
// 由 users 插件扩展的过滤器运算符
$isCurrentUser: true,
status: {
$ne: -1
}
},
fields: ['id', 'status', 'createdAt', 'updatedAt']
}
}
});
}
}
```
当用户从客户端查询时,也可以在请求的 URL 上加入其他的参数,比如:
```bash
curl 'http://localhost:13000/api/orders:list?productId=1&fields=id,status,quantity,totalPrice&appends=product'
```
实际的查询条件会合并为:
```json
{
"filter": {
"$and": {
"$isCurrentUser": true,
"status": {
"$ne": -1
},
"productId": 1
}
},
"fields": ["id", "status", "quantity", "totalPrice", "createdAt", "updatedAt"],
"appends": ["product"]
}
```
并得到预期的查询结果。
另外的,如果我们需要对创建订单的接口限制不能由客户端提交订单编号(`id`)、总价(`totalPrice`)等字段,可以通过对 `create` 操作定义默认参数控制:
```ts
import { Plugin } from '@nocobase/server';
export default class ShopPlugin extends Plugin {
async load() {
// ...
this.app.resource({
name: 'orders',
actions: {
create: {
blacklist: ['id', 'totalPrice', 'status', 'createdAt', 'updatedAt'],
values: {
status: 0
}
}
}
});
}
}
```
这样即使客户端故意提交了这些字段,也会被过滤掉,不会存在于 `ctx.action.params` 参数集中。
如果还要有更复杂的限制,比如只能在商品上架且有库存的情况下才能下单,可以通过配置中间件来实现:
```ts
import { Plugin } from '@nocobase/server';
export default class ShopPlugin extends Plugin {
async load() {
// ...
this.app.resource({
name: 'orders',
actions: {
create: {
middlewares: [
async (ctx, next) => {
const { productId } = ctx.action.params.values;
const product = await ctx.db.getRepository('products').findOne({
filterByTk: productId,
filter: {
enabled: true,
inventory: {
$gt: 0
}
}
});
if (!product) {
return ctx.throw(404);
}
await next();
}
]
}
}
});
}
}
```
把部分业务逻辑(尤其是前置处理)放到中间件中,可以让我们的代码更加清晰,也更容易维护。
## 小结
通过上面的示例我们介绍了如何定义资源和相关的操作,回顾一下本章内容:
* 数据表自动映射为资源
* 内置默认的资源操作
* 对资源自定义操作
* 操作的参数合并顺序与策略
本章所涉及到的相关代码放到了一个完整的示例包 [packages/samples/shop-actions](https://github.com/nocobase/nocobase/tree/main/packages/samples/shop-actions) 中,可以直接在本地运行,查看效果。

View File

@ -0,0 +1,44 @@
# Modeling for simple shop scenario
## Register
```ts
yarn pm add sample-shop-actions
```
## Activate
```bash
yarn pm enable sample-shop-actions
```
## Launch the app
```bash
# for development
yarn dev
# for production
yarn build
yarn start
```
## Connect to the API
### Products API
```bash
# create a product
curl -X POST -H "Content-Type: application/json" -d '{"title": "iPhone 14 Pro", "price": 7999, "enabled": true, "inventory": 1}' "http://localhost:13000/api/products"
```
### Orders API
```bash
# create a order
curl -X POST -H "Content-Type: application/json" -d '{"productId": 1, "quantity": 1, "totalPrice": 0, "userId": 2}' 'http://localhost:13000/api/orders'
# {"id": <id>, "status": 0, "productId": 1, "quantity": 1, "totalPrice": 7999, "userId": 1}
# list orders which userId=1 with product
curl 'http://localhost:13000/api/orders?filter={"status":2}&appends=product'
```

4
packages/samples/shop-actions/client.d.ts vendored Executable file
View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/client';
export { default } from './lib/client';

View File

@ -0,0 +1,30 @@
"use strict";
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
var _index = _interopRequireWildcard(require("./lib/client"));
Object.defineProperty(exports, "__esModule", {
value: true
});
var _exportNames = {};
Object.defineProperty(exports, "default", {
enumerable: true,
get: function get() {
return _index.default;
}
});
Object.keys(_index).forEach(function (key) {
if (key === "default" || key === "__esModule") return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
}
});
});

View File

@ -0,0 +1,17 @@
{
"name": "@nocobase/plugin-sample-shop-actions",
"version": "0.7.4-alpha.7",
"main": "lib/server/index.js",
"dependencies": {
},
"devDependencies": {
"@nocobase/client": "0.7.4-alpha.7",
"@nocobase/server": "0.7.4-alpha.7",
"@nocobase/test": "0.7.4-alpha.7"
},
"peerDependencies": {
"@nocobase/client": "*",
"@nocobase/server": "*",
"@nocobase/test": "*"
}
}

4
packages/samples/shop-actions/server.d.ts vendored Executable file
View File

@ -0,0 +1,4 @@
// @ts-nocheck
export * from './lib/server';
export { default } from './lib/server';

View File

@ -0,0 +1,30 @@
"use strict";
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function _getRequireWildcardCache(nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
var _index = _interopRequireWildcard(require("./lib/server"));
Object.defineProperty(exports, "__esModule", {
value: true
});
var _exportNames = {};
Object.defineProperty(exports, "default", {
enumerable: true,
get: function get() {
return _index.default;
}
});
Object.keys(_index).forEach(function (key) {
if (key === "default" || key === "__esModule") return;
if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return;
if (key in exports && exports[key] === _index[key]) return;
Object.defineProperty(exports, key, {
enumerable: true,
get: function get() {
return _index[key];
}
});
});

View File

@ -0,0 +1,5 @@
import React from 'react';
export default React.memo((props) => {
return <>{props.children}</>;
});

View File

@ -0,0 +1 @@
export { default } from './server';

View File

@ -0,0 +1,13 @@
export default {
name: 'categories',
fields: [
{
type: 'string',
name: 'title'
},
{
type: 'hasMany',
name: 'products',
}
]
};

View File

@ -0,0 +1,21 @@
export default {
name: 'deliveries',
fields: [
{
type: 'belongsTo',
name: 'order'
},
{
type: 'string',
name: 'provider'
},
{
type: 'string',
name: 'trackingNumber'
},
{
type: 'integer',
name: 'status'
}
]
};

View File

@ -0,0 +1,33 @@
export default {
name: 'orders',
fields: [
{
type: 'belongsTo',
name: 'product'
},
{
type: 'integer',
name: 'quantity'
},
{
type: 'integer',
name: 'totalPrice'
},
{
type: 'integer',
name: 'status'
},
{
type: 'string',
name: 'address'
},
{
type: 'belongsTo',
name: 'user'
},
{
type: 'hasOne',
name: 'delivery'
}
]
};

View File

@ -0,0 +1,21 @@
export default {
name: 'products',
fields: [
{
type: 'string',
name: 'title'
},
{
type: 'integer',
name: 'price'
},
{
type: 'boolean',
name: 'enabled'
},
{
type: 'integer',
name: 'inventory'
}
]
};

View File

@ -0,0 +1,91 @@
import path from 'path';
import { InstallOptions, Plugin } from '@nocobase/server';
export class ShopPlugin extends Plugin {
getName(): string {
return this.getPackageName(__dirname);
}
beforeLoad() {
// TODO
}
async load() {
await this.db.import({
directory: path.resolve(__dirname, 'collections'),
});
this.app.resource({
name: 'orders',
actions: {
create: {
blacklist: ['id', 'totalPrice', 'status', 'createdAt', 'updatedAt'],
values: {
status: 0
},
middlewares: [
async (ctx, next) => {
const { productId } = ctx.action.params.values;
const product = await ctx.db.getRepository('products').findOne({
filterByTk: productId,
filter: {
enabled: true,
inventory: {
$gt: 0
}
}
});
if (!product) {
return ctx.throw(404);
}
await next();
}
]
},
list: {
filter: {
// 由 users 插件扩展的过滤器运算符
$isCurrentUser: true,
status: {
$ne: -1
}
},
fields: ['id', 'status', 'createdAt', 'updatedAt']
},
async deliver(ctx, next) {
const { resourceIndex } = ctx.action.params;
const orderRepo = ctx.db.getRepository('orders');
const [order] = await orderRepo.update({
filterByTk: resourceIndex,
values: {
status: 2,
delivery: {
...ctx.action.params.values,
status: 0
}
}
});
ctx.body = order;
next();
}
}
});
this.app.acl.allow('products', '*');
this.app.acl.allow('categories', '*');
this.app.acl.allow('orders', '*');
}
async install(options: InstallOptions) {
// TODO
}
}
export default ShopPlugin;

View File

@ -29,7 +29,7 @@ yarn start
```bash
# create a product
curl -X POST -d '{"title": "iPhone 14 Pro", "price": "7999", "enabled": true, "inventory": 10}' "http://localhost:13000/api/products"
curl -X POST -H "Content-Type: application/json" -d '{"title": "iPhone 14 Pro", "price": "7999", "enabled": true, "inventory": 10}' "http://localhost:13000/api/products"
# list products
curl "http://localhost:13000/api/products"
@ -42,7 +42,7 @@ curl "http://localhost:13000/api/products?filterByTk=1"
```bash
# create a order
curl -X POST -d '{"productId": 1, "quantity": 1, "totalPrice": "7999", "userId": 1}' 'http://localhost:13000/api/orders'
curl -X POST -H "Content-Type: application/json" -d '{"productId": 1, "quantity": 1, "totalPrice": "7999", "userId": 1}' 'http://localhost:13000/api/orders'
# list orders which userId=1 with product
curl 'http://localhost:13000/api/orders?filter={"userId":1}&appends=product'