APIは公開した後も変化します。新しいfieldを追加し、表現を改善し、古い項目を整理する必要があります。しかし、server側だけを見て「JSONを返せたから変更成功」と判断すると、既存client、CSV取込、保存済みpayload、CAD連携が突然動かなくなることがあります。
重要なのは、version番号を付けることより先に、どの変更が後方互換で、どの変更が破壊的かを判定できることです。この記事では、APIスキーマをfield、型、必須性、意味の契約として捉え、段階移行とPython adapterまで一つの流れで整理します。
APIスキーマはJSONの形だけではない
APIの契約には、少なくとも次の要素があります。
- endpointとHTTP method
- requestとresponseのfield名
- string、number、boolean、object、arrayなどの型
- 必須、任意、null許可の区別
- enumや数値範囲などの制約
- default値とfield欠落時の扱い
- ID、日時、単位などの意味
- error responseとstatus code
たとえば、次のresponseには4つのfieldがあります。
{
"schema_version": 1,
"id": "asset-0042",
"name": "Hydraulic unit A",
"status": "active"
}
schema_versionはnumber、他はstringです。しかし契約は型だけではありません。idが不変か、statusにどの値が来るか、fieldが必ず存在するかもclientの実装へ影響します。
後方互換性を3つに分ける
Google AIP-180は、後方互換性をsource、wire、semanticの3種類に分けています。この分け方はREST APIやJSON連携でも有効です。
Source compatibility
既存clientのソースコードを変更せず、新しいAPI仕様を使えるかという観点です。生成SDKでmethodやfield名を変えると、通信形式が似ていても再コンパイルできない場合があります。
Wire compatibility
通信上のデータを新旧双方が読み書きできるかという観点です。JSONのfieldを追加しても、受信側が未知fieldを拒否する実装ならwire上の連携は壊れます。
Semantic compatibility
同じfieldと型のままでも、意味や動作が変わっていないかという観点です。status: "active"の意味を「使用可能」から「登録済み」へ変えると、JSON Schemaは通っても業務処理は壊れます。
互換性は「JSONをparseできるか」だけでは判定できません。
変更の影響を判定する
代表的な変更を整理すると、次のようになります。
| 変更 | 一般的な判定 | 注意点 |
|---|---|---|
| 任意のrequest fieldを追加 | 互換にしやすい | 旧clientが送らなくても従来動作にする |
| 必須request fieldを追加 | 破壊的 | 旧clientは値を送れない |
| response fieldを追加 | 条件付きで互換 | 旧clientが未知fieldを無視できること |
| fieldを削除 | 破壊的 | 参照するclientが動かなくなる |
| field名を変更 | 破壊的 | 旧fieldの削除と新fieldの追加に相当 |
| stringをnumberへ変更 | 破壊的 | 値が同じでも型契約が変わる |
| default値を変更 | 破壊的になり得る | field欠落時の動作が変わる |
| responseのenum値を追加 | 破壊的になり得る | 未知値を想定しない分岐が失敗する |
| 制約を厳しくする | 送信側に破壊的 | 以前受理した値を拒否する |
| fieldの意味を変更 | 破壊的 | 構造検証では検出できない |
Google AIP-180でも、既存要素の削除、型変更、default変更、意味変更を互換性上の問題として扱っています。また、responseのenum追加は、未知値を処理できないclientを壊す可能性があります。
requestとresponseを分けて考える
同じ「field追加」でも、データの方向によって影響が逆になります。
Requestの変更
serverが任意fieldを新たに受け付ける変更は、旧clientが従来のrequestを送り続けられるため、互換にしやすい変更です。一方、そのfieldを必須にすると旧clientは更新しない限りrequestを作れません。
入力制約を緩める変更も、多くの場合は旧clientを壊しません。反対に最大文字数を短くする、nullを禁止する、許可していたenum値を削除すると、以前の正常データが異常になります。
Responseの変更
response fieldの追加は、clientが未知fieldを無視する場合に限って互換です。objectを厳密なfield集合としてdeserializeするclientや、JSON全体の一致を検査する処理では失敗することがあります。
また、responseの制約を緩めて新しい値を返す変更は、serverには簡単でもclientを壊し得ます。enum追加、nullの返却開始、最大桁数の拡大が代表例です。
「serverが受理できる範囲」と「clientが受理できる範囲」を別々に評価します。
未知fieldの扱いを契約にする
長く使うAPIでは、responseを読むclientに「未知fieldは無視する」という規則を持たせると、追加変更を行いやすくなります。
一方、requestで未知fieldを無条件に無視すると、material_codeの綴り間違いが成功扱いになり、利用者は更新できたと誤解します。requestでは未定義fieldを拒否し、responseでは未知fieldを許容するなど、方向ごとに方針を変えられます。
JSON SchemaではadditionalPropertiesなどでobjectの許容範囲を表せますが、同じschemaをrequestとresponseへ機械的に使い回すのではなく、それぞれの互換性方針に合わせます。
破壊的変更を段階移行する
fieldを一度に置き換えず、expand、migrate、contractの3段階に分けます。材質名だけのmaterial_nameから、材質コードを追加する例です。
Expand
新しいmaterial_codeを任意fieldとして追加し、旧fieldも維持します。
{
"schema_version": 2,
"id": "asset-0042",
"material_code": "JIS-G3101-SS400",
"material_name": "SS400"
}
serverは移行期間中、新旧形式を受理します。両方が送られた場合の優先順位や、不整合時に拒否する規則も決めます。
Migrate
新しいclientを配布し、保存済みデータを変換します。旧fieldの利用率、旧versionのrequest数、変換失敗数を観測します。単に一定期間待つだけでなく、移行完了をデータで確認します。
Contract
旧fieldを削除します。削除は破壊的なので、同じmajor versionのまま実行せず、新versionへ分離します。移行期限、代替field、停止日を事前に通知します。
versionは互換性の境界に置く
すべてのfield追加でAPI versionを増やすと、利用者と運用の負担が大きくなります。後方互換な追加は同じmajor versionで提供し、既存clientが動かない変更だけを新しいmajor versionへ分けるのが基本です。
Google AIP-185は、Google APIの指針としてmajor versionをURI pathへ置き、互換性のない変更で新しいmajor versionを導入し、移行期間は複数versionを並行提供する考え方を示しています。
/v1/assets/asset-0042
/v2/assets/asset-0042
versionの場所はAPI全体の設計判断ですが、どの方法でも次を明確にします。
- versionが表す契約範囲
- 旧versionの提供期限
- 新旧fieldの対応表
- 移行時の変換規則
- 廃止通知と監視方法
HTTP APIのmajor versionと、保存payloadのschema_versionは役割が異なります。前者はinterfaceの選択、後者は保存済みrecordを正しい変換器へ渡すために使えます。
Python adapterで内部形式へ正規化する
業務ロジックがv1とv2の分岐だらけにならないよう、入口で共通の内部形式へ変換します。
class UnsupportedSchemaVersion(ValueError):
pass
def require_text(data: dict, field: str) -> str:
value = data.get(field)
if not isinstance(value, str) or not value.strip():
raise ValueError(f"{field}は空でない文字列が必要です")
return value.strip()
def normalize_asset(payload: dict) -> dict:
if not isinstance(payload, dict):
raise TypeError("payloadはobjectである必要があります")
# 旧データでは欠落をv1とする、と契約で定義した場合だけ使う
version = payload.get("schema_version", 1)
if type(version) is not int:
raise TypeError("schema_versionは整数が必要です")
asset_id = require_text(payload, "id")
if version == 1:
material_name = require_text(payload, "material_name")
return {
"id": asset_id,
"material_code": None,
"material_name": material_name,
"source_schema_version": 1,
}
if version == 2:
material_code = require_text(payload, "material_code")
material_name = require_text(payload, "material_name")
return {
"id": asset_id,
"material_code": material_code,
"material_name": material_name,
"source_schema_version": 2,
}
raise UnsupportedSchemaVersion(
f"未対応のschema_versionです: {version}"
)
未知versionを最新版として推測せず、明示的に拒否または隔離します。schema_version欠落をv1と見なす処理も、実際に旧契約でそう定義した場合だけ使います。
内部形式では、材質コードが存在しないv1をNoneとして表しています。業務処理はこの共通objectを使い、外部形式の違いをadapterへ閉じ込めます。
元payloadと変換versionを保存する
変換規則が後から修正される可能性があるなら、正規化済みの値だけでなく受信した元データも保持します。
CREATE TABLE integration_messages (
source_system TEXT NOT NULL,
message_id TEXT NOT NULL,
schema_version INTEGER NOT NULL,
payload_json TEXT NOT NULL,
normalization_version INTEGER NOT NULL,
received_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (source_system, message_id)
);
schema_versionは入力構造の版、normalization_versionは変換ロジックの版です。安定したmessage_idを一意キーにすれば、同じデータが再送されても二重登録を防げます。
変換をやり直すときは、正規化済み値へ継ぎ足すのではなく、保存した元payloadから再生成します。これにより変換の二重適用を避けられます。
Schema差分をCIで検査する
OpenAPI SpecificationやJSON Schema Draft 2020-12で契約を機械可読にすると、変更差分をreviewできます。ただし、schema差分toolだけでは意味変更まで検出できません。
自動テストには次を組み合わせます。
- 旧request fixtureを新serverへ送り、従来どおり成功するか
- 新responseを旧client相当のdecoderで読めるか
- 必須性、null、enum、数値範囲の差分を検出する
- v1とv2を内部形式へ変換し、期待する値になるか
- 未知version、不明field、型違反を適切に拒否するか
- 同じmessage IDを再実行してrecordが増えないか
- defaultやfield欠落時の業務結果が変わっていないか
互換性の判定結果をpull requestへ表示し、破壊的変更には新major versionと移行計画を必須にすると、意図しない変更を公開前に止められます。
よくある失敗
Optional fieldだから安全だと決めつける
responseの新fieldでも、旧clientが未知fieldを拒否すれば壊れます。追加前にconsumerの許容方針を確認します。
型を保てば互換だと考える
stringのまま意味、単位、defaultを変えてもsemantic compatibilityは失われます。構造と意味を別々にreviewします。
versionを日時やrevisionと混同する
図面revision Cは設計内容の改訂、schema_version: 2はデータ構造の版です。更新日時も含め、別fieldで保持します。
旧fieldをすぐ削除する
client、保存データ、CSV templateは同時には更新されません。利用状況を観測し、並行期間を設けます。
受信versionから任意の処理を実行する
version文字列をmodule名や式として評価してはいけません。対応versionをallowlistにし、固定したadapterへ分岐します。
セキュリティと異常系
schema versionは外部入力なので信頼できません。負数、極端に大きい数、文字列、未知versionを検証します。payload size、objectの深さ、array件数にも上限を設け、解析資源の枯渇を防ぎます。
API key、token、内部credentialは業務payloadやschema sampleへ含めず、secret管理へ分離します。元payloadを保存する場合も、個人情報や機密設計値の保存期間、暗号化、閲覧権限、log出力を設計します。
変換不能なデータは捨てずに隔離し、source、message ID、schema version、失敗理由を記録します。ただし、error messageへsecretやpayload全文をそのまま出してはいけません。
CAD・BOM・設計データへ展開する
CAD属性、BOM、計算条件、試験結果も、長期保存後に別systemへ渡されます。applicationが更新されても過去データを読めるよう、次を分離します。
record_id:対象の同一性revision:設計内容の改訂schema_version:データ構造の版normalization_version:変換規則の版updated_at:更新日時
外部形式ごとにadapterを用意し、内部では共通modelへ正規化します。OpenAPI、JSON Schema、CSVデータ辞書、DB migration、CAD property対応表を同じ変更記録へ結び付けると、再利用時の意味を追跡できます。
まとめ
APIスキーマの変更で重要なのは、version番号そのものではなく、既存consumerが動き続けるかを複数の観点で判定することです。
- source、wire、semanticの互換性を分けて確認する
- requestとresponseで変更影響を別々に評価する
- 必須field追加、削除、rename、型変更、意味変更を破壊的変更として扱う
- responseのfieldやenum追加もconsumerの実装を確認する
- expand、migrate、contractで段階移行する
- 互換性のない変更は新major versionへ分離する
- adapterで新旧形式を共通の内部modelへ正規化する
- 元payload、schema version、変換versionを残す
- schema差分と旧fixtureをCIで検査する
- 設計revisionとschema versionを混同しない
契約、変換、保存、検証を一体で設計すれば、API、DB、CSV、Python、CADの更新時期が異なっても、データを壊さず段階的に移行できます。

