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}