In my personal experience, composing HTTP requests with multipart form data to send to a remote endpoint has not been a straightforward story in Salesforce Apex code. I didn’t find any built-in helper classes to do this. After many attempts and going down various avenues that led to dead-ends, I was finally able to cobble up a working solution. But before we get to the solution, let’s take a look at the problem:
Background Story (and Use Case)
Recently, I had to write some custom Apex code to make some requests against a legacy API. In this particular case, I’m being a bit generous with the term “API”; it is simply a remote endpoint that breaks a lot of the rules and ergonomics of a well-built API:
- There was only one endpoint, to service all requests. There is a JSON blob that you pass to this one endpoint which has an “action” attribute that specifies the action you’re trying to take against the remote system.
- HTTP verbs were not utilized properly. Everything is a POST. And when the system responds, it always gives you a 200 OK, even for errors – you have to parse the return JSON to see if you got an actual response or an error message.
- In certain cases, I needed to pass this endpoint binary data. PDF files. In those instances, it didn’t take the binary data as a JSON property, perhaps as a Base64 encoded string, like it did with everything else but required you to send it as part of a multipart/form-data submission. That’s what lead me to figuring out how to do that in Apex and hence, this blog post.
If I had to draw a picture of what my request payload needed to look like, it is something akin to this:
Your need may look like this:
In essence, if you need to make an HTTP post in Apex mixing in some key-value pairs of textual data along with a binary file, I have a solution, below:
Show me the Code
The biggest piece of the puzzle is a class that I found on GitHub, here:
To avoid the risk of that code changing on me or becoming inaccessible, here’s my raw version, with class names changed to make it a bit more generic as it is certainly a multipurpose helper class:
public virtual class HttpMultipartFormDataManager { // The boundary is alligned so it doesn't produce padding characters when base64 encoded. private final static string Boundary = '1ff13444ed8140c7a32fc4e6451aa76d'; public static String getBoundary() { return Boundary; } /** * Returns the request's content type for multipart/form-data requests. */ public static string GetContentType() { return 'multipart/form-data; charset="UTF-8"; boundary="' + Boundary + '"'; } /** * Pad the value with spaces until the base64 encoding is no longer padded. */ public static string SafelyPad( string value, string valueCrLf64, string lineBreaks) { string valueCrLf = ''; blob valueCrLfBlob = null; while (valueCrLf64.endsWith('=')) { value += ' '; valueCrLf = value + lineBreaks; valueCrLfBlob = blob.valueOf(valueCrLf); valueCrLf64 = EncodingUtil.base64Encode(valueCrLfBlob); } return valueCrLf64; } /** * Write a boundary between parameters to the form's body. */ public static string WriteBoundary() { string value = '--' + Boundary + '\r\n'; blob valueBlob = blob.valueOf(value); return EncodingUtil.base64Encode(valueBlob); } /** * Write a boundary at the end of the form's body. */ public static string WriteBoundary( EndingType ending) { string value = ''; if (ending == EndingType.Cr) { // The file's base64 was padded with a single '=', // so it was replaced with '\r'. Now we have to // prepend the boundary with '\n' to complete // the line break. value += '\n'; } else if (ending == EndingType.None) { // The file's base64 was not padded at all, // so we have to prepend the boundary with // '\r\n' to create the line break. value += '\r\n'; } // Else: // The file's base64 was padded with a double '=', // so they were replaced with '\r\n'. We don't have to // do anything to the boundary because there's a complete // line break before it. value += '--' + Boundary + '--'; blob valueBlob = blob.valueOf(value); return EncodingUtil.base64Encode(valueBlob); } /** * Write a key-value pair to the form's body. */ public static string WriteBodyParameter( string key, string value) { string contentDisposition = 'Content-Disposition: form-data; name="' + key + '"'; string contentDispositionCrLf = contentDisposition + '\r\n\r\n'; blob contentDispositionCrLfBlob = blob.valueOf(contentDispositionCrLf); string contentDispositionCrLf64 = EncodingUtil.base64Encode(contentDispositionCrLfBlob); string content = SafelyPad(contentDisposition, contentDispositionCrLf64, '\r\n\r\n'); string valueCrLf = value + '\r\n'; blob valueCrLfBlob = blob.valueOf(valueCrLf); string valueCrLf64 = EncodingUtil.base64Encode(valueCrLfBlob); content += SafelyPad(value, valueCrLf64, '\r\n'); return content; } /** * Write a key-value pair to the form's body for a blob. */ public static string WriteBlobBodyParameter(string key, string file64, string fileName) { String mimeType = resolveMimeType(fileName); string contentDisposition = 'Content-Disposition: form-data; name="' + key + '"; filename="'+fileName+'"'; string contentDispositionCrLf = contentDisposition + '\r\n'; blob contentDispositionCrLfBlob = blob.valueOf(contentDispositionCrLf); string contentDispositionCrLf64 = EncodingUtil.base64Encode(contentDispositionCrLfBlob); string content = SafelyPad(contentDisposition, contentDispositionCrLf64, '\r\n'); string contentTypeHeader = 'Content-Type: ' + mimeType; string contentTypeCrLf = contentTypeHeader + '\r\n\r\n'; blob contentTypeCrLfBlob = blob.valueOf(contentTypeCrLf); string contentTypeCrLf64 = EncodingUtil.base64Encode(contentTypeCrLfBlob); content += SafelyPad(contentTypeHeader, contentTypeCrLf64, '\r\n\r\n'); integer file64Length = file64.length(); String last4Bytes = file64.substring(file64.length()-4,file64.length()); // Avoid padding the file data with spaces, which SafelyPad does // http://salesforce.stackexchange.com/a/33326/102 EndingType ending = EndingType.None; if (last4Bytes.endsWith('==')) { // The '==' sequence indicates that the last group contained only one 8 bit byte // 8 digit binary representation of CR is 00001101 // 8 digit binary representation of LF is 00001010 // Stitch them together and then from the right split them into 6 bit chunks // 0000110100001010 becomes 0000 110100 001010 // Note the first 4 bits 0000 are identical to the padding used to encode the // second original 6 bit chunk, this is handy it means we can hard code the response in // The decimal values of 110100 001010 are 52 10 // The base64 mapping values of 52 10 are 0 K // See http://en.wikipedia.org/wiki/Base64 for base64 mapping table // Therefore, we replace == with 0K // Note: if using \n\n instead of \r\n replace == with 'oK' last4Bytes = last4Bytes.substring(0,2) + '0K'; file64 = file64.substring(0,file64.length()-4) + last4Bytes; // We have appended the \r\n to the Blob, so leave footer as it is. ending = EndingType.CrLf; } else if (last4Bytes.endsWith('=')) { // '=' indicates that encoded data already contained two out of 3x 8 bit bytes // We replace final 8 bit byte with a CR e.g. \r // 8 digit binary representation of CR is 00001101 // Ignore the first 2 bits of 00 001101 they have already been used up as padding // for the existing data. // The Decimal value of 001101 is 13 // The base64 value of 13 is N // Therefore, we replace = with N last4Bytes = last4Bytes.substring(0,3) + 'N'; file64 = file64.substring(0,file64.length()-4) + last4Bytes; // We have appended the CR e.g. \r, still need to prepend the line feed to the footer ending = EndingType.Cr; } content += file64; content += WriteBoundary(ending); return content; } private static String resolveMimeType(String fileName) { String fileType = fileName.substringAfterLast('.'); String mimeType = 'image/png'; // fallback value if (fileType.equalsIgnoreCase('png')) { mimeType = 'image/png'; } else if (fileType.equalsIgnoreCase('jpeg') || fileType.equalsIgnoreCase('jpg')) { mimeType = 'image/jpg'; } else if (fileType.equalsIgnoreCase('pgm')) { mimeType = 'image/x-portable-graymap'; } else if (fileType.equalsIgnoreCase('ppm')) { mimeType = 'image/x-portable-pixmap'; } else if (fileType.equalsIgnoreCase('pdf')) { mimeType = 'application/pdf'; } return mimeType; } /** * Helper enum indicating how a file's base64 padding was replaced. */ public enum EndingType { Cr, CrLf, None } }
With the aid of the above helper class, you can construct a multipart/form-data request like so:
Http http = new Http(); HttpRequest multipartRequest = new HttpRequest(); multipartRequest.setMethod('POST'); multipartRequest.setEndpoint("some-url"); multipartRequest.setHeader('Content-Type', HttpMultipartFormDataManager.GetContentType()); // TODO: Get the binary data that you want to send as part of the multipart/form-data // and put that data into a Blob variable. Blob profilePicBlob = (get blob data from somewhere, here); // Compose form string form64 = ''; form64 += HttpMultipartFormDataManager.WriteBoundary(); form64 += HttpMultipartFormDataManager.WriteBodyParameter('name', 'John Doe'); form64 += HttpMultipartFormDataManager.WriteBoundary(); form64 += HttpMultipartFormDataManager.WriteBodyParameter('email', 'john-doe@test.com'); form64 += HttpMultipartFormDataManager.WriteBoundary(); form64 += HttpMultipartFormDataManager.WriteBodyParameter('dob', 'Jan 1, 1981'); form64 += HttpMultipartFormDataManager.WriteBoundary(); form64 += HttpMultipartFormDataManager.WriteBodyParameter('favorite-color', 'blue'); form64 += HttpMultipartFormDataManager.WriteBoundary(); form64 += HttpMultipartFormDataManager.WriteBlobBodyParameter('profile-pic', EncodingUtil.base64Encode(profilePicBlob), 'my-profile-pic.jpg'); Blob formBlob = EncodingUtil.base64Decode(form64); string contentLength = string.valueOf(formBlob.size()); multipartRequest.setBodyAsBlob(formBlob); HttpResponse multipartResponse = new HttpResponse(); multipartResponse = http.send(multipartRequest); if(multipartResponse.getStatusCode() == 200) { System.debug('Yaaaay!'); System.debug(multipartResponse.getBody()); }else{ System.debug('Blah! ' + String.valueOf(multipartResponse.getStatusCode()) + multipartResponse.getBody()); }
Some things to highlight, in the above code:
- Each item in the form is separated by a boundary, a string tag of sorts.
- The binary data, enclosed in a blob is first Base64 encoded and then written as a body parameter.
- The entire request body is then Base64 decoded and set as a blob body in the HttpRequest object.
Parting Thoughts
If you are so inclined, you can dig a bit deeper into some of the challenges associated with composing and sending a request that mixes in some binary data. I’ll link a couple of posts that I came across in my explorations that shed a bit more light into what those challenges actually are:
Post File From Salesforce Apex to External HTTP Webservices – Docparser
Nerd @ Work: [Salesforce / Apex] POST Mutipart/form-data with HttpRequest (enreeco.blogspot.com)