What’s a Service/API
A service/API is a function that is well-defined, self-contained, and does not depend on the context or state of other services.
1. Abstract vs Concrete
When developing software we often use abstraction and polymorphism to get most of our applications. We want to reuse as much of the code as possible. Should we write our APIs that way too?The answer is NO. Concrete is better than abstract.
Can you guess why?Let me show you a few examples.
Let’s look at two API versions. Is it better to have an API that has one /customers or an API that has /accounts, /contacts and, /users separately?Which one seems more descriptive to you as a developer? Which API would you rather use?I would always choose the second one.
2. URI Formatting
Treat the resource like a noun, and the HTTP method as a verb. If you do it like that, you’ll end up with something like this:
RESOURCE | POST | GET | PUT | DELETE |
---|---|---|---|---|
/accounts | Creates a new account | List accounts | Replace accounts with new accounts (Bulk update) | Delete all accounts |
/accounts/0014P00002gqPpm | Return error | Show specific account | If exist update account else error | Delete specific account |
This is a cleaner and more precise way to use the API. It is immediately clear to the end user, and there is a method to the madness.
Use a constants class to define all the endpoints and use {version} so it becomes a variable for each endpoint. So we can manage different versions of and endpoint in the same class.
public with sharing class Rest_Constants { public static final String API_VERSION = '{version}'; public static final String API_VERSION_STR = '/' + API_VERSION; public static final String URI_ACCOUNTS = API_VERSION_STR + '/accounts'; }
Return all endpoint declarations as part of /endpoints declaration
https://na132.salesforce.com/services/apexrest/v1/settings/endpoints
{ "result": [ { "path": { "operation": "HTTPGET", "uri": "/{version}/accounts" } } ], "timeStamp": "2020-05-06T16:49:00.671Z", "totalTime": 0.037, "userId": "0054P00000C8OBlQAN" }
I prefer using plurals for resources names, read more here: https://stackoverflow.com/questions/6845772/rest-uri-convention-singular-or-plural-name-of-resource-while-creating-it
3. Error Handling
This is another important aspect of API building. There are a few good ways to handle errors.
Create a static resource file with all the define errors of the app
[ { "id": "QUERY_OPERATION_FAILED", "code": 991, "clientTemplate": "Encountered an error", "systemTemplate": "Query operation failed UserId: {0} | TraceId: {1} | TotalTime: {2} | Method: {3} | Path: {4} | Body: {5}" }, { "id": "SOBJECT_OPERATION_FAILED", "code": 992, "clientTemplate": "Encountered an error", "systemTemplate": "SObject operation failed UserId: {0} | TraceId: {1} | TotalTime: {2} | Method: {3} | Path: {4} | Body: {5}" }, { "id": "JSON_SERIALIZATION_FAILED", "code": 993, "clientTemplate": "Encountered an error", "systemTemplate": "Json serialization failed, check if Heroku is up or restart services UserId: {0} | TraceId: {1} | TotalTime: {2} | Method: {3} | Path: {4} | Body: {5}" }, { "id": "DATABASE_OPERATION_FAILED", "code": 994, "clientTemplate": "Encountered an error", "systemTemplate": "Database operation failed UserId: {0} | TraceId: {1} | TotalTime: {2} | Method: {3} | Path: {4} | Body: {5}" }, { "id": "CALLOUT_FAILED", "code": 995, "clientTemplate": "Encountered an error", "systemTemplate": "Callout to external service failed UserId: {0} | TraceId: {1} | TotalTime: {2} | Method: {3} | Path: {4} | Body: {5}" }, { "id": "SEARCH_FAILED", "code": 996, "clientTemplate": "Searching failed, try again", "systemTemplate": "Searching Salesforce failed UserId: {0} | TraceId: {1} | TotalTime: {2} | Method: {3} | Path: {4} | Body: {5}" }, { "id": "REST_DISPATCHER_DEBUG", "code": 997, "clientTemplate": "Encountered an error", "systemTemplate": "Debug UserId: {0} | TraceId: {1} | TotalTime: {2} | Method: {3} | Path: {4} | Body: {5}" }, { "id": "SERIALIZATION_FAILED", "code": 998, "clientTemplate": "Encountered an error", "systemTemplate": "Serialization failed, error: {0}" }, { "id": "REST_DISPATCHER_FAILED", "code": 999, "clientTemplate": "Encountered an error", "systemTemplate": "An error occurred UserId: {0} | TraceId: {1} | TotalTime: {2} | Method: {3} | Path: {4} | Body: {5}" } ]
Create a object representation of the error codes from the static resources
global with sharing class Rest_Error_ResponseCode { public String id { get; set; } public Integer code { get; set; } public String clientTemplate { private get; set { clientTemplate = value; } } public String systemTemplate { private get; set { systemTemplate = value; } } public String formattedClientMessage { get; set; } public String formattedSystemMessage { get; set; } global void formatMessages(List<String> args, Exception ex) { if (args != null && args.size() != 0) { formattedClientMessage = String.format(clientTemplate, args); if (systemTemplate != null) { formattedSystemMessage = String.format(systemTemplate, args); } } else { formattedClientMessage = clientTemplate; formattedSystemMessage = systemTemplate; } if (ex != null) { if (formattedSystemMessage == null) { formattedSystemMessage = formattedClientMessage; } formattedSystemMessage += ' -> Cause by: ' + ex.getTypeName().stripHtmlTags() + ' - ' + ex.getMessage().stripHtmlTags() + '. Cause trace: ' + ex.getStackTraceString().stripHtmlTags(); } } global Rest_Error_ResponseCode copy() { Rest_Error_ResponseCode theNewObj = new Rest_Error_ResponseCode(); theNewObj.id = this.id; theNewObj.code = this.code; theNewObj.clientTemplate = this.clientTemplate; theNewObj.systemTemplate = this.systemTemplate; return theNewObj; } global Rest_Error_ResponseCode(){} global override String toString() { return 'BrightPlan_ResponseCode(id=' + id + ';code=' + code + ';clientTemplate=' + clientTemplate + ';systemTemplate=' + systemTemplate + ';formattedClientMessage=' + formattedClientMessage + ';formattedSystemMessage=' + formattedSystemMessage + ')'; } }
Create a util to read the static resource file and create a map of error codes
public with sharing class Rest_Error_ResponseCodes_Util { private static Map<String, Rest_Error_ResponseCode> itsResponseCodes = new Map<String, Rest_Error_ResponseCode>(); static { try { StaticResource theCodesSrc = [SELECT Id, Body FROM StaticResource WHERE Name = 'Rest_Error_ResponseCodes' LIMIT 1]; if (theCodesSrc != null) { String theCodesJSON = theCodesSrc.Body.toString(); List<Rest_Error_ResponseCode> theCodesList = (List<Rest_Error_ResponseCode>) JSON.deserialize(theCodesJSON, List<Rest_Error_ResponseCode>.class); for (Rest_Error_ResponseCode theCode: theCodesList) { itsResponseCodes.put(theCode.id, theCode); } System.debug('Loaded ' + itsResponseCodes.size() + ' response codes into static map'); } else { System.debug(LoggingLevel.ERROR, 'Cannot query Error_ResponseCodes static resource from DB'); } } catch (Exception ex) { System.debug(LoggingLevel.ERROR, 'ERROR loading response codes: ' + ex.getMessage()); } } public static Rest_Error_ResponseCode getCode(String anID) { return getCode(anID, null, null); } public static Rest_Error_ResponseCode getCode(String anID, Exception anExp) { return getCode(anID, anExp, null); } public static Rest_Error_ResponseCode getCode(String anID, List<String> args) { return getCode(anID, null, args); } public static Rest_Error_ResponseCode getCode(String anID, Exception anExp, List<String> args) { Rest_Error_ResponseCode theCode = itsResponseCodes.get(anID); if (theCode == null) { theCode = itsResponseCodes.get('UNEXPECTED_RESPONSE_CODE').copy(); List<String> theArgs = new List<String>(); theArgs.add(anID); if (args != null) { theArgs.add(args + ''); } theCode.formatMessages(theArgs, anExp); System.debug(LoggingLevel.ERROR, 'Unknown response code ' + anID + '. Returning UNEXPECTED_RESPONSE_CODE and ignoring args list ' + args); } else { theCode = theCode.copy(); theCode.formatMessages(args, anExp); } return theCode; } }
Errors returned will look like follows
{ "responseCode": { "clientTemplate": "Incorrect operation, please try again", "code": 989, "formattedClientMessage": "Incorrect operation, please try again", "formattedSystemMessage": "Url does not exist or incorrect operation UserId: 0054P00000C8OBlQAN Type: HTTPGET URI: /v1/account Body: ", "id": "URL_INVALID", "systemTemplate": "Url does not exist or incorrect operation UserId: {0} Type: {1} URI: {2} Body {3}" }, "timeStamp": "2020-05-07T17:21:47.278Z", "totalTime": 0.053, "userId": "0054P00000C8OBlQAN", "mapKeysFlag": false, "serializeNulls": false }
4. API Versioning
Use the letter ‘v’ in the URL to denote the API version as below and handle the versions in the Rest_Dispatcher.Dispatchable
global with sharing class Rest_Accounts { global with sharing class Get implements Rest_Dispatcher.Dispatchable { private Rest_Request requestBody; global String getURIMapping(){ return Rest_Constants.URI_ACCOUNTS; } global void setRequestBody(Rest_Request requestBody){ this.requestBody = requestBody; } global Rest_Response execute(Map<String, String> parameters){ Rest_Response restResponse = new Rest_Response(); String version = parameters.get('version'); if ('v1'.equals(version)){ ... } else if ('v2'.equals(version)){ ... } else if ('v3'.equals(version)){ ... } restResponse.setResult(''); return restResponse; } global override String toString(){ Rest_Endpoint.Path path = new Rest_Endpoint.Path(); path.setPath(getURIMapping()); Rest_Endpoint endpoint = new Rest_Endpoint(path); return JSON.serialize(endpoint); } } }
5. Filtering
Filtering will be passed as key values where key to the Rest_Dispatcher.Dispatchable
GET /accounts?limit=50&offset=10
Execute method will receive filer key/value as parameters
global Rest_Response execute(Map<String, String> parameters){ Rest_Response restResponse = new Rest_Response(); String limitVal = parameters.containsKey('limit') : parameters.get('limit') :null; String offsetVal = parameters.containsKey('offset') : parameters.get('offset') : null; List<Accounts> accoutsLst = new List<Account>(); if (limitVal!=null && offsetVal!=null) accoutsLst = [Select Id, Name from Account limit :limit limitVal offset :offsetVal]; else accoutsLst = [Select Id, Name from Account]; restResponse.setResult(accoutsLst); return restResponse; }
6. Security
Security is one of the major concerns when compared to SOAP because still there are no standards such as ws-security defined for REST.
- HTTPS is the default for all Apex REST API calls
- Payload returned has timestamp
Example of payload the contains timeStamp
{ "result": { "AccountId": "0014P00002hysSZQAY", "Id": "0054P00000C8OBlQAN" }, "timeStamp": "2020-05-06T18:39:43.474Z", "totalTime": 0.034, "userId": "0054P00000C8OBlQAN", "mapKeysFlag": false, "serializeNulls": false }
7. Analytics
Log every API call request to build an analytical platform on top of that. Having analytics in your REST API will give you a good insight of what’s happening your API. To detect:
1. Long running queries
2. Duplicate requests by same user
3. Errors by type
public class Rest_Log { public static Rest_Log instance {get; private set;} static { instance = new Rest_Log(); } private static final String PREFIX = 'Rest Log'; public static HttpRequest setupLogEntries(){ HttpResponse res=new HttpResponse(); HttpRequest req = new HttpRequest(); String logEntricUrl = 'https://webhook.logentries.com/noformat/logs/{logEntriesToken}'; req.setEndpoint(logEntricUrl); req.setTimeout(60000); req.setMethod('POST'); return req; } @future(callout=true) public static void putLogCentricLog(String responseCode) { try{ HttpRequest req = setupLogEntries(); if (responseCode!=null){ req.setBody(PREFIX + ' : ' + responseCode); } Http http = new Http(); http.send(req); } catch (System.CalloutException ex){ System.debug('Callout failed ' + ex.getMessage()); } catch (Exception ex){ System.debug('Callout failed ' + ex.getMessage()); } } }
Example of the logging every API call to LogEntries (https://elements.heroku.com/addons/logentries)
11 May 2020 10:20:05.167 Rest Log : Debug UserId: 0054P00000C8OBlQAN | TraceId: null | TotalTime: 0.042 | Method: HTTPGET | Path: /v1/accounts | Body: 11 May 2020 10:20:10.934 Rest Log : Debug UserId: 0054P00000C8OBlQAN | TraceId: null | TotalTime: 0.007 | Method: HTTPGET | Path: /v1/settings/endpoints | Body:
Example of a dashboard with widgets in LogEntries to monitor response times, errors and slow endpoints
8. Documentation
Proper Documentation is vital for the API. It doesn’t matter how great your API design is if the API consumers can’t consume it properly.
Override toString for each endpoint
global override String toString(){ Rest_Endpoint.Path path = new Rest_Endpoint.Path(); path.setPath(getURIMapping()); path.setSummary('Get accounts for logged in user'); path.setProduces(Rest_Endpoint.getModelByObject('Account')); Rest_Endpoint endpoint = new Rest_Endpoint(path); return JSON.serialize(endpoint); }
Convert and return List of Rest Endpoints
public static List<Rest_Endpoint> convertToEndpoint(Map<RequestType, List<Dispatchable>> dispatchableMap){ List<Rest_Endpoint> restEndpoints = new List<Rest_Endpoint>(); for (RequestType dpMap : dispatchableMap.keySet()){ List<Dispatchable> dispatchableList = dispatchableMap.get(dpMap); for (Dispatchable dpList : dispatchableList){ Rest_Endpoint endpoint = (Rest_Endpoint)System.JSON.deserialize(dpList.toString(), Rest_Endpoint.class); Rest_Endpoint.Path path = endpoint.getPath(); path.setOperation(String.valueOf(dpMap)); restEndpoints.add(endpoint); } } return restEndpoints; }
Calling GET /settings/endpoints will return api documentation for all endpoints
{ "result": [ { "path": { "operation": "HTTPGET", "produces": { "Ownership_Check__c": "ownershipCheck", "Total_Assets__c": "totalAssets", "Number_of_Contacts__c": "numberofContacts", "Match_Billing_Address__c": "matchBillingAddress", "SLAExpirationDate__c": "sLAExpirationDate", "SLASerialNumber__c": "sLASerialNumber", "UpsellOpportunity__c": "upsellOpportunity", "NumberofLocations__c": "numberofLocations", "Active__c": "active", "SLA__c": "sLA", "CustomerPriority__c": "customerPriority", "SicDesc": "sicDesc", "AccountSource": "accountSource", "Jigsaw": "jigsaw", "IsCustomerPortal": "isCustomerPortal", "IsPartner": "isPartner", "Site": "site", "Rating": "rating", "Description": "description", "TickerSymbol": "tickerSymbol", "Ownership": "ownership", "NumberOfEmployees": "numberOfEmployees", "AnnualRevenue": "annualRevenue", "Industry": "industry", "Sic": "sic", "Website": "website", "AccountNumber": "accountNumber", "Fax": "fax", "Phone": "phone", "ShippingLongitude": "shippingLongitude", "ShippingLatitude": "shippingLatitude", "ShippingCountry": "shippingCountry", "ShippingPostalCode": "shippingPostalCode", "ShippingState": "shippingState", "ShippingCity": "shippingCity", "ShippingStreet": "shippingStreet", "BillingLongitude": "billingLongitude", "BillingLatitude": "billingLatitude", "BillingCountry": "billingCountry", "BillingPostalCode": "billingPostalCode", "BillingState": "billingState", "BillingCity": "billingCity", "BillingStreet": "billingStreet", "ParentId": "parentId", "Type": "type", "Name": "name" }, "summary": "Get account for logged in user", "uri": "/{version}/accounts" } } ], "timeStamp": "2020-05-11T23:43:51.063Z", "totalTime": 0.202, "userId": "0054P00000C8OBlQAN", "mapKeysFlag": false, "serializeNulls": false }