2

When I try to make a call to the language/sentiment API, I recieve a 400 error. I have the correct parameters (as listed in the docs). The issue seems to be in creating the form data for the http request.

This does work in curl with the correct params, but something is going wrong in Apex.

here are the docs for api endpoint language/sentiment: https://metamind.readme.io/docs/use-pre-built-models-sentiment

Here is a screenshot of httpRequest.getBody():

https://screencast.com/t/ZyqOGNyo7CqW

To run in execute anon apex:

LanguageController lc = new LanguageController();
lc.callEinsteinLanguangeUrl();

Here is my code:

Sentiment - contructs the HTTP form from a sample string and a model Id. I think the issue is in the form thats created.

HttpFormBuilder owns WriteBoundary and WriteParameters - where I think things are going haywire.

LanguageController creates the secure token and would, in theory, be where I called kicked off these various processes. as of now just houses callEinsteinLanguangeUrl()

Sentiment

public class Sentiment {

public static String SENTIMENT_API = 'https://api.einstein.ai/v2/language/sentiment';
public static List<Prediction> getStringSentiment(String url, String access_token) {
    return predictInternal(url, access_token, false);
}



private static List<Prediction> predictInternal(String sample, String access_token) {
    string contentType = HttpFormBuilder.GetContentType();
    //  Compose the form
    string form64 = '';
    system.debug('yes we are here');
    form64 += HttpFormBuilder.WriteBoundary();
    form64 += HttpFormBuilder.WriteBodyParameter('modelId', 'CommunitySentiment');
    form64 += HttpFormBuilder.WriteBoundary();
    form64 += HttpFormBuilder.WriteBodyParameter('document', sample);
    form64 += HttpFormBuilder.WriteBoundary(HttpFormBuilder.EndingType.Cr);

    blob formBlob = EncodingUtil.base64Decode(form64);
    string contentLength = string.valueOf(formBlob.size());
    //  Compose the http request
    HttpRequest httpRequest = new HttpRequest();

    httpRequest.setBodyAsBlob(formBlob);
    //httpRequest.setBody('modelId='+EncodingUtil.urlEncode('CommunitySentiment', 'UTF-8')+'&document='+EncodingUtil.urlEncode(sample, 'UTF-8'));

    httpRequest.setHeader('Connection', 'keep-alive');
    httpRequest.setHeader('Content-Length', contentLength);
    httpRequest.setHeader('Content-Type', 'multipart/form-data');
    httpRequest.setMethod('POST');
    httpRequest.setTimeout(120000);
    httpRequest.setHeader('Authorization','Bearer ' + access_token);
    httpRequest.setEndpoint(SENTIMENT_API);

    Http http = new Http();
    List<Prediction> predictions = new List<Prediction>();
    try {
          system.debug(httpRequest.getBody());
          HTTPResponse res = http.send(httpRequest);
          if (res.getStatusCode() == 200) {
              System.JSONParser parser = System.JSON.createParser(res.getBody());
              while (parser.nextToken() != null) {
                  if ((parser.getCurrentToken() == JSONToken.FIELD_NAME) && (parser.getText() == 'probabilities')) {
                      parser.nextToken();
                        if (parser.getCurrentToken() == JSONToken.START_ARRAY) {
                            while (parser.nextToken() != null) {
                                // Advance to the start object marker to
                                //  find next probability object.
                                if (parser.getCurrentToken() == JSONToken.START_OBJECT) {
                                    // Read entire probability object
                                    system.debug(parser.getText());
                                    //Prediction probability = (Prediction)parser.readValueAs(Vision.Prediction.class);
                                    //predictions.add(probability);
                                }
                            }
                        }
                  break;
                  }
              }
          }
          //System.debug(res.toString());
          //System.debug('STATUS:'+res.getStatus());
          //System.debug('STATUS_CODE:'+res.getStatusCode());
     } catch(System.CalloutException e) {
            System.debug('ERROR:' + e);
     }
     return(predictions);
}

public class Prediction {
    public String label {get;set;}
    public Double probability {get;set;}
}
}

LanguageController

public class LanguageController {
    // You can upload the `einstein_platform.pem` into your Salesforce org as `File` sObject and read it as below
public String getAccessToken() {
    // Ignore the File upload part and "jwt.pkcs" if you used a Salesforce certificate to sign up 
    // for an Einstein Platform account
    ContentVersion base64Content = [SELECT Title, VersionData FROM ContentVersion where Title='einstein_platform' OR  Title='predictive_services' ORDER BY Title LIMIT 1];
    String keyContents = base64Content.VersionData.tostring();
    keyContents = keyContents.replace('-----BEGIN RSA PRIVATE KEY-----', '');
    keyContents = keyContents.replace('-----END RSA PRIVATE KEY-----', '');
    keyContents = keyContents.replace('\n', '');

    // Get a new token
    JWT jwt = new JWT('RS256');
    //jwt.cert = 'JWTCert'; // Uncomment this if you used a Salesforce certificate to sign up for an Einstein Platform account
    jwt.pkcs8 = keyContents; // Comment this if you are using jwt.cert
    jwt.iss = 'developer.force.com';
    jwt.sub = 'myemailaddress@email.com';
    jwt.aud = 'https://api.metamind.io/v1/oauth2/token';
    jwt.exp = '3600';
    String access_token = JWTBearerFlow.getAccessToken('https://api.metamind.io/v1/oauth2/token', jwt);        system.debug(access_token);
    return access_token;    
}

public void callEinsteinLanguangeUrl() {
    // Get a new token
    String access_token = getAccessToken();

    // Make a prediction using URL to a file
    Sentiment.getStringSentiment('hello world',access_token);
}
}

HttpFormBuilder

public class HttpFormBuilder {
//  The boundary is alligned so it doesn't produce padding characters when base64 encoded.
private final static string Boundary = '1ff13444ed8140c7a32fc4e6451aa76d';

/**
 *  Returns the request's content type for multipart/form-data requests.
 */
public static string GetContentType() {
    return 'multipart/form-data;"; boundary="' + Boundary + '"';
}

/**
 *  Pad the value with spaces until the base64 encoding is no longer padded.
 */
private 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;
}

/**
 *  Helper enum indicating how a file's base64 padding was replaced.
 */
public enum EndingType {
    Cr,
    CrLf,
    None
}
}
2

There were a few issues in your Sentiment class.

The final boundary should have used CRLF as a parameter rather than just CR. It's a long and detailed story about why this is. If you are interested, see Steps required to support POSTing multipart/form-data Content-Type from Apex.

The other main modification was around the Content-Type header. I used the return value from HttpFormBuilder.GetContentType();. It will have a value more like the following:

multipart/form-data; charset="UTF-8"; boundary="1ff13444ed8140c7a32fc4e6451aa76d"

Note how it defines the boundary identifier. This is one of my issues with using curl to define API examples. It hides the finer details of the request.

End result as follows. Note that I can see the expected HTTP response here, but it doesn't appear to be parsing the predictions correctly from the JSON response. I'll leave that as an exercise to the reader.

public class Sentiment {

    public static String SENTIMENT_API = 'https://api.einstein.ai/v2/language/sentiment';

    public static List<Prediction> getStringSentiment(String url, String access_token) {
        return predictInternal(url, access_token);
    }

    private static List<Prediction> predictInternal(String sample, String access_token) {
        System.assertNotEquals(null, access_token, 'access_token is required');

        string contentType = HttpFormBuilder.GetContentType();

        //  Compose the form
        string form64 = '';

        form64 += HttpFormBuilder.WriteBoundary();
        form64 += HttpFormBuilder.WriteBodyParameter('modelId', EncodingUtil.urlEncode('CommunitySentiment', 'UTF-8'));
        form64 += HttpFormBuilder.WriteBoundary();
        form64 += HttpFormBuilder.WriteBodyParameter('document', EncodingUtil.urlEncode(sample, 'UTF-8'));
        form64 += HttpFormBuilder.WriteBoundary(HttpFormBuilder.EndingType.CrLf);

        blob formBlob = EncodingUtil.base64Decode(form64);
        string contentLength = string.valueOf(formBlob.size());
        //  Compose the http request
        HttpRequest httpRequest = new HttpRequest();

        httpRequest.setBodyAsBlob(formBlob);

        httpRequest.setHeader('Connection', 'keep-alive');
        httpRequest.setHeader('Content-Length', contentLength);
        httpRequest.setHeader('Content-Type', contentType);
        httpRequest.setMethod('POST');
        httpRequest.setTimeout(120000);
        httpRequest.setHeader('Authorization','Bearer ' + access_token);
        httpRequest.setEndpoint(SENTIMENT_API);

        System.debug('httpRequest:' + httpRequest);

        Http http = new Http();
        List<Prediction> predictions = new List<Prediction>();
        try {
            HTTPResponse res = http.send(httpRequest);
            if (res.getStatusCode() == 200) {
                System.debug('BODY:'+res.getBody());

                System.JSONParser parser = System.JSON.createParser(res.getBody());
                while (parser.nextToken() != null) {
                    if ((parser.getCurrentToken() == JSONToken.FIELD_NAME) && (parser.getText() == 'probabilities')) {
                        parser.nextToken();
                        if (parser.getCurrentToken() == JSONToken.START_ARRAY) {
                            while (parser.nextToken() != null) {
                                // Advance to the start object marker to
                                //  find next probability object.
                                if (parser.getCurrentToken() == JSONToken.START_OBJECT) {
                                    // Read entire probability object
                                    system.debug(parser.getText());
                                    //Prediction probability = (Prediction)parser.readValueAs(Vision.Prediction.class);
                                    //predictions.add(probability);
                                }
                            }
                        }
                        break;
                    }
                }
            }
            else {
                System.debug(res.toString());
                System.debug('BODY:'+res.getBody());
                System.debug('STATUS:'+res.getStatus());
                System.debug('STATUS_CODE:'+res.getStatusCode());
                System.assert(false, 'STATUS_CODE:'+res.getStatusCode());
            }

        } catch(System.CalloutException e) {
            System.debug('ERROR:' + e);
        }
        return(predictions);
    }

    public class Prediction {
        public String label {get;set;}
        public Double probability {get;set;}
    }
}
  • I don't have enough internet hugs to send you! Thanks so much for your help. I overlooked that I was setting a variablestring contentType = HttpFormBuilder.GetContentType(); – Aaron M Jul 2 '17 at 14:29
  • As for parsing the prediction response - I adapted this from the Vision trailhead, so now I need to get that part aligned. Again, thanks so much! – Aaron M Jul 2 '17 at 14:36
  • @AaronM Glad I could help and spare you going too far down the path of manually getting multipart support going in Apex. Feel free to come back here and update an answer if you get a clean parser going. Also have a look at salesforce-einstein-vision-apex and JSON2Apex – Daniel Ballinger Jul 2 '17 at 23:54

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.