Heroku Integration をほんのちょっぴりだが体験した

> おれは"Salesforce Functions"を試そうと思ったら、"Heroku Integration"を試していた
> な、何を言っているのかわからねーと思うが(ry

ようこそ Heroku Integration !!
Dreamforce2024で発表されたHeroku Integrationを触ってみたので紹介したいと思います。
※2024.12月時点ではまだパイロット版で、今後変わる可能性もあります。

本記事では、[Getting Started Heroku Integration](https://devcenter.heroku.com/articles/getting-started-heroku-integration)に沿って試してみて、つまずいたポイントなども併せて紹介します。

# 0.前提

* Heroku Integration Pilot Programに申込済みであること
* Salesforce Sandbox環境(or ScratchOrg)があること
* Heroku CLIをインストールしていること
* 環境
    * MacOS 14.5 (Apple M2)
    * Node.js: v20.15.0
    * npm: 10.7.0
    * heroku-cli: heroku/9.5.0 darwin-arm64 node-v16.20.2

# 1.Heroku Integration Pluginのインストール

以下のコマンドでインストールします。

```bash
$ heroku plugins:install @heroku-cli/plugin-integration
```

正常にインストールされれば、以下のコマンドで `integration 0.0.9` が返ってきます。

```bash
$ heroku plugins
integration 0.0.9
```

# 2.Integration Projectの作成

```bash
$ heroku integration:project welcome-integration

Generating a Salesforce integration project in JavaScript
   create bin/invoke.sh
   create src/plugins/heroku-salesforce.js
   create src/plugins/README.md
   create src/routes/index.js
   create src/app.js
   create test/plugins/heroku-salesforce.test.js
   create test/routes/example.test.js
   create test/routes/root.test.js
   create test/helper.js
   create api-spec.yaml
   create app.json
   create Procfile
   create README.md
   create package.json
```
こんな感じにプロジェクトが作成されます。

![screenshot_001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1840/89415772-a5ed-62f1-08ee-ef4c5158ab28.png)

### ちょっと一息(1)

(12月時点では)さりげない落とし穴があるので、ちょっと一息つきましょう。
上記コマンドで作成された `api-spec.yaml` を確認してみます。

<details><summary>api-spec.yaml</summary>

```yaml
openapi: 3.0.0
info:
  version: 1.0.0
  title: Heroku-Salesforce API
  description: Example Heroku app as an API using Salesforce SDK.
servers:
  - url: http://127.0.0.1:300
paths:
  /accounts:
    get:
      operationId: GetAccounts,
      description: Returns list of array of Accounts
      responses:
        '200':
          description: Successfully returned a list of Accounts
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                    name:
                      type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/DeveloperError'
        '503':
          $ref: '#/components/responses/UnknownError'
  /unitofwork:
    post:
      operationId: UnitOfWork
      description:
        Receives a payload containing Account, Contact, and Case details and uses the
        Unit of Work pattern to assign the corresponding values to to its Record
        while maintaining the relationships. It then commits the unit of work and
        returns the Record Id's for each object.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                accountName:
                  type: string
                lastName:
                  type: string
                subject:
                  type: string
                callbackUrl:
                  type: string
      responses:
        '201':
          description: Received UnitOfWork API request
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/DeveloperError'
        '503':
          $ref: '#/components/responses/UnknownError'
      callbacks:
        unitOfWorkResponse:
          '{$request.body#/callbackUrl}':
            post:
              description: Response from /unitofwork API
              operationId: unitOfWorkResponseCallback
              requestBody:
                content:
                  application/json:
                    schema:
                      type: object
                      properties:
                        accountId:
                          type: string
                        contactId:
                          type: string
                        cases:
                          type: object
                          properties:
                            serviceCaseId:
                              type: string
                            followupCaseId:
                              type: string
              responses:   # Expected responses to the callback message
                '200':
                  description: Your server returns this code if it accepts the callback
components:
  responses:
    NotFound:
      description: The specified resource was not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: Unauthorized
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    DeveloperError:
      description: DeveloperError
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    UnknownError:
      description: UnknownError
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    # Schema for error response body
    Error:
      type: object
      properties:
        code:
          type: string
        message:
          type: string
      required:
        - code
        - message

```

なんと、`operationId: GetAccounts,` と謎のカンマが。
これがあると、Salesforceにインポートした後の動作検証で期待通りに動かなくなります。
この時点で余計なカンマは外しておきましょう。

![screenshot_002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1840/ae3c7775-b3da-1013-7cba-b6b244f27a80.png)

</details>

--- (ちょっと一息(1)終わり) ---

そのまま、モジュールをインストールして確認してみましょう

```bash
$ npm install
...

$ npm run dev

> welcone-integration@0.0.0 dev
> fastify start -w -l debug -d -P src/app.js

Debugger listening on ws://127.0.0.1:9320/ac4c56e5-10db-4e4b-8ba6-a4530a6c81b7
For help, see: https://nodejs.org/en/docs/inspector
[17:13:23.614] INFO (71180): Server listening at http://[::1]:3000
[17:13:23.615] INFO (71180): Server listening at http://127.0.0.1:3000
```

ひとまず、ローカルでの動作を確認できました。

さらに`Procfile`を以下のように変更します。

```
web: APP_PORT=3000 heroku-integration-service-mesh npm start
```

# 3.Heroku Appの用意とデプロイ

Heroku Appを作成して、addonを追加します。

```
$ heroku create welcome-integration
...

$ heroku addons:create heroku-integration -a welcome-integration

Creating heroku-integration on ⬢ welcome-integration... free
Your add-on is being provisioned.
integration-round-64060 is being created in the background. The app will restart when complete...
Use heroku addons:info integration-round-64060 to check creation progress
Use heroku addons:docs example-app to view documentation
```

heroku-integration を addon として追加すると、管理画面にも追加されたことが確認できます。（まだ `Test` というラベルが付いてますね）

![screenshot_003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1840/22959a75-93e8-af76-1678-849c55fb8ea5.png)

次に、buildpackを追加します。heroku-integration-service-meshと、nodejsの2つになります。

```
$ heroku buildpacks:add https://github.com/heroku/heroku-buildpack-heroku-integration-service-mesh -a welcome-integration
$ heroku buildpacks:add heroku/nodejs -a welcome-integration
```

[heroku-integration-service-mesh](https://github.com/heroku/heroku-integration-service-mesh)のREADMEにもありますが、service meshがHeroku Routerとデプロイしたアプリの間に入る仕組みになっているようです。

### ちょっと一息(2)

Heroku Appの環境変数に`FASTIFY_PORT`という名前で`3000`という値を追加します。
fastifyがheroku-integration-service-meshからのリクエストを受け付けられるよう、ポート番号として`3000`を指定しておきます。
Procfile内に記載している`APP_PORT`と合わせておく、という具合です
[fastify起動時のオプションについて](https://github.com/fastify/fastify-cli?tab=readme-ov-file#options)

ここがずれていると、heroku-integration-service-meshからのリクエストで
```
Failed to forward request: Get \"http://127.0.0.1:3000/accounts\
```
というエラーが発生します。

--- (ちょっと一息(2)終わり) ---

最後に、herokuにソースコードをデプロイします

```bash
$ heroku git:remote -a welcome-integration
$ git push heroku main
```

# 4.Salesforceへの接続

※sandbox環境で接続してみますが、Scratch Orgでも可能です。

```bash
$ heroku salesforce:connect sandbox-org --login-url "https://test.salesforce.com" -a welcome-integration --store-as-run-as-user
Press any key to open up the browser to connect ⬢ welcome-integration to sandbox-org or q to exit:
Opening browser to https://test.salesforce.com/services/oauth2/authorize?client_id=…
Connecting ⬢ welcome-integration to sandbox-org... done
Connected org 00Dxxxxxxxxxxxxx to ⬢ welcome-integration.
```

ブラウザが起動してログインを要求されるので、そのままログインしてください。
（APIインテグレーション用ユーザでログインすることが推奨されてます）

正常に接続完了すると、以下のコマンドで接続状態を確認できます。

```bash
$ heroku integration:connections -a welcome-integration
=== Heroku Integration connections for app ⬢ welcome-integration

 Type           Org Name    State     Run As User
 ────────────── ─────────── ───────── ──────────────────────────────────────
 Salesforce Org sandbox-org Connected (接続したユーザID)

```

さらに、Salesforce側には以下2つの権限セットが作成されます。

* Heroku Integration
* HerokuIntegrationAuthorization



# 5.Heroku AppをSalesforce環境にインポート

Salesforce側でApexやFlowで使用できるように、外部サービスとして登録します。

```$bash
$ heroku salesforce:import api-spec.yaml -a welcome-integration --org-name sandbox-org --client-name HerokuAPI --generate-auth-permission-set

Importing app ⬢ welcome-integration as 'HerokuAPI' to org sandbox-org... done
```

管理画面 > Heroku > アプリケーションメニューで、Herokuアプリケーションが表示されますので、その中身を見ると以下のようになります。
先ほどのコマンドで client-name に指定した値が名前として登録されるようです。

![screenshot_004.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1840/2e2e43cd-5d96-6631-eee3-45f112cffc09.png)

さらに、上記コマンドで以下の権限セットが作成されるので、この外部サービスを利用したいユーザに割り当てておきます。

* HerokuAPI
* HerokuAPIAuthorization
    * 2つ目はセッションベースの権限セットになります。ユーザ画面から紐つける際には「権限セットの割り当て: 有効化が必要」のセクションから追加します。

# 6.Salesforce環境での動作検証

では、Salesforce側から呼び出してみましょう。
開発者コンソールで匿名実行させてみます。

```apex
ExternalService.HerokuAPI herokuAPI = new ExternalService.HerokuAPI();
ExternalService.HerokuAPI.GetAccounts_Response response = herokuAPI.GetAccounts();
System.debug(JSON.serializePretty(response));
```

```
18:25:41.0 (59086675)|METHOD_ENTRY|[3]||ExternalService.HerokuAPI.GetAccounts()
18:25:43.750 (2750098585)|NAMED_CREDENTIAL_REQUEST|NamedCallout[Named Credential Id=0XAA20000000AH7, Named Credential Name=HerokuAPI, Endpoint=https://o360-integration-working-43d0c9ef2b77.herokuapp.com/accounts, Method=GET, External Credential Type=EXTERNAL, HTTP Header Authorization=Method: Not set - Credential: Not set, Content-Type=null, Request Size bytes=-1, Retry on 401=True]
18:25:43.751 (2751218621)|NAMED_CREDENTIAL_RESPONSE|NamedCallout[Named Credential Id=0XAA20000000AH7, Named Credential Name=HerokuAPI, Status Code=200, Content-Type=application/json; charset=utf-8, Response Size bytes=6695, Overall Callout Time ms=1375, Connect Time ms=3
18:25:43.754 (2754400793)|EXTERNAL_SERVICE_REQUEST|ExternalServiceSchemaType:OpenApi3|ExternalServiceName:HerokuAPI|SystemVersion:5|Action:GET|InvocationType:Apex|InvocationContext:Synchronous|SchemaProvider:Heroku|SystemNamespace:ExternalService|InputParameters:{}
18:25:43.754 (2754862708)|EXTERNAL_SERVICE_RESPONSE|ExternalServiceSchemaType:OpenApi3|ExternalServiceName:HerokuAPI|SystemVersion:5|Action:GET|InvocationType:Apex|InvocationContext:Synchronous|SchemaProvider:Heroku|SystemNamespace:ExternalService|OutputParameters:{"200":"[{\"id\":\"001F700001j0eAqIAI\",\"name\":\"テストデータ01株式会社\"},{\"id\":\"001F700001j0eArIAI\",\"name\":\"テストデータ03株式会社\"},{\"id\":\"001F700001j0eAsIAI\",\"name\":\"株式会社テストデータ05\"},{\"id\":\"001F700   <... truncated: 4804 characters ...>   |HttpResponseStatus:200
18:25:43.935 (2935429904)|HEAP_ALLOCATE|[10]|Bytes:116
18:25:43.935 (2935523731)|HEAP_ALLOCATE|[10]|Bytes:3
18:25:43.935 (2936069853)|HEAP_ALLOCATE|[10]|Bytes:4
18:25:43.935 (2936089505)|HEAP_ALLOCATE|[10]|Bytes:12
18:25:43.935 (2936141298)|HEAP_ALLOCATE|[EXTERNAL]|Bytes:4
18:25:43.935 (2936187532)|VARIABLE_ASSIGNMENT|[10]|this.Code200|[]|0x12463033
18:25:43.935 (2936458603)|HEAP_ALLOCATE|[10]|Bytes:156
18:25:43.935 (2936478975)|HEAP_ALLOCATE|[10]|Bytes:7
18:25:43.935 (2950907353)|METHOD_EXIT|[3]||ExternalService.HerokuAPI.GetAccounts()
18:25:43.935 (2950928263)|SYSTEM_MODE_EXIT|false
18:25:43.935 (2951066885)|VARIABLE_ASSIGNMENT|[3]|response|{"Code200":"0x47fd04c3","responseCode":200}|0x12463033
18:25:43.935 (2951075774)|STATEMENT_EXECUTE|[4]
18:25:43.935 (2951122098)|SYSTEM_MODE_ENTER|false
18:25:43.935 (2951138650)|HEAP_ALLOCATE|[4]|Bytes:5
18:25:43.935 (2951213515)|HEAP_ALLOCATE|[4]|Bytes:24
18:25:43.935 (2951237797)|SYSTEM_METHOD_ENTRY|[1]|JSON.JSON()
18:25:43.935 (2951244037)|STATEMENT_EXECUTE|[1]
18:25:43.935 (2951284410)|SYSTEM_METHOD_EXIT|[1]|JSON
18:25:43.935 (2951299291)|METHOD_ENTRY|[4]||System.JSON.serializePretty(Object)
18:25:43.935 (2954243835)|METHOD_EXIT|[4]||System.JSON.serializePretty(Object)
18:25:43.935 (2954261007)|SYSTEM_MODE_EXIT|false
18:25:43.935 (2954290719)|USER_DEBUG|[4]|DEBUG|{
  "responseCode" : 200,
  "Code200" : [ {
    "name_set" : true,
    "name" : "テストデータ01株式会社",
    "id_set" : true,
    "id" : "001F700001j0eAqIAI"
  }, {
    "name_set" : true,
    "name" : "テストデータ03株式会社",
    "id_set" : true,
    "id" : "001F700001j0eArIAI"
  }, {
   ...
  } ]
}
```

SalesforceからHeroku Appに、Salesforceの取引先を取得するようリクエストした結果が返ってきました。

Heroku App側のログを見ると、heroku-integration-service-meshがリクエストを受け取り、認証後にデプロイしたアプリにリクエストを転送しているようです。

```
app/web.1 time=2024-12-14T09:25:43.638Z level=INFO msg="Processing request to /accounts..." app=local source=heroku-integration-service-mesh request-id=00Dxxxxxxxxxxxxxxx-d78d4596-87a2-4164-8682-bd23a77ea566
app/web.1 time=2024-12-14T09:25:43.639Z level=INFO msg="Validating request..." app=local source=heroku-integration-service-mesh request-id=00Dxxxxxxxxxxxxxxx-d78d4596-87a2-4164-8682-bd23a77ea566
app/web.1 time=2024-12-14T09:25:43.641Z level=INFO msg="Valid request!" app=local source=heroku-integration-service-mesh request-id=00Dxxxxxxxxxxxxxxx-d78d4596-87a2-4164-8682-bd23a77ea566
app/web.1 time=2024-12-14T09:25:43.641Z level=INFO msg="Found Salesforce request" app=local source=heroku-integration-service-mesh request-id=00Dxxxxxxxxxxxxxxx-d78d4596-87a2-4164-8682-bd23a77ea566
app/web.1 time=2024-12-14T09:25:43.641Z level=INFO msg="Authenticating Salesforce request for org 00Dxxxxxxxxxxxxxxx, domain https://xxxxx.sandbox.my.salesforce.com..." app=local source=heroku-integration-service-mesh request-id=00Dxxxxxxxxxxxxxxx-d78d4596-87a2-4164-8682-bd23a77ea566
app/web.1 time=2024-12-14T09:25:43.997Z level=INFO msg="Authenticated request!" app=local source=heroku-integration-service-mesh request-id=00Dxxxxxxxxxxxxxxx-d78d4596-87a2-4164-8682-bd23a77ea566
app/web.1 time=2024-12-14T09:25:43.997Z level=INFO msg="Forwarding request..." app=local source=heroku-integration-service-mesh request-id=00Dxxxxxxxxxxxxxxx-d78d4596-87a2-4164-8682-bd23a77ea566
app/web.1  {"level":30,"time":1734168344008,"pid":27,"hostname":"94b8d350-abb3-42ed-8275-e24e9ab9887b","reqId":"req-1","req":{"method":"GET","url":"/accounts","hostname":"127.0.0.1:3000","remoteAddress":"127.0.0.1","remotePort":40998},"msg":"incoming request"}
app/web.1  {"level":30,"time":1734168344017,"pid":27,"hostname":"94b8d350-abb3-42ed-8275-e24e9ab9887b","reqId":"req-1","msg":"GET /accounts: {}"}
app/web.1  {"level":30,"time":1734168344017,"pid":27,"hostname":"94b8d350-abb3-42ed-8275-e24e9ab9887b","reqId":"req-1","msg":"Querying org 00Dxxxxxxxxxxxxxxx Accounts..."}
heroku/router at=info method=GET path="/accounts" host=welcome-integration-43d0c9ef2b77.herokuapp.com request_id=00Dxxxxxxxxxxxxxxx-d78d4596-87a2-4164-8682-bd23a77ea566 fwd="141.163.209.254" dyno=web.1 connect=0ms service=732ms status=200 bytes=6839 protocol=https
app/web.1  {"level":30,"time":1734168344345,"pid":27,"hostname":"94b8d350-abb3-42ed-8275-e24e9ab9887b","reqId":"req-1","msg":"For org 00Dxxxxxxxxxxxxxxx, found the following Accounts: [{\"id\":\"001F700001j0eAqIAI\",\"name\":\"テストデータ01株式会社\"},{\"id\":\"001F700001j0eArIAI\",\"name\":\"テストデータ03株式会社\"},
```

Heroku側の処理としては、どうなっているのでしょう。
以下、主な箇所だけ抜き出しました。

```javascript
fastify.get('/accounts', async function (request, reply) {
  const { event, context, logger } = request.sdk;

  // (略)...
  
  // Query invoking org's Accounts
  logger.info(`Querying org ${org.id} Accounts...`);
  const result = await org.dataApi.query('SELECT Id, Name FROM Account');
  const org = context.org;
  const accounts = result.records.map(rec => rec.fields);
  logger.info(`For org ${org.id}, found the following Accounts: ${JSON.stringify(accounts || {})}`);
});
```

requestオブジェクトの中に組み込まれた`sdk`オブジェクトには、Salesforceのデータ操作機能が含まれており、これを使うことでまるでApexでsoqlを書くかのようにデータをクエリすることができます。（その他DMLも）

従って、Heroku Integrationを使うことでsalesforceへの認証やAPIへのリクエストは不要になります。
また、場合によってはSalesforce側でApex REST APIを用意する必要があることもあったかと思いますが、上に示したようにクエリやDMLが容易に記述できるのであれば、Apexで実装する必要も減らせるのではないでしょうか。

公式でも紹介されていますが、Salesforceのデータを元にHeroku App側でPDFを生成して、それをSalesforceの元のレコードに添付する、といったことはもう少し楽に実現できそうですね。


# 7.その他気になった点など

* API消費はカウントされてしまうのは惜しい
    * [Heroku Integration > 制約事項](https://devcenter.heroku.com/articles/heroku-integration#considerations-and-limitations)
    * でもまぁ仕方ないかな、というところもありますね
* ユーザモードが3種用意されているようですので、レコードのアクセス制御が必要な場合にも対応できそう（と思う）
    * [Heroku Integration > セキュリティ](https://devcenter.heroku.com/articles/heroku-integration#security)
* あくまで、Salesforceが起点となってHeroku側に実装された機能を利用する、という仕組みだと捉えてますが、外部からHerokuにリクエストが来て（どうにかして）Salesforce側に連携できるようになると良いな、と思いました。
    * そういったこともできるのかもしれないですが、よくわからず。
    * 詳しい人からのツッコミをお待ちします。
* 料金体系はどうなるか...？

(2025.01.02 追記)

Heroku側から見ると、1)機能の実装により集中できる 2)リクエストを処理する前の認証機構は不要 というメリットがあるのではないかと思いました。
1. 今回提供されているサンプルを見ると、Salesforceの認証情報付きのオブジェクトが提供されており、必要な機能の実装により集中できるようになっていると思います。
2. Salesforce -> Heroku APIへのリクエストでは、heroku-integration-service-meshが間に入ってリクエストが妥当かどうか検証しているようです。
すなわち、Heroku側で受け取ったリクエストが妥当なものかどうか検証する仕組みを「自前で」構築する必要がなくなる、ということになります。自前で構築する必要がない、というのはなかなか大きなアドバンテージになるのではないでしょうか？

Heroku Integrationという仕組みによって、サーバーレスにより近い状態になってきたのではないでしょうか？
（いやまぁ...実際にはHerokuアプリの状態を監視したり、WebDynoとして常駐させたり、自動スケーリングは条件付きだったり、とはありますが。）


# 8.おわりに

Getting Startedまで進められた、ということでひとまず「完全に理解した」状態になりましたでしょうか？

Salesforce Functionsが、こういった形でカムバックしてきたのだと思いますが、これは非常に良いことだと思います。SalesforceとHerokuの両方を使っている方々におかれましては、積極的に使っていきましょう！

その他、Heroku Integrationに関する詳細は[公式ドキュメント:Heroku Integration](https://devcenter.heroku.com/articles/heroku-integration)も併せて参照ください。

なお、本記事は[Heroku Advent Calendar 2024](https://qiita.com/advent-calendar/2024/heroku)の15日目の記事になります。