salesforce-composite-envelo.../composite-envelope-builder/force-app/main/default/classes/DocusignAPIService.cls

272 lines
10 KiB
OpenEdge ABL

/**
* @description Service class for Docusign REST API interactions
* @author Paul Huliganga
* @date 2026-02-23
*/
public with sharing class DocusignAPIService {
private static final Integer TIMEOUT_MS = 120000; // 120 seconds
private static final Integer MAX_RETRIES = 2;
/**
* @description Creates a composite envelope in Docusign
* @param envelopeJSON JSON string containing envelope definition
* @param creds Docusign credentials object
* @return Envelope ID (GUID)
* @throws CalloutException if API call fails
*/
public static String createCompositeEnvelope(
String envelopeJSON,
DocusignCredentials creds
) {
Long startTime = System.currentTimeMillis();
// Build HTTP request
HttpRequest req = buildCreateEnvelopeRequest(envelopeJSON, creds);
// Make callout with retry logic
HttpResponse res = executeWithRetry(req, MAX_RETRIES);
Long duration = System.currentTimeMillis() - startTime;
// Log request/response (sanitized)
logAPICall(req, res, duration);
// Parse response
if (res.getStatusCode() == 201) {
return parseEnvelopeId(res);
} else {
handleAPIError(res);
return null; // Won't reach here, handleAPIError throws
}
}
/**
* @description Builds HTTP request for creating envelope
* @param envelopeJSON JSON body
* @param creds Credentials
* @return Configured HttpRequest
*/
@TestVisible
private static HttpRequest buildCreateEnvelopeRequest(
String envelopeJSON,
DocusignCredentials creds
) {
HttpRequest req = new HttpRequest();
// Use Named Credential if available, otherwise build URL manually
String endpoint = buildEnvelopeEndpoint(creds);
req.setEndpoint(endpoint);
req.setMethod('POST');
req.setHeader('Authorization', 'Bearer ' + creds.getAccessToken());
req.setHeader('Content-Type', 'application/json');
req.setHeader('Accept', 'application/json');
req.setBody(envelopeJSON);
req.setTimeout(TIMEOUT_MS);
return req;
}
/**
* @description Builds envelope endpoint URL
* @param creds Docusign credentials
* @return Full endpoint URL
*/
private static String buildEnvelopeEndpoint(DocusignCredentials creds) {
// Check if using Named Credential (starts with 'callout:')
if (creds.getBaseUrl().startsWith('callout:')) {
return creds.getBaseUrl() + '/accounts/' + creds.getAccountId() + '/envelopes';
} else {
return creds.getBaseUrl() + '/restapi/v2.1/accounts/' + creds.getAccountId() + '/envelopes';
}
}
/**
* @description Executes HTTP request with retry logic
* @param req HTTP request
* @param maxRetries Maximum number of retry attempts
* @return HTTP response
* @throws CalloutException if all retries fail
*/
@TestVisible
private static HttpResponse executeWithRetry(HttpRequest req, Integer maxRetries) {
Http http = new Http();
HttpResponse res;
Integer attempt = 0;
while (attempt <= maxRetries) {
attempt++;
try {
res = http.send(req);
// Success or non-retryable error
if (res.getStatusCode() == 201 || !isRetryableError(res.getStatusCode())) {
return res;
}
// Rate limit - wait before retry
if (res.getStatusCode() == 429 && attempt <= maxRetries) {
Integer waitMs = calculateBackoff(attempt);
System.debug('Rate limited, waiting ' + waitMs + 'ms before retry ' + attempt);
// Note: Apex doesn't have Thread.sleep, but in production this would be handled by Platform Events
// For now, just retry immediately
}
} catch (System.CalloutException e) {
// Timeout or connection error
if (attempt > maxRetries) {
throw new CalloutException('Docusign API callout failed after ' + maxRetries + ' retries: ' + e.getMessage());
}
System.debug('Callout failed (attempt ' + attempt + '): ' + e.getMessage());
}
}
// All retries exhausted
throw new CalloutException('Docusign API callout failed after ' + maxRetries + ' retries. Last status: ' + res.getStatusCode());
}
/**
* @description Determines if an HTTP status code is retryable
* @param statusCode HTTP status code
* @return True if error is transient and should be retried
*/
private static Boolean isRetryableError(Integer statusCode) {
// 401 (token expired), 429 (rate limit), 500-599 (server errors)
return statusCode == 401 || statusCode == 429 || (statusCode >= 500 && statusCode < 600);
}
/**
* @description Calculates exponential backoff delay
* @param attempt Retry attempt number
* @return Delay in milliseconds
*/
private static Integer calculateBackoff(Integer attempt) {
// Exponential backoff: 2^attempt * 1000ms (1s, 2s, 4s, ...)
return (Integer) Math.pow(2, attempt) * 1000;
}
/**
* @description Parses envelope ID from successful response
* @param res HTTP response
* @return Envelope ID (GUID)
*/
@TestVisible
private static String parseEnvelopeId(HttpResponse res) {
try {
Map<String, Object> responseMap = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
String envelopeId = (String) responseMap.get('envelopeId');
if (String.isBlank(envelopeId)) {
throw new CalloutException('Envelope ID not found in Docusign response');
}
return envelopeId;
} catch (Exception e) {
throw new CalloutException('Failed to parse Docusign response: ' + e.getMessage());
}
}
/**
* @description Handles API error responses
* @param res HTTP response with error
* @throws CalloutException with detailed error message
*/
@TestVisible
private static void handleAPIError(HttpResponse res) {
String errorMessage = 'Docusign API error [' + res.getStatusCode() + ']';
try {
Map<String, Object> errorBody = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
String errorCode = (String) errorBody.get('errorCode');
String message = (String) errorBody.get('message');
if (String.isNotBlank(errorCode)) {
errorMessage += ' ' + errorCode;
}
if (String.isNotBlank(message)) {
errorMessage += ': ' + message;
}
} catch (Exception e) {
// Could not parse error body, use raw response
errorMessage += ': ' + res.getBody();
}
// Map common errors to user-friendly messages
errorMessage = enhanceErrorMessage(res.getStatusCode(), errorMessage);
throw new CalloutException(errorMessage);
}
/**
* @description Enhances error messages with user-friendly guidance
* @param statusCode HTTP status code
* @param originalMessage Original error message
* @return Enhanced error message
*/
private static String enhanceErrorMessage(Integer statusCode, String originalMessage) {
switch on statusCode {
when 400 {
return originalMessage + ' - Check template IDs and request parameters.';
}
when 401 {
return originalMessage + ' - Authentication failed. Check API credentials and access token.';
}
when 403 {
return originalMessage + ' - User lacks permission to create envelopes.';
}
when 404 {
return originalMessage + ' - One or more templates not found in Docusign account.';
}
when 429 {
return originalMessage + ' - API rate limit exceeded. Please try again later.';
}
when 500, 503 {
return originalMessage + ' - Docusign server error. Please try again.';
}
when else {
return originalMessage;
}
}
}
/**
* @description Logs API call details (sanitized, no credentials)
* @param req HTTP request
* @param res HTTP response
* @param durationMs Duration in milliseconds
*/
private static void logAPICall(HttpRequest req, HttpResponse res, Long durationMs) {
System.debug(LoggingLevel.INFO, '=== Docusign API Call ===');
System.debug(LoggingLevel.INFO, 'Method: ' + req.getMethod());
System.debug(LoggingLevel.INFO, 'Endpoint: ' + sanitizeEndpoint(req.getEndpoint()));
System.debug(LoggingLevel.INFO, 'Request Body Length: ' + req.getBody().length() + ' bytes');
System.debug(LoggingLevel.INFO, 'Response Status: ' + res.getStatusCode() + ' ' + res.getStatus());
System.debug(LoggingLevel.INFO, 'Response Body Length: ' + res.getBody().length() + ' bytes');
System.debug(LoggingLevel.INFO, 'Duration: ' + durationMs + 'ms');
// Only log full bodies in DEBUG mode (not in production)
if (Test.isRunningTest()) {
System.debug(LoggingLevel.FINEST, 'Request Body: ' + req.getBody());
System.debug(LoggingLevel.FINEST, 'Response Body: ' + res.getBody());
}
}
/**
* @description Sanitizes endpoint URL for logging (removes account ID)
* @param endpoint Full endpoint URL
* @return Sanitized URL
*/
private static String sanitizeEndpoint(String endpoint) {
// Replace account ID with placeholder for security
return endpoint.replaceAll('/accounts/[a-f0-9\\-]+/', '/accounts/{accountId}/');
}
/**
* @description Custom exception for API errors
*/
public class CalloutException extends Exception {}
}