API 版本遷移 - API Versioning Migration 實戰範例
「我們要把 /api/v1/users 改成 /api/v2/users,欄位也變。」這是 API 團隊最常見、也最容易出事的任務。手機 App 不會立刻更新、第三方整合商可能半年才跟一次版、內部服務更可能有几十處呼叫。一旦 v1 跟 v2 行為分叉,客訴、refund、線上事故會一起來。
本範例示範如何用 Superpowers 的規劃型 + 執行型 skills,把版本遷移做成「可回滾、可並存、可監控」的漸進工程。
專案概述
情境:Python Flask 後端的「使用者 API」要把回應格式從 snake_case 改成 camelCase,created_at 改為 createdAt,並把 phone_number 拆成 country_code + national_number,越南與印尼 App 團隊各自跟進不同時程。
目標:
v1與v2並存 6 個月- 監控哪些 client 還在用 v1,过期(nudge)再移除
- v1 行為不變,v2 新欄位提供更乾淨的 schema
技術棧:Python 3.11 + Flask 3 + SQLAlchemy + Pytest
核心挑戰:
- 6 個內部服務呼叫 v1
- 2 家第三方整合商打 v1(無法強制升級)
- schema 變更必須同時兼容 response builder
完整開發流程
第 1 步:Brainstorming 探索遷移策略
觸發:
使用 brainstorming skill 探索使用者 API 從 v1 遷移到 v2 的策略
AI 回應摘要:
## 遷移策略比較
### 方向 A:Big Bang(同時廢 v1、上 v2)
- 優點:程式碼乾淨、無雙版本維護負擔
- 缺點:所有 client 必須同步升級 → 對內、對外都不可行
### 方向 B:Strangler Fig + 雙版本共存
- 優點:各 client 獨立節奏、可隨時回滾、風險分散
- 缺點:過渡期 schema 雙維護、需明確 deprecation 政策
### 方向 C:Adapter Pattern(只保留 v2,v1 用 adapter 翻譯)
- 優點:核心邏輯單一
- 缺點:欄位拆分/合併難透用 adapter 解,易寫出神奇邏輯
### 建議:方向 B + deprecation header
第 2 步:Writing Plans 拆解實作計畫
觸發:
使用 writing-plans skill 制定 API 版本遷移的實作計畫
實作計畫(簡化版):
階段 1(2 天):建立 v2 路由骨架
- [ ] 新增 /api/v2/ prefix 與 blueprint
- [ ] v2 schema 定義 (Pydantic)
- [ ] 從 v1 共用 data layer、不重複 DB 存取
階段 2(1.5 天):Response Builder 雙格式
- [ ] 抽出 transform_to_v1() 與 transform_to_v2()
- [ ] v2 多回 Sunset/Deprecation header 提示
- [ ] 兩套 schema 的 contract test
階段 3(2 天):Monitoring 與 deprecation
- [ ] Prometheus metric: api_version_usage_total{version}
- [ ] Access log 標註 client 與版本
- [ ] v1 response 加 Link header 指向 v2 文件
階段 4(1 天):文件與內部升級
- [ ] OpenAPI 文件雙版本
- [ ] 內部 6 個服務的呼叫 patch
- [ ] 第三方整合商通知
階段 5(0.5 天):CI 與 feature flag
- [ ] 部署 feature flag control_v2_enabled
- [ ] 灰度上線 5% → 50% → 100%
驗收標準:
- [ ] v1 所有既有測試通過(行為不變)
- [ ] v2 新測試覆蓋核心欄位
- [ ] monitoring 能區分 v1/v2 流量
- [ ] 內部服務全切 v2,第三方保留 v1
第 3 步:TDD 實作 v2 schema 與 transformer(handler)
紅燈:先寫 schema 與 transformer 的失敗測試
# tests/api/v2/test_user_schema.py
import pytest
from app.api.v2.schemas import UserV2Schema
from app.api.v1.transformers import transform_to_v2
class TestUserV2Schema:
def test_camelcase_field_names(self):
"""v2 應使用 camelCase"""
user = {'id': 1, 'created_at': '2024-01-01T00:00:00Z'}
result = transform_to_v2(user)
assert 'createdAt' in result
assert 'created_at' not in result
def test_phone_number_split(self):
"""電話號碼應拆成 country_code + national_number"""
user = {'phone_number': '+886912345678'}
result = transform_to_v2(user)
assert result['countryCode'] == '886'
assert result['nationalNumber'] == '912345678'
assert 'phone_number' not in result
def test_v2_deprecation_header_on_v1(self):
"""v1 回應應附 Sunset / Deprecation header"""
# v1 route handler 應自動加上 header
pass # 先紅燈,handler 還沒實作
跑 pytest → 全失敗(紅燈,預期)。
綠燈:最小實作讓測試通過
# app/api/v2/schemas.py
from pydantic import BaseModel, Field
from datetime import datetime
class UserV2Schema(BaseModel):
id: int
email: str
createdAt: datetime = Field(alias='created_at')
countryCode: str | None = None
nationalNumber: str | None = None
class Config:
populate_by_name = True
# app/api/v1/transformers.py
import re
PHONE_RE = re.compile(r'^\+(\d{1,3})(\d+)$')
def transform_to_v2(user: dict) -> dict:
result = {
'id': user['id'],
'email': user['email'],
'createdAt': user['created_at'],
}
if phone := user.get('phone_number'):
if m := PHONE_RE.match(phone):
result['countryCode'] = m.group(1)
result['nationalNumber'] = m.group(2)
return result
跑 pytest → 綠燈。
第 4 步:Executing Plans 落地 v2 route
每個階段查 todo → 實作 → 勾選,推進到完成 v2 endpoint:
# app/api/v2/routes.py
from flask import Blueprint, jsonify, request, g
from app.models import User
from app.api.v1.transformers import transform_to_v2
bp = Blueprint('api_v2', __name__, url_prefix='/api/v2')
@bp.get('/users/<int:user_id>')
def get_user(user_id: int):
user = User.query.get_or_404(user_id)
return jsonify(transform_to_v2(user.to_dict()))
# app/api/v1/routes.py (修改現有,加 header)
from flask import make_response
@bp.get('/users/<int:user_id>')
def get_user_v1(user_id: int):
user = User.query.get_or_404(user_id)
resp = make_response(jsonify(user.to_dict()))
resp.headers['Deprecation'] = 'true'
resp.headers['Sunset'] = 'Wed, 31 Dec 2025 23:59:59 GMT'
resp.headers['Link'] = '</api/v2/users/%d>; rel="successor-version"' % user_id
return resp
第 5 步:Verification 驗收清單
觸發:
使用 verification-before-completion skill 驗證 API 版本遷移完成
驗收清單:
✅ 行為兼容性
- v1 既有 23 個測試全綠 ✓
- v2 新增 18 個 contract 測試全綠 ✓
- 整合測試涵蓋 v1/v2 兩版本 ✓
✅ Monitoring 落地
- Prometheus metric api_version_usage_total{version="v1|v2"} 上線 ✓
- 24h 數據顯示 v1 = 78%、v2 = 22%(內部服務已切一半)✓
✅ deprecation 政策
- v1 回應附 Deprecation/Sunset/Link header ✓
- 文件標示 deprecation timeline ✓
- 第三方整合商 email 通知已寄送 ✓
✅ 部署能力
- feature flag control_v2_enabled 可隨時回滾 ✓
- staging 跑 7 天,零錯誤告警 ✓
關鍵學習點
1. 版本遷移的本質不是「改 code」,是「控管變更節奏」
新手常誤以為「加個 v2 路由」就完事。 真正的工程:(a) 向後兼容期、(b) 監控可見性、(c) 客戶溝通、(d) 回滾能力。skills 幫你把這四件事一個不漏。
2. Response Transformer 是低成本槓桿
把「schema 轉換」集中在一個函式,而不是散在每個 route。10 行 transformer 換來 route 完全不改,後續 v3、v4 也沿用模式。
3. Monitoring 不是 Nice-to-have
沒有 metric 就無法判斷「v1 還有多少流量」。半年後想關 v1 會變成「沒人敢動」。of Day 1 就要埋點。
工具使用摘要
| Skill | 用途 | 在本例的作用 |
|---|---|---|
| Brainstorming | 探索方向 | Big Bang / Strangler / Adapter 三選一 |
| Writing Plans | 拆解計畫 | 把遷移分成 5 階段 + 驗收標準 |
| Test-Driven Development | 紅→綠迴圈 | 先寫 schema 測試,再落地 transformer |
| Executing Plans | 執行節奏 | 按 todo 推進、不跳階 |
| Verification Before Completion | 完成前驗收 | 兼容性、Monitoring、政策、部署四面向 |
總結
API 版本遷移看起來「只是加個 prefix」,但做起來涉及 schema、client、流量、文件、回滾多個維度。Superpowers 的 skills 在這個例子提供的價值是紀律:
- 不跳過 brainstorm 直接動手
- 不憑感覺列計畫,用 writing-plans 強制拆解
- 不寫完 code 就上線,用 verification 確認每個面向
新手最容易學到的教訓:版本遷移的成功,往往不在 v2 寫得多漂亮,而在 v1 是否能安全下線。