mybook

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

FDE の現場では REST が 7 割を占める。ただし「全部 REST で統一」は思考停止。リアルタイム要件や高スループット要件を見極めて、適切なパターンを選定するのが FDE の腕の見せ所。

ソウタは Nextera の要件を振り返った。

  • 取引データの CRUD → REST(標準的な操作)
  • AI モデルの推論結果配信 → gRPC(低レイテンシ・型安全)
  • リスクアラートの即時通知 → WebSocket(リアルタイム双方向)
  • モバイルアプリ向けデータ取得 → GraphQL(必要なフィールドだけ取得)
  • 監査ログの配信 → Pub/Sub(複数のサブスクライバに配信)
  • バッチ処理の非同期実行 → Message Queue(確実な処理保証)

「6 つ全部使うんですか?」ソウタは驚いた。

「大企業のインテグレーションでは珍しくない。大事なのは、なぜそのパターンを選んだかを顧客に説明できること。FDE は技術選定の理由を言語化する仕事でもある」


API 抽象化レイヤーの設計

カイは次に、インテグレーションの寿命を左右する設計判断について語った。

「Nextera は今は AWS を使っているけど、来年 GCP に移るかもしれない。AI プロバイダも OpenAI から Claude に切り替えるかもしれない。プロバイダが変わるたびにアプリケーション全体を書き直すのか?」

「Adapter パターンですね」ソウタはすぐに理解した。

「そう。抽象化レイヤーを一枚挟む。変更点を一箇所に閉じ込める」

Loading diagram...
# 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
end

WARNING

抽象化レイヤーは「最初から完璧に作る」のではなく、2 つ目のプロバイダが必要になったタイミングで導入するのがベスト。YAGNI を忘れない。ただし FDE 案件では顧客のプロバイダ切り替えが頻繁なため、初期設計に含める価値が高い。

OAuth2 による企業認証

Nextera Financial のセキュリティチームとの初回ミーティングで、ソウタは厳しい要件を突きつけられた。

「弊社では全システムに SSO を必須としています。SAML 2.0 による認証連携と、SCIM によるユーザープロビジョニングを実装してください。API アクセスには OAuth 2.0 の Authorization Code Flow を使い、アクセストークンの有効期限は 15 分、リフレッシュトークンは 24 時間です」

ソウタはカイに相談した。

「エンタープライズでは PKCE 拡張、SAML 連携、SCIM プロビジョニングがセットで来る」

Loading diagram...
# 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
end

INFO

エンタープライズ顧客では、トークンの有効期限が極端に短い(15 分以下)ことが多い。アクセストークンを DB に保存し、リクエストのたびに有効期限をチェックする仕組みを必ず用意する。

レート制限とリトライ戦略

「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
end

WARNING

Exponential Backoff のジッターは「Full Jitter」を使う。Equal Jitter や Decorrelated Jitter もあるが、AWS の研究によると Full Jitter が最も完了時間が短く、リクエスト数も少ない。

API バージョニング戦略

「カイさん、Nextera 側の API が v1 と v2 で全然レスポンス構造が違います。v1 は半年後に廃止予定で...」

「API バージョニングは FDE が最も頭を悩ませる問題の一つだ。提供する側と消費する側、両方の視点が必要になる」

戦略方式メリットデメリット
URL パス/api/v1/resources明示的で直感的URL が変わる
ヘッダーAccept: application/vnd.arclight.v2+jsonURL が安定発見しにくい
クエリ?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
end

INFO

Sunset ヘッダー(RFC 8594)を使えば、API の廃止予定日をプログラム的に通知できる。FDE は顧客に廃止スケジュールを伝えるだけでなく、レスポンスヘッダーにも埋め込んでおくのがベストプラクティス。

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
end

WARNING

Webhook の署名検証では必ず secure_compare(定数時間比較)を使う。通常の == 演算子はタイミング攻撃に脆弱。1 文字ずつ比較するため、一致する文字数に応じてレスポンス時間が変わり、署名を推測される恐れがある。

サーキットブレーカーパターン

インテグレーションを本番稼働させて数日後、Nextera の API が断続的に 503 を返し始めた。ソウタのアプリケーションはリトライを繰り返し、スレッドプールを食い潰していた。

「障害が起きている相手にリクエストを送り続けるのは、倒れている人を蹴り続けるようなものだ」カイは言った。「サーキットブレーカーを入れよう」

Loading diagram...
# 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
end

INFO

サーキットブレーカーが Open 状態のときはキャッシュデータを返す、デフォルト値を返す、あるいはグレースフルにエラーを返すなど、フォールバック戦略を必ず定義しておく。「エラーを返すだけ」は最悪のフォールバック。

AWS アーキテクチャ: 顧客向けインテグレーション基盤

すべてのパターンを実装したソウタに、カイは最後のピースを見せた。

「顧客向けインテグレーションの基盤は、スケーラビリティと障害分離が命だ」

Loading diagram...

「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
end

WARNING

顧客ごとに SQS キューを分離することを検討する。1 つのキューに全顧客の Webhook を投げると、特定顧客の大量エラーがキュー全体を詰まらせる。顧客数が少ないうちはキュー 1 本でもよいが、10 社を超えたら分離のタイミング。

学びの整理 ── ソウタの振り返り

Nextera Financial とのインテグレーションが本番稼働して 1 ヶ月。ソウタは自分のノートにこう書いた。

FDE の API インテグレーションで学んだこと:

  1. パターンの選択は要件から ── 「全部 REST」は思考停止。通信の性質(同期/非同期、頻度、レイテンシ要件)から逆算する
  2. 抽象化レイヤーは保険 ── プロバイダは必ず変わる。変更コストを一箇所に閉じ込める
  3. 認証は顧客の文化 ── エンタープライズの SSO 要件は交渉の余地がない。事前に確認する
  4. レート制限は送信側も守る ── 相手のレート制限を超えないよう、自分の側でもトークンバケットで制御する
  5. 冪等性は Webhook の命 ── ネットワークは不安定。重複配信は起きる前提で設計する
  6. サーキットブレーカーは優しさ ── 障害中の相手にリクエストを送り続けない。フォールバックを用意する
  7. バージョニングは約束 ── 廃止スケジュールを Sunset ヘッダーで伝え、移行期間を十分に取る

カイはソウタの振り返りを読んで、一つだけ付け加えた。

「技術的に完璧なインテグレーションでも、顧客が使いこなせなければ意味がない。API ドキュメント、サンプルコード、移行ガイド ── 顧客のエンジニアが自走できるようにするところまでが FDE の仕事だ」

ソウタはうなずいた。Nextera のエンジニアたちが自信を持って API を叩けるように。そのための設計を、自分はこれからも続けていく。