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 (packagecom.abhirockzz.funcy
) - and, the rest of the stuff includes model classes aka POJOs - packages
com.abhirockzz.funcy.model.giphy
andcom.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)
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)
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
*/
}
}
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;
}
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;
}
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);
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));
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();
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();
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;
}
....
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!
- Azure Functions developers guide (general) and the Azure Functions Java developer guide
- Quickstart on how to use Java to create and publish a function to Azure Functions
- Maven Plugin for Azure Functions
- How to code and test Azure Functions locally
- How to manage connections in Azure Functions
- Maven Plugins for Azure Services on GitHub
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.