API インテグレーション — 顧客システムとの接続点を設計する
「ソウタさん、来週から Nextera Financial のインテグレーション案件に入ってもらいます」——カイからの Slack メッセージを読んだとき、ソウタの手が一瞬止まった。Nextera Financial は国内有数の金融グループだ。Arclight AI のプラットフォームを彼らの既存システムに繋ぐ——FDE としての初めての顧客向けインテグレーション案件だ。
「先方のアーキテクチャは複雑です。基幹系は SOAP ベース、フロントは React SPA、リアルタイム通知には独自の WebSocket サーバーがある。全部を一つの API で繋ごうとすると破綻します」
カイはホワイトボードの前に立った。「API インテグレーションは FDE の仕事の核です。顧客のシステムと自社プロダクトの接続点を設計する。ここを間違えると、後から何を直しても手遅れになる」
6つの API パターンを使い分ける
カイはまず、FDE が現場で扱う 6 つの通信パターンを整理した。
「顧客ごとに最適なパターンは違う。Nextera のような大企業は、1 つのプロジェクトで複数のパターンを組み合わせることが当たり前です」
| パターン | 通信方式 | 主な用途 | レイテンシ | 実装複雑度 |
|---|---|---|---|---|
| REST | 同期・リクエスト/レスポンス | CRUD 操作全般 | 中 | 低 |
| gRPC | 同期/ストリーミング | サービス間通信 | 低 | 中 |
| WebSocket | 双方向リアルタイム | ダッシュボード・通知 | 極低 | 高 |
| GraphQL | 同期・クエリ駆動 | モバイル・柔軟な取得 | 中 | 中 |
| Pub/Sub | 非同期・イベント駆動 | イベント配信・疎結合 | 不定 | 中 |
| Message Queue | 非同期・キュー | 信頼性の高い非同期処理 | 不定 | 中 |
INFO
ソウタは Nextera の要件を振り返った。
- 取引データの CRUD → REST(標準的な操作)
- AI モデルの推論結果配信 → gRPC(低レイテンシ・型安全)
- リスクアラートの即時通知 → WebSocket(リアルタイム双方向)
- モバイルアプリ向けデータ取得 → GraphQL(必要なフィールドだけ取得)
- 監査ログの配信 → Pub/Sub(複数のサブスクライバに配信)
- バッチ処理の非同期実行 → Message Queue(確実な処理保証)
「6 つ全部使うんですか?」ソウタは驚いた。
「大企業のインテグレーションでは珍しくない。大事なのは、なぜそのパターンを選んだかを顧客に説明できること。FDE は技術選定の理由を言語化する仕事でもある」
API 抽象化レイヤーの設計
カイは次に、インテグレーションの寿命を左右する設計判断について語った。
「Nextera は今は AWS を使っているけど、来年 GCP に移るかもしれない。AI プロバイダも OpenAI から Claude に切り替えるかもしれない。プロバイダが変わるたびにアプリケーション全体を書き直すのか?」
「Adapter パターンですね」ソウタはすぐに理解した。
「そう。抽象化レイヤーを一枚挟む。変更点を一箇所に閉じ込める」
# app/services/ai_provider/base.rb
module AiProvider
class Base
def initialize(api_key:, endpoint:)
@api_key = api_key
@endpoint = endpoint
end
def predict(input) = raise NotImplementedError
def health_check = raise NotImplementedError
def batch_predict(inputs) = inputs.map { |input| predict(input) }
private
def with_retry(max_attempts: 3)
attempts = 0
begin
attempts += 1
yield
rescue StandardError => e
raise if attempts >= max_attempts
sleep((2**attempts) + rand(0.0..1.0))
retry
end
end
end
end# app/services/ai_provider/arclight.rb
module AiProvider
class Arclight < Base
def predict(input)
with_retry do
response = connection.post("/v2/predict") do |req|
req.headers["Authorization"] = "Bearer #{@api_key}"
req.body = { input: input, model: "arclight-v3" }.to_json
end
raise AiProvider::Error, "API error: #{response.status}" unless response.success?
body = response.body
AiProvider::Result.new(prediction: body["prediction"],
confidence: body["confidence"], model_version: body["model_version"])
end
end
def health_check
connection.get("/health").status == 200
end
private
def connection
@connection ||= Faraday.new(url: @endpoint) do |f|
f.request :json
f.response :json
f.adapter Faraday.default_adapter
f.options.timeout = 30
end
end
end
end
# app/services/ai_provider/registry.rb
module AiProvider
class Registry
PROVIDERS = {
arclight: AiProvider::Arclight,
openai: AiProvider::OpenAi,
anthropic: AiProvider::Anthropic
}.freeze
def self.resolve(provider_name)
klass = PROVIDERS.fetch(provider_name.to_sym) do
raise ArgumentError, "未対応プロバイダ: #{provider_name}"
end
config = Rails.application.credentials.ai_providers[provider_name.to_sym]
klass.new(api_key: config[:api_key], endpoint: config[:endpoint])
end
end
endWARNING
OAuth2 による企業認証
Nextera Financial のセキュリティチームとの初回ミーティングで、ソウタは厳しい要件を突きつけられた。
「弊社では全システムに SSO を必須としています。SAML 2.0 による認証連携と、SCIM によるユーザープロビジョニングを実装してください。API アクセスには OAuth 2.0 の Authorization Code Flow を使い、アクセストークンの有効期限は 15 分、リフレッシュトークンは 24 時間です」
ソウタはカイに相談した。
「エンタープライズでは PKCE 拡張、SAML 連携、SCIM プロビジョニングがセットで来る」
# config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider :saml,
issuer: "https://arclight.ai",
idp_sso_service_url: ENV.fetch("NEXTERA_IDP_SSO_URL"),
idp_cert_fingerprint: ENV.fetch("NEXTERA_IDP_CERT_FINGERPRINT"),
name_identifier_format: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
end# app/services/oauth_token_manager.rb
class OauthTokenManager
TOKEN_EXPIRY_BUFFER = 60.seconds
def initialize(integration:)
@integration = integration
end
def valid_access_token
refresh_token! if token_expired?
@integration.access_token
end
private
def token_expired?
@integration.token_expires_at <= Time.current + TOKEN_EXPIRY_BUFFER
end
def refresh_token!
response = Faraday.post(@integration.token_endpoint) do |req|
req.body = {
grant_type: "refresh_token",
refresh_token: @integration.refresh_token,
client_id: @integration.client_id,
client_secret: @integration.client_secret
}
end
body = JSON.parse(response.body)
@integration.update!(
access_token: body["access_token"],
refresh_token: body["refresh_token"],
token_expires_at: Time.current + body["expires_in"].seconds
)
rescue Faraday::Error => e
raise OauthTokenManager::RefreshError, "トークン更新に失敗: #{e.message}"
end
endINFO
レート制限とリトライ戦略
「Nextera の既存 API にはレート制限があって、1 分あたり 600 リクエストまでです」
ソウタはインテグレーション仕様書を読みながら言った。
「レート制限を超えたらどうなるか知ってる?」カイが聞いた。
「429 が返ってきて、Retry-After ヘッダーに待機時間が入る...はずです」
「そう。ここで大事なのは 2 つ。トークンバケットで送信側を制御すること。そして超えてしまった場合の Exponential Backoff with Jitter。ジッターがないと、制限解除の瞬間に全クライアントが一斉にリクエストして再び制限にかかる ── Thundering Herd 問題だ」
# app/services/rate_limiter.rb
# Redis + Lua スクリプトによるトークンバケット実装
class RateLimiter
def initialize(key:, max_tokens:, refill_rate:)
@key = "rate_limit:#{key}"
@max_tokens = max_tokens
@refill_rate = refill_rate # tokens per second
end
def acquire!(tokens: 1)
allowed = Redis.current.eval(TOKEN_BUCKET_LUA, keys: [@key],
argv: [@max_tokens, @refill_rate, tokens, Time.current.to_f])
raise RateLimitExceeded, "レート制限超過: #{@key}" unless allowed == 1
end
# Lua スクリプト: 経過時間でトークンを補充し、要求分を消費
TOKEN_BUCKET_LUA = <<~LUA
local key, max, rate = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2])
local requested, now = tonumber(ARGV[3]), tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or max
local last = tonumber(bucket[2]) or now
tokens = math.min(max, tokens + (now - last) * rate)
if tokens >= requested then
redis.call('HMSET', key, 'tokens', tokens - requested, 'last_refill', now)
redis.call('EXPIRE', key, 300)
return 1
end
return 0
LUA
end# app/services/resilient_api_client.rb
class ResilientApiClient
MAX_RETRIES = 5
BASE_DELAY = 0.5
MAX_DELAY = 30.0
def initialize(base_url:, rate_limiter:)
@base_url = base_url
@rate_limiter = rate_limiter
end
def request(method:, path:, body: nil)
retries = 0
begin
@rate_limiter.acquire!
response = connection.public_send(method, path) { |req| req.body = body.to_json if body }
handle_response(response)
rescue RateLimitExceeded, Faraday::TooManyRequestsError, Faraday::ServerError => e
retries += 1
raise if retries > MAX_RETRIES
sleep(calculate_backoff(retries))
retry
end
end
private
# Exponential Backoff with Full Jitter
# delay = random(0, min(cap, base * 2^attempt))
def calculate_backoff(attempt)
max_delay_for_attempt = [MAX_DELAY, BASE_DELAY * (2**attempt)].min
rand(0.0..max_delay_for_attempt)
end
def handle_response(response)
case response.status
when 200..299 then response.body
when 429 then raise Faraday::TooManyRequestsError, "Rate limited"
when 500..599 then raise Faraday::ServerError, "Server error: #{response.status}"
else raise ApiError, "Unexpected status: #{response.status}"
end
end
endWARNING
API バージョニング戦略
「カイさん、Nextera 側の API が v1 と v2 で全然レスポンス構造が違います。v1 は半年後に廃止予定で...」
「API バージョニングは FDE が最も頭を悩ませる問題の一つだ。提供する側と消費する側、両方の視点が必要になる」
| 戦略 | 方式 | メリット | デメリット |
|---|---|---|---|
| URL パス | /api/v1/resources | 明示的で直感的 | URL が変わる |
| ヘッダー | Accept: application/vnd.arclight.v2+json | URL が安定 | 発見しにくい |
| クエリ | ?version=2 | 実装が簡単 | キャッシュに影響 |
# app/controllers/api/base_controller.rb
module Api
class BaseController < ApplicationController
before_action :determine_api_version
SUPPORTED_VERSIONS = [1, 2, 3].freeze
SUNSET_DATES = { 1 => "Sat, 31 Dec 2026 23:59:59 GMT" }.freeze
private
def determine_api_version
@api_version = extract_version
render json: { error: "Unsupported API version" }, status: :not_acceptable unless SUPPORTED_VERSIONS.include?(@api_version)
end
def extract_version
# URL パス (/api/v2/...) → Accept ヘッダー → デフォルト v2
if (m = request.path.match(%r{/api/v(\d+)/})) then m[1].to_i
elsif (m = request.headers["Accept"]&.match(/vnd\.arclight\.v(\d+)/)) then m[1].to_i
else 2
end
end
# RFC 8594 Sunset ヘッダーで廃止予定を通知
def set_sunset_header(version)
if (date = SUNSET_DATES[version])
response.headers["Sunset"] = date
response.headers["Deprecation"] = "true"
response.headers["Link"] = '</api/v2/>; rel="successor-version"'
end
end
end
end# app/controllers/api/v1/predictions_controller.rb
module Api
module V1
class PredictionsController < Api::BaseController
before_action -> { set_sunset_header(1) }
def create
result = prediction_service.predict(prediction_params)
# v1: フラットなレスポンス(レガシー)
render json: { result: result.prediction, score: result.confidence }
end
end
end
end
# app/controllers/api/v2/predictions_controller.rb
module Api
module V2
class PredictionsController < Api::BaseController
def create
result = prediction_service.predict(prediction_params)
# v2: JSON:API 風の構造化レスポンス
render json: {
data: {
type: "prediction", id: result.id,
attributes: {
prediction: result.prediction,
confidence: result.confidence,
model_version: result.model_version
}
},
meta: { api_version: 2, request_id: request.request_id }
}
end
end
end
endINFO
Webhook の信頼性設計
Nextera Financial から追加要件が来た。AI モデルの推論が完了したタイミングで、Nextera のシステムに Webhook で通知してほしい、というものだ。
「Webhook は便利だけど、信頼性の設計を怠ると地獄を見る」カイは言った。「ネットワーク障害でリクエストが失われる。重複配信で二重処理が走る。受信側がダウンしていてイベントが消える。全部、起きる」
「対策は 3 つ。冪等性キー、指数バックオフ付きリトライ、そしてデッドレターキューだ」
# app/models/webhook_event.rb
class WebhookEvent < ApplicationRecord
belongs_to :integration
enum :status, { pending: "pending", delivering: "delivering",
delivered: "delivered", failed: "failed", dead_letter: "dead_letter" }
MAX_ATTEMPTS = 8
scope :retryable, -> {
where(status: [:pending, :failed]).where("attempts < ?", MAX_ATTEMPTS)
.where("next_retry_at <= ?", Time.current)
}
def schedule_retry!
delay = (2**attempts) + rand(0.0..1.0) # 指数バックオフ + ジッター
update!(status: :failed, next_retry_at: Time.current + delay.seconds)
end
def mark_dead_letter!
update!(status: :dead_letter)
WebhookDeadLetterNotifier.notify(self)
end
end# app/services/webhook_delivery_service.rb
class WebhookDeliveryService
def deliver(event)
event.update!(status: :delivering, attempts: event.attempts + 1)
response = send_webhook(event)
if response.success?
event.update!(status: :delivered, delivered_at: Time.current)
else
handle_failure(event, "HTTP #{response.status}")
end
rescue Faraday::Error => e
handle_failure(event, e.message)
end
private
def send_webhook(event)
timestamp = Time.current.to_i.to_s
payload = "#{timestamp}.#{event.payload.to_json}"
signature = OpenSSL::HMAC.hexdigest("SHA256", event.integration.webhook_secret, payload)
Faraday.post(event.integration.webhook_url) do |req|
req.headers["Content-Type"] = "application/json"
req.headers["X-Arclight-Signature"] = signature
req.headers["X-Arclight-Timestamp"] = timestamp
req.headers["X-Idempotency-Key"] = event.idempotency_key
req.body = event.payload.to_json
req.options.timeout = 10
end
end
def handle_failure(event, error_message)
event.update!(last_error: error_message)
event.attempts >= WebhookEvent::MAX_ATTEMPTS ? event.mark_dead_letter! : event.schedule_retry!
end
end# app/controllers/api/v2/webhooks_controller.rb(受信側)
module Api
module V2
class WebhooksController < Api::BaseController
skip_before_action :verify_authenticity_token
def receive
return render json: { error: "Invalid signature" }, status: :unauthorized unless valid_signature?
idempotency_key = request.headers["X-Idempotency-Key"]
return render json: { status: "already_processed" } if ProcessedWebhook.exists?(idempotency_key:)
ActiveRecord::Base.transaction do
ProcessedWebhook.create!(idempotency_key:)
WebhookProcessorJob.perform_later(params[:event_type], params[:data].to_json)
end
render json: { status: "accepted" }, status: :accepted
end
private
def valid_signature?
payload = "#{request.headers['X-Arclight-Timestamp']}.#{request.raw_post}"
expected = OpenSSL::HMAC.hexdigest("SHA256", webhook_secret, payload)
ActiveSupport::SecurityUtils.secure_compare(request.headers["X-Arclight-Signature"], expected)
end
end
end
endWARNING
secure_compare(定数時間比較)を使う。通常の == 演算子はタイミング攻撃に脆弱。1 文字ずつ比較するため、一致する文字数に応じてレスポンス時間が変わり、署名を推測される恐れがある。サーキットブレーカーパターン
インテグレーションを本番稼働させて数日後、Nextera の API が断続的に 503 を返し始めた。ソウタのアプリケーションはリトライを繰り返し、スレッドプールを食い潰していた。
「障害が起きている相手にリクエストを送り続けるのは、倒れている人を蹴り続けるようなものだ」カイは言った。「サーキットブレーカーを入れよう」
# app/services/circuit_breaker.rb
class CircuitBreaker
def initialize(service_name:, failure_threshold: 5, reset_timeout: 30, half_open_max: 3)
@service_name = service_name
@failure_threshold = failure_threshold
@reset_timeout = reset_timeout
@half_open_max = half_open_max
@state = :closed
@failure_count = 0
@last_failure_at = nil
@half_open_successes = 0
@mutex = Mutex.new
end
def call(&block)
check_state!
begin
result = yield
record_success
result
rescue StandardError => e
record_failure
raise
end
end
private
def check_state!
@mutex.synchronize do
if @state == :open
if Time.current - @last_failure_at >= @reset_timeout
transition_to(:half_open)
else
raise CircuitOpenError, "Circuit open for #{@service_name}"
end
end
end
end
def record_success
@mutex.synchronize do
if @state == :half_open
@half_open_successes += 1
transition_to(:closed) if @half_open_successes >= @half_open_max
end
@failure_count = 0
end
end
def record_failure
@mutex.synchronize do
@failure_count += 1
@last_failure_at = Time.current
transition_to(:open) if @failure_count >= @failure_threshold || @state == :half_open
end
end
def transition_to(new_state)
Rails.logger.info("[CircuitBreaker] #{@service_name}: #{@state} -> #{new_state}")
@state = new_state
@failure_count = 0 if new_state == :closed
@half_open_successes = 0 if new_state == :half_open
end
end# 使用例: サーキットブレーカー + レート制限 + フォールバック
class NexteraApiClient
def initialize
@circuit = CircuitBreaker.new(service_name: "nextera_api", failure_threshold: 5, reset_timeout: 30)
@rate_limiter = RateLimiter.new(key: "nextera", max_tokens: 600, refill_rate: 10)
end
def fetch_transactions(account_id:, from:, to:)
@circuit.call do
@rate_limiter.acquire!
connection.get("/api/v2/accounts/#{account_id}/transactions",
from: from.iso8601, to: to.iso8601)
end
rescue CircuitOpenError
CachedTransactions.latest(account_id:) # フォールバック: キャッシュデータ
end
endINFO
AWS アーキテクチャ: 顧客向けインテグレーション基盤
すべてのパターンを実装したソウタに、カイは最後のピースを見せた。
「顧客向けインテグレーションの基盤は、スケーラビリティと障害分離が命だ」
「API Gateway でスロットリングと認証の第一段階を行い、Lambda で OAuth トークンの検証をする。重い処理は SQS に投げて Worker が非同期で処理する。Webhook の配信も Worker 経由だ」
# app/jobs/webhook_delivery_job.rb
class WebhookDeliveryJob < ApplicationJob
queue_as :webhooks
retry_on StandardError, wait: :polynomially_longer, attempts: 8
def perform(webhook_event_id)
event = WebhookEvent.find(webhook_event_id)
WebhookDeliveryService.new.deliver(event) unless event.delivered?
end
endWARNING
学びの整理 ── ソウタの振り返り
Nextera Financial とのインテグレーションが本番稼働して 1 ヶ月。ソウタは自分のノートにこう書いた。
FDE の API インテグレーションで学んだこと:
- パターンの選択は要件から ── 「全部 REST」は思考停止。通信の性質(同期/非同期、頻度、レイテンシ要件)から逆算する
- 抽象化レイヤーは保険 ── プロバイダは必ず変わる。変更コストを一箇所に閉じ込める
- 認証は顧客の文化 ── エンタープライズの SSO 要件は交渉の余地がない。事前に確認する
- レート制限は送信側も守る ── 相手のレート制限を超えないよう、自分の側でもトークンバケットで制御する
- 冪等性は Webhook の命 ── ネットワークは不安定。重複配信は起きる前提で設計する
- サーキットブレーカーは優しさ ── 障害中の相手にリクエストを送り続けない。フォールバックを用意する
- バージョニングは約束 ── 廃止スケジュールを Sunset ヘッダーで伝え、移行期間を十分に取る
カイはソウタの振り返りを読んで、一つだけ付け加えた。
「技術的に完璧なインテグレーションでも、顧客が使いこなせなければ意味がない。API ドキュメント、サンプルコード、移行ガイド ── 顧客のエンジニアが自走できるようにするところまでが FDE の仕事だ」
ソウタはうなずいた。Nextera のエンジニアたちが自信を持って API を叩けるように。そのための設計を、自分はこれからも続けていく。