Monday, November 11, 2019

Generic Clone Record Component

In Salesforce classic, we can use URL hack to clone the record and pre populate the data. In lightning, URL hack doesn't work. However, we can use e.force:createRecord component to initialize the values for create record.

For cloning, we need to see previous records and sometime it is custom logic that we need to implement to initialize the value for cloning. Hence, we wrote generic component to take care of this. Currently it only supports fieldset.

We started with Base Clone Component (CloneBaseComponent)

CloneBaseComponent.cmp


1
2
3
4
5
6
<aura:component extensible="true" description="CloneComponentBase" controller="CloneComponentBaseController" implements="force:lightningQuickActionWithoutHeader,lightning:actionOverride,force:hasRecordId">
    <aura:attribute name="recordId" type="String"/>
    <aura:attribute name="sObjectApiName" type="String"/>
    <aura:attribute name="fieldSetName" type="String"/>
    <aura:handler name="init" value="{!this}" action="{!c.doInit}"/>
</aura:component>

CloneBaseComponentController.js


 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
32
33
34
35
36
/**
 * Created by Spring7 on 9/19/2019.
 */

({
    doInit: function(component, event, helper) {
        var action = component.get('c.getRecord');
        var recordId = component.get('v.recordId');
        var fieldSetName = component.get('v.fieldSetName');
        var sObjectApiName = component.get('v.sObjectApiName');
        action.setParams({recordId: recordId,fieldSetName:fieldSetName,sObjectApiName:sObjectApiName});
        action.setCallback(this, function (response) {
            if(response.getState() === 'SUCCESS'){
                var defaultFieldValues = response.getReturnValue();
                helper.createRecord(component, defaultFieldValues);
                helper.closeQuickActionModal(component);
            }else if (response.getState() ==="ERROR") {
                var errors = response.getError();
                if (errors) {
                    if (errors[0] && errors[0].message) {
                        var toastEvent = $A.get("e.force:showToast");
                        toastEvent.setParams({
                            "title": "Error",
                            "message": errors[0].message
                        });
                        toastEvent.fire();
                        helper.closeQuickActionModal(component);
                    }
                } else {
                    console.log("Unknown error");
                }
            }
        });
        $A.enqueueAction(action);
    }
});

CloneBaseComponentHelper.js


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
/**
 * Created by Spring7 on 9/19/2019.
 */

({
    closeQuickActionModal : function(component){
        var dismissActionPanel = $A.get('e.force:closeQuickAction');
        dismissActionPanel.fire();
    },
    createRecord : function(component, defaultFieldValues) {
        delete defaultFieldValues['Id'];
        var createRecordEvent = $A.get('e.force:createRecord');
        var sObjectApiName = component.get("v.sObjectApiName");
        createRecordEvent.setParams({
            entityApiName: sObjectApiName,
            defaultFieldValues: defaultFieldValues,
        });
        createRecordEvent.fire();
    }
})

How to use it

In Salesforce, we can not pass parameter to component when it is bound to a button. Hence we created a new component with just a few lines, which extends the base component and tie to a button.

CloneAccount Component
It calls CloneBaseComponent with fieldset (CloneAccount) which will be used for copy


1
2
3
4
5
6
7
8
<!--
 - Created by Spring7 on 9/19/2019.
 -->

<aura:component description="CloneAccount" extends="c:CloneComponentBase" implements="force:lightningQuickActionWithoutHeader,lightning:actionOverride,force:hasRecordId">
    <aura:set attribute="sObjectApiName" value="Account">
    <aura:set attribute="fieldSetName" value="CloneAccount">
</aura:set></aura:set></aura:component>

CloneAccount Button

We will need to bind this component to button



CloneAccount FieldSet
We will need to create the field set which will be used by clone base component on which fields to be copied.



In UI, we can see fields are cloned now:




source code can be downloaded from unmanaged package or github

Sunday, November 10, 2019

Scheduling job every 5 min

In salesforce, with UI, we can schedule the job on daily bases, and using cron expression we can do hourly. There is no facility to run the job every minute, or every 5 minutes. However salesforce allows to start the job a few seconds from now or a few minutes from now. We can use that hack to run the job every 5 minutes.

E.g. below : during the execute call, we can reschedule the job, which will abort existing job and create new job based on frequency provided.


/**
 * Created by Chintan Shah on 11/10/2019.
 */

public with sharing class MasterScheduler implements Schedulable  {

    private static final Integer BATCH_FREQUENCY_IN_MINUTES = 5; // ideally it should goto custom settings.
    private static final String JOB_NAME = 'MasterScheduler';

    public void execute(SchedulableContext schedulableContext) {
        System.debug('MasterScheduler.execute : ');
        rescheduleJob( JOB_NAME, BATCH_FREQUENCY_IN_MINUTES );
    }

    public static void rescheduleJob(String jobName, Integer batchFrequencyMinutes) {
        System.debug('MasterScheduler.rescheduleJob : jobName ' + jobName + ' batchFrequencyMinutes ' + batchFrequencyMinutes );
        abortJobByName(jobName);
        Datetime dt = system.now().addMinutes(batchFrequencyMinutes);
        String cronExpression = '0 ' + dt.minute() + ' ' + dt.hour() + ' ' + dt.day() + ' ' + dt.month() + ' ? ' + ' ' + dt.year();
        System.debug('MasterScheduler.rescheduleJob : cronExpression ' + cronExpression );
        System.schedule(jobName, cronExpression, new MasterScheduler());
    }

    public static void abortJobByName(String jobName) {
        System.debug('MasterScheduler.abortJobByName : jobName ' + jobName );
        List<CronTrigger> cronTriggers = [select id, TimesTriggered, NextFireTime, CronExpression, PreviousFireTime, StartTime, EndTime from CronTrigger];
        for(CronTrigger cronTrigger : cronTriggers ) {
            abortJobById(cronTrigger.Id);
        }
    }

    public static void abortJobById(String jobId) {
        System.debug('MasterScheduler.abortJobById : jobId ' + jobId );
        System.abortJob(jobId);
    }


}


We can start the job from anonymous block:


new MasterScheduler().execute(null);

This will run the job every 5 minutes. This is not best practice as we might be over using salesforce cloud resources, but it can come handy for certain situations.

Sandbox Refresh

Usually there are a lot of things needs to be done when doing sandbox refresh, e.g. activating uses, changing email address of contacts or other objects, etc.. We can plugin the sandbox refresh script mentioned here. However, we still have to write a lot of code or use tools to achieve many things needed depending on fullcopy or dev refresh.

We wrote framework to simplify the refresh of the sandbox, where we can add multiple plugins, and also added static resource configuration file which can help with plug in configuration.

1
2
3
public interface SandboxRefreshPlugin {
    String execute(SandboxRefreshTemplate sandboxRefreshTemplate);
}

We also wrote multiple plugins to do the actual work, e.g.
  • SandboxRefreshProvisionPlugin
    • This will provision the users.
    • e.g. activate them, change their profiles
    • in static resource, it is under ProvisionUserRecords tag
  • SandboxRefreshDataCreationPlugin
    • This will create sample data after refresh (useful for dev sandbox)
    • In static resource it is under sObjectRecords records
  • SandboxRefreshInvalidateEmailPlugin
    • This will invalidate email addressed for specified fields
    • E.g. Contact or other email address that might be used for sending emails
    • In static resource, it is under inValidateEmailsObjects
  • SandboxRefreshEncryptFieldsDataPlugin
    • This will encrypt the data for specified field
    • E.g. some of the PII, PHI data needs to be encrypted
    • In static resource it is under encryptFieldsData
Configuration static resource would help the plugin to be more configurable. Hence, we created static resource file as below

 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
{
  "ProvisionUserRecords": [
    {
      "userName": "test@test.com.cshah",
      "profile": "System Administrator",
      "isActive": true
    }
  ],
  "notificationEmails": "test@springsoa.com",
  "inValidateEmailsObjects": [
    {
      "sObjectName": "Contact",
      "emailFields": "Email"
    },
    {
      "sObjectName": "Lead",
      "emailFields": "Email"
    },
    {
      "sObjectName": "Case",
      "emailFields": "SuppliedEmail"
    }
  ],
  "encryptFieldsData": [
    {
      "sObjectName": "Contact",
      "sObjectFields": [
        {
          "fieldName": "AssistantPhone",
          "format": "999{N}{N}{N}{N}{N}{N}{N}"
        },
        {
          "fieldName": "Email",
          "format": "test@{S}{S}{S}{S}.com"
        }
      ]
    }
  ],
  "sObjectRecords": [
    {
      "sObjectName": "Account",
      "reference": "ISO_Account_1",
      "attributes": {
        "Name": "1 West Finance",
        "Type": "ISO"
      }
    },
    {
      "sObjectName": "Account",
      "reference": "Merchant_Account1",
      "attributes": {
        "Name": "The Carrington Group Limited",
        "Type": "Merchant"
      }
    },
    {
      "sObjectName": "Opportunity",
      "reference": "Opportunity1",
      "attributes": {
        "Name": "The Carrington Group Limited - 190529",
        "AccountId": "reference-Merchant_Account1",
        "StageName": "Approved",
        "CloseDate": "10/24/2018"
      }
    },
    {
      "sObjectName": "Account",
      "reference": "Merchant_Account2",
      "attributes": {
        "Name": "Otto Case",
        "Type": "Merchant"
      }
    },
    {
      "sObjectName": "Opportunity",
      "reference": "Opportunity2",
      "attributes": {
        "Name": "Otto Case - 190307",
        "AccountId": "reference-Merchant_Account2",
        "StageName": "Approved",
        "CloseDate": "10/24/2018"
      }
    }
  ]
}

Most of the script initiates the batch file, as the full copy data could be quite huge. Now all we need to do is to specify the class SandboxRefresh during the refresh of the sandbox.

Code can be installed using unmanaged package : https://login.salesforce.com/packaging/installPackage.apexp?p0=04t2E000003ZQyl  or can be downloaded from github https://github.com/springsoa/springsoa-salesforce



Saturday, November 9, 2019

Large Modal

When we create lightning component and associate with button, it has configurable height but width is always half of the screen.

E.g. lightning component


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!--
 - Created by Chintan Shah on 10/20/2019.
 -->

<aura:component description="DemoLargeModal" implements="force:lightningQuickActionWithoutHeader,force:hasRecordId">
    <!--ltng:require styles="{!$Resource.largeModal}" /-->
    <section role="dialog" tabindex="-1" aria-labelledby="modal-heading-01" aria-modal="true" aria-describedby="modal-content-id-1"
             class="slds-modal slds-fade-in-open slds-modal_large">
        <div class="slds-modal__container">
            <header class="slds-modal__header">
                <lightning:buttonIcon iconName="utility:close"
                                      onclick="{! c.closeModel }"
                                      alternativeText="close"
                                      variant="bare"
                                      class="slds-modal__close">
                </lightning:buttonIcon>
                <h2 id="modal-heading-01" class="slds-text-heading_medium slds-hyphenate"> Header </h2>
            </header>
            <div class="slds-modal__content slds-p-around_medium" id="modal-content-id-1">
                Content
            </div>
        </div>
    </section>
</aura:component>

bound to button :



The height is configuration but width is not, and upon clicking the button, it would show narrow modal.




We can fix this by adding stylesheet to our lightning component. However it needs to be added using stylesheet.

Create CSS static resource:




1
2
3
4
.slds-modal__container{
    max-width: 70rem !important;
    width:80% !important;
}


Add the CSS in the lightning component:



 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!--
 - Created by Chintan Shah on 10/20/2019.
 -->

<aura:component description="DemoLargeModal" implements="force:lightningQuickActionWithoutHeader,force:hasRecordId">
    <ltng:require styles="{!$Resource.largeModal}" />
    <section role="dialog" tabindex="-1" aria-labelledby="modal-heading-01" aria-modal="true" aria-describedby="modal-content-id-1"
             class="slds-modal slds-fade-in-open slds-modal_large">
        <div class="slds-modal__container">
            <header class="slds-modal__header">
                <lightning:buttonIcon iconName="utility:close"
                                      onclick="{! c.closeModel }"
                                      alternativeText="close"
                                      variant="bare"
                                      class="slds-modal__close">
                </lightning:buttonIcon>
                <h2 id="modal-heading-01" class="slds-text-heading_medium slds-hyphenate"> Header </h2>
            </header>
            <div class="slds-modal__content slds-p-around_medium" id="modal-content-id-1">
                Content
            </div>
        </div>
    </section>
</aura:component>


Now modal shows up wide:



Source code can be found at : https://github.com/springsoa/springsoa-salesforce

Sunday, October 20, 2019

Email Log

I know it has been around for a long time, but recently found this useful feature, where I can request the logs of all emails that has been sent. It can be super useful for troubleshooting.


It would be nice to have some heroku app which can download this periodically and create cases for emails that are not delivered.

Mock Framework

As Salesforce has introduced powerful mock framework for testing, we wrote thin layer on top of it to take full advantage of the dynamic mock class that you can create.



It is always a good practice to break down your classes, and methods into small chunks, and mostly take further reusable methods in services. However, at the time of testing, it gets hard to test your code if it relies on too many services method. You have to have to knowledge of the service.

In code, it may look like


/**
 * Created by Chintan Shah on 10/18/2019.
 */

/**
 * this is the service that will be called by caller
 * it would be faked out during the testing framework.
 */
public with sharing class MockDemoService {

    public List<String> toUpper(List<String> inputs) {
        List<String> outputs = new List<String>();
        for(String input : inputs ) {
            outputs.add( input.toUpperCase() );
        }
        return outputs;
    }

    public String concat(List<String> inputs) {
        String output = null;
        for(String input : inputs ) {
            output = ( output == null ? '' : ( output + ' ' ) ) + input;
        }
        return output;
    }


}


And caller


/**
 * Created by Chintan Shah on 10/18/2019.
 */

/**
 * demo of how to use this framework.
 *
 * MockDemoServiceCaller -> MockDemoService
 *
 * During testing, we will just mock the entire MockDemoService
 *
 */
public with sharing class MockDemoServiceCaller {

    /**
     * instance of mockDemoService
     */
    public MockDemoService mockDemoService;

    /**
     * regular constructor
     */
    public MockDemoServiceCaller() {
        mockDemoService = new MockDemoService();
    }

    /**
     * entry point for fake service
     *
     * @param
     */
    public MockDemoServiceCaller(MockDemoService mockDemoService) {
        this.mockDemoService = mockDemoService;
    }


    /**
     * sample routine that we are going to test.
     */
    public String sampleRoutine(List<String> inputs) {
        try {
            System.debug(' MockDemoServiceCaller.sampleRoutine inputs ' + JSON.serialize(inputs) );
            List<String> outputs = mockDemoService.toUpper( new List<String> { 'John', 'Doe' } );
            System.debug(' MockDemoServiceCaller.sampleRoutine outputs ' + JSON.serialize(outputs) );
            String output = mockDemoService.concat( outputs );
            System.debug(' MockDemoServiceCaller.sampleRoutine output ' + output );
            return output;
        } catch(Exception e) {
            throw new MockException('What\' up? ' + e.getStackTraceString() );
        }
    }


}

Now when we write the test class for MockDemoServiceCaller, it would be good to have full control over the output of mock demo service. We should be able to return various result including the exception. Salesforce mock framework allows you to mock the service so you have full control when you write the test class for service caller.



Here is example of the code. You can see in line 19 and 20, we create dynamic service instance using mock framework, as well as send mock individual method and return the response.

On line 34, we can see, we can even return the exception in the dynamic mock instance that we create, and hence we have full control on how to test the service caller.



 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
 * Created by Chintan Shah on 10/18/2019.
 */

/**
 * test class for MockDemoServiceCaller
 */

@isTest
public with sharing class MockDemoServiceCallerTest {

    @testSetup
    public static void testSetup() {

    }

    @isTest
    public static void testSampleRoutine1() {
        MockProvider mockDeMockServiceProvider = new MockProvider( MockDemoService.class );
        MockDemoService mockDemoService = (MockDemoService) mockDeMockServiceProvider.getMock();
        mockDeMockServiceProvider.addReturnValue( 'toUpper',  new List<String> { 'HELLO', 'WORLD' } );
        mockDeMockServiceProvider.addReturnValue( 'concat',  'HELLO TEST' );
        // initialize MockDemoServiceCaller with mock MockDemoService
        MockDemoServiceCaller mockDemoServiceCaller = new MockDemoServiceCaller( mockDemoService );
        String output = mockDemoServiceCaller.sampleRoutine( new List<String> { 'John', 'Doe'} );
        System.assertEquals( output, 'HELLO TEST', 'It should be HELLO TEST based on mock ');
    }

    @isTest
    public static void testSampleRoutine2() {
        MockProvider mockDeMockServiceProvider = new MockProvider( MockDemoService.class );
        MockDemoService mockDemoService = (MockDemoService) mockDeMockServiceProvider.getMock();
        mockDeMockServiceProvider.addReturnValue( 'toUpper',  new List<String> { 'HELLO', 'WORLD' } );
        mockDeMockServiceProvider.addReturnValue( 'concat',  new MockException('Test Exception') );
        // initialize MockDemoServiceCaller with mock MockDemoService
        MockDemoServiceCaller mockDemoServiceCaller = new MockDemoServiceCaller( mockDemoService );
        try {
            String output = mockDemoServiceCaller.sampleRoutine( new List<String> { 'John', 'Doe'} );
            System.assert( false, 'Exception was expected, success was not expected ');
        } catch(Exception e) {
            System.assert( true, 'Exception was expected. ');
        }
    }


}

With wrapper around salesforce framework, we don't need to write individual mock classes, but we can declarative use what to mock and what it would return.


Web Service Mock
Web service mock framework has been around for years, however we have to write mock class for each web service we are trying to call. With this framework, we can declarative create dynamic instance of the mock class and test the class which is calling web service. e.g. below




Actual Class


 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
 * Created by Chintan Shah on 10/19/2019.
 */

public with sharing class MockDemoWebServiceCaller {

    public List<Map<String,Object>> httpGetCallout(String url) {
        List<Map<String,Object>> returnEmployees = new List<Map<String,Object>>();
        Http http = new Http();
        HttpRequest request = new HttpRequest();
        request.setEndpoint( url );
        request.setMethod('GET');
        System.debug(' MockDemoWebServiceCaller.httpGetCallout url ' + url );
        HttpResponse response = http.send(request);
        if (response.getStatusCode() == 200) {
            System.debug(' response.getBody() ' + response.getBody() );
            List<Object> employees = (List<Object>) JSON.deserializeUntyped( response.getBody() );
            for(Object employee : employees ) {
                Map<String, Object> currentEmployee = ( Map<String, Object> ) ( employee );
                returnEmployees.add( currentEmployee );
            }
        } else {
            throw new MockException( response.getStatusCode() + ' ' + response.getBody() );
        }
        system.debug( ' MockDemoWebServiceCaller.httpGetCallout returnEmployees ' + returnEmployees  );
        return returnEmployees;
    }

    public List<Map<String,Object>> getAllEmployees() {
        try {
            return httpGetCallout('https://basic-authentication-ws.herokuapp.com/hr/employees');
        } catch(Exception e) {
            throw new MockException(e);
        }
    }

    public List<Map<String,Object>> getEmployee(String column, String value) {
        try {
            return httpGetCallout('https://basic-authentication-ws.herokuapp.com/hr/employee/' + column + '/' + value );
        } catch(Exception e) {
            throw new MockException(e);
        }
    }


}


Test Class


 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
 * Created by Chintan Shah on 10/19/2019.
 */

@isTest
public with sharing class MockDemoWebServiceCallerTest {
    @testSetup
    public static void testSetup() {

    }

    @isTest
    public static void testGetAllEmployees() {
        MockHttpCallout mockHttpCallout = new MockHttpCallout();
        mockHttpCallout.setMock();
        mockHttpCallout.addJsonHttpResponse('https://basic-authentication-ws.herokuapp.com/hr/employees', '[{"ExternalId":"E-001","Name":"Chintan Shah","DisplayUrl":"https://external.com/E-001","EmployeeAccountKey":"Account-001"},{"ExternalId":"E-002","Name":"Chuck Summer","DisplayUrl":"https://external.com/E-002","EmployeeAccountKey":"Account-001"},{"ExternalId":"E-003","Name":"Chris Walker","DisplayUrl":"https://external.com/E-003","EmployeeAccountKey":"Account-001"},{"ExternalId":"E-004","Name":"Dan Topper","DisplayUrl":"https://external.com/E-004","EmployeeAccountKey":"Account-002"},{"ExternalId":"E-005","Name":"Drew Berry","DisplayUrl":"https://external.com/E-005","EmployeeAccountKey":"Account-002"},{"ExternalId":"E-006","Name":"Don Aiken","DisplayUrl":"https://external.com/E-006","EmployeeAccountKey":"Account-002"}]');
        System.assertEquals( 6, new MockDemoWebServiceCaller().getAllEmployees().size(), 'Should be 6');
    }

    @isTest
    public static void testGetEmployee() {
        //
        MockHttpCallout mockHttpCallout = new MockHttpCallout();
        mockHttpCallout.setMock();
        mockHttpCallout.addJsonHttpResponse('https://basic-authentication-ws.herokuapp.com/hr/employee/Name/Chintan', '[{"ExternalId":"E-001","Name":"Chintan Shah","DisplayUrl":"https://external.com/E-001","EmployeeAccountKey":"Account-001"}]');
        System.assertEquals( 1, new MockDemoWebServiceCaller().getEmployee('Name','Chintan').size() , 'Should be 1');
    }

    @isTest
    public static void testGetAllEmployees2() {
        MockHttpCallout mockHttpCallout = new MockHttpCallout();
        mockHttpCallout.setMock();
        mockHttpCallout.addErrorResponse('https://basic-authentication-ws.herokuapp.com/hr/employees', 401 );
        try {
            new MockDemoWebServiceCaller().getAllEmployees().size();
            System.assert( false, 'Exception was expected, success was not expected ');
        } catch(Exception e) {
            System.assert( true, 'Exception was expected. ');
        }
    }

    @isTest
    public static void testGetEmployee2() {
        //
        MockHttpCallout mockHttpCallout = new MockHttpCallout();
        mockHttpCallout.setMock();
        mockHttpCallout.addErrorResponse('https://basic-authentication-ws.herokuapp.com/hr/employee/Name/Chintan', 401);
        try {
            new MockDemoWebServiceCaller().getEmployee('Name','Chintan').size();
            System.assert( false, 'Exception was expected, success was not expected ');
        } catch(Exception e) {
            System.assert( true, 'Exception was expected. ');
        }
    }


}


As you can see, we don't have to write the mock class for each web service, we simply just need to use the http mock callout and set the response as mentioned in line 25 and 33.

You can download the code from : https://github.com/springsoa/springsoa-salesforce
or from unmanaged package here




Thursday, October 10, 2019

External Data Service

As mentioned in my previous blog : https://chintanblog.blogspot.com/2017/05/odataheroku-with-salesforce-integrate.html  , if we can expose the external data as OData 2.0 or 4.0, we can directly consume them in salesforce as External Objects. The second option would be to directly consume those web service using External Data Service and consume them as external objects.



As you can see above, either you can put thin layer against external web service to do protocol conversion to support Odata (as mentioned in https://chintanblog.blogspot.com/2017/05/odataheroku-with-salesforce-integrate.html) or write the plugin in Apex.

Here we will discuss how to write External Data Service in Apex.

1) I created a sample external web service to just for the demo - It is HR service, which return list of employees, and also employees by name, account, etc..

https://github.com/c-shah/basic-authentication






2) As next step, we need to write

ExternalDataSourceProvider -> ExternalDataSourceConnection -> ExternalDataService

a) ExternalDataSourceProvider

this class basically extends DataSource.Provider, and provides what capability are supported from both authentication and database support (e.g. query, update)



/**
 * Created by chint on 10/4/2019.
 */

global without sharing class ExternalDataSourceProvider extends DataSource.Provider {

    override global List<DataSource.AuthenticationCapability> getAuthenticationCapabilities() {
        System.debug(' ExternalDataSourceProvider.getAuthenticationCapabilities ');
        List<DataSource.AuthenticationCapability> capabilities =  new List<DataSource.AuthenticationCapability> {
                DataSource.AuthenticationCapability.ANONYMOUS
        };
        System.debug(' ExternalDataSourceProvider.getAuthenticationCapabilities ' + JSON.serialize(capabilities) );
        return capabilities;
    }

    override global List<DataSource.Capability> getCapabilities() {
        System.debug(' ExternalDataSourceProvider.getCapabilities ');
        List<DataSource.Capability> capabilities = new List<DataSource.Capability> {
            DataSource.Capability.ROW_QUERY
        };
        System.debug(' ExternalDataSourceProvider.getCapabilities ' + JSON.serialize(capabilities) );
        return capabilities;
    }

    override global DataSource.Connection getConnection(DataSource.ConnectionParams connectionParams) {
        System.debug(' ExternalDataSourceProvider.getConnection connectionParams: ' + connectionParams);
        DataSource.Connection connection = new ExternalDataSourceConnection(connectionParams);
        System.debug(' ExternalDataSourceProvider.getConnection connection: ' + connection);
        return connection;
    }

}


b) ExternalDataSourceConnection

This class extends DataSource.Connection, and responsible for providing table structures and as well as data results which will be consumed by query associated with External Objects



/**
 * Created by chint on 10/4/2019.
 */

global without sharing class ExternalDataSourceConnection extends DataSource.Connection {

    private DataSource.ConnectionParams connectionInfo ;

    global ExternalDataSourceConnection(DataSource.ConnectionParams connectionInfo) {
        System.debug(' ExternalDataSourceConnection.ExternalDataSourceConnection connectionInfo: ' + JSON.serialize(connectionInfo));
        this.connectionInfo = connectionInfo;
    }

    override global List<DataSource.Table> sync() {
        System.debug(' ExternalDataSourceConnection.sync ');
        List<DataSource.Table> tables = ExternalDataService.getInstance().getTables();
        System.debug(' ExternalDataSourceConnection.sync tables ' + JSON.serialize(tables) );
        return tables;
    }

    override global DataSource.TableResult query(DataSource.QueryContext context) {
        try {
            printContent(context);
            DataSource.TableResult tableResult = DataSource.TableResult.get(context, ExternalDataService.getInstance().getData(context) );
            System.debug(' ExternalDataSourceConnection.query tableResult ' + JSON.serialize(tableResult));
            return tableResult;
        } catch (Exception currentException) {
            String message = currentException.getMessage() + currentException.getStackTraceString() ;
            System.debug(' ExternalDataSourceConnection.query exception : ' + message );
            throw new DataSource.DataSourceException(message);
        }
    }

    public static void printContent(DataSource.QueryContext context) {
        System.debug(' ExternalDataSourceConnection.printContent queryMoreToken ' + context.queryMoreToken + ' tableSelected ' + context.tableSelection.tableSelected );
        DataSource.Filter filter = context.tableSelection.filter;
        List<DataSource.Order> orders = context.tableSelection.order;
        List<DataSource.ColumnSelection> columnsSelected = context.tableSelection.columnsSelected;
        if( filter != null ) {
            System.debug('ExternalDataSourceConnection.printContent filter columnName ' + filter.columnName + ' columnValue ' + filter.columnValue + ' subfilters ' + filter.subfilters + ' tableName ' + filter.tableName + ' type ' + filter.type );
        }
        if( orders != null ) {
            for(DataSource.Order order : orders ) {
                System.debug('ExternalDataSourceConnection.printContent order columnName ' + order.columnName  + ' tableName ' + order.tableName + ' direction ' + order.direction );
            }
        }
        if( columnsSelected != null ) {
            for(DataSource.ColumnSelection columnSelected : columnsSelected ) {
                System.debug('ExternalDataSourceConnection.printContent columnSelected columnName ' + columnSelected.columnName  + ' tableName ' + columnSelected.tableName + ' aggregation ' + columnSelected.aggregation );
            }
        }
    }
}



3) ExternalDataService, which takes care of calling web service and returning the data needed for ExternalDataSourceConnection.


/**
 * Created by chint on 10/4/2019.
 */

public without sharing class ExternalDataService {

    private static ExternalDataService instance;

    private ExternalDataService() {
        System.debug(' ExternalDataService.ExternalDataService ');
    }

    public static ExternalDataService getInstance() {
        System.debug(' ExternalDataService.getInstance ');
        if( instance == null ) {
            instance = new ExternalDataService();
        }
        return instance;
    }

    public List<DataSource.Table> getTables() {
        System.debug(' ExternalDataService.getTables ');
        List<DataSource.Table> tables = new List<DataSource.Table> {
                getEmployeeTable(),
                getAddressTable() };
        System.debug(' ExternalDataService.getTables tables ' + JSON.serialize(tables) );
        return tables;
    }

    public List<Map<String, Object>> getData(DataSource.QueryContext context) {
        System.debug(' ExternalDataService.getData context ' + context );
        return getEmployeeData(context);
    }

    private DataSource.Table getEmployeeTable() {
        List<DataSource.Column> columns;
        columns = new List<DataSource.Column>();
        columns.add(DataSource.Column.text('ExternalId', 255));
        columns.add(DataSource.Column.text('Name', 255));
        columns.add(DataSource.Column.url('DisplayUrl'));
        columns.add(DataSource.Column.indirectLookup('EmployeeAccountKey', 'Account', 'Account_Key__c'));
        return DataSource.Table.get('Employee', 'ExternalId', columns);
    }

    private List<Map<String, Object>> getDummyData() {
        Map<String,Object> row1 = new Map<String,Object> {
                'ExternalId' => 'Emp1',
                'Name' => 'Chintan Shah',
                'EmployeeAccountKey' => 'ACN1'
        };
        Map<String,Object> row2 = new Map<String,Object> {
                'ExternalId' => 'Emp2',
                'Name' => 'Mark Twain',
                'EmployeeAccountKey' => 'ACN1'
        };
        Map<String,Object> row3 = new Map<String,Object> {
                'ExternalId' => 'Emp3',
                'Name' => 'John Doe',
                'EmployeeAccountKey' => 'ACN2'
        };
        List<Map<String,Object>> dataRows = new List<Map<String,Object>> { row1, row2, row3 };
        return dataRows;
    }

    private List<Map<String, Object>> getEmployeeData(DataSource.QueryContext context) {
        if( context.tableSelection != null && context.tableSelection.tableSelected == 'Employee' ) {
            DataSource.Filter filter = context.tableSelection.filter;
            String url = '/hr/employees';
            if( filter != null ) {
                url = '/hr/employee/' + filter.columnName + '/' + filter.columnValue;
            }
            List<Map<String,Object>> response = httpGetCallout('HerokuBasicAuth', url);
            return response;
        }
        return new List<Map<String,Object>>();
    }

    public static List<Map<String,Object>> httpGetCallout(String namedCredentials, String url) {
        List<Map<String,Object>> returnEmployees = new List<Map<String,Object>>();
        Http http = new Http();
        HttpRequest request = new HttpRequest();
        request.setEndpoint('callout:' + namedCredentials + url );
        request.setMethod('GET');
        HttpResponse response = http.send(request);
        if (response.getStatusCode() == 200) {
            System.debug(' response.getBody() ' + response.getBody() );
            List<Object> employees = (List<Object>) JSON.deserializeUntyped( response.getBody() );
            for(Object employee : employees ) {
                Map<String, Object> currentEmployee = ( Map<String, Object> ) ( employee );
                returnEmployees.add( currentEmployee );
            }
        }
        system.debug( ' ExternalDataService.httpGetCallout returnEmployees ' + returnEmployees  );
        return returnEmployees;
    }

    private DataSource.Table getAddressTable() {
        DataSource.Table table = new DataSource.Table();
        List<DataSource.Column> columns;
        columns = new List<DataSource.Column>();
        columns.add(DataSource.Column.text('ExternalId', 255));
        columns.add(DataSource.Column.text('Street', 255));
        columns.add(DataSource.Column.text('ZipCode', 255));
        columns.add(DataSource.Column.url('DisplayUrl'));
        columns.add(DataSource.Column.indirectLookup('AddressAccountKey', 'Account', 'Account_Key__c'));
        return DataSource.Table.get('Address', 'ExternalId', columns);
    }

    private List<Map<String, Object>> getAddressData(DataSource.QueryContext context) {
        List<Map<String,Object>> dataRows = new List<Map<String,Object>>();
        return dataRows;
    }


}

This basically :
  • understand the context (name of the table, query criteria
  • calls the webservice 
  • returns the data in List> format. 

We can see external Objects now:







Once it is done, it is ready to be tested on Account page layout, due to indirect lookup relationship, Employee external object would be available in Account Page layout.






This way we can consume external service as external object without external Odata layer.


Source Code : 

https://github.com/springsoa/springsoa-salesforce/tree/master/classes
https://github.com/c-shah/basic-authentication