TTechSpecs

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 團隊各自跟進不同時程。

目標:

  1. v1v2 並存 6 個月
  2. 監控哪些 client 還在用 v1,过期(nudge)再移除
  3. 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 是否能安全下線