iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0

Day12_前後端分離的溝通方式,RESTful API與契約

前言

Day 11 處理了「誰正在呼叫 API」以及「他能不能執行這個操作」。確認身分與權限之後,還有一個很實際的問題:前端要把請求送到哪裡?要帶哪些資料?後端成功或失敗時,又會怎麼回答?

可以把前端和後端想成同一棟大樓裡的兩個部門。前端負責接待使用者,後端負責處理資料。兩邊透過 API 櫃檯傳遞申請單。申請單要填哪些欄位、送到哪個窗口,以及辦理後會拿到什麼回覆,這些約定合起來就是 API 契約。

如果雙方各自猜測規則,即使前端和後端單獨執行都沒有錯,接在一起仍可能失敗。API 契約就是先把共同語言說清楚。

什麼是 API?

API 是程式開放給其他程式使用的操作入口。前端不必知道後端使用哪套框架,也不必知道資料存在哪張表,只要按照約定送出請求,就能查詢或修改資料。

它很像服務櫃檯。來辦事的人只要知道窗口位置、申請方式和必填資料,不需要走進辦公室查看承辦人怎麼整理檔案。辦公室日後就算更換檔案櫃,只要櫃檯規則沒改,來辦事的人就不受影響。

所以設計 API 時,我們要先問使用者想完成什麼。以 ProjectManagementWeb 來說,使用者需要的是「查看我參與的專案」或「更新工作項目的狀態」,不是直接操作 ProjectsTaskItems 資料表。

如果 API 完全照搬資料表,資料庫欄位一改,前端也得跟著修改,還可能把內部欄位送出去。比較穩定的做法是先依照使用情境設計 JSON,再由後端把它轉換成內部使用的資料模型。

RESTful API 又是什麼?

REST(Representational State Transfer)是一種設計網路服務的風格。它不是新的通訊協定,也不等於「用 JSON 寫 CRUD」。REST 常搭配 HTTP 使用,讓網址表示要處理的資源,再用 HTTP 方法表示這次想做的動作。

可以把網址想成櫃檯窗口,把 HTTP 方法想成申請單上的辦理項目。/api/todos 是「待辦事項窗口」,至於要查詢、新增或刪除,則交給 GETPOSTDELETE 表達:

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 服務,但不是所有需求的唯一答案。大量即時訊息或特殊查詢可能更適合其他做法,仍要依實際情境選擇。

簡單的 API 範例

下面用 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 契約?

API 契約就是前後端共同使用的「申請說明」。它會寫清楚窗口位置(路徑)、辦理方式(HTTP 方法)、申請資料(Header 與 JSON),以及成功或失敗時會收到什麼結果(狀態碼與錯誤內容)。如果 API 支援分頁、排序、驗證或版本,也應一起說明。

我會把 API 契約放在正式開發之前討論。前端可以先依照契約準備模擬資料和畫面,後端同時撰寫處理邏輯,測試人員也能提早整理成功與失敗的案例。這樣比較不會等到串接時才發現,前端送的是 taskId,後端等的卻是 id

契約不需要一開始就寫到永遠不能改。先完成一小段可討論的設計或 Mock API,實際試過後再調整會比較踏實。不過,一旦 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_issuerValidateAccountAsync 都由專案內其他元件提供。

接著,Program.cs 會依序把驗證與授權放進 HTTP 請求管線:

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

UseAuthentication 先確認呼叫者是誰,並把結果放進 HttpContext.UserUseAuthorization 再查看 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,可以改用 editorEditor123! 登入。

這兩組帳號和密碼只用於教學。範例的 RSA 金鑰在程式啟動時產生,沒有提交真正的私密金鑰;程式重啟後,舊 Token 也會跟著失效。正式專案仍要使用真正的帳號資料與密碼雜湊,並把簽章金鑰放在受管控的密鑰服務中。

Token 缺少、過期或驗證失敗時,API 通常回傳 401 Unauthorized。通過 [Authorize] 只表示「識別證是真的」,不代表整棟大樓都能進。ProjectManagementWeb 的 Service 還會檢查功能權限、專案成員關係與資源範圍;身分有效但權限不足時,回傳 403 Forbidden。前端隱藏按鈕可以減少誤操作,但真正的門禁仍要放在後端。

小結

前後端分離後,API 是雙方交換資料的櫃檯,API 契約則是共同遵守的辦理規則。REST 用網址表示資源,用 HTTP 方法表達操作,再用狀態碼告訴呼叫端處理結果。只要這些規則保持穩定,前端與後端就能各自調整內部程式。

身分驗證也在同一條請求流程中。JWT Bearer 驗證先確認 Token 是否可信,授權邏輯再判斷使用者能做什麼。到了 Day 13,我們會把視角從單一 API 往外拉,看看規格如何把契約、權限、狀態轉換與驗收條件串在一起。

參考資料與筆記


上一篇
Day11_API不是人人都可以呼叫的,來談談什麼是JWT
下一篇
Day13_什麼是規格驅動開發(SDD)?
系列文
Codex的規格驅動開發 :30 天打造 .NET 內部專案管理系統14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言