- name
- elf-api-design
- description
- Elf Express RESTful API 設計規範(ASP.NET Core 10 Minimal API),規定 URL 命名、HTTP method 與狀態碼、
endpoint → service → db 分層、ProblemDetails 錯誤格式、分頁/排序/篩選參數、版本控制、Bearer 認證、CORS、
rate limiting、OpenAPI 與 /api/health。
當任務涉及新增或修改 API endpoint、命名路由、決定回傳狀態碼、設計錯誤回應、分頁查詢、
API 版本 (v1/v2)、Authorization header、CORS 設定、限流、OpenAPI/Swagger 或健康檢查時觸發。
- metadata
- {"version":"1.1.0","owner":"Elf Express"}
# Elf Express RESTful API 設計規範
後端技術棧:ASP.NET Core 10 **Minimal API** + SqlSugar 5.x + PostgreSQL 18。
本規範描述「API 表面長什麼樣子」。前後端如何保持同步(`docs/api-contract.md`、`types.ts`、mock)請看 **elf-api-contract**;
C# 分層、DI、設定請看 **elf-dotnet**;查詢與分頁的 SqlSugar 寫法請看 **elf-sqlsugar**;
端對端 HTTP 測試請看 **elf-integration**;容器與 healthcheck 請看 **elf-cicd-docker**;PR 審查規則請看 **elf-cicd-review**。
> 標示「**新專案 MUST(參考專案未實作)**」的規則是 ASP.NET Core 10 標準做法,參考專案尚未採用;新專案一律照做,舊專案修改到該處時一併導入。
> 目錄慣例:API 專案在 `server/src/<App>.Api/`,測試在 `server/tests/<Project>.Tests/`;HTTP / 契約測試在 `server/tests/<App>.Api.IntegrationTests/`(見 elf-integration)。
---
## 1. 何時使用
- 新增、修改、刪除任何 HTTP endpoint。
- 決定 URL、HTTP method、狀態碼、查詢參數名稱。
- 撰寫或修改錯誤處理、`Program.cs` 的 middleware 管線(forwarded headers、例外、CORS、認證、限流、OpenAPI、health)。
- Code review 時檢查 API 是否符合團隊慣例。
---
## 2. 固定規則
### 2.1 路由與命名
1. **MUST** 所有 endpoint 掛在 `app.MapGroup("/api")` 底下。前端 `VITE_API_BASE_URL=/api`、nginx `location /api/` 都依賴這個前綴。
2. **MUST** 每個功能一個 `<Feature>Endpoints` 靜態類別,對外只暴露一個擴充方法:
`public static RouteGroupBuilder Map<Feature>(this IEndpointRouteBuilder app, string prefix = "/<resources>")`,
在 `Program.cs` 以 `api.Map<Feature>();` 註冊。檔案放 `server/src/<App>.Api/Api/<Feature>Endpoints.cs`。
分層固定 **endpoint → service → db**:endpoint 只做綁定、格式驗證、呼叫 `I<Feature>Service`(`server/src/<App>.Api/Services/<Feature>Service.cs`,Scoped)、把結果對應成狀態碼;
**MUST NOT** 在 endpoint 注入 `ISqlSugarClient`。每個 handler **MUST** 宣告 `CancellationToken ct` 並一路傳到 service 與 SqlSugar(`FirstAsync(ct)`、`ToPageListAsync(..., ct)`)。
3. **MUST** 資源用**複數名詞、全小寫**:`/categories`、`/texts`、`/words`、`/sessions`。多字用 **kebab-case**:`/error-books`。**MUST NOT** 用 `camelCase`、`snake_case` 或動詞(`/getCategories`)。
4. **MUST** 路由參數加型別約束:`/{id:int}`、`/{id:guid}`;以代碼識別的資源用 `/{code}`。
5. **MUST** 目前登入者自己的資料用 `/<resources>/me`(例:`GET /sessions/me`)。**MUST NOT** 讓前端在路由或 query 傳自己的 `userId`,一律從 JWT claims(`ElfClaimTypes.UserId`)取。
6. **MUST** 子資源最多巢狀一層:`/models/{code}/install`。更深的關係改用 query:`/texts?category=basic`。
7. **MUST** 非 CRUD 的動作依下列判斷選路由:
- 動作結果是「某個狀態存在 / 不存在」、事後可以 `GET` 查到(安裝、訂閱、釘選、啟用)→ 建模成**子資源**,`POST` 建立、`DELETE` 移除:`POST /models/{code}/install`、`DELETE /models/{code}/install`。
- 動作是一次性計算或批次處理、不留下可單獨 `GET` 的狀態(翻譯、匯入、重算)→ 才用**動作路由**,且 **MUST** 用 `POST`:`POST /translate/locales`、`POST /words/import`。
8. **MUST** Query 參數用 **camelCase**:`?category=&level=`、`?lang=en&days=30`、`?locale=zh-TW`。
9. **MUST** 每個 endpoint 呼叫 `.WithName("<Verb><Noun>")`(例 `GetModels`、`TranslateLocales`),每個 group 呼叫 `.WithTags("<Feature>")`,OpenAPI 才有穩定的 operationId 與分組。
10. **MUST NOT** 回傳 SqlSugar Entity,一律投影成 `<Name>Dto` record。**MUST NOT** 在新專案使用泛型 `MapCrud<T>`;既有專案保留的 `MapCrud<T>` 只准用在「沒有敏感欄位、純後台維護」的表,**MUST NOT** 用在含 `PasswordHash`、token、個資的 Entity,且必須符合規則 11、14(`Location` 含 `/api`、PUT 以路由 id 為準)。
### 2.2 HTTP method 與狀態碼
11. **MUST** 依下表回應。參考專案 `Api/CrudEndpoints.cs` 只有部分符合(PUT 忽略路由 id、`Location` 少 `/api`),**MUST NOT** 以它為範本:
| 操作 | Method | 成功 | 常見失敗 |
|---|---|---|---|
| 列表 | `GET /x` | `200` + `T[]` 或 `PagedResult<T>` | `400` 參數錯 |
| 取單筆 | `GET /x/{id}` | `200` + DTO | `404` |
| 建立 | `POST /x` | `201 Created`,`Location: /api/x/{id}`,body 為完整 DTO | `400`、`409` 重複 |
| 整筆更新 | `PUT /x/{id}` | `204 No Content` | `400`、`404` |
| 部分更新 | `PATCH /x/{id}` | `204` | `400`、`404` |
| 刪除 | `DELETE /x/{id}` | `204` | `404` |
| 子資源動作 | `POST`/`DELETE /x/{id}/<action>` | `204`(無回傳)或 `200` + 結果 | `404`、`409` |
| 計算型 POST | `POST /translate/locales` | `200` + 結果 DTO | `400` |
12. **MUST** 未登入或 token 無效回 `401`(前端收到會清掉 token),已登入但沒權限回 `403`。**MUST NOT** 用 `200` + `success:false` 表示錯誤。
13. **MUST** 驗證失敗回 `400`(`ValidationProblem`),業務衝突(重複代碼、狀態不允許)回 `409`,被限流回 `429`,未預期例外回 `500`。
14. **MUST** `PUT /x/{id}` 以路由的 `id` 為準:service 先依路由 id 讀出再套用 body;body 若也帶 id 且與路由不同,回 `400`。**MUST NOT** 直接 `Updateable(body)`。
15. **MUST** `GET`、`PUT`、`DELETE` 為冪等;`GET` **MUST NOT** 有副作用。
### 2.3 錯誤回應(ProblemDetails)
16. **MUST** 所有錯誤回應為 RFC 9457 **ProblemDetails**(`Content-Type: application/problem+json`)加 `code`,形狀固定:
```json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"detail": "Category 42 was not found.",
"instance": "/api/categories/42",
"code": "category.not_found",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
```
- `detail`:給人看的英文句子。
- `code`:機器可判斷的錯誤碼,格式 `<resource>.<reason>`,全小寫 snake_case(`category.not_found`、`session.score_mismatch`、`auth.invalid_credentials`)。前端要依錯誤分流時**只能**判斷 `code`,不可比對 `detail` 字串。所有 `code` 列在 `docs/api-contract.md` 的「Error codes」表(elf-api-contract 規則 7)。
- 驗證錯誤另有 `errors: { "<camelCaseField>": ["message"] }`,`code` 固定 `validation.failed`。
17. **MUST** 錯誤處理分工如下(與 elf-dotnet 相同):
- **service** 對可預期的失敗回傳**結果 enum**(寫入類,例 `CategoryWriteResult.DuplicateCode`)或 `T?`(查詢類,`null` = 找不到)。service **MUST NOT** throw `AppException`、**MUST NOT** 回傳 `IResult`。
- **endpoint** 把結果對應成 `Problems.NotFound/Conflict/BadRequest/Forbidden(code, detail)`(內部為 `TypedResults.Problem` + `code` extension)或 `ApiValidation.Problem(...)`。`AppException` 只允許在 endpoint 層、無法回傳 `IResult` 的 helper 中使用。
- `Program.cs` 啟用 `AddProblemDetails()`、`AddExceptionHandler<GlobalExceptionHandler>()`、`app.UseExceptionHandler()`、`app.UseStatusCodePages()`,未預期例外由全域 handler 轉成 `500`。
- 參考專案目前回 `Results.BadRequest(new { message = "..." })`;新程式碼 **MUST NOT** 再寫 `{ message }` 形狀。
18. **MUST NOT** 把 exception message、stack trace、SQL 回傳給前端。`500` 的 `detail` 固定為 `"An unexpected error occurred."`、`code` 為 `server.error`,細節只寫 log(帶 `traceId`)。
19. **MUST** 前端 `normalizeError` 依序讀 `detail → title → message`(`message` 相容舊回應),見 elf-api-contract。
完整程式碼:[`references/server-templates.md` §1](references/server-templates.md#1-錯誤處理)。
### 2.4 分頁、排序、篩選
20. **MUST** 任何**可能超過 500 列**(= `size` 上限)的列表一律分頁,包括紀錄、歷史、使用者產生的資料;只有能確定永遠 ≤ 500 列的目錄型資料(分類、內建 prompt、模型清單)可回傳裸陣列 `T[]`。無法確定時就分頁。
21. **MUST** 分頁參數固定為 `?page=1&size=50`:`page` 從 **1** 開始、`size` 預設 `50`、上限 `500`,伺服器端 `Math.Max(page, 1)`、`Math.Clamp(size, 1, 500)`。**MUST NOT** 使用 `pageIndex`、`pageSize`、`limit`、`offset`、`skip`、`take`。
22. **MUST** 分頁回應形狀固定為 `PagedResult<T>`:`{ "total": 1234, "page": 2, "size": 50, "rows": [ ... ] }`,C# 定義為 `record PagedResult<T>(int Total, int Page, int Size, IReadOnlyList<T> Rows)`(與 elf-sqlsugar 同一份)。
回應中的 `page`/`size` **MUST** 是 clamp 後實際使用的值(參考專案回傳原始輸入,是反例)。
23. **新專案 MUST(參考專案未實作)** 排序用 `?sort=<camelCaseField>&order=asc|desc`,未帶 `order` 時為 `desc`。endpoint **MUST** 以白名單 `switch` 把字串轉成 enum,未知值回 `400`(`query.invalid_sort` / `query.invalid_order`);service 只收 enum。**MUST NOT** 把字串直接拼進 SQL 或 `OrderBy(string)`。
24. **MUST** 篩選用具名 query 參數(`?category=basic&level=A1`、`?topic=food&q=apple`):關鍵字搜尋固定叫 `q`;日期範圍用 `from`/`to`(ISO 8601);天數用 `days`;多值用重複參數 `?tag=a&tag=b`(綁定 `string[] tag`)。**MUST NOT** 自訂逗號分隔格式。
25. **MUST** 語系用 `?locale=zh-TW`;未帶時讀 `Accept-Language`,並**依 `q` 值排序**(不是依出現順序),`q=0` 代表不接受。
範本:[`references/server-templates.md` §2](references/server-templates.md#2-分頁排序篩選)。
### 2.5 版本控制
26. **新專案 MUST(參考專案未實作)** 第一版不加版本段:`/api/<resources>`。出現**無法向下相容**的變更時,只為受影響的 endpoint 新增 `app.MapGroup("/api/v2")`,舊路由保留到前端全部切換後再移除,並在 `docs/api-contract.md` 標註 `deprecated`。
27. **MUST** 以下屬於相容變更、**不需要**開新版本:新增 endpoint、回應新增欄位、新增選填 query 參數。以下屬於破壞性變更、**需要**新版本或同 PR 同步改前端:刪除/改名欄位、改型別、改狀態碼語意、選填變必填。
28. **MUST NOT** 用 header(`api-version`)或 query(`?v=2`)分版——nginx 只按路徑轉發,前端 base URL 也只認路徑。
### 2.6 認證
29. **MUST** 認證 header 固定為 `Authorization: Bearer <jwt>`。**MUST NOT** 自訂 `X-Token`、`X-Auth`,**MUST NOT** 把 token 放 query string。
30. **MUST** 登入 `POST /auth/login`、註冊 `POST /auth/register`,request 為 `LoginRequest { username, password }`,回傳 `AuthUserDto`(含 `token`)。
31. **新專案 MUST(參考專案未實作)** 以 `AddAuthentication().AddJwtBearer()` + `AddAuthorization()` 驗證;受保護的 group 呼叫 `.RequireAuthorization()`;`/auth/*` 與 `/health` 呼叫 `.AllowAnonymous()`。JWT 金鑰走 `appsettings` / 環境變數(`Jwt__Key`),**MUST NOT** 寫死在程式碼。Claim 名稱一律用 `ElfClaimTypes`(`sub`、`company_id`、`role`)。
### 2.7 CORS
32. **MUST** 使用具名 policy,origin 從設定 `Cors:Origins`(字串陣列)讀取,程式內 fallback 只放本機開發 origin(範本見 §3.1)。
33. **MUST NOT** 使用 `AllowAnyOrigin()`,更 **MUST NOT** 搭配 `AllowCredentials()`。
34. **MUST** 正式環境走 nginx 同源代理(瀏覽器只打 `/api`,無跨來源請求),因此 `Cors:Origins` 只列開發伺服器與桌面殼,例如 `http://localhost:5173`、`http://tauri.localhost`、`https://tauri.localhost`。
### 2.8 Forwarded headers 與 rate limiting
35. **MUST** 啟用 `UseForwardedHeaders()`,並放在管線**最前面**(在 `UseExceptionHandler`、`UseCors` 之前);API 永遠在 nginx 後面,否則 client IP 都是 nginx 容器。**新專案 MUST(參考專案未實作)** 使用內建 `AddRateLimiter()`,至少對 `/auth/*` 套用依 client IP 分區的 fixed-window policy,被拒回 `429` + `rate_limit.exceeded`。**MUST NOT** 呼叫 `UseHttpsRedirection()`(TLS 在 nginx 結束)。範本:[`references/server-templates.md` §5](references/server-templates.md#5-rate-limiting-與-forwarded-headers)。
### 2.9 OpenAPI
36. **MUST** 使用內建 `Microsoft.AspNetCore.OpenApi`:`builder.Services.AddOpenApi();`,並**只在 Development** 執行 `app.MapOpenApi();`(文件位於 `/openapi/v1.json`)。**MUST NOT** 另外安裝 Swashbuckle。
37. **MUST** handler 回傳 `TypedResults`(例 `Task<Results<Ok<CategoryDto>, ProblemHttpResult>>`),或補 `.Produces<T>(200)` / `.ProducesProblem(404)`,讓 OpenAPI 推得出回應型別。
### 2.10 健康檢查
38. **MUST** 提供匿名的 `GET /api/health`(路徑已定案),回 `200` + `{ "status": "ok", "utc": "<ISO 8601>" }`,並 `.WithName("Health")`。容器 healthcheck **MUST NOT** 依賴 `wget`(`aspnet` 映像沒有);由 **elf-cicd-docker** 的 `api.Dockerfile` 在最終階段以 `apt-get install -y --no-install-recommends curl` 安裝 curl,compose 以 `curl -fsS http://localhost:8080/api/health` 探測。
39. **新專案 MUST(參考專案未實作)** 需要檢查 PostgreSQL 連線時,另開 `GET /api/health/ready`;`/api/health` 保持不碰 DB,避免 DB 短暫抖動讓容器被判定不健康。
### 2.11 JSON 慣例
40. **MUST** 維持 ASP.NET Core 預設 web JSON 設定:屬性名 **camelCase**、`null` 照常輸出。**MUST NOT** 用 `[JsonPropertyName]` 改成其他命名風格。
41. **MUST** 時間一律 UTC、ISO 8601 字串(`2026-09-27T08:00:00Z`);enum 以 **camelCase 字串**輸出(`new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)`,`SpeedUnit.Wpm` → `"wpm"`),值與前端 `types.ts` 字串聯集完全一致。
42. **MUST** DTO 用 positional `record`:`public record CategoryDto(int Id, string Code, string Name, string Color);`。Request DTO 命名 `<Name>Request`,Response DTO 命名 `<Name>Dto`;名稱與 `types.ts` 的 interface 完全相同(elf-api-contract 規則 8)。
---
## 3. 標準範本
### 3.1 `Program.cs`(管線順序即規範)
```csharp
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.HttpOverrides;
Ver no GitHub