3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Salesforce】クライアントログイン情報フローでRailsアプリケーションとAPI連携してみた

Posted at

はじめに

Railsアプリケーションで作成した求人データを、SalesforceにAPI連携する処理を実装してみました。

馴染みがあるのは、OAuth 2.0 ユーザー名パスワードフローでした。
しかし、公式が非推奨としていたので、クライアントログイン情報フロー(Client Credentials Flow) を使用しました。

特別なシナリオの_OAuth_2_0_ユーザー名パスワードフロー.png

完成形

Railsアプリケーション(求人管理システム)
Cursor_と_Salesforce_Api_Practice.png

Salesforce_Api_Practice.png

Salesforce_Api_Practice.png

Salesforce(Developer Edition)
デザイナー募集___求人___Salesforce_🔊.png

クライアントログイン情報フローとは?

サーバー間通信専用の認証方式で、アプリケーション自体の認証情報だけでSalesforce APIにアクセスできます。
つまり、 アプリ同士が「合言葉」(Client ID + Secret)で認証し合って、ユーザー情報なしでデータをやり取りできる仕組みです。

従来の方式との違い

従来(ユーザー名パスワードフロー)

Rails → Salesforce
「ユーザー名: user@example.com」
「パスワード: password123」
「セキュリティトークン: ABC123」

❌ 問題点: 実際のユーザー情報が必要、セキュリティリスク高

クライアントログイン情報フロー

Rails → Salesforce  
「Client ID: アプリのID」
「Client Secret: アプリの秘密鍵」

✅ メリット: ユーザー情報不要、アプリ専用の認証

開発環境

Salesforce

  • Developer Edition

Railsアプリケーション

  • Ruby: 3.3.0
  • Rails: 8.0.2
  • PostgreSQL
  • TailwindCSS

実装した主要機能

今回作成したRailsアプリケーション(求人管理システム)では、以下の機能を実装しました。
CursorのProプランに登録したので、フル活用して作成しました。

1. 基本的なCRUD機能

  • 求人の作成・表示・編集・削除

2. Salesforce API連携機能

  • クライアントログイン情報フローによる認証
  • 求人データの自動同期
  • 個別同期機能
  • 接続テスト機能

3. 論理削除機能

  • 求人の論理削除
  • 削除済みデータの復元機能
  • Salesforce側との同期も含めた削除処理

4. データクリーンアップ機能

  • 6ヶ月以上前の削除済みデータの自動物理削除
  • Rakeタスクによる定期実行
  • 管理画面からの手動実行

5. 重複防止機能

  • Salesforce側の外部ID(RailsJobID__c)による重複防止
  • upsert処理(作成/更新の自動判定)

Job.rb

class Job < ApplicationRecord
  validates :title, presence: true
  validates :company, presence: true
  validates :description, presence: true
  validates :location, presence: true

  # 論理削除機能
  default_scope { where(deleted_at: nil) }
  
  scope :active, -> { where(deleted_at: nil) }
  scope :deleted, -> { unscoped.where.not(deleted_at: nil) }
  scope :soft_deleted_old, -> { deleted.where('deleted_at < ?', 6.months.ago) }

  # Salesforce連携用の属性
  attr_accessor :sync_to_salesforce

  # 論理削除メソッド
  def soft_delete!
    update!(deleted_at: Time.current)
  end

  def restore!
    update!(deleted_at: nil)
  end

  # 6ヶ月以上前の削除済みレコードをクリーンアップ
  def self.cleanup_old_deleted_records
    old_records = soft_deleted_old
    deleted_count = 0
    
    old_records.find_each do |job|
      # Salesforce側も削除
      service = SalesforceService.new
      service.delete_job_record(job)
      
      # 物理削除
      job.destroy
      deleted_count += 1
    end
    
    { success: true, deleted_count: deleted_count }
  end
end

JobsController.rb

class JobsController < ApplicationController
  # 求人作成時のSalesforce同期
  def create
    @job = Job.new(job_params)

    if @job.save
      if should_sync_to_salesforce?
        sync_result = sync_to_salesforce_client_credentials(@job)
        # 同期結果に応じたフラッシュメッセージ
      end
      redirect_to @job
    end
  end

  # 論理削除(Salesforce側も削除)
  def destroy
    @job.soft_delete!
    
    # Salesforce側も削除
    sync_result = SalesforceService.new.delete_job_record(@job)
    
    flash[:notice] = "求人が削除され、Salesforceからも削除されました"
    redirect_to jobs_path
  end

  # 削除済み求人の復元
  def restore
    @job.restore!
    
    # Salesforceにも復元(再作成)
    sync_result = sync_to_salesforce_client_credentials(@job)
    
    redirect_to @job
  end

  # 古い削除済みデータのクリーンアップ
  def cleanup_old_deleted
    result = Job.cleanup_old_deleted_records
    
    if result[:success]
      if result[:deleted_count] > 0
        flash[:notice] = "#{result[:deleted_count]}件の古い削除済みレコードを物理削除しました"
      else
        flash[:info] = "クリーンアップ対象のレコードはありませんでした"
      end
    end
    
    redirect_to deleted_jobs_path
  end
end

カスタムオブジェクトの作成

Salesforce側で、カスタムオブジェクトと外部クライアント接続アプリケーションを作成します。

1. Job__cオブジェクトの作成

Rails側の求人データを格納するためのカスタムオブジェクトを作成します。

設定手順:

  1. 設定 → オブジェクトマネージャー → 作成 → カスタムオブジェクト
  2. オブジェクト名:Job
  3. API参照名:Job__c
  4. レコード名:求人タイトル

2. カスタム項目の作成

以下の項目をJob__cオブジェクトに追加します:

項目名 API参照名 データ型 説明
会社名 Company__c テキスト(255) 会社名
詳細 Description__c ロングテキストエリア 詳細説明
勤務地 Location__c テキスト(255) 勤務地情報
給与 Salary__c 数値(18,0) 年収
雇用形態 EmploymentType__c 選択リスト 正社員/契約社員等
投稿日 Posted_Date__c 日付/時間 求人投稿日
Rails求人ID RailsJobID__c テキスト(255) 外部ID(重複防止用)

3. 外部IDの設定(重要)

RailsJobID__c項目を外部IDとして設定します:

  1. RailsJobID__c項目の編集画面を開く
  2. 「外部ID」にチェックを入れる
  3. 「一意」にチェックを入れる

これにより、Rails側のIDをキーとしたupsert処理が可能になります。

外部クライアント接続アプリケーションの作成

クライアントログイン情報フローを使用するための接続アプリケーションを設定します。

1. 接続アプリケーションの作成手順

  1. 設定 → アプリケーション → 外部クライアントアプリケーションマネージャー
  2. 「新規外部クライアントアプリケーション」をクリック
  3. 基本情報を入力:
    • 接続アプリケーション名: 任意
    • API参照名: 任意
    • 取引先責任者メール: 自分のメールアドレス

外部クライアントアプリケーションマネージャー___Salesforce.png

2. OAuth設定

「OAuth設定の有効化」にチェックを入れる。
外部クライアントアプリケーションマネージャー___Salesforce.png

以下を設定:

  • コールバックURL: https://localhost:3000/(ダミーURL)
  • 選択したOAuth範囲:
    • API を使用してユーザーデータを管理 (api)
    • フルアクセス (full)
    • いつでも要求を実行 (refresh_token, offline_access)

外部クライアントアプリケーションマネージャー___Salesforce.png

3. クライアントログイン情報フローの有効化

「クライアントログイン情報フローを有効化」にチェックを入れます。
外部クライアントアプリケーションマネージャー___Salesforce.png

4. 認証情報の取得

作成後、以下の情報を控えておきます:

  • コンシューマー鍵(Client ID)
  • コンシューマーの秘密(Client Secret)

外部クライアントアプリケーションマネージャー___Salesforce.png

API連携処理の実装

Rails側でSalesforce APIとの連携を担当するSalesforceServiceクラスを実装します。

1. 基本構成

# app/services/salesforce_service.rb
class SalesforceService
  require 'net/http'
  require 'uri'
  require 'json'
  require 'restforce'

  def initialize
    @token_data = nil
    @client = nil
  end

  # 接続テスト
  def test_connection
    token_data = get_access_token
    return { success: false, error: "トークン取得に失敗しました" } unless token_data

    client = create_restforce_client(token_data)
    org_info = client.query("SELECT Id, Name FROM Organization LIMIT 1").first
    
    {
      success: true,
      message: "Client Credentials Flow 接続成功",
      organization: org_info.Name
    }
  rescue => e
    { success: false, error: "接続エラー: #{e.message}" }
  end
end

2. Client Credentials Flow認証の実装

private

def get_access_token
  return @token_data if @token_data

  begin
    uri = URI("https://#{ENV['SALESFORCE_HOST']}/services/oauth2/token")

    params = {
      "grant_type" => "client_credentials",
      "client_id" => ENV["SALESFORCE_CLIENT_ID"],
      "client_secret" => ENV["SALESFORCE_CLIENT_SECRET"]
    }

    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    http.verify_mode = OpenSSL::SSL::VERIFY_PEER
    http.open_timeout = 10
    http.read_timeout = 30

    request = Net::HTTP::Post.new(uri)
    request.set_form_data(params)
    request["Content-Type"] = "application/x-www-form-urlencoded"
    request["Accept"] = "application/json"

    response = http.request(request)
    
    if response.code == "200"
      @token_data = JSON.parse(response.body)
      Rails.logger.info "Salesforce認証成功: #{@token_data['instance_url']}"
      @token_data
    else
      Rails.logger.error "Salesforce認証失敗: #{response.code} - #{response.body}"
      nil
    end
  rescue => e
    Rails.logger.error "Salesforce認証エラー: #{e.message}"
    nil
  end
end

def create_restforce_client(token_data)
  Restforce.new(
    oauth_token: token_data['access_token'],
    instance_url: token_data['instance_url'],
    api_version: '61.0',
    ssl: { verify: true }
  )
end

3. upsert処理の実装(重複防止)

def upsert_job_record(job)
  client = get_authenticated_client
  return { success: false, error: "認証に失敗しました" } unless client

  begin
    job_data = {
      Name: job.title,
      Company__c: job.company,
      Description__c: job.description,
      Location__c: job.location,
      Salary__c: job.salary,
      Employment_Type__c: job.employment_type,
      Requirements__c: job.requirements,
      Posted_Date__c: job.posted_at&.iso8601,
      RailsJobID__c: job.id.to_s  # 外部IDとして使用
    }

    # upsert!メソッドで作成/更新を自動判定
    # 第1引数: オブジェクト名
    # 第2引数: 外部ID項目名
    # 第3引数: データ
    result = client.upsert!("Job__c", "RailsJobID__c", job_data)

    # 戻り値で作成/更新を判定
    operation = result.is_a?(String) ? "created" : "updated"
    salesforce_id = result.is_a?(String) ? result : "更新済み"

    {
      success: true,
      salesforce_id: salesforce_id,
      operation: operation,
      message: "Salesforceに#{operation == 'created' ? '作成' : '更新'}されました"
    }
  rescue Restforce::ResponseError => e
    Rails.logger.error "Salesforce API エラー: #{e.message}"
    { success: false, error: "API エラー: #{e.message}" }
  rescue => e
    Rails.logger.error "予期しないエラー: #{e.message}"
    { success: false, error: "予期しないエラー: #{e.message}" }
  end
end

private

def get_authenticated_client
  token_data = get_access_token
  return nil unless token_data
  
  @client ||= create_restforce_client(token_data)
end

4. 削除処理の実装

def delete_job_record(job)
  client = get_authenticated_client
  return { success: false, error: "認証に失敗しました" } unless client

  begin
    # 外部IDでSalesforceのレコードを検索
    query = "SELECT Id FROM Job__c WHERE RailsJobID__c = '#{job.id}' LIMIT 1"
    result = client.query(query)

    if result.empty?
      return {
        success: true,
        message: "Salesforceにレコードが見つかりませんでした(既に削除済みの可能性)"
      }
    end

    salesforce_id = result.first.Id
    
    # Salesforceからレコードを削除
    client.destroy!("Job__c", salesforce_id)

    {
      success: true,
      salesforce_id: salesforce_id,
      message: "SalesforceのレコードID: #{salesforce_id} を削除しました"
    }
  rescue Restforce::ResponseError => e
    Rails.logger.error "Salesforce削除エラー: #{e.message}"
    { success: false, error: "API エラー: #{e.message}" }
  rescue => e
    Rails.logger.error "削除処理エラー: #{e.message}"
    { success: false, error: "削除エラー: #{e.message}" }
  end
end

5. 全件同期処理の実装

def sync_all_jobs
  client = get_authenticated_client
  return { success: false, error: "認証に失敗しました" } unless client

  success_count = 0
  error_count = 0
  errors = []

  begin
    # アクティブな求人を同期
    Job.active.find_each do |job|
      result = upsert_job_record(job)
      if result[:success]
        success_count += 1
      else
        error_count += 1
        errors << "Job ID #{job.id}: #{result[:error]}"
      end
    end

    # 削除済み求人をSalesforceからも削除
    Job.deleted.find_each do |job|
      result = delete_job_record(job)
      if result[:success]
        success_count += 1
      else
        error_count += 1
        errors << "Job ID #{job.id} (削除): #{result[:error]}"
      end
    end

    {
      success: true,
      success_count: success_count,
      error_count: error_count,
      errors: errors,
      message: "同期完了: 成功 #{success_count}件, エラー #{error_count}件"
    }
  rescue => e
    Rails.logger.error "全件同期エラー: #{e.message}"
    { success: false, error: "全件同期エラー: #{e.message}" }
  end
end

環境変数の設定

.envファイルの作成

# .env
SALESFORCE_HOST=your-domain.develop.my.salesforce.com
SALESFORCE_CLIENT_ID=your_client_id_here
SALESFORCE_CLIENT_SECRET=your_client_secret_here

GemfileにRestforceとdotenv-railsを追加

# Gemfile
gem "restforce"
gem "dotenv-rails"

バッチ処理の実装

def bulk_upsert_jobs(jobs)
  client = get_authenticated_client
  return { success: false, error: "認証に失敗しました" } unless client

  # 複数レコードを一度に処理
  job_data_array = jobs.map do |job|
    {
      Name: job.title,
      Company__c: job.company,
      RailsJobID__c: job.id.to_s
      # 他の項目...
    }
  end

  begin
    # バルクAPIを使用(大量データの場合)
    results = client.bulk.upsert("Job__c", job_data_array, "RailsJobID__c")
    
    {
      success: true,
      processed_count: results.size,
      results: results
    }
  rescue => e
    { success: false, error: "バルク処理エラー: #{e.message}" }
  end
end

まとめ

今回は、クライアントログイン情報フローでRailsアプリケーションとAPI連携を実装しました。

最初は、ユーザー名パスワードフローで実装を試みましたが、パスワード認証が失敗し続けて苦戦しました。
ログイン履歴___Salesforce.png

今回使用したクライアントログイン情報フローの方が、セキュリティ的にも安全かつ実装も簡単でした。
(Railsアプリケーション側はCursor頼みでしたが…)

今後は、Slackなどの外部ツールとのAPI連携についても学んでいきたいと思います。

3
2
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
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?