前後端不再打架,讓 ts-rest 發揮 TypeScript 的魔法吧!(實務篇)
距離上次概念篇也快一年了,時間過得真快。(´・ω・`)
本文延續概念篇內容,紀錄一些實作筆記,大家若有更好的做法還請不吝指教。(*´∀`)~♥
技術堆疊以 Vue、NestJS 為例,相關套件版本如下:
- @nestjs/core:
11.1.5
- @ts-rest/core:
3.52.1
- @ts-rest/nest:
3.52.1
- zod:
3.25.76
TIP
ts-rest 預計 ^3.53.0
開始支援 Standard Schema
驗證,只要支援此規範的驗證器(例:Zod v4、Valibot、ArkType)的驗證器都可以用來定義合約資料,不限於 Zod 了!✧⁑。٩(ˊᗜˋ*)و✧⁕。
本文分成 3 個主要部分:
通用概念
不分前後端的注意事項與心得
前端
前端 ts-rest 應用,包含初始化 Client、狀態與結果資料、表單資料驗證等
後端
後端 ts-rest 應用,包含定義 API 合約、驗證資料、處理請求等
讓我們開始吧!♪( ◜ω◝و(و
不定期更新
本文為 ts-rest 應用筆記,會隨著時間推移而變化、更新,甚至過時,我會盡快更新,還請大家多多包涵。(´,,•ω•,,)
通用概念
jsonQuery
ts-rest 提供的 jsonQuery
參數可以讓你輕鬆處理 REST API 的 query string。
一般來說 REST API 的 query string 會被視為字串,其中數字、布林與矩陣可能會出現歧異。
啟用 jsonQuery
就可以讓 query string 中的資料使用 JSON 形式發送,這樣就可以避免以上問題。
舉個栗子。( ´ ▽ ` )ノ🌰
數字與布林
若 URL 為 /posts?take=10&draft=false
,伺服器會取得 { take: "10", draft: "false" }
,可以看到內容都是字串。
你可能會說:Zod 可以用 z.coerce.number()
、z.coerce.boolean()
轉換啊?
的確可以轉換沒錯,但這裡有個隱藏的小地雷,就是字串的 "true"
與 "false"
,經過 z.coerce.boolean()
都會變成 true
!╭(°A ,°`)╮
當然可以自定義轉換器解決,不過若啟用 jsonQuery
(前端後端都要開),伺服器會取得 { take: 10, draft: false }
,就不用特別轉換了。(≖‿ゝ≖)✧
矩陣
若 URL 為 /posts?tags=js&tags=ts
,伺服器會拿到 ['js', 'ts']
,但若 tags
只有一個呢?
URL 變為 /posts?tags=cod
,
這樣伺服器取到的參數可能會變成 { tags: 'cod' }
,而不是 { tags: ['cod'] }
! Σ(ˊДˋ;)
若啟用 jsonQuery
,則 URL 會變成 /posts?tags=["js","ts"]
,就沒有以上分歧的問題了。✧⁑。٩(ˊᗜˋ*)و✧⁕。
路人:「如果 client 端不是使用 ts-rest client,是不是可能有一樣的問題?」
鱈魚:「你說的沒錯 ...(›´ω`‹ ),如果使用端不是用 JSON Query Parameters 發送,還是要乖乖的把合約內的驗證器與轉換器寫好寫滿了 ( ˘・з・)」
ClientInfer、ServerInfer 傻傻分不清楚?
ts-rest 本身提供了很方便的 Type Helper 工具,可以輕鬆提取合約內的型別。
不過有分 ClientInfer 與 ServerInfer,看的出來一個是 Client 視角,一個是 Server 視角,所以具體差在哪呢?(*´・д・)
簡單來說就是進去與出來的資料型別不同,假設合約這麼寫:
export const collectionDataContract = contract.router({
find: {
method: 'GET',
path: '/v1/collection-data',
query: z.object({
age: z.string().transform(Number),
}),
responses: {
200: collectionDataSchema.array(),
},
summary: '取得 collection-data',
},
})
可以看出 age
輸入型別是 string
,但輸出型別是 number
。
現在讓我們實際看看 ClientInfer 與 ServerInfer 取出來的 age
// type 為 string
type ClientInput = ClientInferRequest<typeof collectionDataContract>['find']['query']['age']
// type 為 number
type ServerInput = ServerInferRequest<typeof collectionDataContract>['find']['query']['age']
對 Request 來說,client 是提供端(輸入至合約),而 server 是接收端(取得合約輸出)。
所以得到 ClientInput
為 string
,而 ServerInput
為 number
。
不難發現只有在輸入與輸出型別不同才會有明顯差別,否則兩者型別會相同。
以上就是 ClientInfer 與 ServerInfer 具體差別,有沒有比較清楚了呢?ԅ(´∀` ԅ)
前端
初始化 Client
如同文件描述,使用 initClient
即可以將合約轉換成可直接呼叫的 Client。
可以考慮多一層封裝,提供基礎設定值:
import type { AppRouter } from '@ts-rest/core'
import { initClient } from '@ts-rest/core'
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? ''
export function useClient<T extends AppRouter>(
router: T,
options?: Parameters<typeof initClient>[1],
) {
return initClient(router, {
baseUrl,
baseHeaders: {},
jsonQuery: true,
...options,
})
}
Client 會在網頁中到處出現,希望不要重複建立,避免不必要的記憶體浪費。
可以存在某個變數並 export
給其他檔案使用,不過這樣每次微調 options
都要建立共用變數有點麻煩(命名好難 (。-`ω´-)),使用 memoization 方式處理會更簡單。
甚麼是 memoization
先前的文章曾經聊過,有興趣的朋友可以來看看
這裡用 lodash-es
提供的 memoize
實作:
import hash from 'object-hash'
export const useClient = memoize(
<T extends AppRouter>(
router: T,
options?: Parameters<typeof initClient>[1],
) => initClient(router, {
baseUrl,
baseHeaders: {},
jsonQuery: true,
...options,
}),
(router, options) => hash({ router, options }),
)
hash
是為了計算快取用的 key,這裡使用 object-hash
處理,大家可以換成自己喜歡的實作。ヾ(◍'౪`◍)ノ゙
需要授權的 API 需要在 Authorization Header 提供 Access Token 才能存取,讓我們加入自動加入 Authorization Header 的功能:
import { initClient, tsRestFetchApi } from '@ts-rest/core'
/** 自動加入 Authorization Header */
export const useClient = memoize(
<T extends AppRouter>(
router: T,
options?: Parameters<typeof initClient>[1],
) => initClient(router, {
baseUrl,
baseHeaders: {},
jsonQuery: true,
api: async (args) => {
const { accessToken } = useAuthStore()
return tsRestFetchApi({
...args,
headers: {
Authorization: `Bearer ${accessToken}`,
...args.headers,
},
})
},
...options,
}),
(router, options) => hash({ router, options }),
)
api
參數可以讓我們自定義請求如何送出。(ゝ∀・)b
說到 Access Token,就一定有 Refresh Token,讓我們加入 401 時自動 Refresh Token 邏輯:
import { authContract } from '@project-code/shared'
/** 自動加入 Authorization Header 且 401 時自動 refresh */
export const useClient = memoize(
<T extends AppRouter>(
router: T,
options?: Parameters<typeof initClient>[1],
) => initClient(router, {
baseUrl,
jsonQuery: true,
api: async (args) => {
const authStore = useAuthStore()
let accessToken = authStore.accessToken
let res = await tsRestFetchApi({
...args,
headers: {
Authorization: `Bearer ${accessToken}`,
...args.headers,
},
})
// 只有 401 需要處理 refreshToken
if (res.status !== 401) {
return res
}
const authApi = initClient(authContract, {
baseUrl,
...options,
})
const result = await authApi.refresh()
if (result.status === 200) {
accessToken = result.body.accessToken
authStore.setAccessToken(accessToken)
}
/** 再發送一次剛剛被 401 的請求 */
res = await tsRestFetchApi({
...args,
headers: {
Authorization: `Bearer ${accessToken}`,
...args.headers,
},
})
if (res.status === 401) {
// 可加入要求使用者登入邏輯
}
return res
},
...options,
}),
(router, options) => hash({ router, options }),
)
這時候有個小問題,若有請求同時發出,就會重複 refresh 很多次。
甚麼?你說你們後端不在意?那就跳...還是來加個基本處理好了。(・∀・)9
使用 Vue 的 ref
與 VueUse 的 until
實現基本版:
/** 用於防止多次 refresh */
const isRefreshing = ref(false)
/** 自動加入 Authorization Header 且 401 時自動 refresh */
export const useClient = memoize(
<T extends AppRouter>(
router: T,
options?: Parameters<typeof initClient>[1],
) => initClient(router, {
baseUrl,
jsonQuery: true,
api: async (args) => {
const authStore = useAuthStore()
await until(isRefreshing).toBe(false)
let res = await tsRestFetchApi({
...args,
headers: {
Authorization: `Bearer ${authStore.accessToken}`,
...args.headers,
},
})
// 只有 401 需要處理 refreshToken
if (res.status !== 401) {
return res
}
// 防止多次 refresh
if (!isRefreshing.value) {
const refreshClient = initClient(authContract, {
baseUrl,
...options,
})
isRefreshing.value = true
const result = await refreshClient.refresh()
isRefreshing.value = false
if (result.status === 200) {
authStore.setAccessToken(result.body.accessToken)
}
}
await until(isRefreshing).toBe(false)
/** 再發送一次剛剛被 401 的請求 */
res = await tsRestFetchApi({
...args,
headers: {
Authorization: `Bearer ${authStore.accessToken}`,
...args.headers,
},
})
if (res.status === 401) {
// 可加入要求使用者登入邏輯
}
return res
},
...options,
}),
(router, options) => hash({ router, options }),
)
這樣可以處理基本 9 成常見的情境了。◝( •ω• )◟
二次封裝後可以很輕鬆地加入各種自定義邏輯,有需求的話還可以加入 Queue 等等花式功能,這個就看實際需求而定了。੭ ˙ᗜ˙ )੭
眼尖的朋友可能還有注意文件有提到 Query Client,實際上就是 Vue Query(Tanstack Query)。
Vue Query 內建資料快取、背景更新、自動重試等等強大功能,推薦大家可以玩玩看。
與一般的 Client 差別在初始化與參數稍微不同而已,所以 Query Client 的封裝同理:
import { initQueryClient } from '@ts-rest/vue-query'
/** 自動加入 Authorization Header 且 401 時自動 refresh */
export const useQueryClient = memoize(
<T extends AppRouter>(
router: T,
options?: Parameters<typeof initQueryClient>[1],
) => initQueryClient(router, {
baseUrl,
baseHeaders: {},
jsonQuery: true,
api: async (args) => {
// ...
},
...options,
}),
(router, options) => hash({ router, options }),
)
可以注意到 ts-rest 支援同時使用兩種 Client,這讓實務開發上更有彈性
資料邏輯很單純的網頁使用一般的 Client 即可,有複雜需求的部分再使用 Query Client。
正所謂小朋友才做選擇,我兩種都要!ヽ(●`∀´●)ノ
如此可以讓專案保持簡單、乾淨,更不容易出現協作斷層。( ‧ω‧)ノ╰(‧ω‧ )
最後讓我們來點測試,確認一下 memoize
有正常工作:
/apps/admin-web/src/common/api.test.ts
import { initContract } from '@ts-rest/core'
import { describe, expect, it } from 'vitest'
import { useClient } from './api'
const baseContract = initContract().router({
create: {
method: 'GET',
path: '/v1/collection-data',
responses: {},
summary: '建立 collection-data',
},
}, {
pathPrefix: '/api',
})
describe('api', () => {
describe('useClient', () => {
it(`相同設定應取得同一個實例`, () => {
const client1 = useClient(baseContract)
const client2 = useClient(baseContract)
expect(client1).toBe(client2)
})
it(`不同設定應取得不同實例`, () => {
const client1 = useClient(baseContract)
const client2 = useClient(baseContract, {
baseUrl: '/custom',
baseHeaders: { a: 'a', b: 'b' },
})
expect(client1).not.toBe(client2)
})
it(`同樣設定但順序不同,也應取得同一個實例`, () => {
const client1 = useClient(baseContract, {
baseUrl: '/custom',
baseHeaders: { a: 'a', b: 'b' },
})
const client2 = useClient(baseContract, {
baseHeaders: { b: 'b', a: 'a' },
baseUrl: '/custom',
})
expect(client1).toBe(client2)
})
})
})
以上程式碼可以在此取得。
狀態與結果資料
ts-rest 回應的結果會將 HTTP Code 包含在資料內,其結構範例如下:
{
status: 200,
body: {
name: 'cod'
},
header: {}
}
假設 API 合約為:
const c = initContract()
export const accountContract = c.router({
create: {
method: 'POST',
path: '/v1/accounts',
body: z.object({
username: z.string(),
password: z.string(),
name: z.string(),
description: z.string().optional(),
}),
responses: {
200: z.object({
id: z.string(),
}),
400: z.object({
reason: z.enum([
'username-duplicate',
]).optional(),
message: z.string(),
}),
401: c.noBody(),
403: c.noBody(),
},
summary: '建立 account',
},
})
可以根據 status
取得具體內容,例如:
const accountApi = useClient(accountContract)
const result = await accountApi.create({})
if (result.status === 200) {
const newId = result.body.id
// 提示成功建立使用者
}
if (result.status === 400) {
if (result.body.reason === 'username-duplicate') {
// 處理重複的使用者名稱
}
else {
// 處理其他錯誤
}
}
這樣前後端只要約定好 status
或列舉資料(像是 reason
欄位),就可以精準處理各種業務邏輯了。( ´ ▽ ` )ノ
當然如果不需要關心細節,直接比較數字即可:
const accountApi = useClient(accountContract)
const result = await accountApi.create({})
if (result.status >= 400) {
// 提示建立使用者失敗
}
表單資料驗證
若 UI 套件的 Form 可以使用驗證器,可以直接與取得合約內的驗證器進行表單驗證,以 Nuxt UI Form 為例:
<script setup lang="ts">
import type { FormSubmitEvent } from '@nuxt/ui'
import { accountContract } from '@project-code/shared'
import * as z from 'zod'
const schema = accountContract.create.body
type Schema = z.output<typeof schema>
const state = reactive<Partial<Schema>>({
email: undefined,
password: undefined
})
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: 'The form has been submitted.', color: 'success' })
console.log(event.data)
}
</script>
<template>
<UForm
:schema="schema"
:state="state"
class="space-y-4"
@submit="onSubmit"
>
<UFormField label="Email" name="email">
<UInput v-model="state.email" />
</UFormField>
<UFormField label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormField>
<UButton type="submit">
Submit
</UButton>
</UForm>
</template>
若 Form 不能直接使用驗證器(Zod schema、ArkType type 等等),也不是大問題,來個轉換器就行。
以我常用的 Quasar 為例,可以參考此實作。
感謝優秀的同事們
此轉換器是公司的夥伴們開發,我幫忙寫測試與文件,感謝他們的努力!(´,,•ω•,,)
後端
合約 + 測試 = 更可靠的 API
後端實作合約基本上就和概念篇相同,實作上最重要的部分是可以拿合約寫 e2e 測試,這樣只要測試有過,API 基本上不會有錯惹。ԅ(´∀` ԅ)
所以要怎麼拿合約寫 e2e 測試呢?
概念如下:
- 將資料輸入合約,取得 API 請求資料(例如 url、query string、body data)
- 利用第 1 步取得的資料,使用測試工具(例如 Supertest)發送請求
接下來以 collection-data
合約為例,一步一步來吧。
建立一個將 ts-rest 合約轉換成 API 請求資料的工具:
apps\api-server\src\common\utils\utils-ts-rest.ts
type ExtractQuery<T> = T extends { query: infer U } ? U : never
type ExtractBody<T> = T extends { body: infer U } ? U : never
interface UseContractReturn<Query, Body> {
url: string;
method: Lowercase<AppRoute['method']>;
query: Query;
body: Body;
}
/** 協助取出合約 API 指定 API 內容
*
* 主要用於 e2e 測試
*/
export function useContract<
Route extends AppRouteQuery,
Data extends ClientInferRequest<Route>,
>(
route: Route,
data?: Data
): UseContractReturn<ExtractQuery<Data>, ExtractBody<Data>>
export function useContract<
Route extends AppRouteMutation,
Data extends ClientInferRequest<Route>,
>(
route: Route,
data?: ClientInferRequest<Route>
): UseContractReturn<ExtractQuery<Data>, undefined>
export function useContract<
Route extends AppRouteQuery | AppRouteMutation,
>(
route: Route,
data?: ClientInferRequest<Route>,
): any {
const {
method,
path,
} = route
const url = path.replace(
/:(\w+)/g,
(match, key) => data?.params?.[key],
)
if (data && 'body' in data) {
return {
url,
method: method.toLowerCase() as Lowercase<AppRoute['method']>,
query: data?.query,
body: data,
}
}
return {
url,
method: method.toLowerCase() as Lowercase<AppRoute['method']>,
query: data?.query,
}
}
測試工具使用 Supertest,建立一個將合約資料透過 Supertest 發送請求的工具:
apps/api-server/src/resource/collection-data/collection-data.e2e.ts
import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core'
import type { IncomingHttpHeaders } from 'node:http'
import {
collectionDataContract,
} from '@ts-rest-practice/shared'
import request from 'supertest'
import { useContract } from '../../common/utils/utils-ts-rest'
// 這裡不可能出現其他 Contract,所以可以簡短命名
type Contract = typeof collectionDataContract
type ContractRequest = ClientInferRequest<Contract>
interface ApiOptions {
headers?: IncomingHttpHeaders;
}
/** e2e 測試之合約轉換層
*
* 將合約轉換成 API 呼叫,這樣就可以直接拿合約內容進行 e2e 測試
*
* 也就是說只要合約有變動,這裡也要跟著變動,這樣就可以確保合約的正確性
*
* 同時只要 e2e 測試通過,即表示合約正確,可以交付
*
* 另一個附加好處是如果其他資源的 e2e 測試有依賴此資源,也可以直接拿去重複使用
*
* @param server 測試環境中的 HTTP server 主體
* @param globalOptions 額外的選項。同 method 內的 options,method 內的 options 會覆蓋這裡的 options
*/
export function createCollectionDataApi(
server: any,
globalOptions?: ApiOptions,
) {
return {
async create<
Code extends keyof Contract['create']['responses'] = 200,
>(
data: ContractRequest['create']['body'],
code = 200 as Code,
options?: ApiOptions,
) {
const { url, method } = useContract(collectionDataContract.create, {
body: data,
})
const { body, statusCode, headers } = await request(server)[method](url)
.set({
...globalOptions?.headers,
...options?.headers,
})
.send(data)
if (code !== statusCode) {
throw new Error(`${statusCode} : ${JSON.stringify(body, null, 2)}`)
}
return {
headers,
body: body as ClientInferResponseBody<
typeof collectionDataContract.create,
Code
>,
}
},
// 其他方法同理
}
}
最後就針對此資源建立 e2e 測試(以下為參考,實作可以替換成自己喜歡的方式):
type CollectionDataContract = ClientInferRequest<typeof collectionDataContract>
describe('collectionData e2e', () => {
/** 使用記憶體版本 MongoDB 運行測試 */
let mongodb: MongoMemoryReplSet
let mongoConnection: Connection
let app: INestApplication
let server: ReturnType<INestApplication['getHttpServer']>
let collectionDataApi: ReturnType<typeof createCollectionDataApi>
beforeAll(async () => {
// ...啟動 Server 與 DB
collectionDataApi = createCollectionDataApi(server)
}, 60000)
/** 測試結束,關閉 DB */
afterAll(async () => {
// ...
})
async function clearAll() {
// ...
}
/** 每次測試開始前與結束時,都清空 collection 資料,以免互相影響 */
beforeEach(async () => await clearAll())
afterEach(async () => await clearAll())
function createCollectionData(
params?: Partial<CollectionDataContract['create']['body']>,
) {
const expectData: CollectionDataContract['create']['body'] = {
name: 'test',
description: '安安',
...params,
}
return collectionDataApi.create(expectData)
}
describe('建立 collection-data', () => {
it('全部參數', async () => {
const expectData: Required<CollectionDataContract['create']['body']> = {
name: 'name',
description: 'description',
remark: 'remark',
}
const { body: result } = await collectionDataApi.create(expectData)
expect(result).toMatchObject(pick(
expectData,
['name', 'description', 'remark'],
))
})
})
describe('取得 collection-data', () => {
it('取得指定筆數資料', async () => {
await Promise.allSettled([
createCollectionData(),
createCollectionData(),
])
const { body: result } = await collectionDataApi.find({ limit: 1 })
expect(result.total).toBe(2)
expect(result.data).toHaveLength(1)
})
})
describe('取得指定 collection-data', () => {
it('取得指定資料', async () => {
const { body: data } = await createCollectionData()
const { body: newData } = await collectionDataApi.findOne(data.id)
expect(newData).toEqual(data)
})
})
describe('更新指定 collection-data', () => {
it('修改 name 為 cod', async () => {
const { body: data } = await createCollectionData()
const { body: newData } = await collectionDataApi.update(data.id, {
name: 'cod',
})
expect(newData.name).toBe('cod')
})
})
describe('刪除指定 collection-data', () => {
it('刪除資料後,find 無法取得', async () => {
const { body: data } = await createCollectionData()
await collectionDataApi.remove(data.id)
const { body: result } = await collectionDataApi.find()
expect(result.data).toHaveLength(0)
})
})
})
為了防止篇幅過長,這裡只展示部分測試內容,完整測試程式碼可以在此查看。
理論上測試內容只要覆蓋合約內的所有 API 與業務邏輯之測試案例,就可以確保 API 正常交付惹。
至於測試案例怎麼列?這個就是一門藝術了。(◜௰◝)
總結 🐟
感謝大家看到這裡,以上為使用 ts-rest 實際開發的點點滴滴,內容與觀念會隨著時間推移而變化,所以本文會不定期更新。
特別感謝公司優秀的同事們,導入 ts-rest、專案結構、使用設計與實作,很多靈感都來自同事們的提問與建議,非常感謝大家。( ‧ω‧)ノ╰(‧ω‧ )
也感謝前端社群的 Danny 大大推薦 ts-rest 這個好東西,有任何問題還請大家不吝指教。(*´∀`)~♥
本文完整程式碼可以在此取得。