ApexでAccount Engagementの行動履歴を取得する

# 概要
ApexでAccount Engagementの行動履歴をAPIで取得する処理を作成したときの備忘です。
API実行に必要なアクセストークンを取得する認証APIとAccount Engagementの行動履歴を取得するAPIの2コを利用します。
認証APIにはいくつか方式がありますが、ユーザー名パスワードフローを使用しました。ユーザ名とパスワードを送信するため、非推奨とされていますが、クライアントログイン情報フローを使うとコールバックURLにリダイレクトしてしまうため、おなじApex内で処理が続行できませんでした。ユーザー名パスワードフローはリダイレクトしなかったのですが、クライアントログイン情報フローでリダイレクトしないやり方はないのですかね。ご存じの方、いらっしゃいましたら、教えてください。
2024年8月時点の情報になります。

# Account Engagementの行動履歴とは
プロスペクトがメールを受信、開封したり、ランディングページにアクセスしたりといったプロスペクトのアクティビティに表示される情報です。この情報はSales Cloudからオブジェクトとして参照できないので、APIで取得します。
![アクティビティ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45425/91636f0d-9a0a-7169-ea4a-5cae90c2019d.png)

# 設定
## アプリケーションマネージャー
「アプリケーションマネージャー」で「新規接続アプリケーション」を作成します。
![アプリケーションマネージャー1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45425/d1334202-795d-be1b-8e95-4fc936cb6c87.png)

「OAuth 設定の有効」のチェックを「オン」に変更し、「利用可能な OAuth 範囲」を選択します。今回は、Account Engagement APIを利用したいので、「Pardot サービスを管理 (pardot_api)」を選択します。
![アプリケーションマネージャー2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45425/52527b83-b5c3-06dd-d89a-fe013040a331.png)

接続アプリケーションを作成すると「コンシューマー鍵」と「コンシューマーの秘密」が発行されます。認証APIで必要になるので、覚えておきます。
![アプリケーションマネージャー3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45425/98333c6a-af41-69da-8fe2-0291094b76c3.png)

接続元のIPアドレスの制限として「IP制限の緩和」という項目があり、値を「IP制限の緩和」としました。今回はApexから呼び出すので、厳格にやる場合は、「IP制限を適用」とし、あとで作成する接続用ユーザのプロファイルのIPアドレス制限に[SalesforceのIPアドレス](https://help.salesforce.com/s/articleView?id=000384438&type=1)をすべて設定します。
![ip制限の緩和.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45425/cb7cc790-7971-36b7-8464-ab0667f91e42.png)

## リモートサイトの設定
「リモートサイトの設定」でSalesforceから外部接続を許可するURLを追加します。
![リモートサイトの設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45425/6c288ff6-05a2-3153-473e-cb8cbd04dd48.png)

※本番環境とテスト環境でURLが異なるので注意してください

production
* 認証API用：https://login.salesforce.com
* Account Engagement API用：https://pi.pardot.com

sandbox
* 認証API用：https://test.salesforce.com
* Account Engagement API用：https://pi.demo.pardot.com

## OAuth および OpenID Connect 設定
「OAuth および OpenID Connect 設定」で「OAuthユーザー名パスワードフローを許可」を「オン」に変更します。
![OAuth および OpenID Connect 設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45425/caf2b00e-2e16-416a-ec9f-2d75b4a60fcb.png)

## ユーザー
「ユーザー」でAPI実行用ユーザーを作成します。ユーザーライセンスは「Identity」プロファイルは「Identity User」を選択します。
![ユーザー1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45425/5c9101f4-7103-2ebb-814d-fb22a5a5402a.png)

ユーザーを作成したら、権限セットの割り当てで「Account Engagement Integration User」を追加します。Account Engagementの情報を参照するのに必要な権限になります。
![ユーザー2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45425/4b3908f4-e009-e08b-ff97-527de62142c6.png)

# 認証API
SalesforceのAPIを実行するためのアクセストークンを取得するAPI
## マニュアル
[特別なシナリオの OAuth 2.0 ユーザー名パスワードフロー](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_username_password_flow.htm&type=5)
## リクエスト
エンドポイント
| 環境 | エンドポイント | メソッド |
|:-|:-|:-|
| production | https://login.salesforce.com/services/oauth2/token | POST |
| sandbox  | https://test.salesforce.com/services/oauth2/token | POST |

パラメータ
| キー | 値 |
|:-|:-|
| grant_type | password |
| client_id | 接続アプリケーションで発行した「コンシューマー鍵」 |
| client_secret | 接続アプリケーションで発行した「コンシューマーの秘密」 |
| username | API実行用に作成したユーザの「ユーザ名」 |
| password | API実行用に作成したユーザの「パスワード」 |

ヘッダー
不要

## レスポンス
| キー | 値 |
|:-|:-|
| access_token | APIで利用するアクセストークン |
| instance_url | 使わない |
| id | 使わない |
| token_type | 使わない |
| issued_at | 使わない |
| signature | 使わない |

# Account Engagement Visitor Activity API
Account Engagementの行動履歴を取得するAPI
## マニュアル
[Visitor Activity](https://developer.salesforce.com/docs/marketing/pardot/guide/visitor-activity-v5.html)
## リクエスト
エンドポイント
| 環境 | エンドポイント | メソッド |
|:-|:-|:-|
| production | https://pi.pardot.com/api/v5/objects/visitor-activities | GET |
| sandbox  | https://pi.demo.pardot.com/api/v5/objects/visitor-activities | GET |

パラメータ（詳細はマニュアルをみてください）
| キー | 値 |
|:-|:-|
| fields | 取得する項目をカンマ区切りで列挙（マニュアルをみてください） |
| limit | データの取得件数（デフォルト200で、1,000まで指定可） |
| ソート項目 | ソート条件（マニュアルをみてください） |
| フィルタ項目 | フィルタ条件（マニュアルをみてください） |

ヘッダー
| キー | 値 |
|:-|:-|
| Authorization | Bearer 認証APIで取得したアクセストークン |
| Pardot-Business-Unit-Id | ビジネスユニットID |

※Authorizationの値のBearerのあとは半角スペースがはいります。
※ビジネスユニットIDは「ビジネスユニット設定」で確認してください。
![ビジネスユニット設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/45425/8c8a8c59-54f3-0867-5d93-0a351fd8eb92.png)

## レスポンス
| キー | 値 |
|:-|:-|
| nextPageToken | 次のページアクセストークン（なければnull） |
| nextPageUrl | 次のページのURL（なければnull） |
| values | 取得した値 |

※1回のリクエストで最大1,000件までのデータしか取得できません。すべてのデータが取得できない場合、「nextPageToken」と「nextPageUrl」に値が設定され、「nextPageUrl」をたどっていくことですべてのデータを取得することができます。「nextPageUrl」にリクエストする際、「nextPageToken」でなく、最初に認証APIで取得したアクセストークンでも動作しました。

# Apex
## 実装
API呼び出しのサンプルプログラムです。
* フローからApexアクションで呼び出すのにInvocableMethodアノテーションを使用しています
* APIを呼び出すには、futureアノテーションを使用しないと、CalloutExceptionが発生します
* データを複数件取得する場合、レスポンスの「nextPageUrl」を判定して、nullになるまで、apiをよぶ必要があります（サンプルプログラムではやっていません）
* APIのエンドポイントなど、環境依存の定数はカスタム表示ラベルを使用しています

``` java:apiSample.apxc
public class ApiSample {
    // 定数
    // 認証API URL
    static final String AUTHENTICATION_API_URL = System.Label.Authentication_Api_Url;
    // VISITOR ACTIVITY API URL
    static final String VISITOR_ACTIVITY_API_URL = System.Label.Visitor_Activity_Api_Url;
    // コンシューマ鍵
    static final String CLIENT_ID = System.Label.Client_Id;
    // コンシューマ秘密
    static final String CLIENT_SECRET = System.Label.Client_Secret;
    // ユーザ名
    static final String USERNAME = System.Label.Username;
    // ユーザパスワード
    static final String PASSWORD = System.Label.Password;
    // ビジネスユニットID
    static final String BUSINESS_UNIT_ID = System.Label.Business_Unit_Id;

    /**
     * フローから呼び出されるメソッド
     * @param batchParams パラメータ
     * @return なし
     * 
     **/
    @InvocableMethod(label='Run Batch Job')
    public static void runBatchJob(List<String> batchParams) {
		main();
    }
    
    /**
     * メイン処理
     * 
     * @return なし
     * 
     **/
    @future(callout=true)
    public static void main() {
        // 認証APIをよぶ
        String accessToken = apiAuthentication();

        // visitor activity apiをよぶ
        apiVisitorActivity(accessToken);        
    }
    
    /**
     * visitor activity apiを実行する
     * 
     * @param accessToken アクセストークン
     * @return apiレスポンスのbody
     * 
     **/
    private static String apiVisitorActivity(String accessToken) {
		// httpリクエストとレスポンスの初期化
        HttpRequest req = new HttpRequest();
        HttpResponse res = null;
		Http http = new Http();
        
        // getパラメータの設定
        String param = '?fields=prospect.email';
        param += '&type=6';
        param += '&limit=1000';
        param += '&orderby=createdAt';
        
        // リクエストの設定
        req.setEndpoint(VISITOR_ACTIVITY_API_URL + param);
        req.setMethod('GET');
        
        // リクエストヘッダの設定
        req.setHeader('Authorization', 'Bearer ' + accessToken);
        req.setHeader('Pardot-Business-Unit-Id', BUSINESS_UNIT_ID);
                
        try {
            // httpリクエスト送信
            res = http.send(req);
            
            // ステータスコードのチェック
            if (res.getStatusCode() != 200) {
                System.debug('api call failure:' + res.getBody());
                return null;
            }
        } catch (Exception e) {
            System.debug('Exception:' + e.getMessage());
        }
        
        return res.getBody();
    }
    
    /**
     * 認証apiを実行する
     * 
     * @param accessToken アクセストークン
     * @return apiレスポンスのbody
     * 
     **/
    private static String apiAuthentication() {       
		// httpリクエストとレスポンスの初期化
        HttpRequest req = new HttpRequest();
        HttpResponse res = null;
		Http http = new Http();
        
        // リクエストの設定
        req.setEndpoint(AUTHENTICATION_API_URL);
        req.setMethod('POST');
        
        // リクエストヘッダの設定
        req.setHeader('Content-Type', 'application/x-www-form-urlencoded');
        
        // リクエストボディの設定
        String body = 'grant_type=' + EncodingUtil.urlDecode('password', 'utf-8');
        body += '&client_id=' + EncodingUtil.urlDecode(CLIENT_ID, 'utf-8');
        body += '&client_secret=' + EncodingUtil.urlDecode(CLIENT_SECRET, 'utf-8');
        body += '&username=' + EncodingUtil.urlDecode(USERNAME, 'utf-8');
        body += '&password=' + EncodingUtil.urlDecode(PASSWORD, 'utf-8');
        req.setBody(body);

        try {
            // httpリクエスト送信
            res = http.send(req);
            
            // ステータスコードのチェック
            if (res.getStatusCode() != 200) {
                System.debug('api call failure:' + res.getBody());
                return null;
            }
        } catch (Exception e) {
            System.debug('Exception:' + e.getMessage());
        }
        
        // アクセストークンを返却
        Map<String, Object> jsonResponse = (Map<String, Object>)JSON.deserializeUntyped(res.getBody());
        return (String)jsonResponse.get('access_token');
    }
}
```

## テストクラス
テストクラスのサンプルプログラムです。
* API呼び出しはモックを使用します
* HttpResponseのレスポンス処理がすべて同一のモック処理になってしまうので、リクエストURLを判定することで、それぞれのリクエストに応じたレスポンスを返すようにしています

``` java:apiSampleTest.apxc
@isTest
private class ApiSampleTest {
	@isTest
    private static void test1() {
        Test.setMock(HttpCalloutMock.class, new CalloutMock());
        
        Test.startTest();
        ApiSample.runBatchJob(null);
        Test.stopTest();
    }
        
    // apiモック
    private class CalloutMock implements HttpCalloutMock {
        public HttpResponse respond(HttpRequest req) {
            HttpResponse res = new HttpResponse();
            
            // 認証apiのレスポンス
            if (req.getEndpoint().contains(System.Label.Authentication_Api_Url)) {
				res.setStatusCode(200);
                res.setBody('{"access_token": "xxx","instance_url": "xxx","id": "xxx","token_type": "xxx","issued_at": "xxx","signature": "xxx"}');
            }
            // visitor activity api１回目のレスポンス
            if (req.getEndpoint().contains(System.Label.Visitor_Activity_Api_Url)) {
                    res.setStatusCode(200);
                    res.setBody('{"nextPageToken": "","nextPageUrl": "https://nextpageurl","values":[]}');
            }
            // visitor activity api２回目のレスポンス
            if (req.getEndpoint().contains('https://nextpageurl')) {
                    res.setStatusCode(200);
                    res.setBody('{"nextPageToken": null,"nextPageUrl": null,"values": []}');
            }

            return res;
        }
    }
}
```

# エラー調査
認証がうまくいかない場合、「設定」「ユーザー」から、API実行用ユーザのログイン履歴をみると、失敗した原因が確認できるので、参考になりました。