Day 9:httpResource 實務篇,統一回覆格式與 chain 相依請求
4.4k 字,約 4 分鐘
2026 iThome 鐵人賽:Angular 22 Signal 進化論。 原文發表於 iThome,也可以在下方留言。
昨天我們透過 Orval 串起 API Contract,了解如何根據 OpenAPI 規格產生前端需要的型別與 API 呼叫方式。
接下來延續這個基礎,看看後端採用「統一回覆格式」時,Response 可以怎麼設計,以及這種格式要如何和 Orval 搭配。
統一回覆格式與前端處理
實務上,後端常會把 API Response 統一包成固定格式,讓前端能用一致的方式判斷請求結果,以及不同狀態碼或業務代碼代表的情況。
例如每支 API 都維持 code、message、data 這幾個固定欄位,前端就不需要針對不同 API 各自理解不同的回傳結構。
- 成功時:透過
code表示操作成功,實際資料放在data中。
{
"code": "SUCCESS",
"message": "取得成功",
"data": {
"id": 1,
"name": "Antonio"
}
}
- 失敗時:透過
code表示失敗原因,再搭配message提供對應的錯誤訊息。
{
"code": "USER_NOT_FOUND",
"message": "查無此使用者",
"data": null
}
這樣前端就能用固定的結構處理 Response,再根據不同的 code 決定後續邏輯。
不過,如果這套格式只存在於後端實作中,而 OpenAPI 文件仍然只描述原本的資料內容,那麼 Orval 產生的型別就會和實際收到的 Response 不一致。
如果後端使用 NestJS,可以透過自定義 Decorator,把統一回覆格式一起描述到 OpenAPI:
- 成功回應:透過
@ApiWrappedResponse()指定data的實際型別。 - 成功訊息:搭配
@ResponseMessage()設定回應中的message。 - 錯誤回應:透過
@ApiWrappedErrorResponse()描述不同 HTTP Status 對應的錯誤格式。
以取得待辦事項清單為例:
@Get()
@ApiOperation({
summary: '取得所有待辦事項',
operationId: 'listTodos',
})
@ApiWrappedResponse(Todo, { isArray: true })
@ResponseMessage('取得成功')
findAll(): Todo[] {
return this.todosService.findAll();
}
Controller 實際回傳的仍然只是 Todo[],{ code, message, data } 這層統一格式,則由 Interceptor 在 Response 送出前統一包裝。
這些 Decorator 不會改變 Controller 真正回傳的資料,它們的用途是讓 OpenAPI 文件能描述「包裝完成後」的 Response 結構。
把後端一路到前端的流程攤開來看,大致會像這樣:
因為 OpenAPI 描述的是包好外層之後的格式,所以 Orval 產生的型別,例如圖中的 ListTodos200Output,也會保留 code、message、data 這三個欄位,而不是直接產生 Todo[]。
到了前端,可以再由 Service 把這層 Response 外殼拆掉,只把畫面需要的 data 與 Resource 狀態提供給 Component。
這樣 Component 不需要每支 API 都重複處理 { code, message, data },也不用知道統一回覆格式是怎麼產生的。
除了成功回應之外,前端通常也會透過 Interceptor 或共用的錯誤處理機制,處理不同層級的失敗情況:
- 連線層錯誤:Request 沒有取得正常的 HTTP Response,例如網路中斷、Server 無法連線,或受到 CORS 阻擋。
- API 錯誤:後端有收到 Request,但回傳 4xx、5xx 等 HTTP Status,這時可以再搭配統一回覆中的
code判斷實際錯誤。 - Runtime Schema 驗證錯誤:HTTP Response 已經成功回來,但資料格式不符合前端預期的 Schema,例如 Zod 驗證失敗。
這三種錯誤可以整理成下面的流程:
把不同層級的錯誤分開處理後,Component 就不需要負責所有錯誤判斷,只要處理真正和 UI 有關的狀態。
這樣也能讓各層職責更清楚。Service 負責整理成功回應並取出畫面需要的資料,而連線錯誤、API 錯誤與 Runtime Validation Error,則交給對應的共用錯誤處理機制。
請求資料彼此相依
有些 API Request 之間會存在先後依賴關係。
例如 B 請求需要 A 請求回傳的資料才能發出,就不能一開始同時送出兩個 Request,而是要先等 A 完成,再根據 A 的結果決定 B 的參數。
這類情境可以搭配 httpResource() 的 chain 功能,讓後面的 Resource 直接依賴前一個 Resource。
例如先取得使用者資料,再根據使用者的 companyId 取得公司資料:
interface User {
id: number;
name: string;
companyId: number;
}
interface Company {
id: number;
name: string;
}
userResource = httpResource<User>(
() => `/api/users/${this.userId()}`
);
companyResource = httpResource<Company>(({ chain }) => {
const user = chain(this.userResource);
return `/api/companies/${user.companyId}`;
});
這裡 companyResource 不會一開始就發出 Request,而是會先等待 userResource 取得資料。
整個流程大致會像這樣:
chain 和直接讀取 userResource.value() 的差別,不只在於取得前一個 Resource 的資料,還會把上游 Resource 的狀態一起帶進這段依賴關係。
當上游還在載入、重新載入或發生錯誤時,下游不會繼續發出自己的 Request;只有在上游有可使用的值時,才會繼續往下執行。
依照上游狀態,可以分成幾種情況:
idle:companyResource也會進入idle,params function 不會繼續執行。loading/reloading:companyResource會進入loading,但不會發出自己的 Request。即使上游正在重新載入,chain()也不會先拿前一次的舊值繼續往下執行。error:companyResource也會進入error,後續 Request 不會發出。resolved/local:chain()才會回傳目前的值,讓 params function 繼續執行,接著根據companyId發出下一個 Request。
補充:
local表示 Resource 的值曾透過set()或update()在前端直接修改。
當 chain() 正在傳遞 idle、loading、reloading 或 error 狀態時,params function 會停在 chain() 這裡,不會繼續執行後面的程式。
例如:
companyResource = httpResource<Company>(({ chain }) => {
const user = chain(this.userResource);
return `/api/companies/${user.companyId}`;
});
只有在 userResource 進入 resolved 或 local 狀態後,才會繼續執行:
return `/api/companies/${user.companyId}`;
另外,即使上游 Resource 已經進入 resolved 或 local,目前的 value() 仍然可能是 undefined。
如果這種情況本來就是允許的,可以在下游 Request 建立前先做判斷:
companyResource = httpResource<Company>(({ chain }) => {
const companyId = chain(this.userResource)?.companyId;
return companyId !== undefined
? `/api/companies/${companyId}`
: undefined;
});
這種狀態傳遞也讓畫面處理簡單很多。面對一串彼此相依的請求,可以主要觀察最後一個 Resource:
@if (companyResource.isLoading()) {
<p>載入中...</p>
}
@if (companyResource.error()) {
<p>取得資料失敗</p>
}
@if (companyResource.hasValue()) {
<p>{{ companyResource.value().name }}</p>
}
不需要再自己把 userResource 和 companyResource 的 loading、error 狀態另外組合起來。
這種依賴關係也不只發生在第一次載入。
例如當 userId 改變:
this.userId.set(2);
userResource 會先根據新的 userId 重新取得資料,等新的使用者資料回來後,companyResource 才會使用新的 companyId 再發出 Request:
因此 chain 建立的不是單純「先做 A,再做 B」的一次性流程,而是一段會跟著上游資料與狀態變化重新執行的非同步依賴關係。
如果只是根據 A 的資料同步計算另一個值,並不需要再發出非同步請求,就不需要使用 chain,直接用 computed() 會更合適。
本日結語
今天的兩個主題看起來一前一後,其實在處理同一件事:讓資料的「形狀」與「狀態」在源頭就被說清楚,下游只要跟著走。
統一回覆格式的關鍵不在 { code, message, data } 這個結構本身,而在於它有沒有進到 API Contract。只要 OpenAPI 描述的是真實的 Response,從 Orval 產生的型別、Service 拆殼到錯誤分層,才有同一份可靠的依據。
chain 則把同樣的想法用在非同步流程上:上游的狀態,就是下游能不能開始的前提。不過這也代表上游失敗時,下游一定會跟著失敗。如果下游只是「有更好、沒有也能顯示」的補充資訊,例如拿不到公司資料時仍要照常顯示使用者,就不適合用 chain 把錯誤一路帶下來,讓兩個 Resource 各自呈現狀態會更合適。
資料來源
- Angular Signals 官方文件:Chaining resources:
reloading時不回傳舊值、上游值可能是undefined,以及純同步計算改用computed()。 - NestJS 官方文件:OpenAPI Operations:
@ApiOperation()與回應描述,本文的自定義 Decorator 建立在這之上。 - NestJS 官方文件:Interceptors:Controller 回傳資料後,由 Interceptor 統一包裝成
{ code, message, data }。
留言
留言使用 GitHub 帳號登入(Giscus),留言會存在 GitHub Discussions。