Multipart Form Data in Salesforce Apex

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:

A visual representation of an HTTP request payload with a key-value item and a binary file

Your need may look like this:

A visual representation of an HTTP request with a binary file among other textual key/value pairs.

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:

https://github.com/muenzpraeger/salesforce-einstein-vision-apex/blob/master/src/classes/EinsteinVision_HttpBodyPart.cls

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)

Leave a Comment

Your email address will not be published. Required fields are marked *