How to implement a Lambda backend based on OpenAPI
Remove duplication of validation, routing, and parameter handling with an OpenAPI-based backend framework
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.