Lightning Connect Custom Adapters Connecting Anything with Salesforce
Lawrence McAlpin Principal Member of Technical Staff [email protected] @lmcalpin
Safe harbor statement under the Private Securities Litigation Reform Act of 1995:
This presentation may contain forward-looking statements that involve risks, uncertainties, and assumptions. If any such uncertainties materialize or if any of the assumptions proves incorrect, the results of salesforce.com, inc. could differ materially from the results expressed or implied by the forward-looking statements we make. All statements other than statements of historical fact could be deemed forward-looking, including any projections of product or service availability, subscriber growth, earnings, revenues, or other financial items and any statements regarding strategies or plans of management for future operations, statements of belief, any statements concerning new, planned, or upgraded services or technology developments and customer contracts or use of our services.
The risks and uncertainties referred to above include – but are not limited to – risks associated with developing and delivering new functionality for our service, new products and services, our new business model, our past operating losses, possible fluctuations in our operating results and rate of growth, interruptions or delays in our Web hosting, breach of our security measures, the outcome of any litigation, risks associated with completed and any possible mergers and acquisitions, the immature market in which we operate, our relatively limited operating history, our ability to expand, retain, and motivate our employees and manage our growth, new releases of our service and successful customer deployment, our limited history reselling non-salesforce.com products, and utilization and selling to larger enterprise customers. Further information on potential factors that could affect the financial results of salesforce.com, inc. is included in our annual report on Form 10-K for the most recent fiscal year and in our quarterly report on Form 10-Q for the most recent fiscal quarter. These documents and others containing important disclosures are available on the SEC Filings section of the Investor Information section of our Web site.
Any unreleased services or features referenced in this or other presentations, press releases or public statements are not currently available and may not be delivered on time or at all. Customers who purchase our services should make the purchase decisions based upon features that are currently available. Salesforce.com, inc. assumes no obligation and does not intend to update these forward-looking statements.
Safe Harbor
Agenda
Background on External Data Sources Custom Adapter Framework Overview
Demo
Provider Implementation
Connection Implementation
Pagination Search
DML
Q&A
Background
● Simplifies integration with external systems
● No magic ○ data is still remote
● Ideal for use cases: ○ data is infrequently
accessed (such as archival data)
○ you do not want to present stale data
○ single point of truth
External Data Sources
• Data must be in a format we understand
▪ OData v2
▪ OData v4
▪ Salesforce
• Apex Custom Adapter Framework
▪ write your own!
• Data must be accessible to Salesforce
Custom Adapter Framework
Allows you to write your own Lightning Connect adapters
Custom Adapter Framework
● Standard Governor limits apply
● No limit to the number of Apex custom adapter classes you can define
● Need Lightning Connect license to configure an External Data Source to use the custom adapter
Demo
Custom Adapter Framework
● DataSource.Provider o describes the capabilities of the external data source o creates the Connection class
● DataSource.Connection o called whenever you import the metadata o called when you execute SOQL, SOSL, DML or equivalent UI interactions
DataSource.Provider
global class DummyDataSourceProvider extends DataSource.Provider { override global List<DataSource.Capability> getCapabilities() { List<DataSource.Capability> capabilities = new List<DataSource.Capability>(); capabilities.add(DataSource.Capability.ROW_QUERY); capabilities.add(DataSource.Capability.SEARCH); return capabilities; } override global List<DataSource.AuthenticationCapability> getAuthenticationCapabilities() { List<DataSource.AuthenticationCapability> capabilities = new List<DataSource.AuthenticationCapability>(); capabilities.add(DataSource.AuthenticationCapability.ANONYMOUS); return capabilities; } override global DataSource.Connection getConnection(DataSource.ConnectionParams connectionParams) { return new DummyDataSourceConnection(connectionParams); } }
DataSource.Provider
override global List<DataSource.Capability> getCapabilities()
ROW_QUERY ROW_CREATE ROW_UPDATE ROW_DELETE SEARCH REQUIRE_ENDPOINT REQUIRE_HTTPS QUERY_TOTAL_SIZE QUERY_PAGINATION_SERVER_DRIVEN
DataSource.Provider
getAuthenticationCapabilities ANONYMOUS BASIC CERTIFICATE OAUTH
DataSource.Provider
override global DataSource.Connection getConnection(DataSource.ConnectionParams connectionParams) { return new DummyDataSourceConnection(connectionParams); }
ConnectionParams properties
String username
String password String oauthToken
AuthenticationProtocol protocol String endpoint
String repository IdentityType principalType
String certificateName
Callouts
Data may be retrieved using HTTP or Web service callouts ● Authentication must be handled manually
o throw OAuthTokenExpiredException to refresh the stored access token o all callout endpoints need to be registered in Remote Site Settings
HttpRequest req = new HttpRequest(); req.setEndpoint('http://www.wherever.com'); req.setMethod('GET'); if (protocol == DataSource.AuthenticationProtocol.PASSWORD) { String username = connectionParams.username; String password = connectionParams.password; Blob headerValue = Blob.valueOf(username + ':' + password); String authorizationHeader = 'BASIC ' + EncodingUtil.base64Encode(headerValue); req.setHeader('Authorization', authorizationHeader); } else if (protocol == DataSource.AuthenticationProtocol.OAUTH) { req.setHeader('Authorization', 'Bearer ' + connectionParams.oauthToken); } Http http = new Http(); HTTPResponse res = http.send(req); if (res.getStatusCode() == 401) throw new OAuthTokenExpiredException();
Callouts with Named Credentials
Named Credentials are more flexible but require additional setup ● no direct access to credentials ● no need to add to Remote Site
Settings HttpRequest req = new HttpRequest(); req.setEndpoint(‘callout:test’); req.setMethod('GET'); Http http = new Http(); HTTPResponse res = http.send(req);
Callouts with Named Credentials
Merge fields {!$CREDENTIAL.xxx} ● USERNAME ● PASSWORD ● OAUTHTOKEN ● AUTHORIZATIONMETHOD (BASIC, OAUTH) ● AUTHORIZATIONHEADERVALUE (Base64 encoded username+password or Oauth token) ● OAUTHCONSUMERKEY
// Concur expects OAuth to prefix the access token, instead of Bearer req.setHeader(‘Authorization’, ‘OAuth {!$Credential.OAuthToken}’); // non-standard authentication req.setHeader(‘X-Username’, ‘{!$Credential.UserName}’); req.setHeader(‘X-Password’, ‘{!$Credential.Password}’); // you can also use it in the body req.setBody(‘Dear {!$Credential.UserName}, I am a Salesforce Prince and as a Prince of Salesforce I naturally own a metric crap ton of RSUs. If you send me 10,000 of teh bitcoins now I will deliver my stock to you as it vests which wil be totes winwin.’);
DataSource.Connection
override global List<DataSource.Table> sync() enumerates the list of Tables that this data source knows about
override global DataSource.TableResult query(DataSource.QueryContext c)
called when executing SOQL or visiting the List or Details pages in the UI
override global List<DataSource.TableResult> search(DataSource.SearchContext c)
called when executing SOSL or using the search functions in the UI
override global List<DataSource.UpsertResult> upsertRows(DataSource.UpsertContext c)
called when executing insert or update DML; also called when editing a record in the UI
override global List<DataSource.DeleteResult> deleteRows(DataSource.DeleteContext c)
called when executing delete DML; also called when deleting a record in the UI
Sync - DataSource.Table override global List<DataSource.Table> sync() {
List<DataSource.Table> tables = new List<DataSource.Table>();
List<DataSource.Column> columns;
columns = new List<DataSource.Column>();
// next slide...
tables.add(DataSource.Table.get('Looper', 'Name', columns));
return tables;
}
Sync - DataSource.Column columns = new List<DataSource.Column>();
columns.add(DataSource.Column.text('ExternalId', 255));
columns.add(DataSource.Column.url('DisplayUrl'));
columns.add(DataSource.Column.text('Name', 255));
columns.add(DataSource.Column.number('NumberOfEmployees', 18, 0));
Query override global DataSource.TableResult query(DataSource.QueryContext c) { HttpRequest req = prepareCallout(c); List<Map<String,Object>> rows = getData(req); // don’t forget the standard fields, especially ExternalId for (Map<String,Object> row : rows) { row.put('ExternalId', row.get(‘key’)); row.put('DisplayUrl', connectionParams.url + ‘/record/’ + row.get(‘key’)); rows.add(row); } return DataSource.TableResult.get(c,rows); }
QueryContext properties TableSelection tableSelection Integer offset Integer maxResults TableSelection properties string tableSelected List<ColumnSelection> columnsSelected Filter filter List<Order> order TableResult properties boolean success String errorMessage String tableName List<Map<String,Object>> rows integer totalSize String queryMoreToken
Query Filters /** Compound types **/
NOT_,
AND_,
OR_, private string getSoqlFilter(string query, DataSource.Filter filter) { if (filter == null) { return query; } DataSource.FilterType type = filter.type; List<Map<String,Object>> retainedRows = new List<Map<String,Object>>(); if (type == DataSource.FilterType.NOT_) { DataSource.Filter subfilter = filter.subfilters.get(0); return ‘NOT ‘ + getSoqlFilterExpression(subfilter); } else if (type == DataSource.FilterType.AND_) { return join('AND', filter.subfilters); } else if (type == DataSource.FilterType.OR_) { return join('OR', filter.subfilters); } return getSoqlFilterExpression(filter); }
/** Simple comparative types **/ EQUALS, NOT_EQUALS, LESS_THAN, GREATER_THAN,
private string getSoqlFilterExpression(DataSource.Filter filter) { string op; string columnName = filter.columnName; object expectedValue = filter.columnValue; if (filter.type == DataSource.FilterType.EQUALS) { op = '='; } else if (filter.type == DataSource.FilterType.NOT_EQUALS) { op = '<>'; } else if (filter.type == DataSource.FilterType.LESS_THAN) { op = '<'; } else if (filter.type == DataSource.FilterType.GREATER_THAN) { op = '>'; } else if (filter.type == DataSource.FilterType.LESS_THAN_OR_EQUAL_TO) { op = '<='; } else if (filter.type == DataSource.FilterType.GREATER_THAN_OR_EQUAL_TO) { op = '>='; } else if (filter.type == DataSource.FilterType.STARTS_WITH) { return mapColumnName(columnName) + ' LIKE \'' + String.valueOf(expectedValue) + '%\''; } else if (filter.type == DataSource.FilterType.ENDS_WITH) { return mapColumnName(columnName) + ' LIKE \'%' + String.valueOf(expectedValue) + '\''; } else { throwException('DF15SpeakerWasLazyException: unimplemented filter type' + filter.type); } return mapColumnName(columnName) + ' ' + op + ' ' + wrapValue(expectedValue); }
LESS_THAN_OR_EQUAL_TO, GREATER_THAN_OR_EQUAL_TO, STARTS_WITH, ENDS_WITH, CONTAINS, LIKE_
QueryMore
Can’t return all results in a single batch! Not all external data sources handle pagination the same way
• page number • token • limit, offset
Three strategies to handle pagination: • client driven, known total size • client driven, unknown total size • server driven
Driven by comabination of capabilities: • QUERY_TOTAL_SIZE • QUERY_SERVER_DRIVEN_PAGINATION
QueryMore - Client Driven, Known Total Size
Provider class declares QUERY_TOTAL_SIZE /services/data/v35.0/query?q=SELECT...FROM+Xds__x ⇒
query • QueryContext maxResults = 1000 • TableResult should have 1000 records as requested!!!
API call returns { "totalSize" => 1500, "done" => false, "nextRecordsUrl" => "/services/data/v35.0/query/01gxx000000B4WAAA0-1000", "records" => [ … 1000 RECORDS … ] }
queryMore call to /services/data/v35.0/query/xxx ⇒ query
• QueryContext maxResults = 1000, and offset = 1000 • TableResult should have 500 records
API call returns { "totalSize" => 1500, "done" => true, "records" => [ … 500 RECORDS … ] }
QueryMore - Client Driven, Unknown Query Result Size
Default strategy used when provider does not support QUERY_TOTAL_SIZE or QUERY_SERVER_DRIVEN_PAGINATION /services/data/v35.0/query?q=SELECT...FROM+Xds__x ⇒
query • QueryContext maxResults = 1001 • TableResult should return 1001 records as requested!!!
SFDC API call returns { "totalSize" => -1, "done" => false, "nextRecordsUrl" => "/services/data/v35.0/query/01gxx000000B4WAAA0-1000", "records" => [ … 1000 RECORDS … ] }
queryMore call to /services/data/v35.0/query/xxx ⇒ query
• QueryContext maxResults = 1001, and offset = 1000 • TableResult should have 500 records
API call returns { "totalSize" => 1500, "done" => true, "records" => [ … 500 RECORDS … ] }
QueryMore - Server Driven
Provider class declares QUERY_SERVER_DRIVEN_PAGINATION /services/data/v35.0/query?q=SELECT...FROM+Xds__x ⇒
query • QueryContext maxResults = 0 • TableResult should have however many records you want • TableResult must provide a queryMoreToken
API call returns the following if queryMoreToken is not null { "totalSize" => -1 or 1500, # depends on QUERY_TOTAL_SIZE support "done" => false, "nextRecordsUrl" => "/services/data/v35.0/query/01gxx000000B4WAAA0-1000", "records" => [ … ??? RECORDS … ] }
queryMore call to /services/data/v35.0/query/xxx ⇒ query
• QueryContext queryMoreToken will be set to the token previously supplied • TableResult should have however many records you want
API call returns the following when queryMoreToken is null { "totalSize" => 1500, "done" => true, "records" => [ … ??? RECORDS … ] }
Search
● SOSL and Search UI operations invoke the search method on your Connection class ● Multiple tables for a single connector may be searched in a single call ● Display URL is intended to point to the search results in the external system
SearchContext properties List<TableSelection> tableSelections String searchPhrase global static List<DataSource.TableResult> search(SearchContext c) {
List<DataSource.TableResult> results = new List<DataSource.TableResult>(); for (DataSource.TableSelection tableSelection : c.tableSelections) {
QueryContext ctx = new QueryContext();
ctx.tableSelection = tableSelection; Table table = c.getTableMetadata(ctx.tableSelection);
tableSelection.filter = new Filter(FilterType.CONTAINS, ctx.tableSelection.tableSelected,
table.nameColumn, c.searchPhrase);
results.add(query(ctx)); }
return results;
}
Search
Inserts, Updates, Deletes
Requires DML capabilities in DataSource.Provider ● ROW_CREATE
● ROW_UPDATE
● ROW_DELETE
Requires ID mapping Requires Allow Create, Edit, and Delete selection on External Data Source
Insert, Updates override global List<DataSource.UpsertResult> upsertRows(DataSource.UpsertContext c) { List<DataSource.UpsertResult> results = new List<DataSource.UpsertResult>(); List<Map<String,Object>> rows = c.rows; for (Map<String,Object> row : rows) { String externalId = String.valueOf(row.get('ExternalId')); // insert or update record in the external system boolean success = // insert or update record if (success) { results.add(DataSource.UpsertResult.success(id)); } else { results.add(DataSource.UpsertResult.failure(id, 'An error occurred updating this record')); } } return results; }
UpsertContext properties String tableSelected List<Map<string,object>> rows
UpsertResult properties Boolean success String errorMessage String externalId
Deletes override global List<DataSource.DeleteResult> deleteRows(DataSource.DeleteContext c) {
Set<Id> externalIds = new Set<Id>();
List<DataSource.DeleteResult> results = new List<DataSource.DeleteResult>();
for (String externalId : c.externalIds) {
boolean success = // delete record in external system
if (result.success) {
results.add(DataSource.DeleteResult.success(id));
} else {
results.add(DataSource.DeleteResult.failure(id, 'An error occurred updating this record'));
}
}
return results;
}
DeleteContext properties String tableSelected List<String> externalIds
DeleteResult properties Boolean success String errorMessage String externalId
DML
● New Database methods used for external objects (only) ○ insertAsync ○ updateAsync ○ deleteAsync
● DML operations are asynchronous ● call getAsyncResult later to get results
Order__x x = new Order__x(); Database.SaveResult locator = Database.insertAsync(x); if (!locator.isSuccess() && locator.getAsyncLocator() != null) { // save was queued up for execution, when the result is ready, do some additional processing completeOrderCreation(asyncLocator); } // must be in another transaction!!! @future public void completeOrderCreation(String asyncLocator) { Database.SaveResult sr = Database.getAsyncResult(asyncLocator); if (sr.isSuccess()) { … } }
DML Callbacks
● Callbacks to handle post-save processing global class XdsSaveCallback extends DataSource.AsyncSaveCallback { virtual global void processSave(Database.SaveResult sr) { if (sr.isSuccess()) { … } } } XdsSaveCallback cb = new XdsSaveCallback(); Order__x x = new Order__x(); Database.insertAsync(x, cb);
DML Callbacks
● Callbacks to handle post-delete processing global class XdsDeleteCallback extends DataSource.AsyncDeleteCallback { virtual global void processDelete(Database.DeleteResult dr) {} } XdsDeleteCallback cb = new XdsDeleteCallback(); Xds__x x = [SELECT Id FROM Xds__x WHERE ExternalId = ‘...’]; Database.deleteAsync(x, cb);
Lawrence McAlpin Lawrence McAlpin Principal Member of Technical Staff
[email protected] @lmcalpin