@weifuwujs/api
v1.3.0
Published
weifuwu 单源 API:一份类型描述符同时生成 REST 路由与 GraphQL schema(graphql-js 引擎)
Downloads
815
Readme
单源 API:REST + GraphQL(@weifuwujs/api)
一份类型描述符同时生成 REST 路由与 GraphQL schema;依赖随包走(graphql 为 peer)。
同一份类型描述与业务逻辑,同时投影出 REST 路由和 GraphQL schema。
独立包,不 import 不背依赖(仅使用方自带 graphql peer):
import { defineApi, defineResource, t } from '@weifuwujs/api'最小可跑示例(零依赖,node 直接运行;test/batteries/readme-examples.test.ts 会真跑它):
import assert from 'node:assert/strict'
import { createApp } from '@weifuwujs/weifuwu'
import { defineApi, t } from '@weifuwujs/api'
const Ping = t.object('Ping', { ok: t.boolean() })
const api = defineApi()
.query('ping', { returns: Ping, rest: 'GET /ping' }, () => ({ ok: true }))
.build()
const app = createApp().mount(api, { openapi: '/openapi.json' })
const response = await app.fetch(new Request('http://readme/ping'))
assert.equal(response.status, 200)
assert.deepEqual(await response.json(), { ok: true })示例标注:
ts exec= 由测试真跑;ts fragment= 片段(依赖前后文,不独立运行);自足示例带包标签(如ts tasks),由测试编译。
GraphQL 的 schema 构建与执行由 graphql + @graphql-tools/schema 完成
(graphql 是 peer dependency):解析、校验、执行都是标准实现。
类型描述符
const Role = t.enum('Role', ['admin', 'member'] as const)
const User = t.object('User', {
id: t.id(),
name: t.string(),
email: t.string().nullable(),
role: Role,
tags: t.list(t.string()),
})
const CreateUserInput = t.input('CreateUserInput', {
name: t.string(),
email: t.string().optional(),
})| 工厂 | GraphQL | TypeScript |
| --- | --- | --- |
| t.id() | ID | string |
| t.string() / t.int() / t.float() / t.boolean() | 对应内置 scalar | string / number / number / boolean |
| t.json<T>() / t.datetime() / t.bigint() | 自定义 scalar | T / Date / bigint |
| t.enum(name, values) | enum | 字面量联合 |
| t.object(name, fields) / t.input(name, fields) | type / input | 结构化对象 |
| t.list(item) | [Item] | T[] |
| t.lazy(() => Type) | 延迟解析,用于递归类型 | 同内层类型 |
修饰符(返回新描述符,不改原值):.nullable()(GraphQL 可空)、
.optional()(参数/输入可省)、.default(v)、.describe(text)。
推导:Infer<typeof User> 得到 { id: string; name: string; email: string | null; ... },
操作参数的 .optional() / .default() 会自动变成可选键。
定义操作
const api = defineApi<Context>()
.query(
'user',
{ returns: User.nullable(), args: { id: t.id() }, rest: 'GET /users/:id' },
({ args, ctx }) => ctx.db.users.find(args.id), // args.id: string
)
.mutation(
'createUser',
{
returns: User,
args: { input: CreateUserInput },
rest: { method: 'POST', path: '/users', status: 201 },
},
({ args, ctx }) => ctx.db.users.create(args.input),
)
.build()
createApp().mount(api, { graphql: '/graphql' })kind由query()/mutation()决定,对应 GraphQL 根类型。rest:'GET /users/:id'或{ method, path, status? };false表示仅 GraphQL。resolve接收{ args, ctx, req, operation, info? };resolver 里可用ctx.req拿到当前请求。info仅在 GraphQL 路径提供(graphql-jsGraphQLResolveInfo,含选择集),REST 路径为undefined—— 字段级授权据此区分「未请求字段」(可裁剪)与「显式请求越权字段」(须拒绝)。
CRUD 语法糖
const users = defineResource<Context>()({
type: User,
path: '/users',
createInput: CreateUserInput,
updateInput: CreateUserInput,
handlers: { list, get, create, update, remove },
})
const api = defineApi().use(users).build()自动生成 GET /users、GET /users/:id、POST /users(201)、PATCH /users/:id、
DELETE /users/:id,以及 GraphQL 的 users / user(id) / createUser(input) /
updateUser(id, input) / deleteUser(id);未提供 handler 的方法不会生成。
类型推断提示:TypeScript 不支持部分类型参数推断。需要自定义
Ctx时用柯里化形态defineResource<Ctx>()({ ... }):第一层只绑定Ctx,配置里的type、createInput、updateInput仍会完整推断,handler 的args/ctx保持精确类型。 若直接写defineResource<Ctx>({ ... }),其余泛型会退回默认值,handler 参数与返回值 退化为any(不会有编译错误,静默失去类型保护);只有不需要精确类型时才这样写。Ctx就用默认值(Context)时,直接defineResource({ ... })即可,无需柯里化。
资源类型进 ctx(typedResource)
内核资源(resource() / postgres() / redis())注入的 ctx[key] 类型可以直接带进 resolver,
不用手写 defineApi<AppContext>():
import { resource } from '@weifuwujs/weifuwu'
import { defineApi, t, typedResource } from '@weifuwujs/api'
interface User {
id: string
name: string
}
const UserType = t.object('User', { id: t.id(), name: t.string() })
const db = resource('db', () => ({ users: [] as User[] }))
export const api = defineApi()
.use(typedResource(db))
.query('users', { returns: t.list(UserType), rest: 'GET /users' }, ({ ctx }) => ctx.db.users)
.build()typedResource 是纯类型载体:资源仍必须挂载(app.use(db)),
懒初始化、single-flight 与 app.close() 清理都归内核资源中间件;
只在 api 里声明、没 app.use → resolver 里 ctx.db 是 undefined(HTTP 500),不会自动注入。
import { createApp, resource } from '@weifuwujs/weifuwu'
import { defineApi, typedResource } from '@weifuwujs/api'
const ping = resource('ping', () => 'pong')
const api = defineApi().use(typedResource(ping)).build()
// 资源中间件挂载在 app 上(typedResource 不做注入):app.use(ping).mount(api)
export const app = createApp().use(ping).mount(api)没有 typedResource 声明的资源字段在编译期是 unknown(Context 有索引签名,所以「能读不能用」):
直接 ctx.db.users 会报类型错,必须先显式收窄——拼错 key 不会静默变成 any。
REST 映射规则
| 来源 | 规则 |
| --- | --- |
| 路径参数 | :id → args.id,按描述符转换 |
| 请求体 | 按参数名取字段;若 body 里没有任何参数名,则整体作为唯一的非路径参数 |
| 查询参数 | GET/DELETE 未在路径/体中找到的参数从 ctx.query 取 |
| 校验失败 | 422 { errors: [{ path, message }] } |
| 返回值 | JSON;HttpError 映射为对应状态,其他异常为 500 |
| 状态码 | 默认 200,可在 rest.status 指定 |
GraphQL 端点
app.mount(api, { graphql: '/graphql' }) 同时注册 GET 与 POST:
curl -H 'Content-Type: application/json' \
-d '{"query":"query($id: ID!){ user(id: $id){ id name } }","variables":{"id":"1"}}' \
http://localhost:3000/graphql- 执行由 graphql-js 提供:query / mutation、变量与默认值、别名、嵌套选择、
命名/内联片段、
@skip/@include、__typename、introspection、错误 path、 非空向上传播。 - schema 由描述符生成,只包含 object / input / enum / scalar 与 Query / Mutation (没有 union / interface / subscription)。
t.json()等自定义 scalar 在查询中不要写子选择集:合法写法是debug, 而不是debug { ... }。- 校验失败:200 +
errors,且不含部分data(GraphQL 规范); 语法错误 400;GET 被禁用 405;非 JSON Content-Type 415。 - 错误脱敏与 HTTP 同口径:
HttpError默认expose = status < 500, 未暴露的错误在响应里只给状态文案(如 500 →Internal Server Error) 并保留extensions.status;原始错误传给graphqlHandler({ onError }), 默认打印console.error(可接结构化日志,例如:api.graphqlHandler({ onError: (error) => logger.error(error) }))。
需要 SDL 或自行组合时:
const { sdl, rootResolvers } = api.schema()
// 或
const sdlText = api.sdl()挂载选项与分组
app.mount(api, { rest: true, graphql: '/graphql' })
app.mount(api, { openapi: '/openapi.json' }) // REST + OpenAPI
app.group('/v1', (group) => group.mount(api, { graphql: '/graphql' })) // /v1/graphql完整可运行示例见 examples/rest-graphql.ts(REST + GraphQL)与 examples/openapi.ts(REST + GraphQL + OpenAPI)。
OpenAPI 3.1 文档
一行挂载即得到 REST 投影的机器可读契约(与 GraphQL 的 api.sdl() 对称):
import { createApp } from '@weifuwujs/weifuwu'
import { defineApi, t } from '@weifuwujs/api'
const Ping = t.object('Ping', { ok: t.boolean() })
const api = defineApi()
.query('ping', { returns: Ping, rest: 'GET /ping' }, () => ({ ok: true }))
.build()
const app = createApp().mount(api, { openapi: '/openapi.json' })
// 或自行取文档 / handler:
export const document = api.openapi({ title: 'Ping API', version: '1.0.0' })
export const handler = api.openapiHandler({ title: 'Ping API', version: '1.0.0' })- 文档由同一份类型描述符生成:路径与方法、路径/查询参数、
requestBody、成功与 400/422/500 响应、components.schemas(object / input / enum)都来自defineApi/defineResource的定义;rest: false的纯 GraphQL 操作不会出现。 - 生成是确定的:同一 API 两次生成的
JSON.stringify逐字节一致(paths按声明序、schemas按名字典序)。 group.mount(api, { openapi: '/openapi.json' })前缀生效;提供了 path 但 API 没有 handler 时显式报错。
交给工具链(Postman / Swagger Editor / Insomnia 可直接导入 URL):
curl http://localhost:3000/openapi.json -o openapi.json
npx --yes openapi-typescript@7 openapi.json -o api-types.d.ts # 生成客户端类型monorepo/workspace 里
npx会把相对输入挂到 npm local prefix(通常是包根):用绝对路径 (npx --yes openapi-typescript@7 "$PWD/openapi.json" -o "$PWD/api-types.d.ts")最稳。
BigInt 与运行时宽容度
t.bigint() 的 JSON 表示统一为 decimal string(超出 JS 安全整数不丢精度):REST 响应与 GraphQL
数据都是 "9007199254740993",输入接受 string / number / bigint;文档中 schema 为
{ type: 'string', pattern: '^-?[0-9]+$' }。
文档描述推荐(canonical)形态;REST 运行时还接受这些宽容形态(不在 spec 内):
| 宽容形态 | 示例 |
| --- | --- |
| 数字 ID | GET /users/42(路径/查询参数本质是字符串) |
| 布尔字符串 | ?flag=true / ?flag=false |
| 单值列表 | body {"tags":"a"} 等价 {"tags":["a"]} |
| 整体 body | 恰有一个非路径参数时,body 可直接是该参数值(spec 的 oneOf 同时描述两种形态) |
import { createApp, type Context } from '@weifuwujs/weifuwu'
import { defineApi, defineResource, t } from '@weifuwujs/api'
const User = t.object('User', { id: t.id(), name: t.string() })
const CreateUserInput = t.input('CreateUserInput', { name: t.string() })
// 自定义上下文用柯里化:显式绑定 Ctx,同时保留 type / createInput 的推断
type AppContext = Context & { db: { name: string } }
const users = defineResource<AppContext>()({
type: User,
path: '/users',
createInput: CreateUserInput,
handlers: {
list: ({ ctx }) => [{ id: '1', name: ctx.db.name }],
get: ({ args }) => ({ id: args.id, name: 'Ada' }),
},
})
const api = defineApi().use(users).build()
const app = createApp().mount(api, { graphql: '/graphql', openapi: '/openapi.json' })