Zenn
💣

SpringのRestTemplateだけでSalesforceのカスタムメタデータレコードを更新する

2025/03/31に公開

はじめに

Salesforce Platformでは、組織に関するカスタマイズ設定は「メタデータ」として定義・管理されています。多くの場合はGUIを通じて設定画面から手動操作で行いますが、外部から「メタデータAPI」と呼ばれる専用のAPIを使い、コマンドラインやプログラムから操作することもできます。

その場合の手段として一番の候補に上がるのは公式のSalesforce DX(sfdx CLI)ですが、Javaからアクセスする場合は、公式のWSC(Web Services Connector)があります。

https://developer.salesforce.com/docs/atlas.ja-jp.salesforce_developer_environment_tipsheet.meta/salesforce_developer_environment_tipsheet/salesforce_developer_environment_wsc.htm

一方、JavaのポピュラーなフレームワークであるSpring Bootでアプリを作成する場合、通常RestClient, WebClient, RestTemplateあたりを使用します。
WSCのHTTP通信はJDK標準のHttpURLConnectionをラップした独自のAPIとなっており、タイムアウト、ロギング、リトライなどの共通的な振る舞いを制御したくなった際に、Spring Bootアプリ側とWSCとで異なる設定方法をメンテしていくのは不便です。またWSCはSalesforceのメジャーリリースに伴うAPIバージョンや依存ライブラリの更新など、継続的にメンテナンスは行われていますが、それ以外の改修は積極的ではないような印象があり、将来性にも少し不安があります。

SalesforceのAPIではSOAP(Simple Object Access Protocol)やXMLなどが登場するためハードルが高いように感じますが、単純なユースケースであればWSCに頼らなくてもよいため、 RestTemplate での実装を紹介します。

前提条件

  • Java 21 (Amazon Corretto 21)
  • Spring Boot 3.4.3

メタデータAPI

タイトルにある通り、今回は「カスタムメタデータレコード」の操作を行います。
カスタムメタデータレコードですが、その前に、Salesforce Platformには一般的な業務システムにおけるデータには大きく2種類あります。

  • メタデータ
  • テーブル・オブジェクトに格納されたレコード(ビジネスデータ)

カスタムメタデータレコードについては、レコードと名前はついていますが、取引先レコードなどのようなオブジェクトのようにREST APIで直接更新することはできません。なぜなら、カスタムメタデータは「メタデータ」として扱われるためです。
カスタムメタデータレコードを操作するには、メタデータAPIが必要になります。

https://developer.salesforce.com/docs/atlas.ja-jp.api_meta.meta/api_meta/meta_rest_deploy.htm

Javaでの実装例

本記事ではJavaアプリケーションにおける application.properties のように、設定名と設定値のペアをもつカスタムメタデータをサンプルとして取り上げます。

カスタムメタデータ型の作成

アプリケーション設定(Setting)としてカスタムメタデータ型を作成しましょう。API参照名は Setting__mdt となります。

設定名については標準のカスタムメタデータレコード名 DeveloperName をそのまま使います。
設定値についてはカスタム項目として作成する必要があり Value(API参照名は Value__c )という項目名の文字列型のカスタム項目で作成します。

Salesforce側の設定は以上です。あとはJava(Spring Boot)側で実装しましょう。

Recordの作成

まず、上記のカスタムメタデータレコードを扱うためのJavaクラスを SettingRecord として作成します。
最終的にデプロイする際にはXMLが必要になるので、このRecordのメソッドとして、XMLを返却する toMetadataXml も用意します。
コード上ではRecord(Java17〜)、テキストブロック(Java15〜)、formattedメソッド(Java15〜)を使っています。

SettingRecord
public record SettingRecord(String id,
                            String developerName,
                            String label,
                            String namespacePrefix,
                            String value) {

    /**
     * メタデータXMLとして返す。
     */
    public String toMetadataXml() {
        return """
                <?xml version="1.0" encoding="UTF-8"?>
                <CustomMetadata xmlns="http://soap.sforce.com/2006/04/metadata">
                    <fullName>Setting__mdt.%s</fullName>
                    <label>%s</label>
                    <values>
                        <field>Value__c</field>
                        <value>%s</value>
                    </values>
                </CustomMetadata>
                """.formatted(developerName, label, value);
    }
}

また、デプロイ結果をレスポンスとして受け取るためのクラスも作っておきます。
デプロイ結果についてはDeployResultというオブジェクト構造であり、詳細は下記のドキュメントの通りです。

https://developer.salesforce.com/docs/atlas.en-us.api_meta.meta/api_meta/meta_deployresult.htm

DeployResult
public record DeployResult(@JsonProperty("id") String id,
                           @JsonProperty("messages") Object messages,
                           @JsonProperty("retrieveResult") Object retrieveResult,
                           @JsonProperty("success") Boolean success,
                           @JsonProperty("checkOnly") Boolean checkOnly,
                           @JsonProperty("ignoreWarnings") Boolean ignoreWarnings,
                           @JsonProperty("rollbackOnError") Boolean rollbackOnError,
                           @JsonProperty("status") String status,
                           @JsonProperty("numberComponentsDeployed") Integer numberComponentsDeployed,
                           @JsonProperty("numberComponentsTotal") Integer numberComponentsTotal,
                           @JsonProperty("numberComponentErrors") Integer numberComponentErrors,
                           @JsonProperty("numberTestsCompleted") Integer numberTestsCompleted,
                           @JsonProperty("numberTestsTotal") Integer numberTestsTotal,
                           @JsonProperty("numberTestErrors") Integer numberTestErrors,
                           @JsonProperty("errorStatusCode") String errorStatusCode,
                           @JsonProperty("errorMessage") String errorMessage) {
}

デプロイ要求に対するレスポンスは、上記のDeployResultidを持つJSONになるようで、こちらのRecordクラスを作っておきます。

DeployResponse
public record DeployResponse(@JsonProperty("id") String id, @JsonProperty("deployResult") DeployResult deployResult) {
}

デプロイ要求

Recordの作成は以上です。いよいよデプロイ要求を行うコードを記述しましょう。
流れとしては以下のようになります。

  1. package.xml を作る
  2. 登録するカスタムメタデータレコード用のメタデータXMLを作る
  3. 1.と2.をzipファイルでまとめる
  4. 3.とデプロイオプションを含めたリクエストフォーム作成
  5. 4.でデプロイ要求をリクエストする
private DeployResponse deployRequest(String accessToken, String restUrl, List<SettingRecord> records) throws IOException {
    String deployUrl = restUrl + "metadata/deployRequest";

    String packageXml = """
            <?xml version="1.0" encoding="UTF-8"?>
            <Package xmlns="http://soap.sforce.com/2006/04/metadata">
                <types>
                    <members>*</members>
                    <name>CustomMetadata</name>
                </types>
                <version>63.0</version>
            </Package>
            """;

    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
    try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
        // 1. package.xml
        ZipEntry packageXmlEntry = new ZipEntry("package.xml");
        zipOutputStream.putNextEntry(packageXmlEntry);
        zipOutputStream.write(packageXml.getBytes(StandardCharsets.UTF_8));
        zipOutputStream.closeEntry();

        // 2. カスタムメタデータレコード用のメタデータXML
        records.forEach(record -> {
            try {
                ZipEntry metadataEntry = new ZipEntry("customMetadata/Setting__mdt.%s.md".formatted(record.developerName()));
                zipOutputStream.putNextEntry(metadataEntry);
                zipOutputStream.write(record.toMetadataXml().getBytes(StandardCharsets.UTF_8));
                zipOutputStream.closeEntry();
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        });
    }
    // 3. zip作成
    byte[] zipBytes = byteArrayOutputStream.toByteArray();

    // 4. リクエストフォーム作成
    MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();

    // 4.1 リクエスト(デプロイオプション)
    HttpHeaders jsonHeaders = new HttpHeaders();
    jsonHeaders.setContentType(MediaType.APPLICATION_JSON);
    String jsonPayload = """
                {
                    "deployOptions" : {
                        "allowMissingFiles" : false,
                        "autoUpdatePackage" : false,
                        "checkOnly" : false,
                        "ignoreWarnings" : false,
                        "performRetrieve" : false,
                        "purgeOnDelete" : false,
                        "rollbackOnError" : false,
                        "runTests" : null,
                        "singlePackage" : true,
                        "testLevel" : "NoTestRun"
                    }
                }
            """;
    HttpEntity<byte[]> jsonEntity = new HttpEntity<>(jsonPayload.getBytes(), jsonHeaders);
    body.add("json", jsonEntity);

    // 4.2 リクエスト(zip)
    body.add("file", new ByteArrayResource(zipBytes) {
        @Override
        public String getFilename() {
            return "package.zip";
        }
    });

    HttpHeaders deployHeaders = new HttpHeaders();
    deployHeaders.setBearerAuth(accessToken);
    deployHeaders.setContentType(MediaType.MULTIPART_FORM_DATA);
    HttpEntity<MultiValueMap<String, Object>> deployRequest = new HttpEntity<>(body, deployHeaders);

    // 5. リクエスト要求
    ResponseEntity<DeployResponse> deployResponse = restTemplate.exchange(deployUrl, HttpMethod.POST, deployRequest, DeployResponse.class);
    return deployResponse.getBody();
}

リクエストに必要なアクセストークンと、接続先ごとに異なるAPIのエンドポイントは引数で渡しています。

デプロイ結果の確認

メタデータデプロイは非同期処理のため、上記のリクエストから応答が返ってきても、デプロイが終わっているわけではありません。上記で取得したデプロイIDを用いて、デプロイが終わっているかどうかを調べる必要があります。
今回は、1秒おきに問い合わせを行いながら、デプロイ処理が終わるのを待つようにしましょう。ただし何らかの理由で無限ループするのを避けるため、暫定的に15秒でタイムアウトするようにしています。

// デプロイ処理が終わるまで待つ
private DeployResult waitForCompletion(String accessToken, String restUrl, String deploymentId) {
    String deployUrl = restUrl + "metadata/deployRequest/" + deploymentId + "?includeDetails=true";

    HttpHeaders headers = new HttpHeaders();
    headers.setBearerAuth(accessToken);

    // タイムアウト(15秒)
    final Duration timeout = Duration.ofSeconds(15);

    final long startTime = System.nanoTime();
    HttpEntity<String> entity = new HttpEntity<>(headers);
    while (Duration.ofNanos(System.nanoTime() - startTime).compareTo(timeout) < 0) {
        ResponseEntity<DeployResponse> response = restTemplate.exchange(deployUrl, HttpMethod.GET, entity, DeployResponse.class);
        DeployResponse responseBody = response.getBody();
        if (responseBody == null) {
            throw new ApplicationException("error");
        }

        DeployResult deployResult = responseBody.deployResult();
        String deployResultStatus = deployResult.status();

        if (deployResultStatus.equals("InProgress") || deployResultStatus.equals("Pending")) {
            Thread.sleep(1000);
        } else {
            return deployResult;
        }
    }

    throw new ApplicationException("metadata deploy timed out.");
}

これらをまとめた実際のデプロイは以下のような処理になります。

public void deploy(String accessToken, String restUrl, List<SettingRecord> records) {
    DeployResponse deployResponse = deployRequest(accessToken, restUrl, records);

    DeployResult deployResult = waitForCompletion(accessToken, restUrl, deployResponse.id());

    if ((deployResult.status().equalsIgnoreCase("Completed") || deployResult.status().equalsIgnoreCase("Succeeded"))
            && deployResult.errorStatusCode() == null
            && deployResult.numberComponentsTotal() == settings.size()) {
        // デプロイ成功
    } else {
        // デプロイエラー
        throw new ApplicationException("deploy error: " + deployResult.errorMessage());
    }
}

上記をうまく呼び出してあげれば、カスタムメタデータレコードを登録・更新することができます。

おわりに

本記事では、WSCを使わずにJava + Spring BootのRestTemplateを使ったカスタムメタデータの登録を行う実装について紹介しました。
ChatGPTをたよりにサクッと動かすつもりだったのですが、なかなかうまく動かず、最終的には公式のドキュメントを見ながら実装するに至りました。
やっていることは本番リリース作業と同等のメタデータのデプロイそのものなので、色々な応用は考えられそうですね。ただ、できればもうちょっとお手軽に操作できるようになってほしいです🙂

株式会社キャリオット

Discussion

ログインするとコメントできます