Writing a Personal Automation API: Part 2 - The framework

Posted by Matthew Watkins on December 17, 2016

In my previous post, I outlined how to begin writing a personal automation API with Google Apps Script. Really, it was just how to write a quick Hello World app that POSTs back with a message. In this post, I will go through the steps of adding a basic framework for running our API application.

Request and response structure

At the end of this, we want to be able to issue request payloads that look like this:

{
  "auth_token": "...",
  "action": "...",
  "data": {}
}

and returns responses that look like this:

{
  "success": true,
  "data": {},
  "error": null
}

or if it failed, like this:

{
  "success": false,
  "data": null,
  "error": {
    "errorCode": -1,
    "errorMessage": "..."
  }
}

For the errors, I defined an enum for error codes the app will use:

var ErrorCode = {
  // Default
  UNKNOWN: -1,
  
  // Invalid request codes
  INVALID_REQUEST: 100,
  NO_CONTENT: 101,
  MALFORMED_CONTENT: 102,
  
  // Auth error codes
  UNAUTHORIZED: 200,
};

And an exception object to represent data suitable to return to the caller:

/*
 * "Constructor" for the WebApiException
 */
function WebApiException(errorCode, errorMessage) {
  this.errorCode = errorCode || ErrorCode.UNKNOWN;
  this.errorMessage = errorMessage || '';
}

In the end, I will want all errors to be logged and any errors that are a WebApiException to be returned to the caller in the response above. All this comes together in the ResponseObject: the object that will eventually be serialized and returned to the caller:

/*
 * "Constructor" for the ResponseObject
 */
function ResponseObject() {
  this.success = false;
  this.data = null;
  this.error = null;
  
  /*
  * Succeeds the ResponseObject
  */
  this.succeed = function(data) {
    this.success = true;
    this.error = null;
    this.data = data || {};
    return this;
  };
  
  /*
  * Fails the ResponseObject
  */
  this.fail = function(webApiException, data) {
    this.success = false;
    this.error = webApiException;
    this.data = data || {};
    return this;
  };
  
  /*
  * Serializes the ResponseObject to JSON
  */
  this.serialize = function(prettyPrint) {
    return prettyPrint ? JSON.stringify(this, null, '  ') : JSON.stringify(this); 
  };
  
  /*
  * Creates the HTTP response object from the ResponseObject
  */
  this.toHttpResponse = function() {
    return ContentService.createTextOutput(this.serialize()).setMimeType(ContentService.MimeType.JSON);
  }
}

Pretty straightforward. Notice the toHttpResponse() function/method. That simply returns the Google Apps Script HTTP response object which the doPost() function will eventually give back to the caller.

Handling requests

I want to describe a bit about how I chose to structure my API. As I mentioned before, all HTTP POST requests come in through the doPost() function in Main.gs. But by the time we’re through this API is going to handle routing requests depending on the action specified by the caller. And it’s going to have to handle malformed request payload bodies, authentication, and data errors. That’s way too much for one function, so I built a separate RequestHandler whose job is to take in the JSON payload string and return a response object:

/*
 * "Constructor" for the RequestHandler
 */
function RequestHandler() {
  /*
   * The controllers for the request handler to call
   */
  this.controllers = [
    new EchoController(),
  ];
  
  /*
   * A function to handle all requests
   */
  this.handleRequest = function(payloadBodyString) {
    // Create an empty response object
    var responseObj = new ResponseObject();
    try {
      // Parse the request
      var requestObject = this.parseRequest(payloadBodyString);
      
      // Authorize the user
      this.authorize(requestObject.auth_token);
      
      // Determine which controller action the user wants to take
      var selectedAction = this.findControllerAction(requestObject.action);
      
      // Call execute on the action and succeed it
      return responseObj.succeed(selectedAction.execute(requestObject.data));
    } catch (err) {
      // Log the full error details
      Logger.log(err);
      
      // Fail the response with the appropriate error
      return responseObj.fail(err /*instanceof WebApiException ? err : new WebApiException(ErrorCode.UNKNOWN, 'An unknown error has occurred')*/);
    }
  };
  
  /*
   * Parses the request payload string and validates that all common required properties are supplied
   */
  this.parseRequest = function(payloadBodyString) {
    if (!payloadBodyString) {
      throw new WebApiException(ErrorCode.NO_CONTENT, 'No payload specified');
    }
    
    // Parse
    var requestObject = null;
    try { requestObject = JSON.parse(payloadBodyString); } catch (e) { }
    if (!requestObject) {
      throw new WebApiException(ErrorCode.MALFORMED_CONTENT, 'Payload not valid JSON');
    }
    
    // Check for required properties
    if (!requestObject.auth_token) {
      throw new WebApiException(ErrorCode.MALFORMED_CONTENT, 'No auth_token specified');
    }
    if (!requestObject.action) {
      throw new WebApiException(ErrorCode.MALFORMED_CONTENT, 'No action specified');
    }
    
    return requestObject;
  };
  
  /*
   * Checks the user's auth token against the allowed tokens. Throws an authorization exception if not allowed
   */
  this.authorize = function(authToken) {
    // For now, assume authorized. We'll change this later
  };
  
  /*
   * Locates the controller action from the action string provided by the user
   */
  this.findControllerAction = function(controllerActionString) {
    // Split the action string on the dot
    var split = controllerActionString.split('.', 2);
    if (!split || split.length < 2 || !split[0] || !split[1]) {
      throw new WebApiException(ErrorCode.INVALID_REQUEST, 'Invalid format for actions');
    }
    
    var controller = null;
    var action = null;
    
    // Find the controller
    for (var i in this.controllers) {
      if (this.controllers[i].name === split[0]) {
        controller = this.controllers[i];
        break;
      }
    }
    
    // Find the action within the controller
    if (controller) {
      for (var i in controller.actions) {
        if (controller.actions[i].name === split[1]) {
          action = controller.actions[i];
        }
      }
    }
    
    if (!controller || !action) {
      throw new WebApiException(ErrorCode.INVALID_REQUEST, 'Invalid action');
    }
    
    return action;
  };
}

The doPost() function in Main.gs then just needs to call that handler and return back the ResponseObject it received as JSON:

/*
 * Main entry point for the program
 */
function doPost(e) {
  // Get the request payload (or empty string if no payload)
  var requestPayloadString = e.postData ? e.postData.contents : '';
  
  // Process the request payload and generate a response payload
  var responsePayloadObject = new RequestHandler().handleRequest(requestPayloadString);
  
  // Return the response
  return responsePayloadObject.toHttpResponse();
}

Controllers/actions

One more thing before we have a working API. We need some controllers and actions for those controllers. For now, our controller actions are just going to be key-value pair type objects that tie an action’s name to its execute function:

/*
 * "Constructor" for the ControllerAction
 */
function ControllerAction(name, execute) {
  this.name = name;
  this.execute = execute;
}

To start the first controller, add a simple EchoController with one action called repeat that parrots back what the user entered in the data field of the API request:

/*
 * "Constructor" for the EchoController
 */
function EchoController() {
  this.name = 'ECHO';
  this.actions = [
    // Returns the data passed in
    new ControllerAction('REPEAT', function(data) {
      return data;
    })
  ];
}

Test it out

Publish your API and try executing a POST request to it using the following payload:

{
  "auth_token": "123",
  "action": "ECHO.REPEAT",
  "data": "Hello world"
}

You should get back the following JSON:

{
  "success": true,
  "data": "Hello world",
  "error": null
}

In my next post, I’ll talk about adding some authentication to the API.

This post first appeared on Another Dev Blog