Day 11 處理了「誰正在呼叫 API」以及「他能不能執行這個操作」。確認身分與權限之後,還有一個很實際的問題:前端要把請求送到哪裡?要帶哪些資料?後端成功或失敗時,又會怎麼回答?
可以把前端和後端想成同一棟大樓裡的兩個部門。前端負責接待使用者,後端負責處理資料。兩邊透過 API 櫃檯傳遞申請單。申請單要填哪些欄位、送到哪個窗口,以及辦理後會拿到什麼回覆,這些約定合起來就是 API 契約。
如果雙方各自猜測規則,即使前端和後端單獨執行都沒有錯,接在一起仍可能失敗。API 契約就是先把共同語言說清楚。
API 是程式開放給其他程式使用的操作入口。前端不必知道後端使用哪套框架,也不必知道資料存在哪張表,只要按照約定送出請求,就能查詢或修改資料。
它很像服務櫃檯。來辦事的人只要知道窗口位置、申請方式和必填資料,不需要走進辦公室查看承辦人怎麼整理檔案。辦公室日後就算更換檔案櫃,只要櫃檯規則沒改,來辦事的人就不受影響。
所以設計 API 時,我們要先問使用者想完成什麼。以 ProjectManagementWeb 來說,使用者需要的是「查看我參與的專案」或「更新工作項目的狀態」,不是直接操作 Projects、TaskItems 資料表。
如果 API 完全照搬資料表,資料庫欄位一改,前端也得跟著修改,還可能把內部欄位送出去。比較穩定的做法是先依照使用情境設計 JSON,再由後端把它轉換成內部使用的資料模型。
REST(Representational State Transfer)是一種設計網路服務的風格。它不是新的通訊協定,也不等於「用 JSON 寫 CRUD」。REST 常搭配 HTTP 使用,讓網址表示要處理的資源,再用 HTTP 方法表示這次想做的動作。
可以把網址想成櫃檯窗口,把 HTTP 方法想成申請單上的辦理項目。/api/todos 是「待辦事項窗口」,至於要查詢、新增或刪除,則交給 GET、POST、DELETE 表達:
| HTTP 方法 | API 路徑 | 用途 | 常見成功狀態碼 |
|---|---|---|---|
GET |
/api/todos |
取得全部待辦事項 | 200 OK |
GET |
/api/todos/1 |
取得編號為 1 的待辦事項 | 200 OK |
POST |
/api/todos |
新增待辦事項 | 201 Created |
PUT |
/api/todos/1 |
用一份完整資料取代原內容 | 204 No Content |
PATCH |
/api/todos/1 |
只修改有送出的欄位 | 200 OK |
DELETE |
/api/todos/1 |
刪除待辦事項 | 204 No Content |
狀態碼是後端給前端的處理結果。200 OK 表示成功並帶回資料,204 No Content 表示事情辦好了,但沒有內容要回傳。找不到資料時可回傳 404 Not Found;輸入錯誤時回傳 400 Bad Request。
接續 Day 11 的情境,沒有通過身分驗證時通常回傳 401 Unauthorized;身分有效,但沒有操作權限時回傳 403 Forbidden。前端會依照這些結果顯示資料、提示錯誤,或請使用者重新登入,所以狀態碼也要寫進 API 契約。
REST 還有一個常見概念叫「無狀態」。它不是說伺服器不能保存資料,而是伺服器不該靠「記得上一個請求」才能理解下一個請求。就像每次送申請單時,都要把這次辦理所需的資料填完整。呼叫受保護的 API 時,Bearer Access Token 也要隨著每個請求送出。
REST 很適合以資源為中心的 HTTP 服務,但不是所有需求的唯一答案。大量即時訊息或特殊查詢可能更適合其他做法,仍要依實際情境選擇。
下面用 ASP.NET Core 實作一個待辦事項 API。資料先放在記憶體,讓我們把注意力放在路徑、HTTP 方法和狀態碼。這是一個可以獨立啟動的教學範例,不需要先準備資料庫。
如果想直接下載完整專案,可以參考 APISampleDemo。GitHub 版本保留本篇的路徑、欄位與狀態碼,並把記憶體資料處理移到 Service,另外加入 JWT 身分驗證、OpenAPI 文件與 Swagger UI。文章裡的程式碼比較精簡,適合先看懂一次請求怎麼進入 Controller;完整專案則比較接近實際開發時的檔案分工。
先建立專案:
dotnet new webapi --use-controllers --no-https --no-openapi -n TodoApi
cd TodoApi
接著新增 Models 資料夾,並放入以下三個檔案。輸入與輸出各自使用不同型別,避免資料庫欄位改動時,API 回傳格式也被迫一起改。
Models/TodoResponse.cs
namespace TodoApi.Models;
/// <summary>回傳給 API 呼叫端的待辦事項。</summary>
public sealed record TodoResponse(int Id, string Title, bool IsCompleted);
Models/TodoRequest.cs
using System.ComponentModel.DataAnnotations;
namespace TodoApi.Models;
/// <summary>新增或完整取代待辦事項時使用的輸入資料。</summary>
public sealed record TodoRequest(
[Required, MaxLength(100)] string Title,
bool IsCompleted = false);
Models/UpdateTodoRequest.cs
using System.ComponentModel.DataAnnotations;
namespace TodoApi.Models;
/// <summary>只修改部分欄位時使用的輸入資料。</summary>
public sealed record UpdateTodoRequest(
[MaxLength(100)] string? Title,
bool? IsCompleted);
再將 Controllers/TodosController.cs 改成以下內容:
using Microsoft.AspNetCore.Mvc;
using TodoApi.Models;
namespace TodoApi.Controllers;
[ApiController]
[Route("api/todos")]
public sealed class TodosController : ControllerBase
{
private static readonly List<TodoResponse> Todos =
[
new TodoResponse(1, "完成認證 API 開發", false)
];
[HttpGet]
public ActionResult<IReadOnlyCollection<TodoResponse>> GetAll()
=> Ok(Todos.ToArray());
[HttpGet("{id:int}")]
public ActionResult<TodoResponse> GetById(int id)
{
TodoResponse? todo = Todos.FirstOrDefault(item => item.Id == id);
return todo is null ? NotFound() : Ok(todo);
}
[HttpPost]
public ActionResult<TodoResponse> Create(TodoRequest request)
{
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest("標題不可空白");
TodoResponse todo = new(
Todos.Count == 0 ? 1 : Todos.Max(item => item.Id) + 1,
request.Title.Trim(), request.IsCompleted);
Todos.Add(todo);
return CreatedAtAction(nameof(GetById), new { id = todo.Id }, todo);
}
[HttpPut("{id:int}")]
public IActionResult Replace(int id, TodoRequest request)
{
int index = Todos.FindIndex(item => item.Id == id);
if (index < 0) return NotFound();
if (string.IsNullOrWhiteSpace(request.Title))
return BadRequest("標題不可空白");
Todos[index] = new TodoResponse(
id, request.Title.Trim(), request.IsCompleted);
return NoContent();
}
[HttpPatch("{id:int}")]
public ActionResult<TodoResponse> Update(int id, UpdateTodoRequest request)
{
int index = Todos.FindIndex(item => item.Id == id);
if (index < 0) return NotFound();
if (request.Title is not null && string.IsNullOrWhiteSpace(request.Title))
return BadRequest("標題不可空白");
TodoResponse current = Todos[index];
TodoResponse updated = current with
{
Title = request.Title?.Trim() ?? current.Title,
IsCompleted = request.IsCompleted ?? current.IsCompleted
};
Todos[index] = updated;
return Ok(updated);
}
[HttpDelete("{id:int}")]
public IActionResult Delete(int id)
{
TodoResponse? todo = Todos.FirstOrDefault(item => item.Id == id);
if (todo is null) return NotFound();
Todos.Remove(todo);
return NoContent();
}
}
最後把 Program.cs 改成以下內容:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
app.Run();
使用固定連接埠啟動,方便接下來測試:
dotnet run --urls http://localhost:5050
如果執行的是 GitHub 上的完整專案,Swagger UI 位於 http://localhost:5209/swagger,OpenAPI JSON 位於 http://localhost:5209/openapi/v1.json。先呼叫登入 API 取得 Access Token,再按 Swagger UI 右上角的 Authorize 貼上 Token,就能測試受保護的 API,不必先準備 curl 指令。
可以用另一個終端機依序送出查詢、新增、完整取代、局部修改和刪除請求:
curl -i http://localhost:5050/api/todos
curl -i -X POST http://localhost:5050/api/todos \
-H 'Content-Type: application/json' \
-d '{"title":"撰寫 API 契約","isCompleted":false}'
curl -i -X PUT http://localhost:5050/api/todos/2 \
-H 'Content-Type: application/json' \
-d '{"title":"完成 API 契約","isCompleted":true}'
curl -i -X PATCH http://localhost:5050/api/todos/2 \
-H 'Content-Type: application/json' \
-d '{"isCompleted":false}'
curl -i -X DELETE http://localhost:5050/api/todos/2
POST 會回傳 201 Created,並在 Location Header 告訴前端新資料的查詢位置。PUT 像交上一份完整的新申請單,沒填的內容不會保留;PATCH 則像填寫變更單,只改這次指定的欄位。
這個範例使用 static List,程式停止後資料就會消失,也沒有處理多人同時修改資料的情況。正式專案仍應把業務規則和資料存取交給 Service 等元件,並使用資料庫保存資料。
API 契約就是前後端共同使用的「申請說明」。它會寫清楚窗口位置(路徑)、辦理方式(HTTP 方法)、申請資料(Header 與 JSON),以及成功或失敗時會收到什麼結果(狀態碼與錯誤內容)。如果 API 支援分頁、排序、驗證或版本,也應一起說明。
我會把 API 契約放在正式開發之前討論。前端可以先依照契約準備模擬資料和畫面,後端同時撰寫處理邏輯,測試人員也能提早整理成功與失敗的案例。這樣比較不會等到串接時才發現,前端送的是 taskId,後端等的卻是 id。
契約不需要一開始就寫到永遠不能改。先完成一小段可討論的設計或 Mock API,實際試過後再調整會比較踏實。不過,一旦 API 已有人使用,刪除欄位或改變原有意思就可能讓呼叫端壞掉。這類修改要搭配版本管理和遷移安排。
前面的 Todo API 為了專心說明 REST,沒有要求使用者登入。實際的 ProjectManagementWeb 會把 API 想成辦公區:先由警衛檢查識別證,再由各部門確認這張識別證能不能進入指定區域。
ASP.NET Core 的 JWT Bearer 驗證就像第一道警衛。ProjectManagementWeb 在 DependencyInjection.cs 註冊驗證與授權服務:
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer();
services.AddAuthorization();
真正的檢查規則放在 JwtBearerOptionsSetup.cs:
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = _options.Issuer,
ValidateAudience = true,
ValidAudience = _options.Audience,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = _issuer.ValidationKey,
ClockSkew = TimeSpan.FromSeconds(30),
NameClaimType = "name",
RoleClaimType = "role"
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = ValidateAccountAsync
};
這裡會檢查 Token 是誰發行的、準備給誰使用、有沒有過期,以及 RSA 簽章是否正確。簽章通過後,ValidateAccountAsync 還會確認帳號目前仍可使用,而且 Token 裡的 token_version 和資料庫一致。帳號被停用或權限版本改變後,舊 Token 因此能夠失效。
上面兩段是 ProjectManagementWeb 的程式節錄,不是可以單獨貼進空白專案執行的完整範例。_options、_issuer 與 ValidateAccountAsync 都由專案內其他元件提供。
接著,Program.cs 會依序把驗證與授權放進 HTTP 請求管線:
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
UseAuthentication 先確認呼叫者是誰,並把結果放進 HttpContext.User。UseAuthorization 再查看 API 上的規則,判斷這位使用者能不能繼續辦理。
需要登入的 Controller 或 Action 會加上 [Authorize]:
[Authorize]
[Route("api/v1/projects/{projectId:guid}/task-items")]
public sealed class TaskItemsController : ApiControllerBase
{
// Actions 省略
}
前端呼叫時,要把 Day 11 取得的 Access Token 放進 Authorization Header:
GET /api/v1/projects/{projectId}/task-items HTTP/1.1
Host: api.example.com
Authorization: Bearer <access-token>
APISampleDemo 也把這段流程做成可以操作的範例。先用 Reader 帳號登入:
POST /api/auth/token HTTP/1.1
Host: localhost:5209
Content-Type: application/json
{
"userName": "reader",
"password": "Reader123!"
}
登入成功會取得有效 30 分鐘的 JWT Access Token。Todo API 全部加上 [Authorize],所以沒有帶 Token 時會回傳 401 Unauthorized。Reader 可以呼叫 GET,但新增、修改與刪除需要 Editor 角色;Reader 呼叫這些 API 時會得到 403 Forbidden。如果要測試完整 CRUD,可以改用 editor/Editor123! 登入。
這兩組帳號和密碼只用於教學。範例的 RSA 金鑰在程式啟動時產生,沒有提交真正的私密金鑰;程式重啟後,舊 Token 也會跟著失效。正式專案仍要使用真正的帳號資料與密碼雜湊,並把簽章金鑰放在受管控的密鑰服務中。
Token 缺少、過期或驗證失敗時,API 通常回傳 401 Unauthorized。通過 [Authorize] 只表示「識別證是真的」,不代表整棟大樓都能進。ProjectManagementWeb 的 Service 還會檢查功能權限、專案成員關係與資源範圍;身分有效但權限不足時,回傳 403 Forbidden。前端隱藏按鈕可以減少誤操作,但真正的門禁仍要放在後端。
前後端分離後,API 是雙方交換資料的櫃檯,API 契約則是共同遵守的辦理規則。REST 用網址表示資源,用 HTTP 方法表達操作,再用狀態碼告訴呼叫端處理結果。只要這些規則保持穩定,前端與後端就能各自調整內部程式。
身分驗證也在同一條請求流程中。JWT Bearer 驗證先確認 Token 是否可信,授權邏輯再判斷使用者能做什麼。到了 Day 13,我們會把視角從單一 API 往外拉,看看規格如何把契約、權限、狀態轉換與驗收條件串在一起。