How to implement a Lambda backend based on OpenAPI

Remove duplication of validation, routing, and parameter handling with an OpenAPI-based backend framework

Author's image
Tamás Sallai
5 mins

API documentation and implementation

API documentation and implementation tend to diverge when they are maintained separately. This results in security problems where something should be validated but isn't and usability issues such as out-of-date documentation.

Let's say there is an API running on NodeJs that allows creating users!

To make sure that only the name property is present in the request, joi is the best validator out there:

const Joi = require("joi");

const body = JSON.parse(event.body);

const schema = Joi.object({
	name: Joi.string()
		.required()
});

// validate
schema.validate(body);

And to document the API, you can use OpenAPI that defines a standard format:

requestBody:
  content:
    'application/json':
      schema:
        type: object
        properties:
          name:
            type: string
        required:
          - name
        additionalProperties: false
  required: true

Since the OpenAPI is a standard format, general-purpose tools can consume it and provide useful things, such as interactive documentation.

The problem is that the documentation is more visible while the implementation is hidden in code. This makes it hard to make sure they are the same and that can easily lead to problems where the API implementation accepts requests the documentation would reject.

The root of the problem is that one information---how to validate---lives in two places---the implementation and the documentation---and that violates the DRY (don't repeat yourself) principle. To solve this, either generate the documentation based on the code or run the validations based on the documentation.

As the OpenAPI is a standardized format, tools can read it and extract how each request should be validated. It's just a small leap to make an OpenAPI-based validator that gets the request and makes sure that it is valid according to the documentation.

Openapi-backend

The openapi-backend is a project to do just that. It reads the YAML file, runs the validators, and also extracts the parameters and does routing. The last part is done by reading the operationId parameter in the OpenAPI document and running the appropriate handler function.

Initialization needs the YAML file and an object of handlers. These are the functions that process the request and return the response.

const {OpenAPIBackend} = require("openapi-backend");

const api = new OpenAPIBackend({definition: "api.yml", handlers: {
	createUser: async (c) => {
		const user = c.request.requestBody;

		// ... return the response
	},
})};

Then when a request comes, extract the different parts and pass them to the api:

module.exports.handler = async (event) => {
	return api.handleRequest({
		method: event.requestContext.http.method,
		path: event.requestContext.http.path,
		query: event.queryStringParameters,
		body: event.body,
		headers: event.headers,
	});
};

For example, the OpenAPI documentation defines the createUser operation with a body object that has a single property:

paths:
  /user:
    post:
      operationId: createUser
      summary: Create user
      requestBody:
        content:
          'application/json':
            schema:
              type: object
              properties:
                name:
                  type: string
              required:
                - name
              additionalProperties: false
        required: true
      responses:
        default:
          description: Success

The above code extracts the request path and the body, then the openapi-backend selects the operation, validates the object, then calls the createUser handler.

Request parameters

Apart from routing, it also extracts the parameters from the request, such as a path parameter:

paths:
  '/user/{userid}':
    parameters:
    - name: userid
      in: path
      required: true
      schema:
        type: string
    delete:
      operationId: deleteUser
      summary: Delete user
      responses:
        200:
          description: Success

When the handler is called, there is no need to parse the path manually. The context object has the userid readily available:

const api = new OpenAPIBackend({definition: "api.yml", handlers: {
	deleteUser: async (c) => {
		const userid = c.request.params.userid;

		// ... return the response
	},
	// ... other handlers
}});

Special handlers

Apart from the handlers that process the operations, there are also a few others for special purposes. The notFound is called when the request does not match any operation. The notImplemented is a catch-all handler. Then the validationFailed is called when the request failed validation.

These special handlers allow interfacing with the Lambda runtime, as it expects the response in a specific structure. To return appropriate response codes for invalid responses, add the necessary handlers:

{
	notFound: (c) => {
		return {
			statusCode: 404,
			body: "Not found",
		};
	},
	validationFail: () => {
		return {
			statusCode: 400,
		};
	},
}

Let's try out how these work!

When the request is for a valid operation and the body matches the schema, everything is fine:

$ curl -X POST -i "$API/user" -H "Content-Type: application/json" -d "{\"name\":\"testuser\"}"
HTTP/2 200
content-type: application/json
content-length: 45

But when there is an extra property, validation fails and returns a 400 response:

$ curl -X POST -i "$API/user" -H "Content-Type: application/json" -d "{\"name\":\"testuser\",\"userid\":\"123\"}"
HTTP/2 400
content-length: 0

And a path that does not exist in the OpenAPI document results in a 404 response:

$ curl -X POST -i "$API/invalid" -H "Content-Type: application/json"
HTTP/2 404
content-type: text/plain; charset=utf-8
content-length: 9

Not found

One or more functions?

This setup uses one Lambda function as the main entry point to the API and uses internal logic to route between different handlers. This is called a monolith and this is one way to write serverless functions. The other approach is to extract different parts to separate functions, such as one for each path or even one for each operation.

The monolith is better in terms of cold starts as once the function is initialized it can handle all requests. On the other hand, separate functions offer better fine-tuning of resource parameters and permissions, and also allows better visibility into how each part works.

Conclusion

The OpenAPI documentation is a standardized format to define APIs and it can be the basis of validation and routing. This eliminates the duplication in the implementation and the documentation and allows the latter to be the source of truth.

The openapi-backend project is one framework to do this, and it works well on AWS Lambda. It handles validation internally and allows you to define handler functions that provide the functionality.

October 30, 2020
In this article