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

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

馴染みがあるのは、[OAuth 2.0 ユーザー名パスワードフロー](https://help.salesforce.com/s/articleView?id=xcloud.remoteaccess_oauth_username_password_flow.htm&type=5)でした。
しかし、公式が非推奨としていたので、**クライアントログイン情報フロー（Client Credentials Flow）** を使用しました。

![特別なシナリオの_OAuth_2_0_ユーザー名パスワードフロー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/0b968ae0-3e27-45ba-a7c9-48225e69303b.png)

# 完成形
Railsアプリケーション（求人管理システム）
![Cursor_と_Salesforce_Api_Practice.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/0ca1cb1f-cccb-44ab-964d-ab926147db6f.png)

![Salesforce_Api_Practice.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/c2e35eae-ee80-49f9-aac0-801a0aed080d.png)


![Salesforce_Api_Practice.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/cb037c95-f962-4264-8b08-06ba019d2029.png)


Salesforce（Developer Edition）
![デザイナー募集___求人___Salesforce_🔊.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/6ebea8f5-9661-4661-bb81-8c548e2ee4de.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](https://www.cursor.com/ja)のProプランに登録したので、フル活用して作成しました。

### 1. 基本的なCRUD機能
- 求人の作成・表示・編集・削除

### 2. Salesforce API連携機能
- **クライアントログイン情報フロー**による認証
- 求人データの自動同期
- 個別同期機能
- 接続テスト機能

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

### 4. データクリーンアップ機能
- 6ヶ月以上前の削除済みデータの自動物理削除
- Rakeタスクによる定期実行
- 管理画面からの手動実行

### 5. 重複防止機能
- Salesforce側の外部ID（`RailsJobID__c`）による重複防止
- upsert処理（作成/更新の自動判定）

### Job.rb
```ruby
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
```ruby
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/2f2ef7a5-d1ef-4565-ae6f-a430a7f7576e.png)


### 2. OAuth設定

「OAuth設定の有効化」にチェックを入れる。
![外部クライアントアプリケーションマネージャー___Salesforce.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/3cb6a1a8-4d75-4080-a5f3-d36ada8c3a56.png)


以下を設定：

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

![外部クライアントアプリケーションマネージャー___Salesforce.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/3d0f943e-6de6-4fce-b823-1b54cf1ded70.png)


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

「クライアントログイン情報フローを有効化」にチェックを入れます。
![外部クライアントアプリケーションマネージャー___Salesforce.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/4d67b6c2-63e8-4283-a996-642caa991b05.png)



### 4. 認証情報の取得

作成後、以下の情報を控えておきます：
- **コンシューマー鍵（Client ID）**
- **コンシューマーの秘密（Client Secret）**

![外部クライアントアプリケーションマネージャー___Salesforce.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/6869d562-0c52-49cd-a3b8-631917d12860.png)


# API連携処理の実装

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

### 1. 基本構成

```ruby
# 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認証の実装

```ruby
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処理の実装（重複防止）

```ruby
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. 削除処理の実装

```ruby
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. 全件同期処理の実装

```ruby
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ファイルの作成

```bash
# .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を追加

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

## バッチ処理の実装

```ruby
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3577266/701e947f-dfc4-48b9-bdc3-0ce8a45c4bf0.png)

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

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