Skip to content

Instantly share code, notes, and snippets.

@renatoliveira
Last active October 2, 2023 16:39
Show Gist options
  • Save renatoliveira/a2275b2ff58ec3a51d835e6ba6d26097 to your computer and use it in GitHub Desktop.
Save renatoliveira/a2275b2ff58ec3a51d835e6ba6d26097 to your computer and use it in GitHub Desktop.
This is a refactored class that uploads files to Google Drive using the service's REST API.
/**
* Callout mock for the Google Drive API class
*
* Please note that the "generateRandomString" was not originally part of this class. I've added this method because there
* were lots of IDs hardcoded into the URL's inside the JSON responses. Those should not, in theory, affect the tests where
* this mock is used.
*/
@IsTest
global class GoogleDriveCalloutMock implements HttpCalloutMock {
global HTTPResponse respond (HTTPRequest req) {
String randomFileString = generateRandomString(33)
String folderId = generateRandomString(28)
if (req.getEndpoint() == 'https://accounts.google.com/o/oauth2/token'){
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'text/xml');
res.setBody('{"access_token" : "' + generateRandomString(129) + '", "expires_in" : "3600", "token_type" : "Bearer"}');
res.setStatusCode(200);
return res;
} else {
HttpResponse res = new HttpResponse();
res.setHeader('Content-Type', 'text/xml');
res.setBody('{ "kind": "drive#file", "id": "' + randomFileString + '", "etag": "z_YVnINIY56ukYDqXpOL80gtv44/MTUyMTQyNTMxMjM1MQ", "selfLink": "https://www.googleapis.com/drive/v2/files/' + randomFileString + '", "webContentLink": "https://drive.google.com/a/company.com/uc?id=' + randomFileString + '&export=download", "alternateLink": "https://drive.google.com/a/company.com/file/d/' + randomFileString + '/view?usp=drivesdk", "embedLink": "https://drive.google.com/a/company.com/file/d/' + randomFileString + '/preview?usp=drivesdk", "iconLink": "https://drive-thirdparty.googleusercontent.com/16/type/image/png", "title": "youse.txt", "mimeType": "image/png", "labels": { "starred": false, "hidden": false, "trashed": false, "restricted": false, "viewed": true }, "createdDate": "2018-03-19T02:08:32.351Z", "modifiedDate": "2018-03-19T02:08:32.351Z", "modifiedByMeDate": "2018-03-19T02:08:32.351Z", "lastViewedByMeDate": "2018-03-19T02:08:32.351Z", "markedViewedByMeDate": "1970-01-01T00:00:00.000Z", "version": "2", "parents": [ { "kind": "drive#parentReference", "id": "' + folderId + '", "selfLink": "https://www.googleapis.com/drive/v2/files/' + randomFileString + '/parents/' + folderId + '", "parentLink": "https://www.googleapis.com/drive/v2/files/' + folderId + '", "isRoot": false } ], "downloadUrl": "https://doc-url.googleusercontent.com/docs/securesc/' + generateRandomString(32) + '/' + generateRandomString(32) + '/1521424800000/00618666553744795488/00618666553744795488/' + randomFileString + '?h=08338985299633023928&e=download&gd=true", "userPermission": { "kind": "drive#permission", "etag": "z_YVnINIY56ukYDqXpOL80gtv44/d_B1NC48ahxx5ymIrScLiOxm4pU", "id": "me", "selfLink": "https://www.googleapis.com/drive/v2/files/' + randomFileString + '/permissions/me", "role": "owner", "type": "user" }, "originalFilename": "youse.txt", "fileExtension": "txt", "md5Checksum": "456f7fa35670112d4240c2b0c28c3d32", "fileSize": "1214", "quotaBytesUsed": "1214", "ownerNames": [ "douglas rodrigues" ], "owners": [ { "kind": "drive#user", "displayName": "douglas rodrigues", "isAuthenticatedUser": true, "permissionId": "00618666553744795488", "emailAddress": "admin@company.com" } ], "lastModifyingUserName": "douglas rodrigues", "lastModifyingUser": { "kind": "drive#user", "displayName": "douglas rodrigues", "isAuthenticatedUser": true, "permissionId": "00618666553744795488", "emailAddress": "admin@company.com" }, "capabilities": { "canCopy": true, "canEdit": true }, "editable": true, "copyable": true, "writersCanShare": true, "shared": true, "explicitlyTrashed": false, "appDataContents": false, "headRevisionId": "' + generateRandomString(51) + '", "imageMediaMetadata": { "width": 0, "height": 0 }, "spaces": [ "drive" ]}');
res.setStatusCode(200);
return res;
}
}
public static String generateRandomString (Integer len) {
final String chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz';
String randStr = '';
while (randStr.length() < len) {
Integer idx = Math.mod(Math.abs(Crypto.getRandomInteger()), chars.length());
randStr += chars.substring(idx, idx+1);
}
return randStr;
}
}
/**
* Created by Renato on 17/04/18.
*
* This is a refactored class, based on a class that was created by a company which worked for a customer previously.
* This new version enables the use for the ContentDocument object, which will eventually replace the Attachment.
* Content Document is the default table that holds attachment information on Lightning Experience.
*/
/**
* "GoogleDriveFile__c" is a custom object that is used to track the status of a file on Google Drive. It references
* a resource (Attachment or ContentDocument) on Salesforce, and provides its status on the drive. Its defaults to
* "Pending". Those pending files are then collected by this class, and an attempt to upload the file to the drive
* is made. If it is successful, the status is updated to "Sent". If not, it is updated to "Error".
*
* This object's main fields are Status__c (see the paragraph above), and FileId__c and RelatedRecordId__c.
* FileId__c is a text field that references the attachment or content document Id.
* RelatedRecordId__c is a text field that references the parent record (the same value of 'ParentId' field on the
* attachment, for example).
*
* "GoogleDriveIntegration__c" is a custom setting that contains three fields:
* - Access_Token__c
* - Authorization_Code__c
* - Client_ID__c
* - Client_Secret__c
* - Refresh_Token__c
*
* which are used to authenticate with the Google Drive service. The access token is refreshed with
* the first callout, and updated at the end of the 'run' method.
*
* Note: a folder id should be specified to the "folderId" constant. It will tell to which folder we upload
* our files. I don't know why this hasn't been moved to a custom setting, but I plan on doing it anyway later.
*
* Drive documentation used in this class: https://developers.google.com/drive/v2/web/about-auth
*/
global class GoogleDriveIntegrationBatch implements Database.Batchable<GoogleDriveFile__c>,
Database.AllowsCallouts {
public List<GoogleDriveFile__c> files;
public static final String folderId = 'GoogleDriveFolderIdGoesHere';
public GoogleDriveIntegrationBatch () {
files = [SELECT Id, Name FROM GoogleDriveFile__c WHERE Status__c = 'Pending'];
}
global Iterable<GoogleDriveFile__c> start (Database.BatchableContext bc) {
return files;
}
/**
* Batch method to upload the files.
*
* @param bc batchable context (never used variable)
* @param files files to be uploaded
*/
global void execute (Database.BatchableContext bc, List<GoogleDriveFile__c> files) {
run(files);
}
/**
* Runs the job to upload the files.
*
* @param files files to upload.
*/
global void run (List<GoogleDriveFile__c> files) {
Credentials creds = new Credentials();
HttpRequest accessTokenRequest = getAccessTokenRequest(
creds.getRefreshToken(),
creds.getClientId(),
creds.getClientSecret()
);
System.debug(LoggingLevel.INFO, 'DML Rows: ' + Limits.getDmlRows());
System.debug(LoggingLevel.INFO, 'DML Statements: ' + Limits.getDmlStatements());
String refreshedToken = getTokenFromResult(new Http().send(accessTokenRequest).getBody());
List<File> filesToUpload = getFiles(files);
Set<Id> parentIds = new Set<Id>();
Set<Id> attachmentIds = new Set<Id>();
for (File f : filesToUpload) {
if (!f.isDeletable()) {
HttpResponse sendResult = sendFile(f, creds, folderId);
handleSaveResult(f, sendResult.getBody());
if (f.getFileKind().toLowerCase() == 'Sales communication') {
parentIds.add(f.getParentId());
attachmentIds.add(f.getAttachmentId());
}
}
}
handleSavedFiles(getSavedFiles(filesToUpload));
delete getRecordsToDelete(filesToUpload);
creds.setAccessToken(refreshedToken);
}
global void finish (Database.BatchableContext bc) {
// striped irrelevant code, which isn't useful for debugging and relies on other irrelevant custom objects
}
/**
* Handles the saved files, by getting the attachment or document id, and then proceeding to delete those inside
* Salesforce (because of how much space they use).
*
* @param files files saved on Google Drive
*/
public void handleSavedFiles (List<GoogleDriveFile__c> files) {
Set<Id> attachmentIds = new Set<Id>();
Set<Id> documentsIds = new Set<Id>();
for (GoogleDriveFile__c f : files) {
if (Attachment.SObjectType.getDescribe().getKeyPrefix() == f.FileId__c.left(3)) {
attachmentIds.add(f.FileId__c);
} else if (ContentDocument.SObjectType.getDescribe().getKeyPrefix() == f.FileId__c.left(3)) {
documentsIds.add(f.FileId__c);
}
}
List<SObject> objectsToDelete = new List<SObject>();
for (Id attachmentId : attachmentIds) {
objectsToDelete.add(new Attachment(Id=attachmentId));
}
for (Id contentId : documentsIds) {
objectsToDelete.add(new ContentDocument(Id=contentId));
}
delete objectsToDelete;
update files;
}
/**
* Returns a list of saved files. The parameters used to determine if a file was saved on Google Drive or not
* depend exclusively on what was read on the JSON response by the 'handleSaveResult' method.
*
* @param files files that were supposedly uploaded to Google Drive.
*
* @return integration records with the attachment/document id
*/
public List<GoogleDriveFile__c> getSavedFiles (List<File> files) {
List<GoogleDriveFile__c> result = new List<GoogleDriveFile__c>();
for (File f : files) {
if (!f.isDeletable() && f.isSaved()) {
result.add(f.getIntegrationFile());
}
}
return result;
}
/**
* Handles the save result. If the file was uploaded successfully, then we flag it as saved. We also set some other
* fields if necessary.
*
* @param savedFile file wrapper
* @param jsonResponse Google Drive's API response
*/
public void handleSaveResult (File savedFile, String jsonResponse) {
Map<String, Object> deserializedResponse = (Map<String, Object>) JSON.deserializeUntyped(jsonResponse);
if (deserializedResponse.containsKey('alternateLink') && deserializedResponse.get('alternateLink') != null) {
if (String.valueOf(deserializedResponse.get('alternateLink')).contains('https')) {
savedFile.setSaved(true);
savedFile.setURL(String.valueOf(deserializedResponse.get('alternateLink')));
if (deserializedResponse.containsKey('id')) {
savedFile.setImageCode(String.valueOf(deserializedResponse.get('id')));
}
} else {
savedFile.setSaved(false);
}
}
}
/**
* Sends the specified file to the Drive through the REST Api.
*
* @param f file being sent
* @param creds credentials used
*
* @return HttpResponse of the request
*/
public HttpResponse sendFile (File f, Credentials creds, String folderId) {
String payload = getRequestPayload(f, folderId);
HttpRequest request = new HttpRequest();
request.setEndpoint('https://www.googleapis.com/upload/drive/v2/files?uploadType=multipart');
request.setHeader('Authorization', 'Bearer ' + creds.getAccessToken());
request.setHeader('Content-Type', 'multipart/mixed; boundary="----------9889464542212"');
request.setHeader('Content-length', String.valueOf(payload.length()));
request.setBody(payload);
request.setMethod('POST');
request.setTimeout(60*1000);
return new Http().send(request);
}
/**
* Converts the file object to a payload to send to the Google Drive API.
*
* @param fileObject the file object we are getting the payload for
*
* @return payload as string
*/
public String getRequestPayload (File fileObject, String folderId) {
String boundary = '----------9889464542212';
String delimiter = '\r\n--' + boundary +'\r\n';
String close_delim = '\r\n--' + boundary + '--';
String bodyEncoded = fileObject.getBody();
String fileName = fileObject.getFileName();
String fileType = fileObject.getContentType();
return delimiter + 'Content-Type: application/json\r\n\r\n'+'{ "title":"'+ fileName + '",' +
' "mimeType":"' + fileType + '", "parents": [{"kind": "drive#fileLink", "id": ' +
'"' + folderId + '"}]}' +
delimiter + 'Content-Type: ' + fileType + '\r\n'+'Content-Transfer-Encoding: base64\r\n'+'\r\n' +
bodyEncoded + close_delim;
}
/**
* Analyzes the result of the request to the Google Drive API v2 to get the access token. If the request was
* successful it returns the access token. Returns null otherwise.
*
* @param resultJson The JSON which was result of the request to the Google Drive authentication endpoint.
*
* @return access token
*/
public String getTokenFromResult (String resultJson) {
JSONParser parser = JSON.createParser(resultJson);
while (parser.nextToken() != null) {
if ((parser.getCurrentToken() == JSONToken.FIELD_NAME) && (parser.getText() == 'access_token')) {
parser.nextToken();
return parser.getText();
}
}
return null;
}
/**
* Connects to the Google Drive API to get the token for us to complete the upload.
*
* @param refreshToken Refresh token stored in the 'GoogleDriveIntegration__c' custom setting
* @param clientId Client ID stored in the 'GoogleDriveIntegration__c' custom setting
* @param clientSecret Client Secret stored in the 'GoogleDriveIntegration__c' custom setting
*
* @return
*/
public HttpRequest getAccessTokenRequest (String refreshToken, String clientId, String clientSecret) {
HttpRequest req = new HttpRequest();
req.setMethod('POST');
req.setEndpoint('https://accounts.google.com/o/oauth2/token');
String requestBody = 'refresh_token=' + refreshToken + '&client_id=' + clientId + '&client_secret=' +
clientSecret + '&grant_type=refresh_token';
req.setBody(requestBody);
req.setHeader('Content-length', String.valueOf(requestBody.length()));
req.setHeader('content-type', 'application/x-www-form-urlencoded');
req.setTimeout(60*1000);
return req;
}
/**
* Returns a list of File instances which holds the content of the file to upload, according to the
* provided list of records of 'GoogleDriveFile__c'.
*
* @param files List of records that point to the attachments to upload.
*
* @return list of files with the content to upload.
*/
public List<File> getFiles (List<GoogleDriveFile__c> files) {
List<File> result = new List<File>();
Set<Id> documentsIds = new Set<Id>();
Set<Id> fileAttachmentsIds = new Set<Id>();
for (GoogleDriveFile__c fileToUpload : files) {
if (fileToUpload.FileId__c.left(3) == ContentDocument.SObjectType.getDescribe().getKeyPrefix()) {
documentsIds.add(fileToUpload.FileId__c);
} else if (fileToUpload.FileId__c.left(3) == Attachment.SObjectType.getDescribe().getKeyPrefix()) {
fileAttachmentsIds.add(fileToUpload.FileId__c);
}
}
Map<Id, Attachment> fileAttachments = new Map<Id, Attachment>([
SELECT
Id
,Name
,Body
,ContentType
FROM Attachment
WHERE Id IN :fileAttachmentsIds
]);
Map<Id, ContentVersion> documents = new Map<Id, ContentVersion>([
SELECT
Id
,ContentDocument.Title
,ContentDocument.FileExtension
,VersionData
,ContentDocument.FileType
FROM ContentVersion
WHERE ContentDocumentId IN :documentsIds
AND IsLatest = TRUE
]);
for (GoogleDriveFile__c fileToUpload : files) {
if (fileAttachments.containsKey(fileToUpload.FileId__c)) {
result.add(new File(fileAttachments.get(fileToUpload.FileId__c), fileToUpload, false));
} else if (documents.containsKey(fileToUpload.FileId__c)) {
result.add(new File(documents.get(fileToUpload.FileId__c), fileToUpload, false));
} else {
result.add(new File(documents.get(fileToUpload.FileId__c), fileToUpload, true));
}
}
return result;
}
public List<GoogleDriveFile__c> getRecordsToDelete (List<File> files) {
List<GoogleDriveFile__c> integrationFiles = new List<GoogleDriveFile__c>();
for (File f : files) {
if (f.isDeletable()) {
integrationFiles.add(f.getIntegrationFile());
}
}
return integrationFiles;
}
/**
* Holds the file to integrate and the record which holds the content to upload.
*/
public class File {
private Attachment att;
private ContentVersion contentVer;
private GoogleDriveFile__c integrationFile;
private Boolean deleteMe;
private String fileName;
private String contentType;
private String body;
private Boolean saved;
private String fileUrl;
private String imageCode;
public File (Attachment att, GoogleDriveFile__c integrationFile, Boolean deleteIntegrationFile) {
this.att = att;
this.integrationFile = integrationFile;
this.deleteMe = deleteIntegrationFile;
if (!this.deleteMe) {
this.fileName = this.att.Name;
this.contentType = this.att.ContentType;
this.body = EncodingUtil.base64Encode(this.att.Body);
}
}
public File (ContentVersion contentVer, GoogleDriveFile__c integrationFile,
Boolean deleteIntegrationFile) {
this.contentVer = contentVer;
this.integrationFile = integrationFile;
this.deleteMe = deleteIntegrationFile;
if (!this.deleteMe) {
this.fileName = this.contentVer.ContentDocument.Title + '.' +
this.contentVer.ContentDocument.FileExtension;
this.contentType = this.contentVer.ContentDocument.FileType;
this.body = EncodingUtil.base64Encode(this.contentVer.VersionData);
}
}
public Boolean isDeletable () {
return this.deleteMe;
}
public GoogleDriveFile__c getIntegrationFile () {
return this.integrationFile;
}
public String getBody () {
return this.body;
}
public String getFileName () {
return this.fileName;
}
public String getContentType () {
return this.contentType;
}
public void setSaved (Boolean b) {
this.saved = b;
if (this.saved) {
this.integrationFile.Status__c = 'Sent';
} else {
this.integrationFile.Status__c = 'Error';
}
}
public Boolean isSaved () {
return this.saved;
}
public void setURL (String url) {
this.fileUrl = url;
}
public void setImageCode (String imageCode) {
this.imageCode = imageCode;
}
public String getFileKind () {
if (this.integrationFile != null && this.integrationFile.DocumentKind__c != null) {
return this.integrationFile.DocumentKind__c;
}
return '';
}
public String getAttachmentId () {
if (this.integrationFile != null) {
return this.integrationFile.FileId__c;
}
return null;
}
public String getParentId () {
if (this.integrationFile != null) {
return this.integrationFile.RelatedRecordId__c;
}
return null;
}
}
/**
* Holds the Google Drive credentials.
*/
public class Credentials {
@TestVisible
private GoogleDriveIntegration__c setting;
public Credentials() {
this.setting = GoogleDriveIntegration__c.getInstance();
if (this.setting.Access_Token__c == null
|| this.setting.Client_Secret__c == null
|| this.setting.Client_ID__c == null) {
throw new GoogleDriveIntegrationException('Access token, client secret or client ID were not ' +
'provided during the integration phase. No files were modified.');
}
}
public String getRefreshToken() {
return this.setting.Refresh_Token__c;
}
public String getClientId() {
return this.setting.Client_ID__c;
}
public String getClientSecret() {
return this.setting.Client_Secret__c;
}
public String getAccessToken() {
return this.setting.Access_Token__c;
}
public void setAccessToken(String token) {
this.setting.Access_Token__c = token;
upsert this.setting;
}
}
public class GoogleDriveIntegrationException extends Exception {}
}
/**
* Created by Renato on 17/04/18.
*/
@IsTest
private class GoogleDriveIntegrationBatchTest {
@TestSetup
public static void setup () {
GoogleDriveIntegration__c driveSettings = new GoogleDriveIntegration__c(
Access_Token__c='abc',
Client_ID__c='def',
Client_Secret__c='random123'
);
insert driveSettings;
Account dummyAccount = new Account(
Name='Dummy'
);
insert dummyAccount;
}
/**
* Tests the integration using the Attachment object (Salesforce Classic).
*/
@IsTest
public static void test_attachment () {
Account dummyAccount = [SELECT Id FROM Account LIMIT 1];
Attachment attachmentDocument = new Attachment(
Name='textfile',
Description='A text file',
Body=Blob.valueOf('test'),
ParentId=dummyAccount.Id
);
insert attachmentDocument;
GoogleDriveFile__c integrationFile = new GoogleDriveFile__c(
IdAnexo__c = attachmentDocument.Id,
IdAnexoPai__c = dummyAccount.Id,
TipoDocumento__c = 'Identity Paper',
Name = attachmentDocument.Name,
Status__c='Pending'
);
insert integrationFile;
Test.startTest();
Test.setMock(HttpCalloutMock.class, new GoogleDriveResponseMock());
GoogleDriveIntegrationBatch batchJob = new GoogleDriveIntegrationBatch();
Database.executeBatch(batchJob, 1);
Test.stopTest();
System.assertEquals(0, [SELECT COUNT(Id) FROM Attachment][0].get('expr0'),
'Should\'ve deleted the file after upload.'
);
}
/**
* Tests the integration using the ContentDocument object (Lightning Experience).
*/
@IsTest
public static void test_contentDocument () {
Account dummyAccount = [SELECT Id FROM Account LIMIT 1];
ContentVersion cv;
System.runAs(new User(Id=UserInfo.getUserId())) {
ContentVersion documentVersion = new ContentVersion(
Title='Tigers',
PathOnClient='cute_tigers.jpg',
VersionData=Blob.valueOf('tigers pic'),
IsMajorVersion=true
);
insert documentVersion;
documentVersion = [
SELECT
Id,
Title,
ContentDocumentId
FROM ContentVersion
LIMIT 1
];
cv = documentVersion;
}
GoogleDriveFile__c integrationFile = new GoogleDriveFile__c(
IdAnexo__c = cv.Id,
IdAnexoPai__c = dummyAccount.Id,
TipoDocumento__c = 'Identity File',
Name = cv.Title,
Status__c='Pending'
);
insert integrationFile;
Test.startTest();
Test.setMock(HttpCalloutMock.class, new GoogleDriveResponseMock());
GoogleDriveIntegrationBatch batchJob = new GoogleDriveIntegrationBatch();
Database.executeBatch(batchJob, 1);
Test.stopTest();
System.assertEquals(0, [SELECT COUNT(Id) FROM ContentDocument][0].get('expr0'),
'Should\'ve deleted the document after uploading the file.'
);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment