4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

大規模OpenAPI仕様をファイル分割で管理する実践ガイド

Last updated at Posted at 2025-12-09

この記事は、 WiseVine Advent Calendar 2025の記事です。

はじめに

OpenAPI仕様が肥大化してくると、1つのファイルで管理するのは困難になります。
本記事では、openapi.yamlを人間が管理しやすいよう役割ごとに分割した方法を書いていきます。

背景

今回参加させて頂いているプロジェクトでOpenAPI-First開発(FE・BE・バリデーションを単一ソースから自動生成する)としていました。
API仕様をopenapi.yaml 1ファイルに書き切っていたため、FE・BE共に影響を受ける重要ファイルになっています。

┌─────────────────────────────────────────────────────────────────┐
│                    OpenAPI 仕様 (openapi.yaml)                  │
│                   Single Source of Truth                        │
└─────────────────────────────────────────────────────────────────┘
                              │
         ┌────────────────────┼────────────────────┐
         ▼                    ▼                    ▼
┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐
│   TypeScript    │  │     Python      │  │      Zod        │
│   型定義        │  │   Pydantic      │  │    スキーマ     │
│  (Frontend)     │  │   (Backend)     │  │  (Validation)   │
└─────────────────┘  └─────────────────┘  └─────────────────┘

元々のディレクトリ構造

backend側にopenapi.yamlファイルを置き、これに全てを書き切っていました。

backend
┗─ openapi.yaml 

課題:巨大なopenapi.yamlの問題点

単一ファイルで管理していったことで、以下の問題が発生しました:

  • 可読性の低下:     数千行のYAMLは見通しが悪い。PJ開始2ヶ月で1,500行に
  • コンフリクトの多発:  複数人で編集するとGitマージが困難
  • 再利用性の欠如:    共通スキーマのコピペが発生
  • レビューの困難さ:   変更箇所の特定が難しい
  • エディタの遅延:    読み込み箇所が多く、動きが鈍くなる
  • 生成AIもギブアップ:  こんなたくさん読めへんでとギブアップをもらうこともしばしば

解決策:ドメイン別ファイル分割

単一ファイルに書き切ることが問題なのだからまずはシンプルに分割するのが良いと思いました。

ディレクトリ構造

分け方はAPI firstということでurlのサブドメインごとに分割。urlはいったん第一階層までの分割として、必要性が出てきたら第二階層の分割も検討ということで、今回は第一階層まで。

backend/openapi/
├── openapi.yaml              # エントリーポイント($ref で各ファイルを参照)
├── openapi.bundled.yaml      # 生成物:単一ファイルに結合したもの
├── paths/                    # パス定義(エンドポイント)
│   ├── common.yaml           # /health, /version, /me
│   ├── auth.yaml             # /auth/login, /auth/logout
│   ├── users.yaml            # /users, /users/{id}
│   ├── orders.yaml           # /orders, /orders/{id}
│   ├── products.yaml         # /products, /products/{id}
│   └── categories.yaml       # /categories
└── components/               # 再利用可能なコンポーネント
    ├── schemas/              # データスキーマ定義
    │   ├── common.yaml       # UUID, ErrorResponse, PaginationResponse
    │   ├── auth.yaml         # LoginRequest, LoginResponse
    │   ├── users.yaml        # UserResponse, UserCreateRequest
    │   ├── orders.yaml       # OrderResponse, OrderCreateRequest
    │   ├── products.yaml     # ProductResponse, ProductCreateRequest
    │   └── categories.yaml   # CategoryResponse
    └── parameters/           # パラメータ定義
        ├── common.yaml       # PageParam, PageSizeParam
        ├── users.yaml        # UserIdPathParam
        ├── orders.yaml       # OrderIdPathParam
        └── products.yaml     # ProductIdPathParam

ディレクトリ配置の基準

調べた中でこれが結構スタンダードな感じだったのでそのまま踏襲。
もっと細かくexampleなど分割できそうだが、いったん事足りそうだったのここまで。

分類 ディレクトリ 役割
エントリーポイント openapi.yaml サーバー情報、セキュリティスキーム、全パスの参照
パス定義 paths/ HTTPメソッドとエンドポイントの組み合わせ
スキーマ定義 components/schemas/ リクエスト/レスポンスのデータ構造
パラメータ定義 components/parameters/ 再利用可能なパスパラメータ、クエリパラメータ

実装例

1. エントリーポイント(openapi.yaml)

openapi: 3.0.3
info:
  title: Sample API
  version: 1.0.0
  description: サンプルECサイトAPI

servers:
  - url: http://localhost:8000/api
    description: 開発環境
  - url: https://api.example.com
    description: 本番環境

components:
  securitySchemes:
    SessionAuth:
      type: apiKey
      in: cookie
      name: sessionid
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  # 各ファイルのスキーマを参照
  schemas:
    # common
    UUID:
      $ref: './components/schemas/common.yaml#/UUID'
    ErrorResponse:
      $ref: './components/schemas/common.yaml#/ErrorResponse'
    PaginationResponse:
      $ref: './components/schemas/common.yaml#/PaginationResponse'

    # users
    UserResponse:
      $ref: './components/schemas/users.yaml#/UserResponse'
    UserCreateRequest:
      $ref: './components/schemas/users.yaml#/UserCreateRequest'

    # orders
    OrderResponse:
      $ref: './components/schemas/orders.yaml#/OrderResponse'
    OrderCreateRequest:
      $ref: './components/schemas/orders.yaml#/OrderCreateRequest'
    OrderDetailResponse:
      $ref: './components/schemas/orders.yaml#/OrderDetailResponse'

  # パラメータ
  parameters:
    PageParam:
      $ref: './components/parameters/common.yaml#/PageParam'
    UserIdPathParam:
      $ref: './components/parameters/users.yaml#/UserIdPathParam'
    OrderIdPathParam:
      $ref: './components/parameters/orders.yaml#/OrderIdPathParam'

# パス定義
paths:
  /health:
    $ref: './paths/common.yaml#/health'

  /users:
    $ref: './paths/users.yaml#/users'

  /users/{user_id}:
    $ref: './paths/users.yaml#/users_{user_id}'

  /orders:
    $ref: './paths/orders.yaml#/orders'

  /orders/{order_id}:
    $ref: './paths/orders.yaml#/orders_{order_id}'

2. パス定義(paths/orders.yaml)

orders:
  get:
    summary: 注文一覧取得
    description: 注文の一覧を取得します
    operationId: listOrders
    tags:
      - Orders
    security:
      - SessionAuth: []
      - BearerAuth: []
    parameters:
      - $ref: '../components/parameters/common.yaml#/PageParam'
      - $ref: '../components/parameters/common.yaml#/PageSizeParam'
    responses:
      '200':
        description: 注文一覧取得成功
        content:
          application/json:
            schema:
              $ref: '../components/schemas/orders.yaml#/PagedOrderListResponse'
      '401':
        description: 認証エラー
        content:
          application/json:
            schema:
              $ref: '../components/schemas/common.yaml#/ErrorResponse'

  post:
    summary: 注文作成
    operationId: createOrder
    tags:
      - Orders
    security:
      - SessionAuth: []
      - BearerAuth: []
    requestBody:
      required: true
      content:
        application/json:
          schema:
            $ref: '../components/schemas/orders.yaml#/OrderCreateRequest'
    responses:
      '201':
        description: 注文作成成功
        content:
          application/json:
            schema:
              $ref: '../components/schemas/orders.yaml#/OrderCreateResponse'

orders_{order_id}:
  get:
    summary: 注文詳細取得
    operationId: getOrder
    tags:
      - Orders
    parameters:
      - $ref: '../components/parameters/orders.yaml#/OrderIdPathParam'
    responses:
      '200':
        description: 注文詳細取得成功
        content:
          application/json:
            schema:
              $ref: '../components/schemas/orders.yaml#/OrderDetailResponse'
      '404':
        description: 注文が見つかりません
        content:
          application/json:
            schema:
              $ref: '../components/schemas/common.yaml#/ErrorResponse'

  delete:
    summary: 注文キャンセル
    operationId: deleteOrder
    tags:
      - Orders
    parameters:
      - $ref: '../components/parameters/orders.yaml#/OrderIdPathParam'
    responses:
      '200':
        description: 注文キャンセル成功
        content:
          application/json:
            schema:
              $ref: '../components/schemas/common.yaml#/SuccessResponse'
      '404':
        description: 注文が見つかりません
        content:
          application/json:
            schema:
              $ref: '../components/schemas/common.yaml#/ErrorResponse'

3. スキーマ定義(components/schemas/common.yaml)

UUID:
  type: string
  format: uuid

ErrorResponse:
  type: object
  required:
    - error_code
  description: 統一エラーレスポンス
  properties:
    error_code:
      type: string
      description: エラーコード
      example: VALIDATION_ERROR
    message:
      type: string
      nullable: true
      description: エラーメッセージ
    details:
      type: array
      items:
        $ref: '#/DetailItem'
      default: []

SuccessResponse:
  type: object
  required:
    - success
  properties:
    success:
      type: boolean
      description: 操作が成功したかどうか
  example:
    success: true

PaginationResponse:
  type: object
  required:
    - count
  properties:
    count:
      type: integer
      description: 総件数

4. スキーマ定義(components/schemas/orders.yaml)

OrderStatus:
  type: string
  enum:
    - pending
    - processing
    - shipped
    - delivered
    - cancelled
  description: 注文ステータス

OrderItemResponse:
  type: object
  required:
    - id
    - product_id
    - product_name
    - quantity
    - unit_price
    - subtotal
  properties:
    id:
      $ref: './common.yaml#/UUID'
    product_id:
      $ref: './common.yaml#/UUID'
    product_name:
      type: string
    quantity:
      type: integer
      minimum: 1
    unit_price:
      type: integer
      format: int64
    subtotal:
      type: integer
      format: int64

OrderResponse:
  type: object
  required:
    - id
    - status
    - total_amount
    - created_at
  properties:
    id:
      $ref: './common.yaml#/UUID'
    status:
      $ref: '#/OrderStatus'
    total_amount:
      type: integer
      format: int64
    created_at:
      type: string
      format: date-time

OrderDetailResponse:
  allOf:
    - $ref: '#/OrderResponse'
    - type: object
      required:
        - items
        - shipping_address
      properties:
        items:
          type: array
          items:
            $ref: '#/OrderItemResponse'
        shipping_address:
          type: string

PagedOrderListResponse:
  allOf:
    - $ref: './common.yaml#/PaginationResponse'
    - type: object
      required:
        - items
      properties:
        items:
          type: array
          items:
            $ref: '#/OrderResponse'

OrderCreateRequest:
  type: object
  required:
    - items
    - shipping_address
  properties:
    items:
      type: array
      items:
        type: object
        required:
          - product_id
          - quantity
        properties:
          product_id:
            $ref: './common.yaml#/UUID'
          quantity:
            type: integer
            minimum: 1
    shipping_address:
      type: string
      maxLength: 500
  example:
    items:
      - product_id: "11111111-1111-1111-1111-111111111111"
        quantity: 2
      - product_id: "22222222-2222-2222-2222-222222222222"
        quantity: 1
    shipping_address: "東京都渋谷区..."

OrderCreateResponse:
  type: object
  required:
    - id
  properties:
    id:
      $ref: './common.yaml#/UUID'
      description: 作成された注文ID

5. パラメータ定義(components/parameters/common.yaml)

PageParam:
  name: page
  in: query
  description: ページ番号
  required: false
  schema:
    type: integer
    default: 1
    minimum: 1

PageSizeParam:
  name: page_size
  in: query
  description: 1ページあたりの件数
  required: false
  schema:
    type: integer
    default: 100
    minimum: 0
    maximum: 1000

6. パラメータ定義(components/parameters/orders.yaml)

OrderIdPathParam:
  name: order_id
  in: path
  description: 注文ID
  required: true
  schema:
    type: string
    format: uuid

openapi.bundled.yamlとは

最初は分割したopenapi.yaml で型生成すればいいと思っていたのだが、BE, FE, zodそれぞれでファイル間の参照の仕方の文法($ref)の書き方が違うことが判明...

各ツールが期待する $ref 形式の違い

環境 ツール 期待する形式 必要な設定/オプション
BE(python) datamodel-codegen ディレクトリ入力 or バンドル済み --input にディレクトリ指定、または事前バンドル
FE(React) Orval parserOptions で設定可能 parserOptions.resolve でカスタマイズ
バリデーション(zod) swagger-codegen 事前バンドル推奨 swagger-cli bundle

なので結局、型生成時はopenapi.yaml(分割ファイル)→openapi.bundled.yaml(統合ファイル)と生成しなおしてここから型生成を行うことにした

ツールチェーン

Redocly CLIの活用

バンドル(分割ファイル → 統合ファイル)

npx @redocly/cli bundle openapi/openapi.yaml -o openapi/openapi.bundled.yaml

バンドルが必要な理由

  • 多くのコード生成ツールは $ref の外部参照を解決できない
  • CI/CDでの検証は単一ファイルの方が扱いやすい
  • API ドキュメント公開用

Lint(検証)

npx @redocly/cli lint openapi/openapi.bundled.yaml

コード自動生成

バックエンド(Python Pydantic)

datamodel-codegen \
  --input openapi/openapi.bundled.yaml \
  --input-file-type openapi \
  --output generated/schemas.py \
  --output-model-type pydantic_v2.BaseModel \
  --use-subclass-enum \
  --use-standard-collections \
  --target-python-version 3.12

フロントエンド(TypeScript)

npx orval --config orval.config.ts

Taskfile によるワークフロー統合

# Taskfile.yml
tasks:
  api:generate:
    desc: OpenAPI仕様からクライアントコード生成
    cmds:
      - task: api:clean
      - task: api:bundle
      - task: backend:codegen
      - task: frontend:orval
    sources:
      - ./backend/openapi/**/*.yaml

  api:bundle:
    desc: OpenAPI仕様をバンドル
    cmds:
      - npx @redocly/cli bundle backend/openapi/openapi.yaml -o backend/openapi/openapi.bundled.yaml
      - npx @redocly/cli lint backend/openapi/openapi.bundled.yaml
    sources:
      - ./backend/openapi/**/*.yaml
    generates:
      - ./backend/openapi/openapi.bundled.yaml

  api:lint:
    desc: OpenAPI仕様のlint実行
    cmds:
      - npx @redocly/cli lint backend/openapi/openapi.bundled.yaml

$ref の書き方

同一ファイル内の参照

$ref: '#/OrderStatus'

別ファイルの参照

# 相対パスで指定
$ref: '../components/schemas/common.yaml#/UUID'
$ref: './components/schemas/orders.yaml#/OrderDetailResponse'

パス名でのアンダースコア

パスに {parameter} が含まれる場合、YAMLのキー名に / は使えないため、アンダースコアで代用します:

# paths/orders.yaml
orders:              # /orders
  get: ...
  post: ...

orders_{order_id}:   # /orders/{order_id}
  get: ...
  delete: ...
# openapi.yaml での参照
paths:
  /orders:
    $ref: './paths/orders.yaml#/orders'

  /orders/{order_id}:
    $ref: './paths/orders.yaml#/orders_{order_id}'

ベストプラクティス

1. ドメイン単位で分割する

機能ドメイン(認証、ユーザー、注文、商品など)ごとにファイルを分けることで:

  • 担当者が明確になる
  • コンフリクトが減少する
  • 変更の影響範囲が限定される

2. 共通コンポーネントを積極的に抽出

以下は必ず common.yaml に切り出す:

  • UUID
  • ErrorResponse
  • SuccessResponse
  • PaginationResponse
  • PageParam, PageSizeParam

まとめ

項目 単一ファイル ファイル分割
可読性 低(数千行) 高(ドメイン別)
コンフリクト 多い 少ない
再利用性 コピペ $ref で参照
ツール対応 直接利用可 バンドル必要

ファイル分割 + Redocly CLI + コード生成を組み合わせることで、大規模なAPI開発でもOpenAPI仕様を効率的に管理できます。

参考リンク

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?