OpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions
- OpenAPI CI バリデーションは 構文(Swagger Parser)→ Lint(Spectral)→ 差分(openapi-diff / oasdiff) の多段で組む。
- API breaking change 検出は公開APIではPRブロック、内部APIは semver + ラベル例外が現実的。
- Spectral カスタムルールで operationId・429・Idempotency-Key を強制し、GitHub Actions で Required Check にしたうえで、staging の contract test(TypeScript + Schemathesis)で実装乖離を検出する。
なぜOpenAPI仕様をCIで検証するのか
API開発では、コードレビューだけでは 契約(Contract)の破壊 を防げない。コントローラのシグネチャを変え、DTOのフィールドを削除し、ステータスコードを差し替えた——実装は動いても、モバイルアプリ・パートナー連携・生成されたTypeScript SDKは静かに壊れる。
OpenAPI(旧Swagger)仕様書を 単一の真実(Single Source of Truth) としてリポジトリに置き、Pull Request のたびに自動検証する CI パイプラインを敷くと、次の問題を merge 前に検出できる。
- 構文エラー — YAMLインデント、
$refの循環参照、components.schemasの未定義参照 - 設計ポリシー違反 —
operationId欠落、説明文なし、security未定義、ページネーション未記載 - 破壊的変更(Breaking Change) — 必須フィールド追加、レスポンス型変更、エンドポイント削除
「OpenAPI CI バリデーション」は、API を プロダクト として扱うチームの必須インフラだ。手動で仕様書を更新し、リリース後にクライアントから問い合わせが来て気づく——このサイクルを断ち切る。
| 検証なし | CIパイプラインあり |
|---|---|
| 仕様書が実装から乖離 | PRごとに spec と diff を強制 |
| 破壊的変更が本番到達 | openapi-diff が merge 前に fail |
| Lint ルールが属人化 | Spectral でポリシーをコード化 |
| ドキュメント SEO が後回し | JSON-LD で TechArticle / FAQPage を自動出力 |
OpenAPI 3.1の基本構成とCIでの検証対象
OpenAPI 3.1 は JSON Schema 2020-12 と整合し、webhooks セクションが正式化された。CI では ファイル全体 だけでなく、以下の セマンティック整合 も検証対象に含める。
| セクション | CIで見るポイント |
|---|---|
openapi: 3.1.x | バージョン固定ルール(3.0混在防止) |
paths | HTTPメソッド、operationId一意性、responses網羅 |
components.schemas | $ref 解決、required と nullable の整合 |
components.securitySchemes | bearer / apiKey / oauth2 の定義統一 |
webhooks | 3.1固有。イベント名、payload schema |
最小構成の OpenAPI 3.1 ファイル例を示す。CI ではこのファイルを openapi/openapi.yaml として置く。
openapi: 3.1.0
info:
title: Orders API
version: 2.4.0
description: |
注文管理REST API。すべてのエンドポイントは HTTPS 必須。
contact:
name: API Platform Team
email: [email protected]
license:
name: MIT
url: https://opensource.org/licenses/MIT
servers:
- url: https://api.example.com/v2
description: Production
- url: https://staging-api.example.com/v2
description: Staging
tags:
- name: orders
description: 注文リソース
paths:
/orders:
get:
operationId: listOrders
tags: [orders]
summary: 注文一覧を取得
parameters:
- $ref: '#/components/parameters/Cursor'
- $ref: '#/components/parameters/Limit'
responses:
'200':
description: 成功
content:
application/json:
schema:
$ref: '#/components/schemas/OrderListResponse'
'429':
$ref: '#/components/responses/TooManyRequests'
security:
- bearerAuth: []
post:
operationId: createOrder
tags: [orders]
summary: 注文を作成
description: |
非べき等な作成操作。クライアントは Idempotency-Key ヘッダーで再送時の二重作成を防ぐ。
詳細は [べき等性設計](/blog/api-idempotency-冪等性-設計/) を参照。
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderRequest'
responses:
'201':
description: 作成成功
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'422':
$ref: '#/components/responses/ValidationError'
'429':
$ref: '#/components/responses/TooManyRequests'
security:
- bearerAuth: []
/orders/{orderId}:
get:
operationId: getOrder
tags: [orders]
summary: 注文を1件取得
parameters:
- $ref: '#/components/parameters/OrderId'
responses:
'200':
description: 成功
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
security:
- bearerAuth: []
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
OrderId:
name: orderId
in: path
required: true
schema:
type: string
format: uuid
Cursor:
name: cursor
in: query
required: false
schema:
type: string
Limit:
name: limit
in: query
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 20
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
description: 操作単位の一意キー(UUID v4 推奨)。同一キーなら同一レスポンスを返す。
schema:
type: string
format: uuid
example: 550e8400-e29b-41d4-a716-446655440000
schemas:
Order:
type: object
required: [id, status, createdAt]
properties:
id:
type: string
format: uuid
status:
type: string
enum: [pending, paid, shipped, cancelled]
createdAt:
type: string
format: date-time
CreateOrderRequest:
type: object
required: [productId, quantity]
properties:
productId:
type: string
format: uuid
quantity:
type: integer
minimum: 1
OrderListResponse:
type: object
required: [data, pagination]
properties:
data:
type: array
items:
$ref: '#/components/schemas/Order'
pagination:
$ref: '#/components/schemas/CursorPagination'
CursorPagination:
type: object
properties:
nextCursor:
type: string
nullable: true
hasMore:
type: boolean
ProblemDetails:
type: object
required: [type, title, status]
properties:
type:
type: string
format: uri
title:
type: string
status:
type: integer
detail:
type: string
instance:
type: string
responses:
NotFound:
description: リソースが存在しない
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetails'
ValidationError:
description: バリデーションエラー
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetails'
TooManyRequests:
description: レート制限超過
headers:
Retry-After:
schema:
type: integer
description: 再試行までの秒数
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetails'
構文バリデーション:Swagger ParserとRedocly CLI
CI パイプラインの 第1関門 は構文と参照解決だ。@apidevtools/swagger-parser は OpenAPI 3.x / Swagger 2.0 をバンドル($ref 展開)し、仕様準拠か検証する。
validate-openapi.mjs(構文チェック部分)
#!/usr/bin/env node
/**
* OpenAPI 構文・参照解決バリデーション
* Usage: node scripts/validate-openapi.mjs openapi/openapi.yaml
*/
import SwaggerParser from '@apidevtools/swagger-parser';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const specPath = resolve(process.argv[2] ?? 'openapi/openapi.yaml');
async function validateSyntax() {
console.log(`[validate] Parsing ${specPath}`);
try {
const api = await SwaggerParser.validate(specPath, {
validate: {
spec: true,
schema: true,
},
});
const version = api.openapi ?? api.swagger;
console.log(`[validate] OK — OpenAPI ${version}, title="${api.info.title}"`);
return api;
} catch (err) {
console.error('[validate] FAILED');
console.error(err.message);
if (err.details) {
for (const detail of err.details) {
console.error(` - ${detail.message} (${detail.path ?? 'root'})`);
}
}
process.exit(1);
}
}
async function assertVersion31(api) {
if (!api.openapi || !api.openapi.startsWith('3.1')) {
console.error('[validate] Expected openapi: 3.1.x');
process.exit(1);
}
}
async function main() {
const raw = readFileSync(specPath, 'utf8');
if (raw.includes('\t')) {
console.error('[validate] Tab character detected — use spaces for YAML indentation');
process.exit(1);
}
const api = await validateSyntax();
await assertVersion31(api);
}
main();
Redocly CLI は Lint + Bundle + Build-docs を一体化できる。構文チェックだけなら redocly lint も選択肢だ。
# Redocly CLI による構文 + 基本Lint
npx @redocly/cli lint openapi/openapi.yaml --format=stylish
分割ファイル(paths/orders.yaml + components/schemas/)を使う場合、CI で swagger-parser bundle または redocly bundle openapi/openapi.yaml -o dist/openapi.bundled.yaml を実行し、bundled 成果物 を openapi-diff の入力にすると $ref 解決漏れを防げる。
validate-openapi.ts(TypeScript 版構文チェック)
Node.js プロジェクトで TypeScript を標準にする場合、構文チェックも .ts で統一できる。
#!/usr/bin/env node
import SwaggerParser from '@apidevtools/swagger-parser';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { OpenAPI } from 'openapi-types';
const specPath = resolve(process.argv[2] ?? 'openapi/openapi.yaml');
async function validateSyntax(): Promise<OpenAPI.Document> {
console.log(`[validate] Parsing ${specPath}`);
const api = await SwaggerParser.validate(specPath, {
validate: { spec: true, schema: true },
});
const version = api.openapi ?? (api as { swagger?: string }).swagger;
console.log(`[validate] OK — OpenAPI ${version}, title="${api.info?.title}"`);
return api;
}
function assertNoTabs(raw: string): void {
if (raw.includes('\t')) {
console.error('[validate] Tab character detected — use spaces for YAML indentation');
process.exit(1);
}
}
function assertVersion31(api: OpenAPI.Document): void {
if (!api.openapi?.startsWith('3.1')) {
console.error(`[validate] Expected openapi: 3.1.x, got ${api.openapi}`);
process.exit(1);
}
}
async function main(): Promise<void> {
const raw = readFileSync(specPath, 'utf8');
assertNoTabs(raw);
const api = await validateSyntax();
assertVersion31(api);
}
main().catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err);
console.error('[validate] FAILED:', message);
process.exit(1);
});
package.json に "openapi:validate": "tsx scripts/validate-openapi.ts openapi/openapi.yaml" を追加すれば、型安全な validate スクリプトを CI に載せられる。
SpectralによるLint:ルール設計と拡張
Spectral は OpenAPI / AsyncAPI 向けの ポリシー as Code リンターだ。@stoplight/spectral-rulesets の spectral:oas を extends し、チーム固有ルールを .spectral.yaml に追加する。
.spectral.yaml(完全版)
extends:
- spectral:oas
- spectral:asyncapi
rules:
# --- バージョン固定 ---
oas31-only:
description: OpenAPI 3.1 のみ許可
message: "{{description}} — found {{value}}"
severity: error
given: $.openapi
then:
function: pattern
functionOptions:
match: ^3\.1\.\d+$
# --- operationId ---
operation-id-camel-case:
description: operationId は lowerCamelCase
severity: error
given: $.paths[*][*].operationId
then:
function: pattern
functionOptions:
match: ^[a-z][a-zA-Z0-9]*$
operation-id-unique:
description: operationId はドキュメント内で一意
severity: error
given: $.paths[*][*].operationId
then:
function: defined
# --- ドキュメント品質 ---
operation-description-required:
description: 各 operation に description または summary が必須
severity: warn
given: $.paths[*][*]
then:
- field: description
function: truthy
- field: summary
function: truthy
# --- セキュリティ ---
global-security-defined:
description: 認証不要を除き security が定義されていること
severity: error
given: $.paths[*][*]
then:
field: security
function: defined
no-bearer-auth-without-scheme:
description: securitySchemes.bearerAuth が components に存在すること
severity: error
given: $.paths[*][*].security[*].bearerAuth
then:
function: truthy
# --- レート制限 ---
list-endpoints-require-429:
description: GET list 系エンドポイントは 429 レスポンスを定義
severity: warn
given: $.paths[*].get.responses
then:
field: '429'
function: defined
post-requires-idempotency-key:
description: POST 操作には Idempotency-Key ヘッダーパラメータを定義
severity: error
given: $.paths[*].post
then:
function: schema
functionOptions:
schema:
type: object
required: [parameters]
properties:
parameters:
type: array
contains:
type: object
properties:
name:
const: Idempotency-Key
# --- エラーレスポンス形式 ---
error-response-problem-json:
description: 4xx/5xx は application/problem+json を推奨
severity: hint
given: $.paths[*][*].responses[?(@property >= 400 && @property < 600)].content
then:
field: application/problem+json
function: defined
# --- ページネーション ---
list-response-has-pagination:
description: 一覧 GET の 200 レスポンスに pagination を含む
severity: warn
given: $.paths[*].get.responses['200'].content['application/json'].schema
then:
function: schema
functionOptions:
schema:
type: object
required: [pagination]
# --- info ---
info-contact-required:
description: info.contact.email を設定(サポート連絡先)
severity: warn
given: $.info
then:
field: contact.email
function: defined
# --- servers ---
servers-https-only:
description: production server は https のみ
severity: error
given: $.servers[*].url
then:
function: pattern
functionOptions:
match: ^https://
# --- タグ ---
operation-tag-defined:
description: operation の tag はトップレベル tags に存在すること
severity: error
given: $.paths[*][*].tags[*]
then:
function: defined
カスタム関数(pagination 検証)— functions/schemaHasProperty.js
Spectral の schema 関数だけでは不足する場合、JavaScript カスタム関数を追加できる。
// .spectral/functions/listHasPagination.js
export default function listHasPagination(input, _opts, { path }) {
const errors = [];
if (!input || typeof input !== 'object') {
return errors;
}
const props = input.properties ?? {};
if (!props.pagination && !props.meta?.properties?.pagination) {
errors.push({
message: 'List response should include pagination field',
path: [...path, 'properties'],
});
}
return errors;
}
.spectral.yaml から参照する。
list-has-pagination-custom:
description: 一覧レスポンスに pagination フィールド
severity: warn
given: $.paths[*].get.responses['200'].content['application/json'].schema
then:
function: listHasPagination
package.json scripts
{
"name": "orders-api",
"private": true,
"type": "module",
"scripts": {
"openapi:validate": "node scripts/validate-openapi.mjs openapi/openapi.yaml",
"openapi:lint": "spectral lint openapi/openapi.yaml --ruleset .spectral.yaml --format stylish",
"openapi:bundle": "redocly bundle openapi/openapi.yaml -o dist/openapi.bundled.yaml",
"openapi:diff": "node scripts/openapi-diff.mjs",
"openapi:ci": "npm run openapi:validate && npm run openapi:lint && npm run openapi:diff"
},
"devDependencies": {
"@apidevtools/swagger-parser": "^10.1.0",
"@redocly/cli": "^1.25.0",
"@stoplight/spectral-cli": "^6.11.0",
"@stoplight/spectral-rulesets": "^1.19.0",
"openapi-diff": "^0.23.7"
}
}
CI ログで Spectral の warn を許容するかはチーム判断だ。error のみ fail にするなら --fail-severity error を付ける。
spectral lint openapi/openapi.yaml --ruleset .spectral.yaml --fail-severity error
API breaking change(破壊的変更)の分類
API breaking change 検出 の前提は、「何を breaking とみなすか」の合意だ。OpenAPITools / oasdiff はデフォルトルールを持つが、チームの semver ポリシーと揃える必要がある。
| 変更内容 | 通常の扱い |
|---|---|
| エンドポイント / HTTPメソッド削除 | Breaking — major バンプ |
| リクエストに required フィールド追加 | Breaking — 旧クライアントは送信しない |
| レスポンスからフィールド削除 | Breaking — パース失敗・undefined |
| フィールド型変更(string → integer) | Breaking |
| optional フィールド追加 | Non-breaking(後方互換) |
| enum 値追加 | Non-breaking(クライアントが exhaustive switch なら注意) |
| 404 → 410 への変更 | Breaking 扱いにすることが多い |
| 429 レスポンス追加 | Non-breaking(推奨) |
semver と OpenAPI diff の対応表を CONTRIBUTING.md に書いておく。
## API 変更ポリシー
| openapi-diff 結果 | info.version bump | リリース |
| --- | --- | --- |
| No breaking changes | PATCH(2.4.0 → 2.4.1) | 自動デプロ可 |
| Additive only | MINOR(2.4.0 → 2.5.0) | 通常 merge |
| Breaking detected | MAJOR(2.4.0 → 3.0.0) | `openapi-breaking-approved` ラベル必須 |
openapi-diffで差分検出する実装
openapi-diff は base ブランチの spec と PR ブランチの spec を比較し、breaking change 一覧を出力する。
scripts/openapi-diff.mjs
#!/usr/bin/env node
/**
* openapi-diff ラッパー — PR で breaking change を検出
* 環境変数:
* BASE_SPEC — 比較元(default: openapi/openapi.yaml on merge-base)
* HEAD_SPEC — 比較先(default: openapi/openapi.yaml)
* ALLOW_BREAKING — "true" なら exit 0(ラベル連動用)
*/
import { execSync } from 'node:child_process';
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
const headSpec = resolve(process.env.HEAD_SPEC ?? 'openapi/openapi.yaml');
const baseSpec = resolve(process.env.BASE_SPEC ?? 'openapi/.merge-base/openapi.yaml');
const allowBreaking = process.env.ALLOW_BREAKING === 'true';
const reportDir = resolve('openapi/reports');
const reportPath = resolve(reportDir, 'openapi-diff.json');
function ensureBaseSpec() {
if (existsSync(baseSpec)) {
return;
}
mkdirSync(dirname(baseSpec), { recursive: true });
try {
const mergeBase = execSync('git merge-base HEAD origin/main', { encoding: 'utf8' }).trim();
execSync(`git show ${mergeBase}:openapi/openapi.yaml > "${baseSpec}"`, { stdio: 'inherit' });
console.log(`[diff] Extracted base spec from merge-base ${mergeBase.slice(0, 7)}`);
} catch {
console.log('[diff] No git history — skipping diff (first commit)');
process.exit(0);
}
}
function runDiff() {
ensureBaseSpec();
if (!existsSync(baseSpec)) {
console.log('[diff] Base spec missing — skip');
process.exit(0);
}
console.log(`[diff] Comparing\n base: ${baseSpec}\n head: ${headSpec}`);
try {
execSync(
`npx openapi-diff "${baseSpec}" "${headSpec}" --format json --output "${reportPath}"`,
{ stdio: 'inherit' }
);
console.log('[diff] No breaking changes detected');
process.exit(0);
} catch (err) {
if (allowBreaking) {
console.warn('[diff] Breaking changes detected but ALLOW_BREAKING=true');
process.exit(0);
}
console.error('[diff] BREAKING CHANGES DETECTED — see report');
if (existsSync(reportPath)) {
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
for (const item of report.breakingChanges ?? []) {
console.error(` - [${item.code}] ${item.message}`);
if (item.path) console.error(` path: ${item.path}`);
}
}
process.exit(1);
}
}
runDiff();
openapi-diff 出力例(breaking change あり)
PR で quantity を required から外し、customerId を required に追加した場合のレポート例。
{
"breakingChanges": [
{
"code": "request-property-required-added",
"message": "Added required property 'customerId' to request body",
"path": "/paths/~1orders/post/requestBody/content/application~1json/schema",
"severity": "breaking"
},
{
"code": "response-property-removed",
"message": "Removed property 'legacyOrderNumber' from response",
"path": "/paths/~1orders~1{orderId}/get/responses/200/content/application~1json/schema",
"severity": "breaking"
}
],
"nonBreakingChanges": [
{
"code": "response-status-code-added",
"message": "Added response status code 429",
"path": "/paths/~1orders/get/responses/429",
"severity": "non-breaking"
}
]
}
GitHub Actions ではこの JSON を PR コメント に貼ると、レビュアーが merge 判断しやすい。
oasdiff による breaking change 検出と changelog 生成
oasdiff は Go 製の OpenAPI 差分ツールで、openapi-diff より高速かつ changelog / HTML レポートが充実している。CI ではバイナリをキャッシュし、breaking 検出とリリースノート生成を1ジョブで担える。
# oasdiff install (Go binary)
go install github.com/tufin/oasdiff@latest
# breaking changes のみ(exit code 1 で fail)
oasdiff breaking openapi/.merge-base/openapi.yaml openapi/openapi.yaml
# Markdown changelog(リリースノート用)
oasdiff changelog openapi/.merge-base/openapi.yaml openapi/openapi.yaml -f markdown -o CHANGELOG-api.md
# HTML レポート(PR 添付用)
oasdiff diff openapi/.merge-base/openapi.yaml openapi/openapi.yaml -f html -o openapi/reports/oasdiff.html
scripts/oasdiff-ci.ts(TypeScript ラッパー)
oasdiff の exit code をパースし、CI ログを構造化する TypeScript ラッパー例。
#!/usr/bin/env node
import { execSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
interface OasdiffBreakingItem {
id: string;
text: string;
level: number;
operation?: string;
path?: string;
}
interface OasdiffReport {
tool: 'oasdiff';
base: string;
head: string;
breakingCount: number;
items: OasdiffBreakingItem[];
}
const headSpec = resolve(process.env.HEAD_SPEC ?? 'openapi/openapi.yaml');
const baseSpec = resolve(process.env.BASE_SPEC ?? 'openapi/.merge-base/openapi.yaml');
const allowBreaking = process.env.ALLOW_BREAKING === 'true';
const reportDir = resolve('openapi/reports');
const reportPath = resolve(reportDir, 'oasdiff-breaking.json');
function runOasdiffBreaking(): string {
return execSync(`oasdiff breaking "${baseSpec}" "${headSpec}"`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
});
}
function parseBreakingOutput(stdout: string): OasdiffBreakingItem[] {
const items: OasdiffBreakingItem[] = [];
for (const line of stdout.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('No changes')) continue;
items.push({ id: `line-${items.length}`, text: trimmed, level: 1 });
}
return items;
}
function main(): void {
if (!existsSync(baseSpec)) {
console.log('[oasdiff] Base spec missing — skip');
process.exit(0);
}
mkdirSync(reportDir, { recursive: true });
console.log(`[oasdiff] Comparing\n base: ${baseSpec}\n head: ${headSpec}`);
try {
const stdout = runOasdiffBreaking();
const items = parseBreakingOutput(stdout);
const report: OasdiffReport = {
tool: 'oasdiff',
base: baseSpec,
head: headSpec,
breakingCount: items.length,
items,
};
writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log('[oasdiff] No breaking changes detected');
process.exit(0);
} catch (err: unknown) {
const execErr = err as { stdout?: string; status?: number };
const stdout = execErr.stdout ?? '';
const items = parseBreakingOutput(stdout);
const report: OasdiffReport = {
tool: 'oasdiff',
base: baseSpec,
head: headSpec,
breakingCount: items.length,
items,
};
writeFileSync(reportPath, JSON.stringify(report, null, 2));
if (allowBreaking) {
console.warn('[oasdiff] Breaking changes detected but ALLOW_BREAKING=true');
process.exit(0);
}
console.error(`[oasdiff] BREAKING CHANGES DETECTED (${items.length})`);
for (const item of items) {
console.error(` - ${item.text}`);
}
process.exit(1);
}
}
main();
openapi-diff と oasdiff を 併用 するチームも多い。openapi-diff で PR ゲート、oasdiff で changelog 自動生成——役割分担が明確だ。
| 観点 | openapi-diff | oasdiff |
|---|---|---|
| 実装 | Node.js | Go(単一バイナリ) |
| CI 速度 | 中 | 速い |
| JSON レポート | あり | breaking 出力はテキスト中心 |
| changelog | なし | Markdown / HTML 対応 |
| 併用 | ゲート + リリースノートの二刀流が定番 | 同上 |
GitHub Actionsパイプラインの完全構成
以下は OpenAPI CI バリデーション の GitHub Actions ワークフロー完全例だ。3ジョブ構成:validate → lint → diff。
.github/workflows/openapi-ci.yml
name: OpenAPI CI
on:
pull_request:
paths:
- 'openapi/**'
- '.spectral.yaml'
- 'scripts/validate-openapi.mjs'
- 'scripts/openapi-diff.mjs'
- 'package.json'
- '.github/workflows/openapi-ci.yml'
push:
branches: [main]
paths:
- 'openapi/**'
concurrency:
group: openapi-ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
validate:
name: Syntax & Reference
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Install dependencies
run: npm ci
- name: Validate OpenAPI syntax
run: npm run openapi:validate
- name: Bundle spec
run: npm run openapi:bundle
- name: Upload bundled artifact
uses: actions/upload-artifact@v4
with:
name: openapi-bundled
path: dist/openapi.bundled.yaml
retention-days: 7
lint:
name: Spectral Lint
runs-on: ubuntu-latest
needs: validate
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Install dependencies
run: npm ci
- name: Run Spectral
run: npm run openapi:lint
- name: Upload Spectral SARIF (optional)
if: always()
run: |
npx spectral lint openapi/openapi.yaml \
--ruleset .spectral.yaml \
--format sarif \
--output spectral.sarif || true
- name: Upload SARIF to GitHub Code Scanning
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: spectral.sarif
category: openapi-spectral
diff:
name: Breaking Change Detection
runs-on: ubuntu-latest
needs: lint
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Install dependencies
run: npm ci
- name: Fetch main for merge-base
run: git fetch origin main:main
- name: Check for breaking-change approval label
id: labels
uses: actions/github-script@v7
with:
script: |
const labels = context.payload.pull_request?.labels?.map(l => l.name) ?? [];
const allowed = labels.includes('openapi-breaking-approved');
core.setOutput('allow_breaking', allowed ? 'true' : 'false');
- name: Run openapi-diff
env:
ALLOW_BREAKING: ${{ steps.labels.outputs.allow_breaking }}
run: npm run openapi:diff
continue-on-error: false
- name: Install oasdiff
run: |
curl -fsSL https://raw.githubusercontent.com/tufin/oasdiff/main/install.sh | sh -s -- -b /usr/local/bin
oasdiff --version
- name: Run oasdiff breaking check
env:
ALLOW_BREAKING: ${{ steps.labels.outputs.allow_breaking }}
run: npx tsx scripts/oasdiff-ci.ts
- name: Generate oasdiff changelog
if: always()
run: |
oasdiff changelog openapi/.merge-base/openapi.yaml openapi/openapi.yaml \
-f markdown -o openapi/reports/CHANGELOG-api.md || true
- name: Upload oasdiff report
if: always()
uses: actions/upload-artifact@v4
with:
name: oasdiff-report
path: |
openapi/reports/oasdiff-breaking.json
openapi/reports/CHANGELOG-api.md
retention-days: 14
- name: Post diff report to PR
if: failure() && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = 'openapi/reports/openapi-diff.json';
let body = '## ⚠️ OpenAPI Breaking Changes Detected\n\n';
body += 'マージには `openapi-breaking-approved` ラベル、または major バージョン bump が必要です。\n\n';
if (fs.existsSync(path)) {
const report = JSON.parse(fs.readFileSync(path, 'utf8'));
body += '### Breaking\n\n';
for (const c of report.breakingChanges ?? []) {
body += `- \`${c.code}\` ${c.message}\n`;
if (c.path) body += ` - path: \`${c.path}\`\n`;
}
if (report.nonBreakingChanges?.length) {
body += '\n### Non-breaking\n\n';
for (const c of report.nonBreakingChanges) {
body += `- ${c.message}\n`;
}
}
} else {
body += '詳細は Actions ログを確認してください。';
}
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
deploy-docs:
name: Deploy API Docs
runs-on: ubuntu-latest
needs: [validate, lint, diff]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Install dependencies
run: npm ci
- name: Build Redoc static HTML
run: npx @redocly/cli build-docs openapi/openapi.yaml -o dist/api-docs/index.html
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: dist/api-docs
destination_dir: api
Branch protection では validate / lint / diff の3チェックを Required status checks に登録する。これで OpenAPI CI バリデーション が merge ゲートになる。
PRレビューと変更通知の運用フロー
CI ツールだけでは文化は変わらない。運用フロー を決めて初めて効く。
feature ブランチで openapi/openapi.yaml を 実装と同時に 更新する
PR テンプレートに「OpenAPI 変更あり / なし」チェックボックスを置く
CI が Spectral warn を出したら、同 PR 内で description 追加か issue リンクで解消する
breaking change なら info.version を major bump し、CHANGELOG-api.md を更新する
openapi-breaking-approved は Tech Lead + API Owner の承認後に付与する
merge 後、Redoc デプロイと Slack #api-changes 通知を自動化する
Slack 通知用の簡易スクリプト例。
// scripts/notify-api-changes.mjs
import { readFileSync } from 'node:fs';
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
const report = JSON.parse(readFileSync('openapi/reports/openapi-diff.json', 'utf8'));
const breaking = report.breakingChanges?.length ?? 0;
const nonBreaking = report.nonBreakingChanges?.length ?? 0;
const text = breaking > 0
? `:rotating_light: *${breaking} breaking* / ${nonBreaking} non-breaking API changes merged`
: `:white_check_mark: API spec updated (${nonBreaking} non-breaking changes)`;
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
モノレポ・マルチサービスでのOpenAPI管理
複数サービスが1リポジトリにある場合、spec ファイルをサービスごとに分離 し、matrix ジョブで並列検証する。
# .github/workflows/openapi-ci-matrix.yml(抜粋)
jobs:
openapi:
strategy:
matrix:
service: [orders-api, users-api, billing-api]
steps:
- run: npm run openapi:ci -- --spec openapi/${{ matrix.service }}/openapi.yaml
共通スキーマ(ProblemDetails、ページネーション)は openapi/common/schemas/ に置き、$ref で参照する。common だけ変更した PR は 全サービスの diff を走らせる——共有コンポーネント変更は breaking になりやすい。
| パターン | メリット | 注意点 |
|---|---|---|
| 1 repo 1 spec | シンプル | マイクロサービス分割後は肥大化 |
| サービス別 spec + common | 境界が明確 | common 変更の影響範囲を diff で全走査 |
| 1 spec 複数 server URL | クライアント1ファイル | デプロイ単位とズレやすい |
生成コードと仕様の双方向同期
OpenAPI から TypeScript クライアント / Go server stub を生成する場合、CI では 生成物の drift 検出 も加える。
# openapi-generator-cli example
npx @openapitools/openapi-generator-cli generate \
-i dist/openapi.bundled.yaml \
-g typescript-fetch \
-o generated/typescript-client
# drift check
git diff --exit-code generated/typescript-client
仕様 → コード の一方向が基本だ。実装から spec を逆生成(code-first)するチームは、テストで OpenAPI スナップショットを取り、drift で fail させる。どちらの流派でも「CI がズレを検知する」点は同じ。
APIエラーハンドリング設計で述べた application/problem+json は、Spectral ルール error-response-problem-json で仕様書に強制できる。レート制限の 429 も list-endpoints-require-429 でカバーする。POST の Idempotency-Key は べき等性設計 とセットで Spectral ルール post-requires-idempotency-key に落とし込むと、仕様と実装のズレを PR 段階で検出できる。
JSON-LDでAPIドキュメントをSEO強化(2026年版)
API リファレンスページは Technical Documentation として検索流入の対象になりうる。2026年時点では、Google は structured data for documentation において TechArticle + FAQPage の組み合わせを有効活用できる。
Redoc で生成した HTML の <head> に、OpenAPI info から JSON-LD を ビルド時注入 する。
scripts/inject-jsonld.mjs
#!/usr/bin/env node
import SwaggerParser from '@apidevtools/swagger-parser';
import { readFileSync, writeFileSync } from 'node:fs';
const specPath = process.argv[2] ?? 'openapi/openapi.yaml';
const htmlPath = process.argv[3] ?? 'dist/api-docs/index.html';
const siteUrl = process.env.SITE_URL ?? 'https://kawagame.com';
const api = await SwaggerParser.parse(specPath);
const pageUrl = `${siteUrl}/api/`;
const techArticle = {
'@context': 'https://schema.org',
'@type': 'TechArticle',
headline: `${api.info.title} API Reference`,
description: api.info.description?.trim() ?? api.info.title,
datePublished: '2026-06-21T09:00:00+09:00',
dateModified: new Date().toISOString(),
author: {
'@type': 'Organization',
name: api.info.contact?.name ?? 'API Platform Team',
url: siteUrl,
},
publisher: {
'@type': 'Organization',
name: 'Example Corp',
logo: {
'@type': 'ImageObject',
url: `${siteUrl}/favicon.svg`,
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': pageUrl,
},
proficiencyLevel: 'Expert',
dependencies: 'OpenAPI 3.1, REST, JSON',
};
const faqPage = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: '認証方法は何ですか?',
acceptedAnswer: {
'@type': 'Answer',
text: 'すべてのエンドポイントで Bearer JWT(Authorization: Bearer <token>)が必要です。トークンは /oauth/token から取得してください。',
},
},
{
'@type': 'Question',
name: 'レート制限はありますか?',
acceptedAnswer: {
'@type': 'Answer',
text: 'はい。プランごとに 100〜10000 req/min です。超過時は 429 Too Many Requests と Retry-After ヘッダーが返ります。',
},
},
{
'@type': 'Question',
name: 'ページネーションの形式は?',
acceptedAnswer: {
'@type': 'Answer',
text: 'cursor ベースのページネーションです。一覧レスポンスの pagination.nextCursor を次リクエストの cursor クエリに渡します。',
},
},
{
'@type': 'Question',
name: 'エラーレスポンスの形式は?',
acceptedAnswer: {
'@type': 'Answer',
text: 'RFC 9457 準拠の application/problem+json です。type, title, status, detail, instance フィールドを含みます。',
},
},
],
};
const breadcrumb = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: siteUrl },
{ '@type': 'ListItem', position: 2, name: 'API Documentation', item: pageUrl },
{ '@type': 'ListItem', position: 3, name: api.info.title, item: pageUrl },
],
};
const jsonLd = [techArticle, faqPage, breadcrumb];
const scriptTag = `<script type="application/ld+json">\n${JSON.stringify(jsonLd, null, 2)}\n</script>\n`;
let html = readFileSync(htmlPath, 'utf8');
if (!html.includes('application/ld+json')) {
html = html.replace('</head>', `${scriptTag}</head>`);
writeFileSync(htmlPath, html);
console.log('[jsonld] Injected TechArticle + FAQPage + BreadcrumbList');
}
生成される JSON-LD 完全例
[
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": "Orders API API Reference",
"description": "注文管理REST API。すべてのエンドポイントは HTTPS 必須。",
"datePublished": "2026-06-21T09:00:00+09:00",
"dateModified": "2026-06-21T12:00:00+09:00",
"author": {
"@type": "Organization",
"name": "API Platform Team",
"url": "https://kawagame.com"
},
"publisher": {
"@type": "Organization",
"name": "Example Corp",
"logo": {
"@type": "ImageObject",
"url": "https://kawagame.com/favicon.svg"
}
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://kawagame.com/api/"
},
"proficiencyLevel": "Expert",
"dependencies": "OpenAPI 3.1, REST, JSON"
},
{
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "認証方法は何ですか?",
"acceptedAnswer": {
"@type": "Answer",
"text": "すべてのエンドポイントで Bearer JWT(Authorization: Bearer <token>)が必要です。"
}
}
]
}
]
2026年の SEO 実務では、Google Rich Results Test で TechArticle と FAQPage の両方がエラーなくパースされることを確認する。API ドキュメント固有の long-tail(「OpenAPI CI バリデーション」「API breaking change 検出」)は、開発者向けブログ記事 側でカバーし、リファレンスページは FAQ JSON-LD で自然言語 Q&A を足す——役割分担が効く。
JSON-LD 構造化データ入門の Article スキーマと併用する場合、同一ページに @type を複数並べるより、ページ種別ごとにスキーマを分ける 方が Google のガイドラインに沿いやすい。
Contract Testingとの接続:静的CIを動的検証で補完する
OpenAPI CI は 静的検証 だ。実行時の契約遵守は Contract Testing で補完する。静的パイプラインが「仕様書の品質」を守り、contract test が「実装が仕様どおり動くか」を守る——二層構造が API 品質の最終防衛線になる。
| レイヤ | ツール | 検証タイミング | 検出できる問題 |
|---|---|---|---|
| 静的構文 | Swagger Parser | PR | YAML 破損、$ref 未解決 |
| 静的Lint | Spectral | PR | operationId 欠落、security 未定義 |
| 静的差分 | openapi-diff / oasdiff | PR | breaking change |
| 動的契約 | Schemathesis / Dredd | staging deploy 後 | 実装と spec の乖離 |
| 型安全クライアント | openapi-typescript + 手動検証 | PR / staging | レスポンス型の drift |
Schemathesis(property-based contract test)
# Schemathesis — property-based testing against running API
pip install schemathesis
schemathesis run openapi/openapi.yaml --base-url https://staging-api.example.com/v2 \
--checks all --hypothesis-max-examples=50
Schemathesis は OpenAPI からリクエストを自動生成し、ステータスコード・レスポンススキーマ・content-type を検証する。staging デプロイ workflow の最後に1本足すだけで、「spec は正しいが実装が違う」問題を検出できる。
TypeScript による contract test(openapi-fetch + 型検証)
生成クライアントと手動 contract test を TypeScript で書く例。OpenAPI から型を生成し、staging に対して実リクエストを送ってレスポンス形状を検証する。
// tests/contract/orders.contract.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
import createClient from 'openapi-fetch';
import type { paths } from '../../generated/openapi-types';
const BASE_URL = process.env.STAGING_API_URL ?? 'https://staging-api.example.com/v2';
const TEST_TOKEN = process.env.STAGING_API_TOKEN;
describe('Orders API contract', () => {
const client = createClient<paths>({ baseUrl: BASE_URL });
beforeAll(() => {
if (!TEST_TOKEN) {
throw new Error('STAGING_API_TOKEN is required for contract tests');
}
});
it('GET /orders returns 200 with pagination schema', async () => {
const { data, error, response } = await client.GET('/orders', {
params: { query: { limit: 5 } },
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
});
expect(response.status).toBe(200);
expect(error).toBeUndefined();
expect(data).toBeDefined();
expect(data).toHaveProperty('data');
expect(data).toHaveProperty('pagination');
expect(Array.isArray(data!.data)).toBe(true);
if (data!.pagination) {
expect(typeof data!.pagination.hasMore).toBe('boolean');
}
});
it('POST /orders requires Idempotency-Key and returns 201', async () => {
const idempotencyKey = crypto.randomUUID();
const { data, error, response } = await client.POST('/orders', {
params: { header: { 'Idempotency-Key': idempotencyKey } },
body: {
productId: '550e8400-e29b-41d4-a716-446655440001',
quantity: 1,
},
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
});
expect(response.status).toBe(201);
expect(error).toBeUndefined();
expect(data).toHaveProperty('id');
expect(data).toHaveProperty('status');
});
it('POST /orders with same Idempotency-Key returns identical response', async () => {
const idempotencyKey = crypto.randomUUID();
const body = {
productId: '550e8400-e29b-41d4-a716-446655440002',
quantity: 1,
};
const headers = {
Authorization: `Bearer ${TEST_TOKEN}`,
};
const first = await client.POST('/orders', {
params: { header: { 'Idempotency-Key': idempotencyKey } },
body,
headers,
});
const second = await client.POST('/orders', {
params: { header: { 'Idempotency-Key': idempotencyKey } },
body,
headers,
});
expect(first.response.status).toBe(201);
expect(second.response.status).toBe(201);
expect(second.data?.id).toBe(first.data?.id);
});
it('GET /orders/{orderId} returns problem+json on 404', async () => {
const { response } = await client.GET('/orders/{orderId}', {
params: {
path: { orderId: '00000000-0000-0000-0000-000000000000' },
},
headers: { Authorization: `Bearer ${TEST_TOKEN}` },
});
expect(response.status).toBe(404);
const contentType = response.headers.get('content-type') ?? '';
expect(contentType).toContain('application/problem+json');
});
});
型生成は CI の validate ジョブ後に実行する。
npx openapi-typescript dist/openapi.bundled.yaml -o generated/openapi-types.ts
contract test 用 GitHub Actions ジョブ
staging デプロイ後に contract test を走らせる workflow 例。
# .github/workflows/contract-test.yml
name: API Contract Test
on:
workflow_run:
workflows: [Deploy Staging]
types: [completed]
jobs:
contract:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci
- name: Generate OpenAPI types
run: |
npm run openapi:bundle
npx openapi-typescript dist/openapi.bundled.yaml -o generated/openapi-types.ts
- name: Run TypeScript contract tests
env:
STAGING_API_URL: ${{ secrets.STAGING_API_URL }}
STAGING_API_TOKEN: ${{ secrets.STAGING_API_TOKEN }}
run: npx vitest run tests/contract/
- name: Run Schemathesis (optional)
run: |
pip install schemathesis
schemathesis run dist/openapi.bundled.yaml \
--base-url "${{ secrets.STAGING_API_URL }}" \
--header "Authorization: Bearer ${{ secrets.STAGING_API_TOKEN }}" \
--checks all \
--hypothesis-max-examples=30
APIエラーハンドリング設計で定義した application/problem+json の contract test は、404・422 の content-type 検証に落とし込める。べき等性設計の Idempotency-Key 再送テストも、上記の same Idempotency-Key ケースで自動化できる。
Spectral が「仕様書に 429 が書いてあるか」を見るのに対し、contract test は「実際に 429 が返るか」を見る。レート制限の実装漏れは動的テストでしか検出できない。両方を CI に入れることで、ドキュメント品質とランタイム品質を同時に担保できる。
トラブルシューティング:よくあるCI失敗
| エラー | 対処 |
|---|---|
| ResolverError: Cannot resolve $ref | bundle 前に分割ファイルのパスを確認。$ref: ../common/schemas/Problem.yaml の相対パス |
| Spectral oas3-schema failed on nullable | OpenAPI 3.1 では type: [string, 'null'] を使用。3.0 の nullable:true 混在に注意 |
| openapi-diff false positive on reorder | プロパティ順序変更は breaking ではない。ツールバージョンを固定(package-lock.json) |
| merge-base spec が空 | fetch-depth: 0 と git fetch origin main を workflow に追加 |
| SARIF upload permission denied | fork PR では Code Scanning upload を skip。if: github.event.pull_request.head.repo.full_name == github.repository |
外部コントリビュータの fork からの PR では、ALLOW_BREAKING やデプロイ系 secrets を渡さない。openapi-diff は fork でも動くが、Slack webhook や Pages deploy は pull_request_target ではなく pull_request + 条件分岐で制限する。
本番導入前チェックリスト
openapi/openapi.yaml をリポジトリに追加し、Swagger Parser でローカル validate が通る
.spectral.yaml を extends spectral:oas から始め、チームルールを3〜5個ずつ追加する
package.json scripts(validate / lint / diff)を npm run openapi:ci で一括実行できるようにする
.github/workflows/openapi-ci.yml を追加し、Branch protection で Required Check 化する
breaking change ポリシーと openapi-breaking-approved ラベル運用を CONTRIBUTING.md に明文化する
Redoc build-docs + JSON-LD 注入で /api/ ドキュメントを公開する
staging で Schemathesis を1本走らせ、静的CIとの差分を確認する
関連記事
この記事は API・バックエンド テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| APIキャッシュ設計ガイド | APIキャッシュ設計ガイド|ETag・Cache-Control・304・CDNキャッシュキーの実務 |
| API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 | API設計におけるページネーションの使い分け:オフセット型とカーソル型の比較 |
| APIレート制限と429対応 | APIレート制限と429対応|X-RateLimit・Retry-Afterを使ったクライアント実装 |
| WebHook設計のベストプラクティス | WebHook設計のベストプラクティス|署名・再試行・べき等性で信頼性を担保する |
| ZodでAPI境界のランタイム検証を設計する | ZodでAPI境界のランタイム検証を設計する|z.infer・Expressミドルウェア・zod-to-openapi(2026) |
| GraphQL N+1 問題の解決 | GraphQL N+1 問題の解決|DataLoader バッチング実装と Apollo Server 完全ガイド |
| Circuit Breaker + Retry パターン実装ガイド | Circuit Breaker + Retry パターン実装ガイド|Node.js opossum/cockatiel・指数バックオフ・Jitter |
| Sagaパターン完全ガイド | Sagaパターン完全ガイド|Orchestration vs Choreography と補償トランザクション(Node.js) |
| Event SourcingとCQRSの基礎 | Event SourcingとCQRSの基礎|Node.js + PostgreSQL 実装ガイド |
| Redisキャッシュスタンピード防止ガイド | Redisキャッシュスタンピード防止ガイド|Singleflight・Mutex・SET NX・確率的早期失効 |
| PostgreSQL接続プール枯渇の対処法 | PostgreSQL接続プール枯渇の対処法|too many connections・PgBouncer・Node.js pg |
| PostgreSQLトランザクション分離レベルの選び方 | PostgreSQLトランザクション分離レベルの選び方|READ COMMITTED・REPEATABLE READ・SERIALIZABLE・ファントムリード・Node.js pg |
まとめ
OpenAPI 3.1 仕様の CI バリデーションは、構文 → Spectral Lint → openapi-diff / oasdiff の多段パイプラインが核になる。
- Swagger Parser / Redocly で
$ref解決と 3.1 準拠を保証する - Spectral で operationId・security・429・problem+json・Idempotency-Key をポリシー as Code 化する
- openapi-diff / oasdiff で API breaking change 検出を PR ゲートにし、semver とラベルで例外管理する
- GitHub Actions で validate / lint / diff / docs deploy / contract test を自動化する
- TypeScript contract test と Schemathesis で静的 CI を動的検証で補完する
- TechArticle + FAQPage JSON-LD で API ドキュメントの SEO(2026)を強化する
「実装は merge したが spec を忘れた」「クライアントが壊れたのに気づかなかった」「再送で二重作成が起きた」——OpenAPI CI バリデーション と contract testing は、その類の事故を Pull Request および staging デプロイの段階 で止める投資だ。APIエラーハンドリングやべき等性設計と組み合わせ、仕様書にポリシーを書き、CI で強制し、staging で実装を検証する——この3点セットが API 品質の基盤になる。最初は Spectral ルールを最小限にし、インシデントからルールを1つずつ足していく。完璧な spec より、毎 PR 必ず通る CI の方がチームの API 品質を上げる。