mybook

ドキュメンテーション — 使われるAPIを作る

ドキュメントなきAPIの悲劇

Livlyの開発から半年。外部の不動産会社がLivlyのAPIを使って自社サービスと連携したいと言ってきた。

「ドキュメントを送ってください」——外部パートナー

ナツミは焦った。ドキュメントが存在しなかった。コードを読めばわかるはずだったが、外部の開発者にコードを見せるわけにもいかない。

「今から作ります……」

ドキュメントは後から書くものではなく、開発と一体で作るものだとナツミは気づいた。


ドキュメント戦略

Loading diagram...

OpenAPI 3.0を中心に据え、RSpecテストから自動生成する。


rswagのセットアップ

# Gemfile
gem 'rswag-api'
gem 'rswag-ui'
gem 'rswag-specs', group: :test
rails generate rswag:install
# config/routes.rb
Rails.application.routes.draw do
  mount Rswag::Ui::Engine => '/api-docs'
  mount Rswag::Api::Engine => '/api-docs'
 
  namespace :api do
    namespace :v1 do
      resources :properties
    end
  end
end

rswagスペックの書き方

rswagではRSpecのDSLでAPIの仕様を記述し、OpenAPI Specを自動生成する。

# spec/swagger/api/v1/properties_spec.rb
require 'swagger_helper'
 
RSpec.describe 'Properties API', type: :request, swagger_doc: 'v1/swagger.yaml' do
  path '/api/v1/properties' do
    get 'Retrieves a list of properties' do
      tags 'Properties'
      description '公開されている物件の一覧を取得します'
      operationId 'getProperties'
      produces 'application/json'
      security [bearerAuth: []]
 
      parameter name: :page, in: :query, type: :integer,
                description: 'ページ番号(デフォルト:1)',
                required: false
      parameter name: :per_page, in: :query, type: :integer,
                description: '1ページあたりの件数(デフォルト:20、最大:100)',
                required: false
      parameter name: :prefecture, in: :query, type: :string,
                description: '都道府県でフィルタリング(例:東京都)',
                required: false
      parameter name: :min_price, in: :query, type: :integer,
                description: '最低賃料(円)',
                required: false
      parameter name: :sort, in: :query, type: :string,
                description: 'ソート順(newest / price_asc / price_desc)',
                required: false,
                enum: %w[newest price_asc price_desc]
 
      response '200', '物件一覧の取得成功' do
        schema type: :object,
               properties: {
                 data: {
                   type: :array,
                   items: { '$ref' => '#/components/schemas/PropertySummary' }
                 },
                 meta: { '$ref' => '#/components/schemas/PaginationMeta' }
               }
 
        let(:Authorization) { "Bearer #{auth_token}" }
        before { create_list(:property, 3, :published) }
 
        run_test! do |response|
          data = JSON.parse(response.body)
          expect(data['data'].length).to eq(3)
          expect(data['meta']['total_count']).to eq(3)
        end
      end
 
      response '401', '未認証' do
        schema '$ref' => '#/components/schemas/UnauthorizedError'
 
        let(:Authorization) { 'invalid_token' }
        run_test!
      end
    end
 
    post 'Creates a property' do
      tags 'Properties'
      description '新しい物件を作成します'
      operationId 'createProperty'
      consumes 'application/json'
      produces 'application/json'
      security [bearerAuth: []]
 
      parameter name: :property, in: :body, schema: {
        '$ref' => '#/components/schemas/CreatePropertyRequest'
      }
 
      response '201', '物件作成成功' do
        schema type: :object,
               properties: {
                 data: { '$ref' => '#/components/schemas/Property' }
               }
 
        let(:Authorization) { "Bearer #{auth_token}" }
        let(:property) do
          {
            property: {
              name: '渋谷マンション',
              price: 150_000,
              area: 35.5,
              prefecture: '東京都',
              city: '渋谷区'
            }
          }
        end
 
        run_test!
      end
 
      response '422', 'バリデーションエラー' do
        schema '$ref' => '#/components/schemas/ValidationError'
 
        let(:Authorization) { "Bearer #{auth_token}" }
        let(:property) { { property: { name: '', price: -1 } } }
        run_test!
      end
    end
  end
 
  path '/api/v1/properties/{id}' do
    parameter name: :id, in: :path, type: :string, description: '物件ID', required: true
 
    get 'Retrieves a property' do
      tags 'Properties'
      description '指定したIDの物件詳細を取得します'
      operationId 'getProperty'
      produces 'application/json'
      security [bearerAuth: []]
 
      response '200', '物件詳細の取得成功' do
        schema type: :object,
               properties: {
                 data: { '$ref' => '#/components/schemas/Property' }
               }
 
        let(:Authorization) { "Bearer #{auth_token}" }
        let(:id) { create(:property, :published).id }
        run_test!
      end
 
      response '404', 'リソース未発見' do
        schema '$ref' => '#/components/schemas/NotFoundError'
 
        let(:Authorization) { "Bearer #{auth_token}" }
        let(:id) { 99999 }
        run_test!
      end
    end
  end
end

共通スキーマの定義

# spec/swagger/support/swagger_helper.rb
require 'rails_helper'
 
RSpec.configure do |config|
  config.swagger_root = Rails.root.join('swagger').to_s
 
  config.swagger_docs = {
    'v1/swagger.yaml' => {
      openapi: '3.0.1',
      info: {
        title: 'Livly API',
        description: '**Livly** の物件情報APIです。REST原則に基づいて設計されており、モバイルアプリおよびパートナーサービスからご利用いただけます。',
        version: 'v1',
        contact: {
          name: 'Livly API Team',
          email: 'api-support@livly.jp'
        }
      },
      servers: [
        { url: 'https://api.livly.jp', description: '本番環境' },
        { url: 'https://api.staging.livly.jp', description: 'ステージング環境' }
      ],
      components: {
        securitySchemes: {
          bearerAuth: {
            type: :http,
            scheme: :bearer,
            bearerFormat: :JWT
          }
        },
        schemas: {
          Property: {
            type: :object,
            properties: {
              id: { type: :string, example: '123' },
              type: { type: :string, example: 'properties' },
              attributes: {
                type: :object,
                properties: {
                  name: { type: :string, example: '渋谷マンション101' },
                  description: { type: :string, nullable: true },
                  price: { type: :integer, example: 150000, description: '月額賃料(円)' },
                  area: { type: :number, format: :float, nullable: true, example: 35.5 },
                  location: { type: :string, example: '東京都渋谷区' },
                  publishedAt: { type: :string, format: 'date', nullable: true },
                  createdAt: { type: :string, format: 'date-time' }
                }
              }
            }
          },
          CreatePropertyRequest: {
            type: :object,
            required: [:name, :price, :prefecture, :city],
            properties: {
              name: { type: :string, example: '渋谷マンション101', maxLength: 100 },
              description: { type: :string, nullable: true, maxLength: 2000 },
              price: { type: :integer, minimum: 0, example: 150000 },
              area: { type: :number, nullable: true, minimum: 0 },
              prefecture: { type: :string, example: '東京都' },
              city: { type: :string, example: '渋谷区' }
            }
          },
          PaginationMeta: {
            type: :object,
            properties: {
              currentPage: { type: :integer },
              totalPages: { type: :integer },
              totalCount: { type: :integer },
              perPage: { type: :integer }
            }
          },
          ValidationError: {
            type: :object,
            properties: {
              type: { type: :string, example: 'https://api.livly.jp/errors/validation_failed' },
              title: { type: :string, example: 'Validation Failed' },
              status: { type: :integer, example: 422 },
              errors: {
                type: :array,
                items: {
                  type: :object,
                  properties: {
                    field: { type: :string },
                    message: { type: :string },
                    code: { type: :string }
                  }
                }
              }
            }
          },
          UnauthorizedError: {
            type: :object,
            properties: {
              type: { type: :string, example: 'https://api.livly.jp/errors/authentication_failed' },
              title: { type: :string, example: 'Unauthorized' },
              status: { type: :integer, example: 401 }
            }
          },
          NotFoundError: {
            type: :object,
            properties: {
              type: { type: :string, example: 'https://api.livly.jp/errors/not_found' },
              title: { type: :string, example: 'Not Found' },
              status: { type: :integer, example: 404 }
            }
          }
        }
      }
    }
  }
end

Redocでドキュメントページを作る

# swagger.yamlを生成
bundle exec rails rswag
<!-- public/api-docs/index.html -->
<!DOCTYPE html>
<html>
  <head>
    <title>Livly API Documentation</title>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
    <style>
      body { margin: 0; padding: 0; }
    </style>
  </head>
  <body>
    <redoc spec-url='/api-docs/v1/swagger.yaml'
           expand-responses="200,201"
           hide-download-button="false"
           theme='{"colors": {"primary": {"main": "#0066cc"}}}'>
    </redoc>
    <script src="https://cdn.jsdelivr.net/npm/redoc/bundles/redoc.standalone.js"></script>
  </body>
</html>

APIバージョニング

ドキュメントと合わせてバージョニング戦略も重要。

# URLベースのバージョニング(最も分かりやすい)
namespace :api do
  namespace :v1 do
    resources :properties
  end
  namespace :v2 do
    resources :properties  # v2の新設計
  end
end
# config/routes.rb
constraints subdomain: 'api' do
  namespace :v1 do
    resources :properties
  end
end
# api.livly.jp/v1/properties

非推奨エンドポイントのアナウンス

# app/controllers/api/v1/properties_controller.rb
before_action :add_deprecation_header
 
def add_deprecation_header
  response.headers['Deprecation'] = 'true'
  response.headers['Sunset'] = 'Sat, 31 Dec 2025 23:59:59 GMT'
  response.headers['Link'] = '<https://api.livly.jp/v2/properties>; rel="successor-version"'
end

WARNING

破壊的変更は慎重に。フィールドの削除・型の変更・エラーコードの変更は破壊的変更。メジャーバージョンを上げ、旧バージョンに Deprecation ヘッダーを付け、最低3ヶ月の猶予期間を設ける。


Changelogの管理

<!-- CHANGELOG.md -->
# API Changelog
 
## v1.3.0 (2024-03-01)
### Added
- `GET /api/v1/properties?sort=price_asc` ソート機能追加
- `GET /api/v1/properties/:id/reviews` 口コミ一覧エンドポイント追加
 
### Fixed
- `PATCH /api/v1/properties/:id` でareaフィールドがnullに上書きされるバグを修正
 
## v1.2.0 (2024-02-01)
### Added
- ページネーションのmeta情報に `total_count` を追加
 
### Changed
- `published_at` のフォーマットを `YYYY-MM-DD` に統一(旧: ISO8601)
 
### Deprecated
- `GET /api/v1/properties?filter[prefecture]=...` は v1.4 で削除予定
`GET /api/v1/properties?prefecture=...` を使用してください

開発者ポータルの構築

大規模な外部公開APIには開発者ポータルが必要。

Loading diagram...
# app/models/api_credential.rb
class ApiCredential < ApplicationRecord
  belongs_to :organization
 
  before_create :generate_api_key
 
  def self.authenticate(key)
    find_by(api_key: key, active: true)
  end
 
  private
 
  def generate_api_key
    self.api_key = "lvly_#{SecureRandom.hex(24)}"
  end
end

まとめ

  • rswag でRSpecテストからOpenAPI Specを自動生成する(テストとドキュメントの一体化)
  • 共通スキーマ($ref)を使って一貫性のあるドキュメントを作る
  • Redoc でデザイン良好なHTMLドキュメントページを提供する
  • URLベースのバージョニング(/api/v1/)が最もシンプルで管理しやすい
  • 破壊的変更には Deprecation ヘッダーと猶予期間を設ける
  • CHANGELOG.md でバージョンごとの変更を明記する

次章(最終章)では、API戦略とDeveloper Experience——APIを長期的に成長させるエコシステムの構築を学ぶ。