はじめに
Railsアプリケーションで作成した求人データを、SalesforceにAPI連携する処理を実装してみました。
馴染みがあるのは、OAuth 2.0 ユーザー名パスワードフローでした。
しかし、公式が非推奨としていたので、クライアントログイン情報フロー(Client Credentials Flow) を使用しました。
完成形
クライアントログイン情報フローとは?
サーバー間通信専用の認証方式で、アプリケーション自体の認証情報だけで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側の求人データを格納するためのカスタムオブジェクトを作成します。
設定手順:
- 設定 → オブジェクトマネージャー → 作成 → カスタムオブジェクト
- オブジェクト名:
Job
- API参照名:
Job__c
- レコード名:
求人タイトル
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として設定します:
-
RailsJobID__c
項目の編集画面を開く - 「外部ID」にチェックを入れる
- 「一意」にチェックを入れる
これにより、Rails側のIDをキーとしたupsert処理が可能になります。
外部クライアント接続アプリケーションの作成
クライアントログイン情報フローを使用するための接続アプリケーションを設定します。
1. 接続アプリケーションの作成手順
- 設定 → アプリケーション → 外部クライアントアプリケーションマネージャー
- 「新規外部クライアントアプリケーション」をクリック
- 基本情報を入力:
- 接続アプリケーション名: 任意
- API参照名: 任意
- 取引先責任者メール: 自分のメールアドレス
2. OAuth設定
以下を設定:
-
コールバックURL:
https://localhost:3000/
(ダミーURL) -
選択したOAuth範囲:
API を使用してユーザーデータを管理 (api)
フルアクセス (full)
いつでも要求を実行 (refresh_token, offline_access)
3. クライアントログイン情報フローの有効化
「クライアントログイン情報フローを有効化」にチェックを入れます。
4. 認証情報の取得
作成後、以下の情報を控えておきます:
- コンシューマー鍵(Client ID)
- コンシューマーの秘密(Client Secret)
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連携を実装しました。
最初は、ユーザー名パスワードフローで実装を試みましたが、パスワード認証が失敗し続けて苦戦しました。
今回使用したクライアントログイン情報フローの方が、セキュリティ的にも安全かつ実装も簡単でした。
(Railsアプリケーション側はCursor頼みでしたが…)
今後は、Slackなどの外部ツールとのAPI連携についても学んでいきたいと思います。