GitHub Issues Custom Adapter for Salesforce Connect

This example creates a custom adapter that links GitHub Issues to products in Salesforce using an indirect lookup relationship. An external lookup relationship also links GitHub Issues to the comments on each issue.

This example illustrates a range of common use cases for custom adapters, including how to:

  • Query external data.
  • Work with a range of external object field types, such as Date and Picklist fields.
  • Use indirect lookup relationships, which link a child external object to a parent standard or custom object.
  • Use external lookup relationships, which link a child standard, custom, or external object to a parent external object.
  • Use Data Manipulation Language (DML) operations to insert, update, and delete external data.

To improve unit tests for the Apex code in this example, you can also return mock records in a testing context. See Mock SOQL Tests for External Objects.

DataSource.Connection Class

This example creates a class named GitHubDataSourceConnection. For this example to work, create a custom field on the Product2 standard object. Specify the name of the custom text field as Repository, and select the External ID and Unique attributes.

1/**
2 *   Defines the connection to GitHub REST API v3 to support
3 *   querying of GitHub profiles.
4 *   Extends the DataSource.Connection class to enable
5 *   Salesforce to sync the external system’s schema
6 *   and to handle queries and searches of the external data.
7 **/
8global class GitHubDataSourceConnection extends DataSource.Connection {
9    private DataSource.ConnectionParams connectionInfo;
10
11    /**
12     *   Constructor for GitHubDataSourceConnection
13     **/
14    global GitHubDataSourceConnection(DataSource.ConnectionParams connectionInfo) {
15        this.connectionInfo = connectionInfo;
16    }
17
18    /**
19     *   Called to query and get results from the external 
20     *   system for SOQL queries, list views, and detail pages 
21     *   for an external object that’s associated with the 
22     *   external data source.
23     *   
24     *   The queryContext argument represents the query to run 
25     *   against a table in the external system.
26     *   
27     *   Returns a list of rows as the query results.
28     **/
29    override global DataSource.TableResult query(DataSource.QueryContext context) {
30        DataSource.Filter filter = context.tableSelection.filter;
31        String url, tableName;
32
33        if(context.tableSelection.tableSelected.equals('GithubIssues')) {
34            tableName = 'GithubIssues';
35            if (filter != null) {
36                String thisColumnName = filter.columnName;
37                if (thisColumnName != null &&
38                   (thisColumnName.equals('ExternalId') ||
39                    thisColumnName.equals('number')))
40                    url = 'callout:GithubNC/issues/' + filter.columnValue;
41                else
42                    url = 'callout:GithubNC/issues';
43            } else {
44                url = 'callout:GithubNC/issues';
45            }
46        } else if(context.tableSelection.tableSelected.equals('IssueComments')) {
47            tableName = 'IssueComments';
48            if (filter != null) {
49                String thisColumnName = filter.columnName;
50                if (thisColumnName != null &&
51                   (thisColumnName.equals('ExternalId') ||
52                    thisColumnName.equals('id')))
53                    url = 'callout:GithubNC/issues/comments/' + filter.columnValue;
54                else
55                    url = 'callout:GithubNC/issues/comments';
56            } else {
57                url = 'callout:GithubNC/issues/comments';
58            }
59        }
60
61        /**
62         * Filters, sorts, and applies limit and offset clauses.
63         **/
64        List<Map<String, Object>> rows = DataSource.QueryUtils.process(context, getData(url, tableName));
65        return DataSource.TableResult.get(true, null, context.tableSelection.tableSelected, rows);
66    }
67
68    /**
69     *   Defines the schema for the external system. 
70     *   Called when the Salesforce admin clicks “Validate and Sync”
71     *   in the user interface for the external data source.
72     **/
73    override global List<DataSource.Table> sync() {
74        List<DataSource.Table> tables =new List<DataSource.Table>();
75        List<DataSource.Column> columns, commentsColumns;
76        columns = new List<DataSource.Column>();
77        commentsColumns = new List<DataSource.Column>();
78
79        // Defines the external lookup field.
80        commentsColumns.add(DataSource.Column.externalLookup('issue_number', 'GithubIssues__x'));
81        commentsColumns.add(DataSource.Column.text('ExternalId', 255));
82        commentsColumns.add(DataSource.Column.url('DisplayUrl'));
83        commentsColumns.add(DataSource.Column.text('Body'));
84        commentsColumns.add(DataSource.Column.text('Created_By'));
85        commentsColumns.add(DataSource.Column.datetime('Created'));
86        commentsColumns.add(DataSource.Column.datetime('Updated'));
87        tables.add(DataSource.Table.get('IssueComments','id', commentsColumns));
88
89        //================================================================================
90
91        // Defines the indirect lookup field. (For this to work,
92        // make sure your Product2 standard object has a
93        // custom unique, external ID field called Repository.)
94        columns.add(DataSource.Column.indirectLookup( 'repository_url', 'Product2', 'Repository__c'));
95        columns.add(DataSource.Column.text('ExternalId',255));
96        columns.add(DataSource.Column.url('DisplayUrl'));
97        columns.add(DataSource.Column.text('Title',255));
98        columns.add(DataSource.Column.text('Description'));
99        columns.add(DataSource.Column.text('Repo_Name'));
100        columns.add(DataSource.Column.url('Repo_URL'));
101        List<Map<String,String>> stateList = new List<Map<String, String>>(); 
102        Map<String, String> open = new Map<String,String>();
103        open.put('Open', 'Open');
104        stateList.add(open);
105        Map<String, String> closed = new Map<String,String>();
106        closed.put('Closed', 'Closed');
107        stateList.add(closed);
108        columns.add(DataSource.Column.picklist('State',stateList));
109
110        List<Map<String,String>> stateReasonList = new List<Map<String, String>>(); 
111        Map<String, String> completed = new Map<String,String>();
112        completed.put('Completed', 'completed');
113        stateReasonList.add(completed);
114        Map<String, String> reopened = new Map<String,String>();
115        reopened.put('Reopened', 'reopened');
116        stateReasonList.add(reopened);
117        Map<String, String> notPlanned = new Map<String,String>();
118        notPlanned.put('Not Planned', 'not_planned');
119        stateReasonList.add(notPlanned);
120        columns.add(DataSource.Column.picklist('State_Reason',stateReasonList));
121
122        columns.add(DataSource.Column.boolean('Locked'));
123        columns.add(DataSource.Column.text('Lock_Reason', 255));
124        columns.add(DataSource.Column.datetime('Created'));
125        columns.add(DataSource.Column.datetime('Updated'));
126        columns.add(DataSource.Column.datetime('Closed_At'));
127
128        tables.add(DataSource.Table.get('GithubIssues','repository_url', columns));
129        return tables;
130    }
131
132    /**
133     *   Called to do a full text search and get results from
134     *   the external system for SOSL queries and Salesforce
135     *   global searches.
136     *
137     *   The SearchContext argument represents the query to run
138     *   against a table in the external system.
139     *
140     *   Returns results for each table that the SearchContext
141     *   requested to be searched.
142     **/
143    override global List<DataSource.TableResult> search(
144            DataSource.SearchContext context) {
145        List<DataSource.TableResult> results =
146                new List<DataSource.TableResult>();
147
148        for (Integer i =0;i< context.tableSelections.size();i++) {
149            String entity = context.tableSelections[i].tableSelected;
150
151            String url = 'callout:GithubNC/issues/' + context.searchPhrase;
152            results.add(DataSource.TableResult.get(true, null, entity, getData(url, entity)));
153        }
154
155        return results;
156    }
157
158    global override List<DataSource.UpsertResult> upsertRows(DataSource.UpsertContext context) {
159        List<DataSource.UpsertResult> results = new List<DataSource.UpsertResult>();
160        String tableName = context.tableSelected;
161
162        // Calls the GitHub API to create and update issues.
163        List<Map<String, Object>> rows = context.rows;
164        for(Integer i = 0; i < rows.size(); i++) {
165            Map<String,Object> row = rows[i];
166            Map<String,Object> obj = new Map<String,Object>();
167            String externalId = (String) row.get('ExternalId');
168            String url, httpMethod;
169
170            if(tableName.equals('GithubIssues')) {
171                url = 'callout:GithubNC/issues';
172                httpMethod = 'POST';
173                if(!String.isBlank(externalId)){
174                    httpMethod = 'PATCH';
175                    url = url+'/'+externalId;
176                }
177
178                obj.put('title', row.get('Title'));
179                obj.put('body', row.get('Description'));
180                obj.put('state', row.get('State'));
181                obj.put('state_reason', String.isBlank((String) row.get('State_Reason'))? null: row.get('State_Reason'));
182                obj.put('closed_at', row.get('Closed_At'));
183            }
184            else if(tableName.equals('IssueComments')) {
185                url = 'callout:GithubNC/issues';
186                if(!String.isBlank(externalId)){
187                    httpMethod = 'PATCH';
188                    url = url+'/comments/'+externalId;
189                } else {
190                    httpMethod = 'POST';
191                    url = url+'/' + row.get('issue_number') + '/comments';
192                }
193                obj.put('body', row.get('Body'));
194            }
195
196            HttpResponse response = getResponse(url, httpMethod, obj);
197            if (response.getStatusCode() != 200){ 
198                results.add(DataSource.UpsertResult.failure(
199                    String.valueOf(row.get('ExternalId')), 'The callout resulted in an error: ' + response.getStatusCode()+' - '+response.getBody()));
200            }
201            System.debug(response.getBody());
202
203            if(tableName.equals('GithubIssues')) {
204                HttpResponse responseForLock = null;
205                if(!String.isBlank(externalId)) {
206                    Boolean currentlyLocked = isIssueLockedCurrently(url);
207                    Boolean isLocked = (Boolean) row.get('Locked');
208                    Boolean lockStatusChanged = currentlyLocked != isLocked;
209                    if(lockStatusChanged) {
210                        url = url + '/lock';
211                        if(isLocked) {
212                            Map<String, Object> lockReasonObj = new Map<String, Object>();
213                            lockReasonObj.put('lock_reason', row.get('Lock_Reason'));
214                            responseForLock = getResponse(url, 'PUT', lockReasonObj);
215                        }
216                        else {
217                            responseForLock = getResponse(url, 'DELETE', null);
218                        }
219
220                        if (responseForLock.getStatusCode() != 200) {
221                            results.add(DataSource.UpsertResult.failure(
222                                String.valueOf(row.get('ExternalId')), 'The callout resulted in an error: ' + responseForLock.getStatusCode()+' - '+responseForLock.getBody()));
223                        }
224                        System.debug(responseForLock.getBody());
225                    }
226                }
227            }
228            
229            results.add(DataSource.UpsertResult.success(String.valueOf(externalId)));
230        }
231        return results;
232    }
233
234    global override List<DataSource.DeleteResult> deleteRows(DataSource.DeleteContext context) {
235        List<DataSource.DeleteResult> results = new List<DataSource.DeleteResult>();
236        String tableName = context.tableSelected;
237
238        // Calls the GitHub API to delete issues.
239        if(tableName.equals('IssueComments')) {
240            for(String externalId: context.externalIds) {
241                String httpMethod = 'DELETE';
242                String url = 'callout:GithubNC/issues/comments/'+externalId;
243    
244                HttpResponse response = getResponse(url, httpMethod, null);
245                if (response.getStatusCode() != 204){ 
246                    results.add(DataSource.DeleteResult.failure(
247                        externalId, 'The callout resulted in an error: ' + response.getStatusCode()+' - '+response.getBody()));
248                }
249                System.debug(response.getBody());
250                results.add(DataSource.DeleteResult.success(String.valueOf(externalId)));
251            }      
252        } else if(tableName.equals('GithubIssues')) {
253            System.debug('Deletion not supported for GitHub Issues.');
254            results.add(DataSource.DeleteResult.failure(String.valueOf(context.externalIds), 'Deletion not supported for GitHub Issues.'));
255        }
256        return results;
257    }
258
259    /**
260     *   Helper method to parse the data.
261     *   The url argument is the URL of the external system.
262     *   Returns a list of rows from the external system.
263     **/
264    public List<Map<String, Object>> getData(String url, String tableName) {
265        String response = getResponse(url, 'GET', null).getBody();
266
267        // Standardize response string
268        if (!response.contains('"items":')) {
269            if (response.substring(0,1).equals('{')) {
270                response = '[' + response  + ']';
271            }
272            response = '{"items": ' + response + '}';
273        }
274
275        List<Map<String, Object>> rows = new List<Map<String, Object>>();
276
277        Map<String, Object> responseBodyMap = (Map<String, Object>) JSON.deserializeUntyped(response);
278
279        /**
280         *   Checks errors.
281         **/
282        Map<String, Object> error = (Map<String, Object>)responseBodyMap.get('error');
283        if (error!=null) {
284            List<Object> errorsList = (List<Object>)error.get('errors');
285            Map<String, Object> errors = (Map<String, Object>)errorsList[0];
286            String errorMessage = (String)errors.get('message');
287            throw new DataSource.OAuthTokenExpiredException(errorMessage);
288        }
289
290        List<Object> fileItems = (List<Object>)responseBodyMap.get('items');
291        if (fileItems != null) {
292            for (Integer i=0; i < fileItems.size(); i++) {
293                Map<String, Object> item = (Map<String, Object>)fileItems[i];
294                rows.add(createRow(item, tableName));
295            }
296        } else {
297            rows.add(createRow(responseBodyMap, tableName));
298        }
299
300        return rows;
301    }
302
303    /**
304     *   Helper method to populate the External ID and Display
305     *   URL fields on external object records based on the 'id'
306     *   value that’s sent by the external system.
307     *
308     *   The Map<String, Object> item parameter maps to the data
309     *   that represents a row.
310     *
311     *   Returns an updated map with the External ID and
312     *   Display URL values.
313     **/
314    public Map<String, Object> createRow(Map<String, Object> item, String tableName) {
315        Map<String, Object> row = new Map<String, Object>();
316        for ( String key : item.keySet() ) {
317            if(tableName.equals('GithubIssues')) {
318                if (key == 'number') {
319                    row.put('ExternalId', item.get(key));
320                } else if (key=='title') {
321                    row.put('Title', item.get(key));
322                } else if (key=='body') {
323                    row.put('Description', item.get(key));
324                } else if (key=='url') {
325                    row.put('DisplayUrl', item.get(key));
326                } else if (key=='repository_url') {
327                    String repoUrl = (String) item.get(key);
328                    row.put('Repo_URL', repoUrl);
329                    //extract repository name from the URL and add it to the Repo_Name field 
330                    String repoName = repoUrl.substring(repoUrl.lastIndexOf('/')+1);
331                    row.put('Repo_Name', repoName);
332                    row.put(key, item.get(key));
333                }  else if (key=='state') {
334                    row.put('State', item.get(key));
335                } else if (key=='state_reason') {
336                    row.put('State_Reason', item.get(key));
337                } else if (key=='locked') {
338                    row.put('Locked', item.get(key));
339                } else if (key=='active_lock_reason') {
340                    row.put('Lock_Reason', item.get(key));
341                } else if (key=='created_at' && item.get(key) != null) {
342                    DateTime createdDateTime = (DateTime)Json.deserialize('"'+item.get(key)+'"', DateTime.class);
343                    row.put('Created', createdDateTime);
344                } else if (key=='updated_at' && item.get(key) != null) {
345                    DateTime updatedDateTime = (DateTime)Json.deserialize('"'+item.get(key)+'"', DateTime.class);
346                    row.put('Updated', updatedDateTime);
347                } else if (key=='closed_at' && item.get(key) != null) {
348                    DateTime closedDateTime = (DateTime)Json.deserialize('"'+item.get(key)+'"', DateTime.class);
349                    row.put('Closed_At', closedDateTime);
350                } else {
351                    row.put(key, item.get(key));
352                }
353            }
354            else if (tableName.equals('IssueComments')) {
355                if (key=='id') {
356                    row.put('ExternalId', item.get(key));
357                } else if (key=='url') {
358                    row.put('DisplayUrl', item.get(key));
359                } else if (key == 'body') {
360                    row.put('Body', item.get(key));
361                } else if (key=='user') {
362                    Map<String, Object> ownerMap = (Map<String, Object>)item.get(key);
363                    row.put('Created_By', ownerMap.get('login'));
364                } else if (key=='created_at' && item.get(key) != null) {
365                    DateTime createdDateTime = (DateTime)Json.deserialize('"'+item.get(key)+'"', DateTime.class);
366                    row.put('Created', createdDateTime);
367                } else if (key=='updated_at' && item.get(key) != null) {
368                    DateTime updatedDateTime = (DateTime)Json.deserialize('"'+item.get(key)+'"', DateTime.class);
369                    row.put('Updated', updatedDateTime);
370                } else if (key=='issue_url') {
371                    String issueUrl = (String) item.get(key);
372                    row.put('issue_number', issueUrl.substring(issueUrl.lastIndexOf('/')+1));
373                } else {
374                 row.put(key, item.get(key));   
375                }
376            }
377        }
378        return row;
379    }
380
381    public Boolean isIssueLockedCurrently(String url) {
382        String existingIssue = getResponse(url, 'GET', null).getBody();
383        Map<String, Object> existingIssueBodyMap = (Map<String, Object>) JSON.deserializeUntyped(existingIssue);
384
385        /**
386         *   Checks errors.
387         **/
388        Map<String, Object> error = (Map<String, Object>) existingIssueBodyMap.get('error');
389        if (error!=null) {
390            List<Object> errorsList = (List<Object>)error.get('errors');
391            Map<String, Object> errors = (Map<String, Object>)errorsList[0];
392            String errorMessage = (String)errors.get('message');
393            throw new DataSource.OAuthTokenExpiredException(errorMessage);
394        }
395        
396        return (Boolean) existingIssueBodyMap.get('locked');
397    }
398
399    /**
400     *   The url argument is the URL of the external system.
401     *   Returns the response from the external system.
402     **/
403    public HttpResponse getResponse(String url, String httpMethod, Map<String,Object> issue) {
404        // Perform callouts for production (non-test) results.
405        Http httpProtocol = new Http();
406        HttpRequest request = new HttpRequest();
407        request.setEndpoint(url);
408        request.setMethod(httpMethod);
409        if(issue != null)
410            request.setBody(JSON.serialize(issue));
411        
412        return httpProtocol.send(request);
413    }
414}

DataSource.Provider Class

This example creates a class named GitHubDataSourceProvider.

1/**
2 *   Extends the DataSource.Provider base class to create a
3 *   custom adapter for Salesforce Connect. The class informs
4 *   Salesforce of the functional and authentication
5 *   capabilities that are supported by or required to connect
6 *   to an external system.
7 **/
8global class GitHubDataSourceProvider extends DataSource.Provider {
9
10    /**
11     *   For simplicity, this example declares that the external 
12     *   system doesn’t require authentication by returning
13     *   AuthenticationCapability.ANONYMOUS as the sole entry 
14     *   in the list of authentication capabilities.
15     **/
16    override global List<DataSource.AuthenticationCapability> getAuthenticationCapabilities() {
17        List<DataSource.AuthenticationCapability> capabilities = new List<DataSource.AuthenticationCapability>();
18        capabilities.add(DataSource.AuthenticationCapability.ANONYMOUS);
19        return capabilities;
20    }
21
22    /**
23     *   Declares the functional capabilities that the
24     *   external system supports, in this case
25     *   only SOQL queries.
26     **/
27    override global List<DataSource.Capability> getCapabilities() {
28        List<DataSource.Capability> capabilities = new List<DataSource.Capability>();
29        capabilities.add(DataSource.Capability.ROW_QUERY);
30        capabilities.add(DataSource.Capability.ROW_CREATE);
31        capabilities.add(DataSource.Capability.ROW_UPDATE);
32        capabilities.add(DataSource.Capability.ROW_DELETE);
33        capabilities.add(DataSource.Capability.PICKLIST);
34        capabilities.add(DataSource.Capability.MULTI_PICKLIST);
35        capabilities.add(DataSource.Capability.SEARCH);
36        return capabilities;
37    }
38
39    /**
40     *   Declares the associated DataSource.Connection class.
41     **/
42    override global DataSource.Connection getConnection(DataSource.ConnectionParams connectionParams) {
43        return new GitHubDataSourceConnection(connectionParams);
44    }
45}