WebフォームからSalesforce連携。レコード作成と画像アップロード。

今回やりたいこと

Webフォームでエンドユーザーからデータ入力(テキスト&画像)を受け付ける。

Salesforceへ入力データを送信。
テキスト情報からレコードを作成し、アップロードした画像と作成したレコードを紐づける。

※ 本記事は2023/10/04時点での筆者の知る範囲での情報の公開であり、再現性や結果を保証するものではありません。当記事の内容を応用する場合は、ご自身でもDYORの精神で情報収集することを推奨します。

APIの接続方法概要

OAuth認証を使用して接続します。
OAuth認証方法の一覧はこちらにありますので、詳細はそちらをご覧ください。

一般的なOAuth認証

イメージ

  1. ユーザがアプリケーションを通してSalesforceのリソースにアクセスしようとすると、アプリケーションはユーザを認証サーバーのログインページに移動します(認証サーバーへのリダイレクト)。
    ユーザは認証サーバーへログイン後、アプリケーションがSalesforceのリソースにアクセスすることを認証するかどうかを求められます。
  2. 1で認証されると、認証サーバーはユーザーを再びアプリケーションのページに案内します(アプリケーションへのリダイレクト)。
    その際にアプリケーションは認証サーバーからSalesforceのリソースにアクセスするためのトークンを取得します(認証コードのステップを挟むなど詳細は認証方法によって変わります)。
  3. ユーザはアプリケーションで任意の操作(データ入力など)を行います。
  4. アプリケーションはアクセストークンを使用してSalesforcへ接続し、データのやりとり(ユーザの入力データを反映させるなど)を行います。

この仕組みにより、ユーザはアプリケーションに対して自身のログイン情報を直接共有せずに特定のアクセス許可を与えることができます。
また、エンドユーザがSalesforceのユーザであることが前提です。

Salesforceで上記の認証をするにはOAuth 2.0 Web サーバーフローを使うことになります。

その他にユーザ名パスワードフローがありますが、この方法はエンドユーザ情報をアプリケーションが知ることになるので非推奨のようです。
2023年夏からはデフォルト設定では無効になっていました。参考

今回行うOAuth認証、固定のユーザでログイン

今回はWebフォームを入力するエンドユーザーはSalesforceのユーザではない場合を想定するので、事前にSalesforceとの接続に使用するユーザを決めておき、そのユーザ情報をもとにアプリケーション側でSalesforceへアクセスします。

イメージ

アプリケーションの中をさらに具体的に見ると、エンドユーザーの入力を受け付けるWebフォームをクライアントサイドで実装し、認証サーバーやSalesforceとの接続はサーバーサイド側で扱います。
サーバーサイドで認証関連を行うことで、ユーザ情報をクライアントサイドに公開しないようにします。

設定方法

Salesforceで上記の認証をするには、OAuth 2.0 クライアントログイン情報フローを使用します。

ちなみに今回はこちらから無料アカウントを作成し本番環境で挙動を確かめたので、Sandbox環境だと少し違いが出るかもしれません。

  1. API用のユーザ(インテグレーションユーザ)を作成します。
    • プロファイルの作成。
      [Salesforce API Only System Integrations]というプロファイルをコピーし新たにプロファイルを作成します。
      作成したプロファイルを編集して、フォームから作成する任意のオブジェクトへの作成権限を有効化します。今回はサンプル(API参照名: Sample__c) というオブジェクトを作ることにします。 [Salesforce API Only System Integrations]はインテグレーションユーザの説明にもあるように、セキュリティの観点からAPIの操作権限を最小化しているものです。
    • ユーザを作成し、ライセンスに[Salesforce Integration]を、プロファイルに先ほど作成したものを設定します。
  2. 接続アプリケーション設定をリンク先に従って進めます。
    特にOAuth設定の部分では以下がポイントです。
    • スコープ設定で以下を選択
      • [API を使用してユーザデータを管理(api)]
      • [いつでも要求を実行(refresh_token, offline_access)]
    • 使用するユーザ(先ほど作成したユーザ)の登録
    • コールバックURL(認証成功後に認証サーバーがリダイレクト・トークン付与する先のURL)の設定はWebサーバーフローなどで必要であり、今回は使用しないためダミーで構いません。

その他の認証方法には、OAuth 2.0 JWT ベアラーフローなどがあります。

画像データの送信方法

画像の扱い方についても先に触れます。

Salesforceで画像を扱うには[ファイル]と[メモ&ファイル]という二つの機能があり、ファイルはLightning Experienceで新しく追加されたものです。
そして、ファイルを操作するには以下のオブジェクトを利用します。

  • CotentDocument.
    ファイルをアップロードすると自動で作成されるもの。
    ファイルそのもののメタデータや属性(例: タイトル、所有者など)を持ちますが、ファイルの実際の内容やバージョン情報は持っていません。
  • ContentVersion.
    ContentVersionはファイルの実際の内容(バイナリデータ)、そのバージョンの詳細、ファイルのタイプやサイズなどの情報を持っています。
  • ContentDocumentLink.
    ファイルと他のオブジェクトとのリンク情報を持ちます。

例えばSalesforceの画面上で、オブジェクトAのレコードページの関連リスト「ファイル」にファイルBをアップロードすると、次のステップが実行されます。

  1. アップロードされたファイルBの情報はContentVersionオブジェクトに保存されます。
  2. ファイルがアップロードされると、関連するContentDocumentが自動的に作成されます。
  3. そして、このファイルがオブジェクトAのレコードに関連付けられるために、ContentDocumentLinkレコードが作成されます。
    このレコードのContentDocumentIdフィールドは、ファイルBのContentDocumentのIDに設定され、LinkedEntityIdフィールドはオブジェクトAのレコードIDに設定されます。

[このセクションの参考資料]

node.jsで簡単な実装

node.jsでサーバーを立ち上げ、実際にSalesforceにアクセスしてみます。

必要な情報

  • クライアントキーとクライアントシークレット。
    [設定] → [アプリケーションマネージャ]で先ほど作成した接続アプリケーションの[参照]を選択すると、以下のような[コンシューマの詳細を管理]というボタンがあるのでクリックします。 すると、[コンシューマ鍵]と[コンシューマの秘密]という二つの情報が表示されるので控えておきます。
  • 環境のドメイン
    [設定] → [私のドメイン]を選択すると私のドメインのURLという情報が表示されるのでこちらも控えます。

コード

npmもしくはyarnを使用してモジュールを作成し、server.jssalesforceAPIというファイルを作成します。
server.jsにサーバー処理、salesforceAPIsalesforceと連携用の関数を用意します。
必要なライブラリ等はimportを見て適宜インストールしてください。

server.js

app.get(...の部分では、GETリクエスト時にhtmlを返却する関数を登録しています。
これによりクライアントサイドでWebフォームを形成します。

また、app.post(...の部分では、WebフォームからPOSTされた情報をsubmitRequestToSalesforceという関数を使用してsalesforceへ送信する関数を登録しています。
submitRequestToSalesforceについては次のセクションで扱います。

import { submitRequestToSalesforce } from "./salesforceAPI.js";
import express from "express"; // httpサーバーを作成するためのモジュール
import multer, { memoryStorage } from "multer"; // multipart/form-dataをパースするためのモジュール(クライアントサイドからのファイルを受け取るために使う)

const app = express();
const PORT = 3000;

// ファイルをメモリ上に一時的に保存するためのmulterの設定
const upload = multer({ storage: memoryStorage() });

// クライアントサイドからのGETに対してhtmlを返す
app.get("/", (req, res) => {
  res.send(`
    <form action="/" method="post" enctype="multipart/form-data">
        <label for="Name">名前:</label><br>
        <input type="text" id="Name" name="Name" required><br><br>

        <label for="file1">ファイル1:</label>
        <input type="file" name="files"><br><br>

        <label for="file2">ファイル2:</label>
        <input type="file" name="files"><br><br>

        <input type="submit" value="Submit">
    </form>
  `);
});

// クライアントサイドからPOSTされた情報をSalesforceへ送信する
app.post("/", upload.array("files", 2), async (req, res) => {
  try {
    const objectApiName = "Sample__c";
    const requestObjectData = {
      Name: req.body.Name,
    };
    const files = req.files;
    const success = await submitRequestToSalesforce(
      objectApiName,
      requestObjectData,
      files
    );
    if (success) {
      res.send("Success");
    } else {
      res.status(500).send("Error occurred while processing your request.");
    }
  } catch (error) {
    console.error(error);
    res.status(500).send(error.message);
  }
});

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

node server.jsを実行し、http://localhost:3000/にアクセスすると以下のようにフォームが表示されます。

salesforceAPI.js

salesforceAPI.jsでは、salesforceとの接続とsalesforceへのデータ送信の二つを行います。
初めに示すコードでは、(salesforceへのデータ送信を扱う)submitRequestToSalesforce関数の中身は省略しています。

authenticateWithSalesforce関数では、先ほど設定したクライアントログイン情報フローによる認証(salesforceとの接続)を行なっています。
認証リクエストの返り値のアクセストークンやインスタンスURLを、後ほどsalesforceへのデータ送信に利用します。

ここで、process.env.SF_CLIENT_IDなどの環境変数はdotenvパッケージを使用して.envファイルから取得しています。
テストであれば環境変数を使わず直接書き込んでも良いと思います。

# .envファイル
SF_CLIENT_ID="事前に控えたコンシューマ鍵"
SF_CLIENT_SECRET="事前に控えたコンシューマの秘密"
SF_MY_DOMAIN="事前に控えた私のドメイン"
import dotenv from "dotenv"; // 環境変数を扱うためのモジュール
import axios from "axios"; // HTTPリクエストを送信するためのモジュール
import FormData from "form-data"; // multipart/form-dataにエンコーディングするためのモジュール(Salesforceへのファイル送信に使う)
dotenv.config();

let sfAccessToken; // Salesforceから取得したアクセストークン
let sfInstanceUrl; // SalesforceのインスタンスURL

export const submitRequestToSalesforce = async (
  objectApiName,
  requestObjectData,
  files
) => {
  if (!sfAccessToken || !sfInstanceUrl) {
    await authenticateWithSalesforce();
  }

  ...
};

const authenticateWithSalesforce = async () => {
  // OAuth 2.0 クライアントログイン情報フローを使用: https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_client_credentials_flow.htm&type=5
  try {
    const response = await axios.post(
      `https://${process.env.SF_MY_DOMAIN}/services/oauth2/token`,
      null,
      {
        params: {
          grant_type: "client_credentials",
          client_id: process.env.SF_CLIENT_ID,
          client_secret: process.env.SF_CLIENT_SECRET,
        },
      }
    );
    sfAccessToken = response.data.access_token;
    sfInstanceUrl = response.data.instance_url;
  } catch (error) {
    if (error.response) {
      console.error("Auth error response:", error.response.data);
    } else {
      console.error("Auth request error:", error.message);
    }
  }
};

次にsubmitRequestToSalesforceについてです。

事前に整形されたデータがrequestObjectDatafilesとして引数で渡ってきます。

初めに(const sfResponse = await axios.post( ...の部分)アクセストークンやrequestObjectDataを使用してサンプルオブジェクトのレコードを作成します。
返り値からは作成したレコードのIdが取得できます。

次にファイルの数だけループ処理で以下を繰り返します。

  1. ContentVersionの作成
  2. 1で自動で作成されるContentDocumentから、レコードとのリンクに使用するidの取得
  3. ContentDocumentLinkを作成して、作成したレコードとファイルを紐づける
export const submitRequestToSalesforce = async (
  objectApiName,
  requestObjectData,
  files
) => {
  if (!sfAccessToken || !sfInstanceUrl) {
    await authenticateWithSalesforce();
  }

  try {
    // Sampleの作成
    const sfResponse = await axios.post(
      `${sfInstanceUrl}/services/data/v58.0/sobjects/${objectApiName}`,
      JSON.stringify(requestObjectData),
      {
        headers: {
          Authorization: `Bearer ${sfAccessToken}`,
          "Content-Type": "application/json",
        },
      }
    );
    const objectId = sfResponse.data.id;

    for (const file of files) {
      // ContentVersionの作成
      const fileName = file.originalname;
      const fileContent = file.buffer;
      let formData = new FormData();
      formData.append(
        "entity_content",
        JSON.stringify({
          ReasonForChange: "Uploaded via form",
          PathOnClient: fileName,
        }),
        {
          contentType: "application/json",
        }
      );
      formData.append("VersionData", fileContent, {
        filename: fileName,
        contentType: "application/octet-stream",
      });
      const sfResponse2 = await axios.post(
        `${sfInstanceUrl}/services/data/v58.0/sobjects/ContentVersion`,
        formData,
        {
          headers: {
            Authorization: `Bearer ${sfAccessToken}`,
            ...formData.getHeaders(),
          },
        }
      );
      const contentVersionId = sfResponse2.data.id;

      // ContentVersionに関連するContentDocumentのIDを取得
      const versionResponse = await axios.get(
        `${sfInstanceUrl}/services/data/v58.0/sobjects/ContentVersion/${contentVersionId}`,
        {
          headers: {
            Authorization: `Bearer ${sfAccessToken}`,
          },
        }
      );
      const contentDocumentId = versionResponse.data.ContentDocumentId;

      // ContentDocumentLinkオブジェクトを作成(ファイルをSample__cオブジェクトに関連付ける)
      const linkData = {
        ContentDocumentId: contentDocumentId,
        LinkedEntityId: objectId,
        ShareType: "V",
      };
      await axios.post(
        `${sfInstanceUrl}/services/data/v58.0/sobjects/ContentDocumentLink`,
        linkData,
        {
          headers: {
            Authorization: `Bearer ${sfAccessToken}`,
            "Content-Type": "application/json",
          },
        }
      );
    }
    return true;
  } catch (error) {
    if (error.response) {
      console.error("REST API error response:", error.response.data);
    } else {
      console.error("REST API request error:", error.message);
    }
    return false;
  }
};

これにより「レコードの作成 → ファイルのアプロード → レコードとファイルの紐付け」が実現できます。

ブラウザからWebフォームを提出すると、

salesforce画面上でレコードが作成されているので(リストを「すべて選択」にしないと見れません)、

中身を確認すると[関連]にファイルも紐づけられています。

[このセクションの参考資料]

まとめ

以上になります!

ちなみに、ここではファイルを扱うのにmulterFormDataaxios等を使用してリクエストを作成しましたが、
別の機会(Next.jsでのアプリ構築)に同じようなリクエスト作成を再現しようとしても400エラー(Multipart message must include a non-binary part)が返ってきてしまいました。
ライブラリによる多少の違いでsalesforceが受け付けてくれないのかもしれません。
その時はこちらに記載されてるリクエストボディを参考にスクラッチでリクエストを作成しました。

メモ