类型安全 API 客户端
本指南介绍 Tuyau,一个为 AdonisJS 应用提供的类型安全 HTTP 客户端。你将学习如何:
- 为 Inertia 和 monorepo 设置安装并配置 Tuyau
- 使用路由名称进行类型安全的 API 调用
- 处理请求参数、验证和错误响应
- 处理文件上传
- 以编程方式生成 URL
- 理解用于端到端类型安全的类型级序列化
概述
Tuyau 是一个类型安全的 HTTP 客户端,可实现 AdonisJS 后端和前端应用之间的端到端类型安全。你无需手动编写 API 客户端代码和管理类型,Tuyau 会根据你的路由、控制器和验证器自动生成一个完全类型化的客户端。
Tuyau 的主要好处是消除了后端 API 定义与前端消费之间的鸿沟。当你在 AdonisJS 中定义一个带有验证的路由时,Tuyau 确保你的前端调用对请求体、查询参数、路由参数和响应数据使用完全相同的类型。这意味着 TypeScript 会在编译时捕获错误,而不是在运行时才发现。
Tuyau 通过分析你的 AdonisJS 路由并生成一个将路由名称映射到其类型的注册表来工作。你的前端导入这个注册表,并用它进行类型安全的 API 调用。每个参数、请求体中的每个字段以及响应中的每个属性都是完全类型化的,并在你的 IDE 中自动补全。
该库构建于 Ky (一个现代的 fetch 封装器)之上,这意味着你可以在保持完全类型安全的同时,获得 Ky 的所有特性,如自动重试、超时处理,以及请求/响应钩子。
安装
Tuyau 的安装方式取决于你使用的是 Inertia(单一仓库)还是带有独立前端和后端应用的 monorepo 设置。
Inertia 应用
对于 Inertia 应用,安装很简单,因为你的前端和后端位于同一个仓库中。官方的 React 和 Vue 起步套件都已预配置好 Tuyau,因此无需手动设置。
-
安装包
npm install @tuyau/core -
配置 assembler 钩子
Assembler 钩子会在你的代码库发生变化时自动生成 Tuyau 注册表。将
generateRegistry钩子添加到你的adonisrc.ts文件中。indexEntities钩子为类型生成索引你的模型和 transformers,indexPages索引你的 Inertia 页面组件,generateRegistry在.adonisjs/client目录中生成 Tuyau 注册表文件。adonisrc.tsimport { indexPages } from '@adonisjs/inertia' import { indexEntities } from '@adonisjs/core' import { defineConfig } from '@adonisjs/core/app' import { generateRegistry } from '@tuyau/core/hooks' export default defineConfig({ // ... 其他配置 hooks: { init: [ indexEntities({ transformers: { enabled: true, withSharedProps: true } }), indexPages({ framework: 'react' }), generateRegistry(), ], }, }) -
配置 TypeScript 路径
在你的 Inertia
tsconfig.json中配置路径别名,以导入生成的注册表。inertia/tsconfig.json{ "compilerOptions": { // ... 其他选项 "paths": { "~/*": ["./*"], "@generated/*": ["../.adonisjs/client/*"] } } } -
配置 Vite 别名
在你的
vite.config.ts中添加匹配的别名。vite.config.tsimport { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import adonisjs from '@adonisjs/vite/client' import inertia from '@adonisjs/inertia/vite' export default defineConfig({ plugins: [ react(), inertia({ ssr: { enabled: false, entrypoint: 'inertia/ssr.tsx' } }), adonisjs({ entrypoints: ['inertia/app.tsx'], reload: ['resources/views/**/*.edge'] }), ], resolve: { alias: { '~/': `${import.meta.dirname}/inertia/`, '@generated': `${import.meta.dirname}/.adonisjs/client/`, }, }, }) -
创建 Tuyau 客户端
创建一个文件来初始化你的 Tuyau 客户端。
inertia/client.tsimport { registry } from '@generated/registry' import { createTuyau } from '@tuyau/core/client' export const client = createTuyau({ baseUrl: '/', registry, }) export const urlFor = client.urlFor由于 Inertia 应用中前端和后端从同一源提供,
baseUrl设置为'/'。
Monorepo 应用
对于前端和后端是独立包的 monorepo 设置,该设置需要额外的配置以在工作区之间共享类型。
本指南假设你使用带有 Turborepo 的 npm workspaces(如 API 起步套件 所用),但概念也适用于其他 monorepo 工具,如 pnpm 或 Yarn workspaces,只是语法上略有差异。
-
组织你的 monorepo
用一个单独的 workspace 来组织你的 API 和前端应用。
目录结构my-app/ ├── apps/ │ ├── backend/ # AdonisJS 后端 │ └── frontend/ # 前端(React、Vue 等) └── package.json -
在后端安装 Tuyau
在你的后端 workspace 中安装
@tuyau/core。它同时处理 assembler 钩子(注册表生成)并暴露客户端供你的前端导入。apps/backend/package.json{ "name": "@my-app/backend", "private": true, "type": "module", "dependencies": { "@tuyau/core": "^1.0.0" } }然后,在你的前端 workspace 中,将你的后端添加为 workspace 依赖,以便它可以导入生成的注册表和 Tuyau 客户端。
apps/frontend/package.json{ "name": "@my-app/frontend", "private": true, "type": "module", "dependencies": { "@my-app/backend": "*" } }"*"版本范围告诉 npm 从你的本地 workspace 解析@my-app/backend。确保包名与后端package.json中的name字段匹配。 -
启用实验性装饰器
Tuyau 内部使用 TypeScript 装饰器。在你的前端
tsconfig.json中启用它们。你还需要包含后端源文件,以便 TypeScript 在类型检查期间可以解析共享类型。apps/frontend/tsconfig.json{ "compilerOptions": { "experimentalDecorators": true, // ... 其他选项 }, "include": [ "./**/*.ts", "./**/*.tsx", "../backend/**/*.ts", "../backend/.adonisjs/**/*.ts" ], "exclude": [ "node_modules", "../backend/build", "../backend/node_modules" ] } -
配置后端
在你的后端 AdonisJS 应用中,像在 Inertia 设置中一样添加
generateRegistry钩子。apps/backend/adonisrc.tsimport { indexEntities } from '@adonisjs/core' import { defineConfig } from '@adonisjs/core/app' import { generateRegistry } from '@tuyau/core/hooks' export default defineConfig({ hooks: { init: [ indexEntities({ transformers: { enabled: true } }), generateRegistry(), ], }, }) -
导出注册表
配置你的后端
package.json以导出生成的 Tuyau 文件,供你的前端导入。apps/backend/package.json{ "name": "@my-app/backend", "version": "0.0.0", "private": true, "type": "module", "exports": { "./registry": "./.adonisjs/client/registry/index.ts", "./data": "./.adonisjs/client/data.d.ts" } }这些导出允许你的前端使用
@my-app/backend/registry导入注册表。 -
创建 Tuyau 客户端
在你的前端中,创建一个文件来初始化 Tuyau。
apps/frontend/src/lib/client.tsimport { createTuyau } from '@tuyau/core/client' import { registry } from '@my-app/backend/registry' export const client = createTuyau({ baseUrl: import.meta.env.VITE_API_URL || 'http://localhost:3333', registry, headers: { Accept: 'application/json' }, hooks: { beforeRequest: [ (request) => { const token = localStorage.getItem('auth_token') if (token) { request.headers.set('Authorization', `Bearer ${token}`) } } ] } })baseUrl应该使用环境变量,以便你可以为开发和生产环境配置不同的 API URL。
查看这个使用 TanStack 作为前端、并搭配 Tuyau 作为类型安全 API 客户端的 monorepo 起步套件 。
你的第一个 API 调用
让我们构建一个完整的示例,展示 Tuyau 如何提供从后端路由到前端 API 调用的端到端类型安全。
-
定义后端路由
start/routes.tsimport router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.post('posts', [controllers.Posts, 'store'])路由名称
posts.store会自动从控制器名称和操作派生。这就是你将用来从前端调用端点的方法。 -
创建验证器
使用 VineJS 定义验证规则。
app/validators/post.tsimport vine from '@vinejs/vine' export const createPostValidator = vine.create({ title: vine.string().minLength(3).maxLength(255), content: vine.string().minLength(10), published: vine.boolean().optional(), }) -
实现控制器
创建一个使用验证器的控制器操作。调用
request.validateUsing()对于 Tuyau 理解你请求体的形状并在前端提供准确类型至关重要。app/controllers/posts_controller.tsimport type { HttpContext } from '@adonisjs/core/http' import Post from '#models/post' import { createPostValidator } from '#validators/post' export default class PostsController { async store({ request }: HttpContext) { const payload = await request.validateUsing(createPostValidator) const post = await Post.create(payload) return post } } -
从前端发起 API 调用
导入你的 Tuyau 客户端并使用路由名称调用路由。
src/pages/posts/create.tsximport { client } from '~/client' async function handleCreatePost() { const post = await client.api.posts.store({ body: { title: '我的第一篇博客文章', content: '这是博客文章的内容', published: true, }, }) console.log('文章已创建:', post) }注意路由名称
posts.store如何变成方法链client.api.posts.store()。body参数根据你的验证器完全类型化。你的 IDE 会自动补全字段,TypeScript 会捕获任何错误。
发起 API 调用
Tuyau 提供了三种不同的 API 调用方式,每种都适合不同的用例。这三种方式都提供完全的类型安全,但在语法和灵活性上有所不同。
使用代理语法和路由名称
推荐的方法是使用路由名称和代理语法。路由名称直接映射到 Tuyau 客户端上的方法链。
// 路由:router.post('register', [controllers.Auth, 'register'])
const result = await client.api.auth.register({
body: { email: 'foo@ok.com', password: 'password123' }
})
// 路由:router.get('users/:id', [controllers.Users, 'show'])
const user = await client.api.users.show({
params: { id: '1' },
query: { include: 'posts' }
})
路由名称的每个段都变成一次属性访问。路由 users.show 变成 client.api.users.show()。这种语法提供了出色的自动补全,并保持代码简洁。
包含下划线的路由名称段在代理语法中会转换为 camelCase。例如,名为 auth.new_account.store 的路由会变成 client.api.auth.newAccount.store()。
使用 request 方法
request 方法提供了一种替代语法,它显式地将路由名称作为字符串传入。
const result = await client.request('auth.register', {
body: { email: 'foo@ok.com', password: 'password123' }
})
const user = await client.request('users.show', {
params: { id: '1' },
query: { include: 'posts' }
})
这种方式在功能上与代理语法相同,但提供了一种不同的构造请求 URL 的 API。
使用 HTTP 方法函数
有时你需要调用不属于 AdonisJS 后端的端点,例如第三方 API 或旧版服务。在这些情况下,你可以使用直接接受 URL 的 HTTP 方法函数。
const user = await client.get('/users/:id', {
params: { id: '123' },
query: { include: 'posts' }
})
const post = await client.post('/posts', {
body: { title: 'Hello', content: 'World' }
})
const updated = await client.patch('/posts/:id', {
params: { id: '456' },
body: { title: 'Updated title' }
})
这种语法镜像了 fetch API,但为参数和响应保持了类型安全。
处理参数
API 调用通常需要不同类型的参数:用于动态 URL 段的路由参数、用于过滤或分页的查询参数,以及用于提交数据的请求体。Tuyau 在处理所有这些时都保持类型安全。
路由参数
路由参数替换 URL 中的动态段。当你定义一个带参数的路由时,Tuyau 会自动为它们添加类型。
router.get('users/:id', [controllers.Users, 'show'])
router.get('users/:userId/posts/:postId', [controllers.Posts, 'show'])
使用 params 选项传递路由参数。TypeScript 会强制要求你提供所有必需的参数且名称正确,并且你的 IDE 会自动补全参数名称并在编译时捕获拼写错误。
// 单个参数
const user = await client.api.users.show({
params: { id: '123' }
})
// 多个参数
const post = await client.api.users.posts.show({
params: { userId: '123', postId: '456' }
})
查询参数
查询参数附加到 URL 上用于过滤、分页或传递可选数据。使用 query 选项来传递它们。查询参数会自动进行 URL 编码,如果你的后端验证查询参数,这些类型会在前端被推断出来。
// 路由:GET /posts
const posts = await client.api.posts.index({
query: {
page: 1,
limit: 10,
status: 'published'
}
})
// 生成:GET /posts?page=1&limit=10&status=published
请求体
对于 POST、PUT 和 PATCH 请求,使用 body 选项发送数据。请求体类型会根据你的验证器自动推断。每个字段都是类型化的,TypeScript 会阻止你发送验证器中不存在的字段或类型不正确的字段。
const post = await client.api.posts.store({
body: {
title: '我的第一篇文章',
content: '这是内容',
published: true
}
})
组合参数
你可以在单个请求中组合路由参数、查询参数和请求体。Tuyau 会处理构建完整 URL、对查询参数进行编码以及对请求体进行序列化的工作,同时保持所有三种参数类型的类型安全。
const comment = await client.api.posts.comments.store({
params: { postId: '123' },
query: { notify: true },
body: {
content: '好文章!',
author: 'John Doe'
}
})
请求验证与类型推断
后端验证器与前端类型之间的连接是 Tuyau 类型安全得以实现的原因。理解其工作原理对于充分利用 Tuyau 至关重要。
request.validateUsing() 的作用
为了让 Tuyau 从你的验证器推断类型,你必须在控制器操作中使用 request.validateUsing()。没有它,Tuyau 无法确定你的请求体应该是什么形状,你的前端类型将回退为 any。
import type { HttpContext } from '@adonisjs/core/http'
import { createPostValidator } from '#validators/post'
export default class PostsController {
async store({ request }: HttpContext) {
const payload = await request.validateUsing(createPostValidator)
const post = await Post.create(payload)
return post
}
}
定义验证器
使用 VineJS 定义验证模式。你定义的每个字段都会成为前端类型签名的一部分。
import vine from '@vinejs/vine'
export const createPostValidator = vine.create({
title: vine.string().minLength(3).maxLength(255),
content: vine.string().minLength(10),
published: vine.boolean().optional(),
categoryId: vine.number(),
tags: vine.array(vine.string()).optional(),
})
在你的前端,body 参数将具有这个确切的形状。TypeScript 会强制要求的字段、阻止额外字段,并确保每个属性都有正确的类型。
await client.api.posts.store({
body: {
title: '我的文章', // string(必填)
content: '内容在此', // string(必填)
published: true, // boolean(可选)
categoryId: 1, // number(必填)
tags: ['news', 'tech'] // string[](可选)
}
})
查询参数验证
查询参数也可以被验证并添加类型。为查询参数定义一个验证器并在你的控制器中使用它。前端查询参数将被类型化为匹配你的验证器,因此 TypeScript 只允许有效的值。
export const listPostsValidator = vine.create({
page: vine.number().optional(),
limit: vine.number().optional(),
status: vine.enum(['draft', 'published']).optional(),
search: vine.string().optional(),
})
export default class PostsController {
async index({ request }: HttpContext) {
const filters = await request.validateUsing(listPostsValidator)
const posts = await Post.query()
.where('status', filters.status)
.paginate(filters.page || 1, filters.limit || 10)
return posts
}
}
const posts = await client.api.posts.index({
query: {
page: 1,
limit: 20,
status: 'published', // 只允许 'draft' 或 'published'
search: 'typescript'
}
})
错误处理
Tuyau 同时支持抛出和非抛出的错误处理。
默认情况下,请求表现得像普通的 promise。成功的响应解析为响应载荷,而失败的请求会抛出。HTTP 失败会抛出 TuyauHTTPError,而传输失败(如 DNS 问题、连接被拒绝或离线状态)会抛出 TuyauNetworkError。
使用 .safe()
如果你不想抛出,请在请求上调用 .safe()。它返回一个元组,第一个元素是数据,第二个元素是错误。错误始终被类型化为 TuyauError,为你提供 HTTP 和网络失败的统一错误形状。
const [data, error] = await client.api.posts.show({
params: { id: '123' }
}).safe()
if (error) {
console.log(error.message)
return
}
console.log(data.title)
使用 isStatus() 收窄 HTTP 错误
当你的控制器返回类型化的非 2xx 响应时,Tuyau 会自动从控制器的返回类型中提取这些错误载荷,并使它们在前端客户端上可用。使用 isStatus() 将错误收窄到特定的状态码。在 error.isStatus(404) 之后,TypeScript 将 error.response 收窄为控制器中 response.notFound() 返回的精确载荷形状。
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async show({ params, response }: HttpContext) {
const post = await Post.find(params.id)
if (!post) {
return response.notFound({ message: 'Post not found', key: 'post_not_found' })
}
return response.ok({ post })
}
}
const [data, error] = await client.api.posts.show({
params: { id: '123' }
}).safe()
if (error?.isStatus(404)) {
// error.response 被收窄为 { message: string, key: string }(从控制器推断)
console.log(error.response.message)
console.log(error.response.key)
return
}
console.log(data.post.title)
验证错误
使用 request.validateUsing() 的路由会自动获得类型化的 422 错误响应。该类型使用来自 @vinejs/vine/types 的 SimpleError。使用 isValidationError() 作为 isStatus(422) 的简写。
const [data, error] = await client.api.posts.store({
body: { title: '', content: '' }
}).safe()
if (error?.isValidationError()) {
// error.response 被类型化为 { errors: SimpleError[] }
for (const err of error.response.errors) {
console.log(err.field, err.message)
}
}
如果你的 API 为验证错误返回不同的形状,你可以通过 generateRegistry 钩子中的 validationErrorType 选项自定义错误类型或禁用它。
generateRegistry({
validationErrorType: '{ errors: { path: string; message: string }[] }',
// 或设置为 `false` 以禁用
})
区分 HTTP 和网络失败
TuyauError 类型包含一个 kind 属性。当你需要以不同于服务器响应的方式处理网络失败时,请使用它。
const [, error] = await client.api.posts.show({
params: { id: '123' }
}).safe()
if (error?.kind === 'network') {
console.log('服务器无法访问')
}
对于网络失败,status 和 response 是 undefined,因为服务器没有发送响应。
使用 try/catch
如果你更喜欢抛出流程,请使用 Route.Error 强制转换捕获的错误,以获得完整的类型收窄。
import type { Route } from '@tuyau/core/types'
try {
await client.api.posts.show({ params: { id: '123' } })
} catch (e) {
const error = e as Route.Error<'posts.show'>
if (error.isStatus(404)) {
// error.response 被收窄为来自控制器的 404 载荷
console.log(error.response.message)
return
}
if (error.kind === 'network') {
console.log('服务器无法访问')
}
}
获取类型
Tuyau 提供了两个类型辅助命名空间 Path 和 Route,让你可以从 API 定义中提取请求、响应和错误类型。当你需要基于 API 模式为变量、函数参数或返回类型添加类型时,这很有用。
两个辅助函数都从 @tuyau/core/types 导入,并暴露相同的工具:Request、Response、Error、Params、Body 和 Query。Route 通过路由名称提取类型,而 Path 通过 HTTP 方法和 URL 模式提取类型。
import type { Route, Path } from '@tuyau/core/types'
// 通过路由名称
type StoreRequest = Route.Request<'posts.store'>
type StoreResponse = Route.Response<'posts.store'>
type ShowError = Route.Error<'posts.show'>
type ShowParams = Route.Params<'posts.show'>
type StoreBody = Route.Body<'posts.store'>
type IndexQuery = Route.Query<'posts.index'>
// 通过 HTTP 方法 + URL 模式
type LoginRequest = Path.Request<'POST', '/auth/login'>
type LoginResponse = Path.Response<'POST', '/auth/login'>
type LoginError = Path.Error<'POST', '/auth/login'>
type UserParams = Path.Params<'GET', '/users/:id'>
type LoginBody = Path.Body<'POST', '/auth/login'>
type PostsQuery = Path.Query<'GET', '/posts'>
Error 辅助函数解析为 TuyauError,这意味着它同时建模 HTTP 和网络失败,同时仍支持 isStatus() 进行 HTTP 错误收窄。
文件上传
Tuyau 通过检测请求体中的 File 对象并切换到 FormData 编码,自动处理文件上传。你无需手动构造 FormData 或更改内容类型。
基础文件上传
当你在请求体中传入一个 File 对象时,Tuyau 会自动将整个载荷转换为 FormData。像 description 这样的其他字段也会包含在同一 FormData 载荷中。
import { client } from '~/client'
async function uploadAvatar(file: File) {
const result = await client.api.users.avatar.update({
body: {
avatar: file,
description: '我的新头像'
}
})
}
// 在你的组件中
function handleFileSelect(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
if (file) {
uploadAvatar(file)
}
}
当 Tuyau 检测到请求体中的 File 对象时,它会将整个载荷转换为 FormData。需要注意以下几点:
- 字段名必须与你的验证器匹配。
body对象中的键(例如avatar、description)会成为 FormData 字段名,必须与你的 VineJS 验证器中相应的键匹配。 - 你可以混合文件和常规字段。 像字符串和数字这样的标量值会作为文件字段一起包含在同一 FormData 载荷中。
- 可选文件字段。 如果文件字段在你的验证器中是可选的,只需从请求体中省略该键。不要发送
undefined或null,因为它们可能会在 FormData 中被序列化为字面字符串。
后端处理
在后端,使用 AdonisJS 的标准文件验证来处理文件上传。
import vine from '@vinejs/vine'
export const updateAvatarValidator = vine.create({
avatar: vine.file({
size: '2mb',
extnames: ['jpg', 'png', 'jpeg'],
}),
description: vine.string().optional(),
})
import type { HttpContext } from '@adonisjs/core/http'
import { updateAvatarValidator } from '#validators/user'
export default class UsersController {
async updateAvatar({ request, auth }: HttpContext) {
const { avatar, description } = await request.validateUsing(updateAvatarValidator)
// 将文件移动到存储
await avatar.move('uploads/avatars', {
name: `${auth.user!.id}.${avatar.extname}`
})
return { success: true }
}
}
多文件上传
通过在载荷中包含多个 File 对象来上传多个文件。Tuyau 会自动处理文件数组的 FormData 序列化。
const result = await client.api.posts.attachments.create({
params: { postId: '123' },
body: {
files: [file1, file2, file3],
visibility: 'public'
}
})
生成 URL
Tuyau 提供 urlFor 辅助函数以类型安全的方式从路由名称生成 URL。当你需要用于链接、重定向或分享的 URL,而不是实际发起 API 调用时,这很有用。
基础 URL 生成
urlFor 方法在所有 HTTP 方法中搜索,并将 URL 作为字符串返回。TypeScript 确保你提供正确的路由名称和必需参数。无效的路由名称或缺失的参数会在编译时被捕获。
import { urlFor } from '~/client'
// 为命名路由生成 URL
const logoutUrl = urlFor('auth.logout')
// 返回:'/logout'
const profileUrl = urlFor('users.profile', { id: '123' })
// 返回:'/users/123/profile'
特定方法的 URL 生成
为了更精细的控制,使用特定方法的变体,如 urlFor.get 或 urlFor.post。它们返回一个包含 HTTP 方法和 URL 的对象。
const userUrl = urlFor.get('users.show', { id: 1 })
// 返回:{ method: 'get', url: '/users/1' }
const createUserUrl = urlFor.post('users.store')
// 返回:{ method: 'post', url: '/users' }
当你需要知道 URL 和应该使用哪个 HTTP 方法时,这很有用,例如构建通用的链接组件时。
URL 中的查询参数
使用 qs 选项将查询参数添加到生成的 URL 中。查询参数会自动进行 URL 编码并附加到生成的 URL 上。
const postsUrl = urlFor.get('posts.index', {}, {
qs: { page: 2, limit: 10, status: 'published' }
})
// 返回:{ method: 'get', url: '/posts?page=2&limit=10&status=published' }
通配符参数
对于带有通配符参数的路由,请将它们作为数组传入。
// 路由:router.get('docs/*', [controllers.Docs, 'show'])
const docsUrl = urlFor.get('docs.show', { '*': ['introduction', 'getting-started'] })
// 返回:{ method: 'get', url: '/docs/introduction/getting-started' }
位置参数
你可以传入一个数组,按照它们在路由中出现的顺序作为参数,而不是一个对象。
// 路由:/users/:id/posts/:postId
// 使用对象语法
const url1 = urlFor.get('users.posts.show', { id: '123', postId: '456' })
// 使用数组语法(位置式)
const url2 = urlFor.get('users.posts.show', ['123', '456'])
// 两者都返回:{ method: 'get', url: '/users/123/posts/456' }
当参数名称从上下文一目了然时,位置参数会很方便。
路由内省
Tuyau 提供了两个在运行时检查路由的方法。这些对于构建突出显示活动链接的导航组件,或根据你的应用中可用路由有条件地渲染 UI 很有用。
检查路由是否存在
has() 方法检查注册表中是否存在某个路由名称。
client.has('users.show') // true
client.has('auth.login') // true
client.has('nope') // false
这对于根据当前应用中是否有某个路由可用,有条件地渲染 UI 元素很有用。
获取当前路由
current() 方法使用 window.location 来确定用户当前所在的路由。它只匹配可导航的路由(GET 或 HEAD)。
不带参数时,它返回当前路由名称,如果没有路由匹配(或在服务端运行时)则返回 undefined。
// 在 /users/42 页面
client.current() // 'users.show'
// 在 /unknown/path 页面
client.current() // undefined
带路由名称时,如果当前 URL 匹配该路由,则返回 true。
// 在 /users/42 页面
client.current('users.show') // true
client.current('auth.login') // false
带通配符模式时,你可以使用 * 匹配一组路由。
// 在 /users/42 页面
client.current('users.*') // true
client.current('posts.*') // false
带选项时,你还可以验证当前 URL 参数和/或查询字符串是否匹配期望值。
// 在 /users/42?foo=bar 页面
client.current('users.show', { params: { id: 42 } }) // true
client.current('users.show', { params: { id: 99 } }) // false
client.current('users.show', { query: { foo: 'bar' } }) // true
client.current('users.show', { query: { foo: 'baz' } }) // false
类型级序列化
在使用 Tuyau 时,一个需要理解的重要概念是类型级序列化(type-level serialization)。这指的是类型如何自动转换以匹配实际通过 JSON 在网络上发送的内容。
日期序列化
当你从后端向前端传递一个 Date 对象时,它无法作为 Date 对象通过 JSON 传输。相反,它被序列化为一个字符串。Tuyau 的类型会自动反映这种转换。
export default class PostsController {
async show({ params }: HttpContext) {
const post = await Post.find(params.id)
return {
id: post.id,
title: post.title,
createdAt: new Date() // 这里是一个 Date 对象
}
}
}
在前端,Tuyau 自动将类型推断为 string,而不是 Date。这是因为日期在通过 HTTP 发送时会序列化为 ISO 字符串格式,而 Tuyau 的类型系统在编译时就反映了这一现实。
const post = await client.api.posts.show({ params: { id: '1' } })
// TypeScript 知道 createdAt 是 string,不是 Date
console.log(post.createdAt.toUpperCase()) // ✅ 可用 —— string 方法
console.log(post.createdAt.getTime()) // ❌ 错误 —— Date 方法不存在
模型序列化
一个常见的错误是直接从控制器返回
Lucid 模型
。当你这样做时,Tuyau 无法准确推断响应类型,因为模型被序列化为一个包含几乎没有任何有用类型信息的通用 ModelObject 类型。
export default class PostsController {
async show({ params }: HttpContext) {
const post = await Post.find(params.id)
return post // 模型被序列化,但类型丢失了
}
}
在前端,你会得到一个没有任何具体字段的通用 ModelObject 类型。
const post = await client.api.posts.show({ params: { id: '1' } })
// post 的类型是 ModelObject —— 没有自动补全,没有类型安全
要保持类型安全,请在返回之前使用 HTTP Transformers 将你的模型显式转换为普通对象。
export default class PostsController {
async show({ params, serialize }: HttpContext) {
const post = await Post.find(params.id)
return serialize(PostTransformer.transform(post))
}
}
现在前端有了准确的类型。
const post = await client.api.posts.show({ params: { id: '1' } })
// post.title 是 string
// post.author.name 是 string
// 完整的自动补全和类型安全
SuperJSON
如上所述,当数据跨越 HTTP 边界时,像 Date、BigInt、Map 或 Set 这样的丰富 JavaScript 类型会丢失——它们被 JSON.stringify() 序列化为字符串或普通对象。
SuperJSON
通过在序列化期间自动保留类型信息来解决这个问题,因此你在前端收到的是正确类型化的值,而不是普通字符串。
@tuyau/superjson 包提供了一个服务端中间件和一个客户端插件,两者协同工作且对使用者透明。
安装
-
安装并配置包
node ace add @tuyau/superjson这会在你的应用中注册 SuperJSON 中间件。该中间件拦截响应并用类型元数据序列化它们,然后在服务器端对传入的请求体进行反序列化。
-
添加客户端插件
在前端,创建 Tuyau 客户端时添加
superjson插件。import { superjson } from '@tuyau/superjson/plugin' const client = createTuyau({ baseUrl: 'http://localhost:3333', registry, plugins: [superjson()], })
基本设置就这些。像 Date、BigInt、Map、Set、RegExp 和 URL 这样的原生 JavaScript 类型现在会在 HTTP 调用中自动保留。
自定义配方
SuperJSON 开箱即用地只处理原生 JavaScript 类型。如果你的应用使用自定义类——例如 Lucid 模型用于日期字段的 Luxon DateTime——SuperJSON 无法识别它们,它们会在前端变成普通字符串。
你可以通过注册一个配方来教 SuperJSON 如何处理任何自定义类型,该配方包含三样东西:用于识别类型的 isApplicable 检查、将其转换为 JSON 安全值的 serialize 函数,以及在另一端重建它的 deserialize 函数。
由于配方必须在服务端和客户端都注册,它们应该放在一个被双方导入的共享文件中。
以下是 Luxon DateTime 的示例,这是 AdonisJS 应用中最常见的情况。
-
创建一个共享的 recipes 文件
app/superjson_recipes.tsimport { DateTime } from 'luxon' import SuperJSON from 'superjson' SuperJSON.registerCustom<DateTime, string>( { isApplicable: (v) => DateTime.isDateTime(v), serialize: (v) => v.toISO()!, deserialize: (v) => DateTime.fromISO(v), }, 'DateTime' ) -
注册为服务端预加载
将 recipes 文件添加到你的预加载中,使其在服务器启动时运行。
adonisrc.tsexport default defineConfig({ preloads: [ () => import('#start/routes'), () => import('#start/kernel'), () => import('#app/superjson_recipes'), ], }) -
在客户端导入
在你创建 Tuyau 客户端的地方导入同一个文件,以便 recipes 在任何 API 调用之前注册。
inertia/client.tsimport '#app/superjson_recipes' import { superjson } from '@tuyau/superjson/plugin' const client = createTuyau({ baseUrl: 'http://localhost:3333', registry, plugins: [superjson()], })
与 transformers 的类型级集成
当将自定义 SuperJSON 配方与
HTTP Transformers
一起使用时,你需要告诉 transformer 类型系统,某些类型在类型级别不应被转换为字符串。如果不这样做,即使 SuperJSON 在运行时保留了 DateTime 类型,TypeScript 仍会在前端响应类型中将你的 DateTime 字段推断为 string。
在你的 API provider 中添加模块扩展,以扩展允许的 JSON 类型。
declare module '@adonisjs/core/types/transformers' {
interface ExtendedJSONTypes {
DateTime: import('luxon').DateTime
}
}
通过这个扩展,返回 DateTime 值的 transformers 会在生成的前端类型中保留 DateTime 类型,而不是将其转换为 string。
响应解析
默认情况下,Tuyau 根据 Content-Type 请求头解析响应:application/json 对应 JSON,application/octet-stream 对应 ArrayBuffer,其它情况对应 text。
你可以使用 responseType 选项('json'、'text'、'arrayBuffer' 或 'blob')为每个请求覆盖此行为。
const blob = await client.api.files.download({
params: { id: '123' },
responseType: 'blob',
})
配置参考
createTuyau 函数接受多个配置选项,用于自定义 API 客户端的行为。
const client = createTuyau({
baseUrl: import.meta.env.VITE_API_URL || 'http://localhost:3333',
registry,
})
baseUrl
你的 API 服务器的基 URL。所有请求都以这个 URL 为前缀。使用环境变量为开发和生产配置不同的 URL。
registry
生成的注册表,将路由名称映射到 URL 和类型。从 .adonisjs/client 中的生成文件导入,或在 monorepo 设置中从你的后端包导入。
推荐选项
这些可选设置对大多数应用都强烈推荐。
const client = createTuyau({
baseUrl: 'http://localhost:3333',
registry,
headers: { Accept: 'application/json' },
credentials: 'include',
})
headers
随每个请求发送的默认请求头。设置 Accept: 'application/json' 可确保你的 API 返回 JSON 响应,而不是 HTML 错误页面或其他格式。
headers: {
Accept: 'application/json',
'X-Custom-Header': 'value'
}
credentials
控制是否在跨源请求中发送 cookie。当你的前端和后端位于不同域时,设置为 'include' 可发送用于基于会话身份认证的 cookie。
credentials: 'include'
当设置了 credentials: 'include' 时,Tuyau 会自动处理 CSRF 保护。它读取 XSRF-TOKEN cookie,并将其作为 X-XSRF-TOKEN 请求头随每个请求发送。无需额外配置。
高级选项
Tuyau 构建于
Ky
之上,这意味着你可以将任何 Ky 选项传递给 createTuyau。一些有用的高级选项包括:
timeout
请求超时时间(毫秒)。超过此持续时间的请求会自动中止。
const client = createTuyau({
baseUrl: 'http://localhost:3333',
registry,
timeout: 30000, // 30 秒
})
retry
为失败请求配置自动重试行为。
const client = createTuyau({
baseUrl: 'http://localhost:3333',
registry,
retry: {
limit: 3,
methods: ['get', 'post'],
statusCodes: [408, 413, 429, 500, 502, 503, 504]
}
})
hooks
添加请求/响应拦截器,用于日志记录、身份认证或错误处理。
const client = createTuyau({
baseUrl: 'http://localhost:3333',
registry,
hooks: {
beforeRequest: [
request => {
console.log('Request:', request.url)
}
],
afterResponse: [
(request, options, response) => {
console.log('Response:', response.status)
}
],
beforeError: [
error => {
console.error('Error:', error.message)
return error
}
]
}
})
访问 token 身份认证
对于使用访问 token 身份认证(如
API 起步套件
)的 API,请使用 hooks.beforeRequest 选项将 Authorization 请求头动态附加到每个请求。
const client = createTuyau({
baseUrl: 'http://localhost:3333',
registry,
headers: { Accept: 'application/json' },
hooks: {
beforeRequest: [
(request) => {
const token = localStorage.getItem('auth_token')
if (token) {
request.headers.set('Authorization', `Bearer ${token}`)
}
}
]
}
})
这种模式会在每个请求之前从存储中读取 token。你可以根据应用登录后存储 token 的位置,将其调整为从其他来源(例如状态管理 store 或 cookie)读取。
有关可用选项的完整列表,请参阅 Ky 文档 。
过滤路由
默认情况下,generateRegistry 包含所有命名路由。使用 routes 选项将特定路由包含或排除在生成的注册表之外。
generateRegistry({
routes: {
only: ['api.*'], // 子串匹配
// 或
except: [/^admin\./, (name) => name.startsWith('internal.')],
},
})
过滤器接受字符串(子串匹配)、RegExp 或函数。你不能同时使用 only 和 except。
相关资源
Tuyau 与 AdonisJS 生态系统的多个部分集成,并为特定用例提供额外的包。
Inertia 集成
如果你使用 Inertia,Tuyau 为 Inertia 专属特性提供了增强的类型安全。@adonisjs/inertia 包导出一个 <TuyauProvider> 组件,启用了类型安全路由和其它很酷的特性。
import { TuyauProvider } from '@adonisjs/inertia/react'
import { client } from '~/client'
function App() {
return (
<TuyauProvider client={client}>
<Link route="auth.login">登录</Link>
</TuyauProvider>
)
}
<Link> 组件的 route prop 是完全类型化的。TypeScript 确保你使用有效的路由名称并提供必需的参数。有关此集成及额外特性的完整详情,请参阅
Inertia 文档
。
TanStack Query 集成
@tuyau/tanstack-query 包提供了 React hooks,将 Tuyau 与 TanStack Query(前身为 React Query)集成,用于数据获取、缓存和状态管理。请参阅
TanStack Query 指南
,了解在你的 React 组件中设置和使用这些 hooks 的说明。
起步套件
与其手动设置 Tuyau,不如考虑使用以下一个预配置了 Tuyau 的起步套件:
- React 起步套件 - 官方 AdonisJS 起步套件,React、Inertia 和 Tuyau 开箱即用
- Vue 起步套件 - 官方 AdonisJS 起步套件,Vue、Inertia 和 Tuyau 开箱即用
- Monorepo 起步套件 - 带有独立前端和后端包的完整 monorepo 设置