Tutorial: Build a Serverless API backend for Slack [part 2]

Abhishek Gupta - Sep 4 '19 - - Dev Community

In the previous blog, we explored how to deploy a serverless backend for a Slack slash command on to Azure Functions. As promised, it's time to walk through the code to see how the function was implemented. The code is available on GitHub for you to grok.

If you are interested in learning Serverless development with Azure Functions, simply create a free Azure account and get started! I would highly recommend checking out the quickstart guides, tutorials and code samples in the documentation, make use of the guided learning path in case that's your style or download the Serverless Computing Cookbook.

Structure

The code structure is as follows:

  • Function logic resides in the Funcy class (package com.abhirockzz.funcy)
  • and, the rest of the stuff includes model classes aka POJOs - packages com.abhirockzz.funcy.model.giphy and com.abhirockzz.funcy.model.slack for GIPHY and Slack respectively

Function entry point

The handleSlackSlashCommand method defines the entry point for our function. It is decorated with the @FunctionName annotation (and also defines the function name - funcy)

@FunctionName(funcy)
public HttpResponseMessage handleSlackSlashCommand(
        @HttpTrigger(name = "req", methods = {HttpMethod.POST}, authLevel = AuthorizationLevel.ANONYMOUS) HttpRequestMessage<Optional<String>> request,
        final ExecutionContext context)
Enter fullscreen mode Exit fullscreen mode

It is triggered via a HTTP request (from Slack). This is defined by the @HttpTrigger annotation.

@HttpTrigger(name = "req", methods = {HttpMethod.POST}, authLevel = AuthorizationLevel.ANONYMOUS)
Enter fullscreen mode Exit fullscreen mode

Note that the function:

  • responds to HTTP POST
  • does not need explicit authorization for invocation

For details, check out the Functions Runtime Java API documentation

Method parameters

The handleSlackSlashCommand consists of two parameters - HttpRequestMessage and ExecutionContext.

HttpRequestMessage is a helper class in the Azure Functions Java library. It's instance is injected at runtime and is used to access HTTP message body and headers (sent by Slack).

ExecutionContext provides a hook into the functions runtime - in this case, it's used to get a handle to java.​util.​logging.Logger.

For details, check out the Functions Runtime Java API documentation

From Slack Slash Command to a GIPHY GIF - core logic

From an end user perspective, the journey is short and sweet! But here is a gist of what's happening behind the scenes.

The Slack request is of type application/x-www-form-urlencoded, and is first decoded and then converted to a Java Map for ease of use.

    Map<String, String> slackdataMap = new HashMap<>();
    try {
        decodedSlackData = URLDecoder.decode(slackData, "UTF-8");
    } catch (Exception ex) {
        LOGGER.severe("Unable to decode data sent by Slack - " + ex.getMessage());
        return errorResponse;
    }

    for (String kv : decodedSlackData.split("&")) {
        try {
            slackdataMap.put(kv.split("=")[0], kv.split("=")[1]);
        } catch (Exception e) {
            /*
            probably because some value in blank - most likely 'text' (if user does not send keyword with slash command).
            skip that and continue processing other attrbiutes in slack data
             */
        }
    }
Enter fullscreen mode Exit fullscreen mode

see https://api.slack.com/slash-commands#app_command_handling

GIPHY API key and Slack Signing Secret are mandatory for the function to work. These are expected via environment variables - we fail fast if they don't exist.

    String signingSecret = System.getenv("SLACK_SIGNING_SECRET");

    if (signingSecret == null) {
        LOGGER.severe("SLACK_SIGNING_SECRET environment variable has not been configured");
        return errorResponse;
    }
    String apiKey = System.getenv("GIPHY_API_KEY");

    if (apiKey == null) {
        LOGGER.severe("GIPHY_API_KEY environment variable has not been configured");
        return errorResponse;
    }
Enter fullscreen mode Exit fullscreen mode

Signature validation

Every Slack HTTP request sent to our function includes a signature in the X-Slack-Signature HTTP header. It is created by a combination of the the body of the request and the Slack application signing secret using a standard HMAC-SHA256 keyed hash.

The function also calculates a signature (based on the recipe) and confirms that it the same as the one sent by Slack - else we do not proceed. This is done in the matchSignature method

private static boolean matchSignature(String signingSecret, String slackSigningBaseString, String slackSignature) {
    boolean result;

    try {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(signingSecret.getBytes(), "HmacSHA256"));
        byte[] hash = mac.doFinal(slackSigningBaseString.getBytes());
        String hexSignature = DatatypeConverter.printHexBinary(hash);
        result = ("v0=" + hexSignature.toLowerCase()).equals(slackSignature);
    } catch (Exception e) {
        LOGGER.severe("Signature matching issue " + e.getMessage());
        result = false;
    }
    return result;
}
Enter fullscreen mode Exit fullscreen mode

Once the signatures match, we can be confident that our function was indeed invoked by Slack.

Invoking the GIPHY API

The GIPHY Random API is invoked with the search criteria (keyword) sent by user (along with the Slash command) e.g. /funcy cat - cat is the keyword. This taken care of by the getRandomGiphyImage and is a simple HTTP GET using the Apache HTTPClient library. The JSON response is marshalled into a GiphyRandomAPIGetResponse POJO using Jackson.

Note that the org.apache.http.impl.client.CloseableHttpClient (HTTP_CLIENT in the below snippet) object is created lazily and there is only one instance per function i.e. a new object is not created every time a function is invoked.

For details, please refer to this section in the Azure Functions How-to guide

....
private static CloseableHttpClient HTTP_CLIENT = null;
....
private static String getRandomGiphyImage(String searchTerm, String apiKey) throws IOException {
    String giphyResponse = null;
    if (HTTP_CLIENT == null) {
        HTTP_CLIENT = HttpClients.createDefault();
        LOGGER.info("Instantiated new HTTP client");
    }
    String giphyURL = "http://api.giphy.com/v1/gifs/random?tag=" + searchTerm + "&api_key=" + apiKey;
    LOGGER.info("Invoking GIPHY endpoint - " + giphyURL);

    HttpGet giphyGETRequest = new HttpGet(giphyURL);
    CloseableHttpResponse response = HTTP_CLIENT.execute(giphyGETRequest);

    giphyResponse = EntityUtils.toString(response.getEntity());

    return giphyResponse;
}
....
GiphyRandomAPIGetResponse giphyModel = MAPPER.readValue(giphyResponse, GiphyRandomAPIGetResponse.class);
Enter fullscreen mode Exit fullscreen mode

Sending the response back to Slack

We extract the required information - title of the image (e.g. cat thanksgiving GIF) and its URL (e.g. https://media2.giphy.com/media/v2CaxWLFw4a5y/giphy-downsized.gif). This is used to create an instance of SlackSlashCommandResponse POJO which represents the JSON payload returned to Slack.

    String title = giphyModel.getData().getTitle();
    String imageURL = giphyModel.getData().getImages().getDownsized().getUrl();

    SlackSlashCommandResponse slackResponse = new SlackSlashCommandResponse();
    slackResponse.setText(SLACK_RESPONSE_STATIC_TEXT);

    Attachment attachment = new Attachment();
    attachment.setImageUrl(imageURL);
    attachment.setText(title);

    slackResponse.setAttachments(Arrays.asList(attachment));
Enter fullscreen mode Exit fullscreen mode

Finally, we create an instance of HttpResponseMessage (using a fluent builder API). The function runtime takes care of converting the POJO into a valid JSON payload which Slack expects.

return request.createResponseBuilder(HttpStatus.OK).header("Content-type", "application/json").body(slackResponse).build();
Enter fullscreen mode Exit fullscreen mode

For details on the HttpResponseMessage interface, check out the Functions Runtime Java API documentation

Before we wrap up, let's go through the possible error scenarios and how they are handled.

Error handling

General errors

As per Slack recommendation, exceptions in the code are handled and a retry response is returned to the user. The SlackSlashCommandErrorResponse POJO represents the JSON payload which requests the user to retry the operation and is returned in the form of a HttpResponseMessage object.

return request.createResponseBuilder(HttpStatus.OK).header("Content-type", "application/json").body(slackResponse).build();
Enter fullscreen mode Exit fullscreen mode

The user gets a Sorry, that didn't work. Please try again. message in Slack

User error

It is possible that the user does not include a search criteria (keyword) along with the Slash command e.g. /funcy. In this case, the user gets an explicit response in Slack - Please include a keyword with your slash command e.g. /funcy cat.

To make this possible, the request data (Map) is checked for the presence of a specific attribute (text) - if not, we can be certain that the user did not send the search keyword.

    ....
HttpResponseMessage missingKeywordResponse = request
.createResponseBuilder(HttpStatus.OK)
.header("Content-type", "application/json")
.body(new SlackSlashCommandErrorResponse("ephemeral", "Please include a keyword with your slash command e.g. /funcy cat")).build();
....
if (!slackdataMap.containsKey("text")) {
return missingKeywordResponse;
}
....
Enter fullscreen mode Exit fullscreen mode




Timeouts

If the call times out (which will if the function takes > than 3000 ms), Slack will automatically send an explicit response - Darn - that slash command didn't work (error message: Timeout was reached). Manage the command at funcy-slack-app.

Resources

The below mentioned resources were leveraged specifically for developing the demo app presented in this blog post, so you're likely to find them useful as well!

I really hope you enjoyed and learned something from this article! Please like and follow if you did. Happy to get feedback via @abhi_tweeter or just drop a comment.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .