Skip to content

Instantly share code, notes, and snippets.

@fiznool
Last active September 16, 2016 09:43
Show Gist options
  • Save fiznool/27fcd362250ce668986a29ad79b59b03 to your computer and use it in GitHub Desktop.
Save fiznool/27fcd362250ce668986a29ad79b59b03 to your computer and use it in GitHub Desktop.

As discussed on gitter, the plan is to implement the error handling as a two-stage process.

  1. Add an additionalCodes property to the error object passed in as the third argument to the api handler. This will specify the additional error responses that should be setup by API Gateway, and the associated regexes.
  2. Create a new claudia-api-errors module, which will allow developers to throw a predefined set of Error objects, which will correspond to response codes.

Only 1. needs to be implemented to allow multiple error responses, 2. is more of a nice-to-have.

Background

API Gateway allows you to parse the error message returned from a lambda function, and apply a response code according to a regex that you configure. Here's a good primer on how this works.

For example, if you return the following error from a lambda function:

new Error('Bad Request: username is required.');

In API Gateway, you can setup the following configuration:

Selection pattern: ^Bad Request: .*

Method response: 400

Now, whenever you return an Error from your lambda function as above, API Gateway will transform this into a 400 response.

Currently, claudia only supports returning a single error code, but we can use the idea above to create multiple error responses.

1. Regex Codes

The plan is to allow API handlers to be setup in the following way:

const ApiBuilder = require('claudia-api-builder');

const api = new ApiBuilder();

api.post('users', function(req) {
  if(!req.body.username) {
    throw new Error('Bad Request: username is required.');
  }

  // Otherwise, we can process the request successfully.
}, {
  success: 201,
  error: {
    defaultCode: 500,  // optional, defaults to `500`
    additionalCodes: [{
      code: 400,
      pattern: '^Bad Request:.*',
      template: '$input.path(\'$.errorMessage\')'  // Optional, defaults to the value shown here
    }]
  }
});

The idea is to enumerate all of the error responses that you expect in your handler as items in the additionalCodes array. Each item includes:

  • the response code to issue
  • the pattern to match in the message passed into the error
  • the (optional) mapping template to apply to the error message

When setting up your routes, claudia will enumerate this array and apply each in turn as API gateway calls. An example of this can be seen here.

Claudia will also need to be modified so that the success response code is setup as the default code in API Gateway. Currently the error code is set up as the default. The error code should be setup as the regex .+ which will act as a 'catch-all' for all errors which do not match any of the others.

2. API Error objects

The above, while functionally complete, can be a bit of a pain to manage, especially if you want to return a JSON object in the response body. The second part of the process is building a set of API Errors which simplify this process.

The idea is to modify the above API handler code so it resembles:

const ApiBuilder = require('claudia-api-builder');
const ApiErrors = require('claudia-api-errors');

const api = new ApiBuilder();

api.post('users', function(req) {
  if(!req.body.username) {
    throw new ApiErrors.BadRequestError({ message: 'username is required' });
  }

  // Otherwise, we can process the request successfully.
}, {
  success: 201,
  error: {
    defaultCode: 500,  // optional, defaults to `500`
    additionalErrors: [
      ApiErrors.BadRequestError
    ]
  }
});

An ApiError will inherit from a base class:

class ApiBuilderError extends Error {
  constructor(code, data) {
    const serializedData = JSON.stringify(data);
    super(`{"code":${code},"data":${serializedData}}`);
  }

  toConfig() {
    return {
      code: this.code,
      pattern: `^{"code":${this.code}.*`,
      template: '$util.parseJson($input.path(\'$.errorMessage\')).data'
    };
  }
}

class BadRequestError extends ApiBuilderError {
  constructor(data) {
    super(400, data);
  }
}

Under the hood, if claudia finds an additionalErrors property on the error object, it will assume that you are passing in a class inherited from ApiError. It will instantiate this and call toConfig to generate the config needed to apply to API Gateway. You'll notice that toConfig returns the same properties as the Regex-based approach, with a couple of extras:

  • Notice that the error message property is set to a JSON string, which begins with the code and follows with the data passed in.
  • The pattern is always set to begin with the code, this allows us to automatically detect the errors that we have create with the error builder. The ^ ensures that we only match on messages which begin with the code, preventing false positives on error messages including code.
  • The template automatically unwraps the data that you passed in to the error, and returns it as the response body.

So, for the API handler above, if the username is not present, we'll receive the following response:

< 400
{
  message: 'username is required'
}

Hence, the error builder is a convenience for returning standard errors.

@phips28
Copy link

phips28 commented Sep 14, 2016

jfyi, need double escape for AWS, otherwise you will get a BadRequest, invalid regex.

return {
      code: this.code,
      pattern: `^\\{\\"code\\":${this.code}.*`,
      template: '$util.parseJson($input.path(\'$.errorMessage\')).data'
    };

@phips28
Copy link

phips28 commented Sep 15, 2016

Template:
When you are using:

{
  code: HTTPStatus.BAD_REQUEST,
  pattern: `^\\{\\"code\\":${HTTPStatus.BAD_REQUEST}.*`,
  template: '$util.parseJson($input.path(\'$.errorMessage\')).data',
}

for your error template and this reject in your code:

const serializedData = JSON.stringify({ message: 'User does not exist' });
return Promise.reject(`{"code":${HTTPStatus.BAD_REQUEST},"data":${serializedData}}`);

you get {message=User does not exist} and this is no valid JSON!


Then I tried this:

{
  code: HTTPStatus.FORBIDDEN,
  pattern: `^\\{\\"code\\":${HTTPStatus.FORBIDDEN}.*`,
  template: '#set ($errorMessageObj = $util.parseJson($input.path(\'$.errorMessage\')).data)'
          + '{"message" : "$errorMessageObj.message"}',
}

results in:

{
  "message": "User does not exist"
}

🎈 juhu, but is not generic


add some generic-ness 🚧

{
  code: HTTPStatus.BAD_REQUEST,
  pattern: `^\\{\\"code\\":${HTTPStatus.BAD_REQUEST}.*`,
  template: '#set ($errorMessageObj = $util.parseJson($input.path(\'$.errorMessage\')).data)' +
  '{' +
   '#foreach($key in $errorMessageObj.keySet())' + // iterate over each error element
   '   "$key" : "$errorMessageObj.get($key)"' + // key: value
   '   #if($foreach.hasNext),#end' + // add a comma if hasNext element
   '#end' +
   '}',
},
// TODO remove all spaces in the string to decrease response size

Test:

const serializedData = JSON.stringify({ message: 'User does not exist', anyOther: 'bla' });

results in:

{
    "message": "User does not exist",
    "anyOther": "bla",
}

🎉 💪 💯

@phips28
Copy link

phips28 commented Sep 15, 2016

some more test:
if you reject with this error object:

const serializedData = JSON.stringify({ message: 'User does not exist', anyOther: 'bla', objec: { a: 123 } });

results in:

{
    "message": "User does not exist",
    "anyOther": "bla",
    "objec": "{a=123}"
}

because the AWS $util.parseJson seems to parse only the first 'layer'

@phips28
Copy link

phips28 commented Sep 15, 2016

I also build a little helper ApiError module but need input on that how to do this the best way.

ApiError.js

'use strict';

const HTTPStatus = require('http-status');

const errorTemplatePlain = '$input.path(\'$.errorMessage\')';
const errorTemplateDynamic = '#set ($errorMessageObj = $util.parseJson($input.path(\'$.errorMessage\')).data)' +
  '{' +
  '#foreach($key in $errorMessageObj.keySet())' + // iterate over each error element
  '"$key":"$errorMessageObj.get($key)"' + // key: value
  '#if($foreach.hasNext),#end' + // add a comma if hasNext element
  '#end' +
  '}';

class ApiBuilderError extends Error {
  constructor(code, data, customTemplate) {
    const serializedData = JSON.stringify(data);
    super(`{"code":${code},"data":${serializedData}}`);
    this.code = code;
    this.customTemplate = customTemplate;
  }

  toConfig() {
    return {
      code: this.code,
      pattern: `^\\{\\"code\\":${this.code}.*`,
      template: this.customTemplate || errorTemplateDynamic,
    };
  }
}

class NotFoundError extends ApiBuilderError {
  constructor(data, customTemplate) {
    super(HTTPStatus.NOT_FOUND, data, customTemplate);
  }
}

class BadRequestError extends ApiBuilderError {
  constructor(data, customTemplate) {
    super(HTTPStatus.BAD_REQUEST, data, customTemplate);
  }
}

module.exports.notFoundError = function notFoundError(data, customTemplate) {
  return new NotFoundError(data, customTemplate);
};

module.exports.badRequestError = function badRequestError(data, customTemplate) {
  return new BadRequestError(data, customTemplate);
};

then I const ApiError = require('../ApiError'); in my route file and define the errors, and then throw it on error.

config for endpoint:

error: {
      defaultCode: 500,  // optional, defaults to `500`
      additionalCodes: [
        ApiError.badRequestError().toConfig(),
      ],
},

throw API Error:

const data = { message: 'User does not exist' };
// return Promise.reject(ApiError.badRequestError(data));
throw ApiError.notFoundError(data);

@fiznool how would you do that ApiError file? (never worked with classes like this)
iterate through 'http-status'? (too much status codes I guess)

@fiznool
Copy link
Author

fiznool commented Sep 15, 2016

@phips28 you're making great progress! Awesome 😄

I think you are on the right track with the ApiError file. One thing that you need to account for is that we can't support dynamic templates in this way. Due to the way that API Gateway works, we need to define all of the mapping templates up front (i.e. outside of the handler), so that when the error is passed in to the config object, we can simply call toConfig and all of the information we need to setup the response (code and mapping template) is available.

So, in order to support a custom mapping, a user would need to create their own error class, which inherits from the error code that they are going to use.

In addition to this, I'm not sure we need to export the badRequestError etc. helper handlers, instead I think we can just export the error classes directly.

With this in mind perhaps the following works better:

'use strict';

const HTTPStatus = require('http-status');

const errorTemplatePlain = '$input.path(\'$.errorMessage\')';
const errorTemplateDynamic = '#set ($errorMessageObj = $util.parseJson($input.path(\'$.errorMessage\')).data)' +
  '{' +
  '#foreach($key in $errorMessageObj.keySet())' + // iterate over each error element
  '"$key":"$errorMessageObj.get($key)"' + // key: value
  '#if($foreach.hasNext),#end' + // add a comma if hasNext element
  '#end' +
  '}';

class ApiBuilderError extends Error {
  constructor(code, data) {
    const serializedData = JSON.stringify(data);
    super(`{"code":${code},"data":${serializedData}}`);
    this.code = code;
  }

  setCustomTemplate(customTemplate) {
    this.customTemplate = customTemplate;
  }

  toConfig() {
    return {
      code: this.code,
      pattern: `^\\{\\"code\\":${this.code}.*`,
      template: this.customTemplate || errorTemplateDynamic,
    };
  }
}

class BadRequestError extends ApiBuilderError {
  constructor(data) {
    super(HTTPStatus.BAD_REQUEST, data);
  }
}
module.exports = {
  BadRequestError,
  // etc.
}

Handler:

const ApiBuilder = require('claudia-api-builder');
const ApiErrors = require('claudia-api-errors');

class CustomBadRequestError extends ApiErrors.BadRequestError {
  constructor(data) {
    super(data);
    this.setCustomTemplate('$input.path(\'$.errorMessage.customData\')');
  }
}   

const api = new ApiBuilder();

api.post('users', function(req) {
  if(!req.body.username) {
    throw new CustomBadRequestError({ message: 'username is required' });
  }

  // Otherwise, we can process the request successfully.
}, {
  success: 201,
  error: {
    defaultCode: 500,  // optional, defaults to `500`
    additionalErrors: [
      CustomBadRequestError
    ]
  }
});

I think we'll need to just specify an error object for every status code, I don't think there is any other way of doing it.

Regarding the configuration, I thought that by passing in the class into the additionalErrors array, when claudia comes to configure API gateway, it would instantiate all of the classes passed in to grab their config (by automatically calling new CustomBadRequestError().toConfig(), saving the user needing to instantiate it).

@fiznool
Copy link
Author

fiznool commented Sep 15, 2016

Actually, I think we can simplify a lot of this by using ES6 static methods.

'use strict';

const HTTPStatus = require('http-status');

const errorTemplatePlain = '$input.path(\'$.errorMessage\')';
class ApiBuilderError extends Error {
  constructor(data = {}) {
    super();
    const code = this.constructor.code;
    const serializedData = JSON.stringify(data);
    this.message = `{"code":${code},"data":${serializedData}}`;
  }

  static get code() {
    return HTTPStatus.INTERNAL_SERVER_ERROR;
  }
  static get customTemplate() {}

  static toConfig() {
    const code = this.code;
    const customTemplate = this.customTemplate;
    return {
      code,
      pattern: `^\\{\\"code\\":${code}.*`,
      template: customTemplate || errorTemplateDynamic
    };
  }
}

class BadRequestError extends ApiBuilderError {
  static get code() {
    return HTTPStatus.BAD_REQUEST;
  }
}

module.exports = {
  BadRequestError,
  // etc.
}

Handler:

const ApiBuilder = require('claudia-api-builder');
const ApiErrors = require('claudia-api-errors');

class CustomBadRequestError extends ApiErrors.BadRequestError {
  static get customTemplate() {
    return '$input.path(\'$.errorMessage.customData\')';
  }
}   

const api = new ApiBuilder();

api.post('users', function(req) {
  if(!req.body.username) {
    throw new CustomBadRequestError({ message: 'username is required' });
  }

  // Otherwise, we can process the request successfully.
}, {
  success: 201,
  error: {
    defaultCode: 500,  // optional, defaults to `500`
    additionalErrors: [
      CustomBadRequestError
    ]
  }
});

Since we making use of static methods now, claudia can just call CustomBadRequest.toConfig() to get the config it needs to setup API Gateway.

Hope this all makes sense!

@phips28
Copy link

phips28 commented Sep 16, 2016

@fiznool my implementation is ready 🎉
claudiajs/claudia-api-builder#5 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment