OpenAPI 3.1仕様のCIバリデーション完全ガイド|Spectral Lint・Breaking Change検出・GitHub Actions

OpenAPI API CI/CD Spectral GitHub Actions DevOps
結論
  • 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 前に検出できる。

  1. 構文エラー — YAMLインデント、$ref の循環参照、components.schemas の未定義参照
  2. 設計ポリシー違反operationId 欠落、説明文なし、security 未定義、ページネーション未記載
  3. 破壊的変更(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混在防止)
pathsHTTPメソッド、operationId一意性、responses網羅
components.schemas$ref 解決、required と nullable の整合
components.securitySchemesbearer / apiKey / oauth2 の定義統一
webhooks3.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
バンドル成果物もCIで生成する

分割ファイル(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-rulesetsspectral: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-diffbase ブランチの specPR ブランチの 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-diffoasdiff
実装Node.jsGo(単一バイナリ)
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 ツールだけでは文化は変わらない。運用フロー を決めて初めて効く。

1

feature ブランチで openapi/openapi.yaml を 実装と同時に 更新する

2

PR テンプレートに「OpenAPI 変更あり / なし」チェックボックスを置く

3

CI が Spectral warn を出したら、同 PR 内で description 追加か issue リンクで解消する

4

breaking change なら info.version を major bump し、CHANGELOG-api.md を更新する

5

openapi-breaking-approved は Tech Lead + API Owner の承認後に付与する

6

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 TestTechArticleFAQPage の両方がエラーなくパースされることを確認する。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 ParserPRYAML 破損、$ref 未解決
静的LintSpectralPRoperationId 欠落、security 未定義
静的差分openapi-diff / oasdiffPRbreaking change
動的契約Schemathesis / Dreddstaging 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 ケースで自動化できる。

静的CIと動的contract testの役割分担

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: 0git 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 のシークレット

外部コントリビュータの fork からの PR では、ALLOW_BREAKING やデプロイ系 secrets を渡さない。openapi-diff は fork でも動くが、Slack webhook や Pages deploy は pull_request_target ではなく pull_request + 条件分岐で制限する。

本番導入前チェックリスト

1

openapi/openapi.yaml をリポジトリに追加し、Swagger Parser でローカル validate が通る

2

.spectral.yaml を extends spectral:oas から始め、チームルールを3〜5個ずつ追加する

3

package.json scripts(validate / lint / diff)を npm run openapi:ci で一括実行できるようにする

4

.github/workflows/openapi-ci.yml を追加し、Branch protection で Required Check 化する

5

breaking change ポリシーと openapi-breaking-approved ラベル運用を CONTRIBUTING.md に明文化する

6

Redoc build-docs + JSON-LD 注入で /api/ ドキュメントを公開する

7

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 品質を上げる。