Apex Rest API Architecture – Best Practices

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
  • Don’t forget to include timestamp in each and every API request and response. Make sure to log them all. In case of a dispute you can refer them.
    • 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
    }
    
  • Leave a Reply

    Fill in your details below or click an icon to log in:

    WordPress.com Logo

    You are commenting using your WordPress.com account. Log Out /  Change )

    Google photo

    You are commenting using your Google account. Log Out /  Change )

    Twitter picture

    You are commenting using your Twitter account. Log Out /  Change )

    Facebook photo

    You are commenting using your Facebook account. Log Out /  Change )

    Connecting to %s

    %d bloggers like this: