Skip to content

API 资源 ​

API 资源是模型与 JSON 响应之间的一层转换:决定输出哪些字段、字段如何命名和格式化、哪些数据对当前访问者隐藏,并且保留分页信息。这些规则集中写在一处,不必散落在每个控制器里。

ts
// app/resource/UserResource.ts
import type { User } from '~/app/model/User'
import { defineResource, when, whenLoaded } from '@hazejs/core'
import { PostResource } from '~/app/resource/PostResource'

export const UserResource = defineResource((user: User, ctx) => ({
  id: user.id,
  name: user.name,
  email: when(ctx.can('update', user), user.email),
  posts: whenLoaded('posts', PostResource),
  createdAt: user.createdAt,
}))
ts
// app/controller/UserController.ts
export class UserController {
  async index(ctx: HttpContext) {
    return UserResource.collection(await User.with('posts').paginate(Number(ctx.query('page') ?? 1), 20))
  }

  async show(ctx: HttpContext) {
    return UserResource.make(await User.findOrFail(ctx.param('id')))
  }
}

用 haze make:resource User 生成(创建 app/resource/UserResource.ts)。

定义资源 ​

defineResource(transform, options?) 接收一个函数,参数是要转换的对象和当前的 HttpContext,返回普通对象。字段的值可以是:

  • 普通值、嵌套对象或数组;
  • Promise:会被 await,同级字段并行求值;
  • 其它资源(PostResource.make(post)、PostResource.collection(posts));
  • 条件值(when、whenLoaded、whenNotNull、merge),条件不成立时该字段不出现在输出中。

转换函数本身也可以是 async。输出中的模型实例与日期照常通过 toJSON() 序列化。

返回资源 ​

调用输出
UserResource.make(user)转换后的对象(null / undefined 输出 null)
UserResource.collection(users)数组
UserResource.collection(await User.paginate(page, 20)){ data, meta, links }
UserResource.collection(await Db.table(logs).paginateX(20)){ data, meta, links },meta 为游标信息

在控制器中直接返回即可,和其它数据一样经过统一响应格式:

json
{
  "code": 0,
  "message": "ok",
  "data": {
    "data": [{ "id": 2, "name": "Bob" }],
    "meta": { "total": 41, "page": 2, "perPage": 20, "lastPage": 3, "from": 21, "to": 40 },
    "links": {
      "first": "/api/users?page=1",
      "last": "/api/users?page=3",
      "prev": "/api/users?page=1",
      "next": "/api/users?page=3"
    }
  }
}

关闭统一格式时(.meta({ envelope: false }) 或 app.response.envelope: false),分页集合直接输出 { data, meta, links },单个资源直接输出对象本身。

分页链接沿用当前路径和查询参数,只替换 page(游标分页为 lastId),参数名可通过 pageName / cursorName 选项修改。

make() 与 collection() 返回一个惰性、可 await 的值。需要普通数据时(例如在测试中,或嵌入别的结构)直接 await:

ts
const data = await UserResource.make(user)   // { id, name, ... }

追加顶层字段 ​

additional() 在结果旁边追加字段:单个对象与数组会包装为 { data };分页结果则与 data、meta、links 并列:

ts
return UserResource.collection(page).additional({ filters: { role } })
// { data: [...], meta: {...}, links: {...}, filters: { role: 'admin' } }

条件字段 ​

辅助函数输出
when(condition, value, fallback?)条件为真时输出 value,否则输出 fallback,没有 fallback 时移除该字段。条件可以是 Promise,如 ctx.can(...)
whenNotNull(value)值不为 null / undefined 时输出
whenLoaded('posts', PostResource?)关联已经预加载(with() / load())时输出,可用资源或函数转换
...merge(condition?, { ... })一次展开多个字段,条件成立时才输出

值可以写成函数,只在条件成立时才求值:

ts
defineResource((user: User, ctx) => ({
  id: user.id,
  stats: when(ctx.query('include') === 'stats', () => loadStats(user.id)),
  ...merge(user.role === 'admin', () => ({ permissions: user.permissions })),
}))

whenLoaded('posts') 作用于正在转换的对象,所以在 async 转换函数中要在第一个 await 之前调用;await 之后请显式传入对象:whenLoaded(user, 'posts', PostResource)。只输出已加载的关联,可以避免不经意间产生 N+1 查询。

在请求之外使用 ​

只要转换函数不访问 ctx,资源同样可以在队列任务、命令和邮件中使用(请求之外访问 ctx 会抛错)。此时分页链接只有查询串部分(?page=2)。

类型与 OpenAPI ​

输出类型由转换函数推导:Promise 被解开,嵌套资源被展开,条件字段变为可选。可以用 ResourceData<typeof UserResource> 引用这个类型,类型安全的客户端(@hazejs/core/client)拿到的也是同一个类型。

生成 OpenAPI 文档时,给资源一个 schema,再在 .doc() 中引用:

ts
export const UserResource = defineResource(transform, {
  schema: v.object({ id: v.number(), name: v.string() }),
})

r.get('/users/:id', [UserController, 'show']).doc({ response: UserResource.schema })

基于 MIT 许可证发布