ドキュメンテーション — 使われるAPIを作る
ドキュメントなきAPIの悲劇
Livlyの開発から半年。外部の不動産会社がLivlyのAPIを使って自社サービスと連携したいと言ってきた。
「ドキュメントを送ってください」——外部パートナー
ナツミは焦った。ドキュメントが存在しなかった。コードを読めばわかるはずだったが、外部の開発者にコードを見せるわけにもいかない。
「今から作ります……」
ドキュメントは後から書くものではなく、開発と一体で作るものだとナツミは気づいた。
ドキュメント戦略
Loading diagram...
OpenAPI 3.0を中心に据え、RSpecテストから自動生成する。
rswagのセットアップ
# Gemfile
gem 'rswag-api'
gem 'rswag-ui'
gem 'rswag-specs', group: :testrails 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
endrswagスペックの書き方
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 }
}
}
}
}
}
}
endRedocでドキュメントページを作る
# 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"'
endWARNING
破壊的変更は慎重に。フィールドの削除・型の変更・エラーコードの変更は破壊的変更。メジャーバージョンを上げ、旧バージョンに 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を長期的に成長させるエコシステムの構築を学ぶ。