Glen Mazza's Weblog

https://glenmazza.net/blog/date/20210704 Sunday July 04, 2021

Publishing Salesforce platform events via Apex triggers and REST controllers

For change data capture and push topic events, Salesforce itself generates event messages whenever specified changes to specified objects occur. Salesforce also offers platform and generic events to allow for users to send messages of a specified structure at times of the developer's choosing. For coverage of Platform Events, Salesforce offers a Developer Guide as well as a Trailhead Module for hands-on learning.

Salesforce's generic events have their own guide but generally provide less developer support compared to platform events. For one, it is only with the CometD client that generic events can be listened to, and these messages apparently have a message size limit of 3K compared to the 1 MB provided for platform events. On the other hand, some advantages for generic events include its ability to send any arbitrary JSON as well as specify the users to receive each message. While Platform event messages will stream as JSON, they don't appear to support JSON objects within its payload. Instead, JSON objects need to be streamed as JSON strings and manually unpacked from the client receiving the message.

In the steps below I'll be featuring additional functionality beyond that covered in the Trailhead tutorial, providing examples of including data from multiple object types (here, both Accounts and Contacts in one message) as well as publishing platform events via triggers attached to specified objects and via an Apex REST controller (the latter allowing external systems to request that an event be generated.). My Salesforce Java client from a previous tutorial includes support for calling Apex REST controllers and the separate Change Data Capture (CDC) listener can be modified to read the platform events generated, as CDC events are a specialized case of platform events.

  1. Define the custom platform event. The Trailhead tutorial shows how platform events are defined. Here, I'm creating an AccountAndContacts__e event below, to show how we can create a message consisting of multiple sObject types (here, Account and Contact), as well as generate multiple messages to report on all contacts (there is a 128K limit for contacts per message, but for demonstration purposes below I'll be limiting each message to five contacts). Note the Contact field will exist as a JSON string, containing an array of objects (for this example, we'll have the User's Salesforce ID, name, and email address). From the perspective of the platform event, however, we can define this only as a string without specifying further definition for its contents.

    Define Platform Event

  2. Create an Apex method (and test class) for generating platform events.. This generator will create, for a given account, one platform event message for every five contacts at that account. I keep a "calls" static variable to indicate a platform event getting published, which helps immensely when writing tests. Note as given in the comments this variable's lifetime is just within- and per-transaction and not global as it would be in Java, further helping its usage in testing.

    public class AccountAndContactsEventGenerator {
    	
        // Counter to check that this generator is being called (useful for testing objects calling the generator)
        // https://salesforce.stackexchange.com/questions/204805/test-that-a-platform-event-was-published/204806
        // Note, lifetime of this object per transaction, not system-wide as in Java, see:
        // https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_classes_static.htm
        public static Integer calls = 0;
    
        public static void publishAccountAndContactsEvents(Account account) {
            List<AccountAndContacts__e> events = getEventMessages(account);        
            List<Database.SaveResult> results = EventBus.publish(events);
                
            for (Database.SaveResult sr : results) {
                if (sr.isSuccess()) {
                    System.debug('Successfully published event: '+ sr);
                    calls++;
                } else {
                    System.debug('Error when publishing event: ' + sr);
                }
            } 
        }
        
        @testVisible 
        private static List<AccountAndContacts__e> getEventMessages(Account account) {
            List<Contact> contacts = [SELECT FirstName, LastName, Email FROM Contact WHERE AccountId = :account.id]; 
    
            // Sending one event message for every five contacts at the company
            List<AccountAndContacts__e> eventList = new List<AccountAndContacts__e>();
            
            AccountAndContacts__e event = initializeNewEvent(account);
            List<ContactData> contactList = new List<ContactData>();
            for (Integer i = 0; i < contacts.size(); i++) {
                if (i > 0 && math.mod(i, 5) == 0) {
                    event.Contacts__c = JSON.serialize(contactList);
                    contactList.clear();
                    eventList.add(event);
                    event = initializeNewEvent(account);
                }
                ContactData cd = new ContactData();
                Contact con = contacts.get(i);
                cd.userId = con.Id;
                cd.emailAddress = con.Email;
                cd.lastName = con.LastName;
                cd.firstName = con.FirstName;
                contactList.add(cd);
            }
            event.Contacts__c = JSON.serialize(contactList);
            eventList.add(event);
            
            return eventList;
        }
        
        @testVisible
        private static AccountAndContacts__e initializeNewEvent(Account account) {
            AccountAndContacts__e event = new AccountAndContacts__e();
            event.AccountName__c = account.name;
            event.AccountSFID__c = account.id;
            return event;
        }
        
        @testVisible private class ContactData {
            public String userId { get; set; }
            public String emailAddress { get; set ; }
            public String lastName { get; set; }
            public String firstName { get; set; }
        }
    }
    

    Test class: The test creates an account with six contacts (so therefore, two messages will be generated) and parses a sample of the AccountAndContacts__e messages generated to confirm they contain the expected contents. Note within Apex test cases it is not necessary to manually delete objects created as they will be rolled back automatically once the test completes.

    @IsTest
    public class AccountAndContactsEventGeneratorTest {
    
        @IsTest
        private static void testEventGeneration() {
            Account account = new Account();
            account.Name = 'Acme Corp';
            insert account;
    
            List<Contact> contacts = new List<Contact>();
            for (Integer i = 0; i < 6; i++) {
                contacts.add(createContact('First' + i, 'Last' + i, account.id));
            }
            
            List<AccountAndContacts__e> messages = AccountAndContactsEventGenerator.getEventMessages(account);
            System.assertEquals(messages.size(), 2);
            AccountAndContacts__e message = messages.get(0);
            System.assertEquals(message.AccountName__c, 'Acme Corp');
            System.assertEquals(message.AccountSFID__c, account.Id);
            List<AccountAndContactsEventGenerator.ContactData> firstFiveContacts 
                = (List<AccountAndContactsEventGenerator.ContactData>) 
                JSON.deserialize(messages.get(0).Contacts__c, List<AccountAndContactsEventGenerator.ContactData>.class);
    
            System.assertEquals(firstFiveContacts.size(), 5);
            System.assertEquals(firstFiveContacts.get(1).userId, contacts.get(1).Id);
            System.assertEquals(firstFiveContacts.get(2).firstName, 'First2');
            System.assertEquals(firstFiveContacts.get(3).LastName, 'Last3');
            System.assertEquals(firstFiveContacts.get(4).emailAddress, 'last4@yopmail.com');
    
            List<AccountAndContactsEventGenerator.ContactData> sixthContact = 
                (List<AccountAndContactsEventGenerator.ContactData>) 
                JSON.deserialize(messages.get(1).Contacts__c, List<AccountAndContactsEventGenerator.ContactData>.class);
            
            System.assertEquals(sixthContact.size(), 1);
        }
        
        private static Contact createContact(String firstName, String lastName, Id accountId) {
            Contact contact = new Contact();
            contact.FirstName = firstName;
            contact.LastName = lastName;
            contact.AccountId = accountId;
            contact.Email = lastName + '@yopmail.com';
            insert contact;
            return contact;
        }
    }
    
  3. Create an Apex trigger (and test class) to activate generator based on desired criteria.. Here I'm placing an after-update trigger on the Account object that will trigger the generator if the Account's name has changed. This demonstrates how the generator can be selectively activated based on the nature of changes to an object.

    trigger trigger_AccountUpdate on Account (after update) {
        
        for (Account a : Trigger.New) {
            if (!a.Name.equals(Trigger.OldMap.get(a.Id).Name)) {
                AccountAndContactsEventGenerator.publishAccountAndContactsEvents(a);
            }
        }
    }
    

    Test class: The trigger test checks that a platform event message is generated if and only if the Account name changes. As discussed earlier, I rely on the calls static variable in the generator for this.

    @isTest
    public class TestAccountUpdate {
        @isTest
        static void testUpdateWithNameChangeGeneratesEvent() {
           Account account = new Account(Name = 'Acme Corp', BillingCity = 'Philadelphia');
           insert account;
            
           account.name = 'New Acme Corp';
           update account;
            
           System.assertEquals(1, AccountAndContactsEventGenerator.calls);   
        }
        
        @isTest
        static void testUpdateWithNoNameChangeGeneratesNoEvent() {
           Account account = new Account(Name = 'Acme Corp', BillingCity = 'Philadelphia');
           insert account;
            
           account.BillingCity = 'Baltimore';
           update account;
    
           System.assertEquals(0, AccountAndContactsEventGenerator.calls);           
        }
    }
    
  4. Create an Apex REST Controller (and test class) to activate generator for a specific Account.. If you're new to Salesforce REST Controllers, recommend its Trailhead tutorial.. It shows convenient ways of making REST calls via cURL and Salesforce's Developer Workbench. Additionally, my Salesforce client has an ApexRestCaller and integrated tests showing a way to call Apex endpoints from Java.

    @RestResource(urlMapping='/AccountAndContactsPE/*')
    global with sharing class AccountAndContactsEventRESTEndpoint {
        
        @HttpPost
        global static void generatePlatformEvent() {
            RestRequest req = RestContext.request;
            String accountId = req.requestURI.substring(req.requestURI.lastIndexOf('/')+1);
            // using List<Account> instead of Account below for safer handling of no matching records
            // see: https://developer.salesforce.com/forums/?id=906F000000094QZIAY
            List<Account> accountList = [SELECT Id, Name FROM Account WHERE Id = :accountID];
            Account account = (accountList != null && accountList.size() > 0) ? accountList[0] : null;
            if (account != null) {
                AccountAndContactsEventGenerator.publishAccountAndContactsEvents(account);
            } else {
                System.debug('Could not find Account w/ID = ' + accountId);
            }
        }
    }
    
    @isTest
    public class AccountAndContactsEventEndpointTest {
    
        @isTest
        static void testGeneratePlatformEventMessage() {
            // Testing method: https://trailhead.salesforce.com/content/learn/modules/apex_integration_services/apex_integration_webservices
            Account account = new Account(Name = 'RestTestAccount');
            insert account;
        	RestRequest request = new RestRequest();
            request.httpMethod = 'POST';
            request.requestUri = 'https://doesntmatterignored/services/apexrest/AccountAndContactsPE/' + account.id;
            RestContext.request = request;
            
            // call method
            AccountAndContactsEventRESTEndpoint.generatePlatformEvent();
            System.assertEquals(1, AccountAndContactsEventGenerator.calls);
        }
    
        @isTest
        static void testNoAccountNoPlatformEventMessage() {
            // creating a contact just to get a SF ID not tied to an account.
            Contact contact = new Contact(firstname='First', lastname='Last');
            
            // Testing method: https://trailhead.salesforce.com/content/learn/modules/apex_integration_services/apex_integration_webservices
            Account account = new Account(Name = 'RestTestAccount');
            insert account;
        	RestRequest request = new RestRequest();
            request.httpMethod = 'POST';
            request.requestUri = 'https://doesntmatterignored/services/apexrest/AccountAndContactsPE/' + contact.id;
            RestContext.request = request;
            
            // call method, no PE because no Account with Contact's SF ID
            AccountAndContactsEventRESTEndpoint.generatePlatformEvent();
            System.assertEquals(0, AccountAndContactsEventGenerator.calls);
        }
    }
    
  5. Subscribe to the event messages. I added a JSON supporting class and processor for this particular platform event to my Salesforce Event Listener covered in an earlier tutorial. The sample logs the contents of any messages received. Due to the trigger created in this tutorial, all that is needed is to change the name of an account in Salesforce and messages, one for every five contacts at the company, will be sent out and picked up by this listener.

Posted by Glen Mazza in Salesforce at 07:00AM Jul 04, 2021 | Tags:  java  salesforce | Comments[0]

Post a Comment:

Calendar
« July 2021
Sun Mon Tue Wed Thu Fri Sat
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
Today
About Me
Java Software Engineer
TightBlog project maintainer
Arlington, Virginia USA
glen.mazza at pm dot me
GitHub profile for Glen Mazza at Stack Overflow, Q&A for professional and enthusiast programmers
Blog Search


Blog article index
Navigation
About Blog
Blog software: TightBlog 3.7.2
Application Server: Tomcat
Database: MySQL
Hosted on: Linode
SSL Certificate: Let's Encrypt
Installation Instructions