javasalesforceapexsfdc

Salesforce: Upload Apex Trigger via Java


I want to upload an ApexTrigger to Salesforce via Java so that my available options as far as I know are either:

For authentication i have the option to either use the OAuth token returned from Salesforce while logging on to the REST API or username, password, security token, client id, client secret, while the first approach to reuse the REST API token would be my favorite one.

None of my actual attempts worked yet and my question is obviously how to get it working or where my mistake is.

I've tried the following:

Metadata API approach

I started with the Metadata API approach:

@Test
    void testTriggerUploadMetadataApi() {
        String username = "REDACTED";
        String password = "REDACTED";
        String securityToken = "REDACTED";
        String url = "https://REDACTEDDOMAIN.my.salesforce.com/services/Soap";
        String authPrefix = "/u";
        String servicePrefix = "/m";
        String version = "/60.0";


        LoginResponse loginResponse = this.authController
                .authLoginPost(new Login("REDACTED", "REDACTED").thirdPartyConfig(thirdPartyConfig))
                .block();
        assertNotNull(loginResponse);
        // Session is here a wrapper for the actual REST API OAuth Token with additional metadata
        Session session =
                assertDoesNotThrow(() -> new ObjectMapper().readValue(loginResponse.getToken(), Session.class));

        ConnectorConfig connectorConfig = new ConnectorConfig();
        connectorConfig.setUsername(username);
        connectorConfig.setPassword(password + securityToken);
        connectorConfig.setAuthEndpoint("https://login.salesforce.com/service/Soap" + authPrefix + version);
        connectorConfig.setServiceEndpoint(url + servicePrefix + version);
        connectorConfig.setProxy("REDACTED", 8080);
        Transport transport = new JdkHttpTransport(connectorConfig);
        connectorConfig.setTransport(transport.getClass());

        MetadataConnection metadataConnection = assertDoesNotThrow(() -> new MetadataConnection(connectorConfig));
        metadataConnection.setSessionHeader(session.getBearerTokenString());

        ApexTrigger apexTrigger = new ApexTrigger();
        apexTrigger.setFullName("SomeTrigger");
        apexTrigger.setStatus(ApexCodeUnitStatus.Active);
        apexTrigger.setApiVersion(56.0);
        apexTrigger.setContent(
                "trigger SomeTrigger on Account(after insert, after update {}".getBytes(StandardCharsets.UTF_8));

        assertDoesNotThrow(() -> metadataConnection.upsertMetadata(new Metadata[] {apexTrigger}));
    }

Which resulted in:

org.opentest4j.AssertionFailedError: Unexpected exception thrown: com.sforce.ws.SoapFaultException: INVALID_TYPE: This type of object is not available for this organization
    at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:152)
    at org.junit.jupiter.api.AssertDoesNotThrow.createAssertionFailedError(AssertDoesNotThrow.java:84)
    at org.junit.jupiter.api.AssertDoesNotThrow.assertDoesNotThrow(AssertDoesNotThrow.java:75)
    at org.junit.jupiter.api.AssertDoesNotThrow.assertDoesNotThrow(AssertDoesNotThrow.java:58)
    at org.junit.jupiter.api.Assertions.assertDoesNotThrow(Assertions.java:3228)
    at redacted.package.name.SFLoginTest.testTriggerUploadMetadataApi(SFLoginTest.java:139)

I then started to research a little and found the following trailblazer thread where in the comments Daniel Ballinger from Salesforce states that it's probably impossible since "This metadata type is not supported by the create(), delete(), and update() calls. While not explicit, this also applies to the createMetadata() method, which create() was depricated to."

He also states "Instead you can use the Metadata API deploy method, or the Tooling API to create the Apex Class." so that i researched further where i found this trailblazer thread where Sunad Rasane states that he used the Tooling API instead with a link to this stackexchange thread where an example for a trigger upload via Tooling API was already present so that I opted for using the Tooling API since it looked convenient to use:

Tooling API approach

@Test
    void testTriggerUploadToolingApi() {

        Map<String, Object> thirdPartyConfig = getThirdPartyConfig();

        LoginResponse loginResponse = this.authController
                .authLoginPost(new Login("REDACTED", "REDACTED").thirdPartyConfig(thirdPartyConfig))
                .block();
        assertNotNull(loginResponse);
        // Session is here a wrapper for the actual REST API OAuth Token with additional metadata
        Session session =
                assertDoesNotThrow(() -> new ObjectMapper().readValue(loginResponse.getToken(), Session.class));
        String url = session.getBaseUrl() + "/services/data/" + session.getApiVersion() + "/sobjects/ApexTrigger";
        System.out.println("Request URL: [" + url + "]");

        Map<String, String> apexTrigger = new LinkedHashMap<>();
        apexTrigger.put("Name", "UploadTest_AccountTrigger");
        apexTrigger.put("TableEnumOrId", "Account");
        apexTrigger.put(
                "Body",
                assertDoesNotThrow(() -> Files.readString(
                        Path.of("src/test/resources/salesforce/apex/SimpleAccountTriggerExpected.txt"))));

        String requestBody = assertDoesNotThrow(() -> new ObjectMapper().writeValueAsString(apexTrigger));
        System.out.println("Request body: [" + requestBody + "]");

        HttpClient httpClient = HttpClient.newBuilder()
                .proxy(ProxySelector.of(new InetSocketAddress("REDACTED", 8080)))
                .connectTimeout(Duration.of(60, ChronoUnit.SECONDS))
                .build();
        HttpRequest uploadRequest = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .timeout(Duration.of(60, ChronoUnit.SECONDS))
                .POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8))
                .header("Authorization", "OAuth " + session.getBearerTokenString())
                .header("Content-Type", "application/json")
                .build();
        HttpResponse<String> response =
                assertDoesNotThrow(() -> httpClient.send(uploadRequest, HttpResponse.BodyHandlers.ofString()));
        System.out.println("Status Code: [" + response.statusCode()+ "]");
        System.out.println("Body: [" + response.body() + "]");
        
        assertTrue(String.valueOf(response.statusCode()).startsWith("2"));
    }

Which results in:

Expected :true
Actual   :false
<Click to see difference>

org.opentest4j.AssertionFailedError: expected: <true> but was: <false>
    at org.junit.jupiter.api.AssertionFailureBuilder.build(AssertionFailureBuilder.java:151)
    at org.junit.jupiter.api.AssertionFailureBuilder.buildAndThrow(AssertionFailureBuilder.java:132)
    at org.junit.jupiter.api.AssertTrue.failNotTrue(AssertTrue.java:63)
    at org.junit.jupiter.api.AssertTrue.assertTrue(AssertTrue.java:36)
    at org.junit.jupiter.api.AssertTrue.assertTrue(AssertTrue.java:31)
    at org.junit.jupiter.api.Assertions.assertTrue(Assertions.java:183)
    at redacted.package.nameSFLoginTest.testTriggerUploadToolingApi(SFLoginTest.java:83)
    at java.base/java.lang.reflect.Method.invoke(Method.java:568)
    at io.micronaut.test.extensions.junit5.MicronautJunit5Extension$2.proceed(MicronautJunit5Extension.java:142)
    at io.micronaut.test.extensions.AbstractMicronautExtension.interceptEach(AbstractMicronautExtension.java:157)
    at io.micronaut.test.extensions.AbstractMicronautExtension.interceptTest(AbstractMicronautExtension.java:114)
    at io.micronaut.test.extensions.junit5.MicronautJunit5Extension.interceptTestMethod(MicronautJunit5Extension.java:129)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)

With the following output on STDOUT:

Request URL: [https://REDACTEDDOMAIN.my.salesforce.com/services/data/56.0/sobjects/ApexTrigger]
Request body: [{"Name":"UploadTest_AccountTrigger","TableEnumOrId":"Account","Body":"trigger Account_AccountWebhook on Account (after insert, after update) {}"}]
Status Code: [404]
Body: [[{"errorCode":"NOT_FOUND","message":"The requested resource does not exist"}]]

Solution

  • So after the depressing finding that the "v" prefix for the version is missing the Tooling API approaches base URL it works now as described.

    Below you can find the correct way to build the request URL (replace this in the Tooling API Approach:

    String url = session.getBaseUrl() + "/services/data/v" + session.getApiVersion() + "/sobjects/ApexTrigger";