Google Books™ Custom Adapter for Salesforce Connect

This example illustrates how to work around the requirements and limits of an external system’s APIs: in this case, the Google Books API Family.
To integrate with the Google Books™ service, we set up Salesforce Connect as follows.
  • The Google Books API allows a maximum of 40 returned results, so we develop our custom adapter to handle result sets with more than 40 rows.
  • The Google Books API can sort only by search relevance and publish dates, so we develop our custom adapter to disable sorting on columns.
  • To support OAuth, we set up our authentication settings in Salesforce so that the requested scope of permissions for access tokens includes https://www.googleapis.com/auth/books.
  • To allow Apex callouts, we define these remote sites in Salesforce:
    • https://www.googleapis.com
    • https://books.google.com

BooksDataSourceConnection Class

1/**
2 *   Extends the DataSource.Connection class to enable
3 *   Salesforce to sync the external system metadata
4 *   schema and to handle queries and searches of the external
5 *   data.
6 **/
7global class BooksDataSourceConnection extends
8    DataSource.Connection {
9 
10    private DataSource.ConnectionParams connectionInfo;
11
12    // Constructor for BooksDataSourceConnection.
13    global BooksDataSourceConnection(DataSource.ConnectionParams
14                                    connectionInfo) {
15        this.connectionInfo = connectionInfo;
16    }
17
18    /**
19     *   Called when an external object needs to get a list of 
20     *   schema from the external data source, for example when 
21     *   the administrator clicks “Validate and Sync” in the 
22     *   user interface for the external data source.   
23     **/
24    override global List<DataSource.Table> sync() {
25        List<DataSource.Table> tables =
26            new List<DataSource.Table>();
27        List<DataSource.Column> columns;
28        columns = new List<DataSource.Column>();
29        columns.add(getColumn('title'));
30        columns.add(getColumn('description'));
31        columns.add(getColumn('publishedDate'));
32        columns.add(getColumn('publisher'));
33        columns.add(DataSource.Column.url('DisplayUrl'));
34        columns.add(DataSource.Column.text('ExternalId', 255));
35        tables.add(DataSource.Table.get('googleBooks', 'title',
36                                        columns));
37        return tables;
38    }
39
40    /**
41     *   Google Books API v1 doesn't support sorting,
42     *   so we create a column with sortable = false.
43     **/
44    private DataSource.Column getColumn(String columnName) {
45        DataSource.Column column = DataSource.Column.text(columnName,
46                                                        255);
47        column.sortable = false;
48        return column;
49    }
50
51    /**
52     *   Called to query and get results from the external
53     *   system for SOQL queries, list views, and detail pages
54     *   for an external object that's associated with the
55     *   external data source.
56     *
57     *   The QueryContext argument represents the query to run
58     *   against a table in the external system.
59     *
60     *   Returns a list of rows as the query results.
61     **/
62    override global DataSource.TableResult query(
63                    DataSource.QueryContext contexts) {
64        DataSource.Filter filter = contexts.tableSelection.filter;
65        String url;
66        if (contexts.tableSelection.columnsSelected.size() == 1 &&
67        contexts.tableSelection.columnsSelected.get(0).aggregation ==
68            DataSource.QueryAggregation.COUNT) {
69            return getCount(contexts);
70        }
71
72        if (filter != null) {
73            String thisColumnName = filter.columnName;
74            if (thisColumnName != null &&
75                thisColumnName.equals('ExternalId')) {
76                url = 'https://www.googleapis.com/books/v1/' +
77                    'volumes?q=' + filter.columnValue +
78                    '&maxResults=1&id=' + filter.columnValue;
79                return DataSource.TableResult.get(true, null,
80                            contexts.tableSelection.tableSelected,
81                            getData(url));
82            }
83            else {
84                url = 'https://www.googleapis.com/books/' +
85                    'v1/volumes?q=' + filter.columnValue +
86                    '&id=' + filter.columnValue +
87                    '&maxResults=40' + '&startIndex=';
88            }
89        } else {
90            url = 'https://www.googleapis.com/books/v1/' +
91                'volumes?q=america&' + '&maxResults=40' +
92                '&startIndex=';
93        }
94        /**
95         *   Google Books API v1 supports maxResults of 40
96         *   so we handle pagination explicitly in the else statement
97         *   when we handle more than 40 records per query.
98         **/
99        if (contexts.maxResults < 40) {
100            return DataSource.TableResult.get(true, null,
101                    contexts.tableSelection.tableSelected,
102                    getData(url + contexts.offset));
103        }
104        else {
105            return fetchData(contexts, url);
106        }
107     }
108
109    /**
110     *   Helper method to fetch results when maxResults is 
111     *   greater than 40 (the max value for maxResults supported 
112     *   by Google Books API v1).
113     **/
114    private DataSource.TableResult fetchData(
115        DataSource.QueryContext contexts, String url) {
116        Integer fetchSlot = (contexts.maxResults / 40) + 1;
117        List<Map<String, Object>> data =
118            new List<Map<String, Object>>();
119        Integer startIndex = contexts.offset;
120        for(Integer count = 0; count < fetchSlot; count++) {
121            data.addAll(getData(url + startIndex));
122            if(count == 0)
123                contexts.offset = 41;
124            else
125                contexts.offset += 40;
126        }
127 
128        return DataSource.TableResult.get(true, null,
129                        contexts.tableSelection.tableSelected, data);
130    }
131
132    /**
133     *   Helper method to execute count() query.
134     **/
135    private DataSource.TableResult getCount(
136        DataSource.QueryContext contexts) {
137        String url = 'https://www.googleapis.com/books/v1/' +
138                    'volumes?q=america&projection=full';
139        List<Map<String,Object>> response =
140            DataSource.QueryUtils.filter(contexts, getData(url));
141        List<Map<String, Object>> countResponse =
142            new List<Map<String, Object>>();
143        Map<String, Object> countRow =
144            new Map<String, Object>();
145        countRow.put(
146            contexts.tableSelection.columnsSelected.get(0).columnName,
147            response.size());
148        countResponse.add(countRow);
149        return DataSource.TableResult.get(contexts, countResponse);
150    }
151
152    /**
153     *   Called to do a full text search and get results from
154     *   the external system for SOSL queries and Salesforce
155     *   global searches.
156     *
157     *   The SearchContext argument represents the query to run
158     *   against a table in the external system.
159     *
160     *   Returns results for each table that the SearchContext
161     *   requested to be searched.
162     **/
163    override global List<DataSource.TableResult> search(
164        DataSource.SearchContext contexts) {
165        List<DataSource.TableResult> results =
166            new List<DataSource.TableResult>();
167
168        for (Integer i =0; i< contexts.tableSelections.size();i++) {
169            String entity = contexts.tableSelections[i].tableSelected;
170            String url = 'https://www.googleapis.com/books/v1' +
171                        '/volumes?q=' + contexts.searchPhrase;
172            results.add(DataSource.TableResult.get(true, null,
173                                                entity,
174                                                getData(url)));
175        }
176
177        return results;
178    }
179
180    /**
181     *   Helper method to parse the data.
182     *   Returns a list of rows from the external system.
183     **/
184    public List<Map<String, Object>> getData(String url) {
185        HttpResponse response = getResponse(url);
186        String body = response.getBody();
187
188        List<Map<String, Object>> rows =
189            new List<Map<String, Object>>();
190
191        Map<String, Object> responseBodyMap =
192            (Map<String, Object>)JSON.deserializeUntyped(body);
193
194    /**
195     *   Checks errors.
196     **/        
197        Map<String, Object> error =
198            (Map<String, Object>)responseBodyMap.get('error');
199        if (error!=null) {
200            List<Object> errorsList =
201                (List<Object>)error.get('errors');
202            Map<String, Object> errors =
203                (Map<String, Object>)errorsList[0];
204            String messages = (String)errors.get('message');
205            throw new DataSource.OAuthTokenExpiredException(messages);
206        }
207
208        List<Object> sItems = (List<Object>)responseBodyMap.get('items');
209        if (sItems != null) {
210            for (Integer i=0; i< sItems.size(); i++) {
211                Map<String, Object> item =
212                    (Map<String, Object>)sItems[i];
213                rows.add(createRow(item));
214            }
215        } else {
216            rows.add(createRow(responseBodyMap));
217        }
218 
219        return rows;
220    }
221
222    /**
223     *   Helper method to populate a row based on source data.
224     *
225     *   The item argument maps to the data that
226     *   represents a row.
227     *
228     *   Returns an updated map with the External ID and
229     *   Display URL values.
230     **/
231    public Map<String, Object> createRow(
232        Map<String, Object> item) {
233        Map<String, Object> row = new Map<String, Object>();
234        for ( String key : item.keySet() ){
235            if (key == 'id') {
236                row.put('ExternalId', item.get(key));
237            } else if (key == 'volumeInfo') {
238                Map<String, Object> volumeInfoMap =
239                    (Map<String, Object>)item.get(key);
240                row.put('title', volumeInfoMap.get('title'));
241                row.put('description',
242                        volumeInfoMap.get('description'));
243                row.put('DisplayUrl',
244                        volumeInfoMap.get('infoLink'));
245                row.put('publishedDate',
246                        volumeInfoMap.get('publishedDate'));
247                row.put('publisher',
248                        volumeInfoMap.get('publisher'));
249            }
250        }
251        return row;
252    }
253
254    /**
255     *   Helper method to make the HTTP GET call.
256     *   The url argument is the URL of the external system.
257     *   Returns the response from the external system.
258     **/
259    public HttpResponse getResponse(String url) {
260        Http httpProtocol = new Http();
261        HttpRequest request = new HttpRequest();
262        request.setEndPoint(url);
263        request.setMethod('GET');
264        request.setHeader('Authorization', 'Bearer '+
265                        this.connectionInfo.oauthToken);
266        HttpResponse response = httpProtocol.send(request);
267        return response;
268    }
269}

BooksDataSourceProvider Class

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 BooksDataSourceProvider extends
9    DataSource.Provider {
10    /**
11     *   Declares the types of authentication that can be used
12     *   to access the external system.
13     **/   
14    override global List<DataSource.AuthenticationCapability>
15        getAuthenticationCapabilities() {
16        List<DataSource.AuthenticationCapability> capabilities =
17            new List<DataSource.AuthenticationCapability>();
18        capabilities.add(
19            DataSource.AuthenticationCapability.OAUTH);
20        capabilities.add(
21            DataSource.AuthenticationCapability.ANONYMOUS);
22        return capabilities;
23    }
24
25    /**
26     *   Declares the functional capabilities that the
27     *   external system supports.
28     **/
29    override global List<DataSource.Capability>
30        getCapabilities() {
31        List<DataSource.Capability> capabilities = new
32            List<DataSource.Capability>();
33        capabilities.add(DataSource.Capability.ROW_QUERY);
34        capabilities.add(DataSource.Capability.SEARCH);
35        return capabilities;
36    }
37
38    /**
39     *   Declares the associated DataSource.Connection class.
40     **/
41    override global DataSource.Connection getConnection(
42        DataSource.ConnectionParams connectionParams) {
43        return new BooksDataSourceConnection(connectionParams);
44    }
45}