Back to main page Traveling Coderman

Node.js Microservice Architecture OpenAPI input validation in Express.js

2062 views

With the creation of an OpenAPI specification, we can both achieve input validation and make the API visually explorable.

Up to this point, we have neglected to validate the inputs of the endpoints.

  • The ID parameter of the endpoints PUT /todos/:id, DELETE /todos/:id and GET /todos/:id needs to be verified to be a valid UUID.
  • The request bodies of POST /todos and PUT /todos/:id need to be verified to be valid todos.

Additionally, we don't provide any information outside of the source code on the available endpoints, what to send to them and what to expect to receive as response.

We want to fix both of these issues with an OpenAPI specification. With an OpenAPI specification openapi.json defined, we can use an Express middleware to validate inputs. Also, we can use a tools like Swagger UI or ReDoc to graphically explore the API.

Defining the OpenAPI spec directly as a JSON file openapi.json instead of using some generation-based approach, gives us the most flexibility. There exists a huge amount of documentation for writing OpenAPI specs. Code editors can autocomplete and lint since OpenAPI specs are described with a JSON schema. Also, there is no library in between that shadows and imitates the possibilities of OpenAPI. Therefore, if the OpenAPI spec advances, we don't rely on some library in between to be updated before we can use the new OpenAPI spec features.

Defining the OpenAPI spec 🔗

On a high-level, the OpenAPI specification defines some meta information, the various endpoints and the structure of requests and responses.

{
"openapi": "3.0.1",
"info": {
"title": "Todo Service",
"version": "0.0.1"
},
"servers": [
{
"url": "/"
}
],
"tags": [],
"paths": {
"/todos/{id}": {
"get": {
// ...
},
"put": {
// ...
},
"delete": {
// ...
}
},
"/todos": {
"post": {
// ...
},
"get": {
// ...
}
}
},
"components": {
"schemas": {
"Todo": {
// ...
}
}
}
}

Note You need to additionally define a second server URL { "url": "<url>" } if your application is not running on root in production. Example: If your API is accessible at /api/v1/, then this path needs to be present besides /. If one of these paths is missing, the input validation fails on either production or locally.

At first, we define a todo with its fields as a OpenAPI component.

  • The ID can be specified to be a UUID.
  • The name and assignee can be limited to be a string of a certain length.
  • The due date can be defined to be a date.
{
"components": {
"schemas": {
"Todo": {
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"name": {
"type": "string",
"maxLength": 40
},
"assignee": {
"type": "string",
"maxLength": 160
},
"dueDate": {
"type": "string",
"format": "date"
}
},
"required": ["name", "assignee", "dueDate"]
}
}
}
}

Note We don't include the ID in the required fields. This way, we can use the same type for the POST and PUT request body. However, this lack of precision means that the response bodies don't guarantee the ID to be present. This is still better though than the complication through the alternative of using allOf.

Additionally, we define a schema for the response body on a 400 status code.

{
"components": {
"schemas": {
"BadRequestError": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"errors": {
"type": "array",
"items": {
"type": "object",
"properties": {
"errorCode": {
"type": "string"
},
"message": {
"type": "string"
},
"path": {
"type": "string"
}
}
}
}
}
}
}
}
}

With these schemas, we can define the endpoints. As an example, let's define the endpoint PUT /todos/:id. The complete endpoints can be found on GitHub.

{
"paths": {
"/todos/{id}": {
"put": {
"summary": "Updates a todo",
"operationId": "updateTodo",
"tags": ["Todos"],
"parameters": [
{
"in": "path",
"name": "id",
"description": "The id of the todo",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Todo"
}
}
}
},
"responses": {
"200": {
"description": "The updated todo",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Todo"
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
}
}
}
}
}
}

We define that the endpoint PUT /todos requires the path parameter id to be a valid UUID. Additionally, it requires a JSON body to be send and this body needs to match the format of a todo. The endpoint can result in a success 200 or validation error 400. On a success, we assert that the response body matches the format of a todo. On a validation error, we assert the response body to have the structure of an error.

Input validation 🔗

There is an npm package express-openapi-validator that acts as an Express middleware before the controllers are reached. We create a file openapi.ts in a new folder pre-request-handlers. A file error-handler.ts in a folder error-handling contains an Express middleware to respond with an error response in case the OpenAPI validation finds errors. The openapi.json is positioned in an assets folder outside the source folder.

assets
`-- openapi.json
src
|-- pre-request-handlers
| `-- openapi.ts
|-- error-handling
| `-- error-handler.ts
|-- controllers
| `-- todos
| `-- todo.router.ts
`-- test.functions.ts

In the openapi.ts, we create a middleware validateInputs.

import path from "path";
import * as OpenApiValidator from "express-openapi-validator";

const spec = path.join("assets", "openapi.json");

export const validateInputs = OpenApiValidator.middleware({
apiSpec: spec,
validateRequests: true,
validateResponses: true,
});

The application loads the static asset assets/openapi.json and passes it to the express-openapi-validator to validate the inputs.

Note In production systems, I recommend to set validateResponses to false. Response validation is helpful during development. On production, you don't want to return the user an error that the server response does not match the expected format. In later posts, we will have a configuration to distinguish production from local system.

The middleware needs to be registered in the todo router todo.router.ts.

Why not in the app.ts? This would lead to the validation of all incoming requests. However, there are endpoints you might not want to include in the OpenAPI specification. These can be the OpenAPI specification itself GET /openapi.json or health checks GET /healthcheck.

import express from "express";
import { validateInputs } from "../../pre-request-handlers/openapi";
import { deleteTodoController } from "./delete-todo.controller";
import { getTodoController } from "./get-todo.controller";
import { getTodosController } from "./get-todos.controller";
import { postTodoController } from "./post-todo.controller";
import { putTodoController } from "./put-todo.controller";

export const todoRoute = express.Router();

// Validating inputs before all todo controllers
todoRoute.use(validateInputs);

todoRoute.get("/:id", getTodoController);
todoRoute.get("", getTodosController);
todoRoute.put("/:id", putTodoController);
todoRoute.delete("/:id", deleteTodoController);
todoRoute.post("", postTodoController);

Globally in the app.ts, we need to make sure that on errors an error response is send.

import express from "express";
import { todoRoute } from "./controllers/todos/todo.router";
import { sendErrorResponse } from "./error-handling/error-handler";

const app = express();

// ...

app.use("/todos", todoRoute);

// If an error occurred, then send an HTTP error response
app.use(sendErrorResponse);

export default app;

The function sendErrorResponse is defined to handle errors past to the Express next function. For OpenAPI validation errors, it sends the status code 400 that the library express-openapi-validator specifies.

import { NextFunction, Request, Response } from "express";

export function sendErrorResponse(
error: Error & { status?: number; errors?: unknown[] },
_request: Request,
response: Response,
_next: NextFunction
): void {
const status = error.status ?? 500;
response.status(status).json({
message: error.message,
errors: error.errors,
});
}

Inspecting the input validation 🔗

With input validation and error responses in place, we can take a look how the application behaves.

Note I'm using a tool httpie but curl or some graphical tool also does the job.

Starting the application with npm start, it runs on http://localhost:3000. A delete request with an invalid ID yields us correctly a bad request response 400.

> http DELETE localhost:3000/todos/123
HTTP/1.1 400 Bad Request
Connection: keep-alive
Content-Length: 174
Content-Type: application/json; charset=utf-8
Date: Thu, 10 Feb 2022 21:19:40 GMT
ETag: W/"ae-f4bL7i+yZUTT/K6oR9ojSGs7bsA"
Keep-Alive: timeout=5
X-Powered-By: Express

{
"errors": [
{
"errorCode": "format.openapi.validation",
"message": "should match format \"uuid\"",
"path": ".params.id"
}
],
"message": "request.params.id should match format \"uuid\""
}

If we send a todo with multiple missing fields to the endpoint POST /todos, then we receive a response with all the occurred errors.

> http POST localhost:3000/todos dueDate=2022-02-04
HTTP/1.1 400 Bad Request
Connection: keep-alive
Content-Length: 363
Content-Type: application/json; charset=utf-8
Date: Thu, 10 Feb 2022 21:23:47 GMT
ETag: W/"16b-nWRX3gNUYyoDnSJNwgQS2UbbbQo"
Keep-Alive: timeout=5
X-Powered-By: Express

{
"errors": [
{
"errorCode": "required.openapi.validation",
"message": "should have required property 'name'",
"path": ".body.name"
},
{
"errorCode": "required.openapi.validation",
"message": "should have required property 'assignee'",
"path": ".body.assignee"
}
],
"message": "request.body should have required property 'name', request.body should have required property 'assignee'"
}

This is very valuable. A naive input validation would only return the first error it found, leaving the user in a cascade of discovering and fixing mistakes.

Testing 🔗

In the tests, you want to include the validation as well. This way, it is possible to test the input validation in the tests of the controllers.

import express, { Express } from "express";
import { sendErrorResponse } from "./error-handling/error-handler";
import { validateInputs } from "./pre-request-handlers/openapi";

export function server(configure: (express: Express) => void): Express {
const app = express();
app.use(express.json({ limit: "1mb" }));
// Validating inputs before all controllers
app.use(validateInputs);
configure(app);
// Send an error response if an error occurred
app.use(sendErrorResponse);
return app;
}

With the input validation in place, the controller tests can be extended with tests of bad requests. As an example, let's take a look at passing an invalid ID to the endpoint DELETE /todos/:id.

import request from "supertest";
import { v4 as uuid } from "uuid";
import { server } from "../../test.functions";
import { deleteTodoController } from "./delete-todo.controller";

jest.mock("./todo.dao");

describe("deleteTodoController", () => {
const route = "/todos/:id";

const app = server((app) => {
app.delete(route, deleteTodoController);
});

// ...

it("rejects an invalid ID", async () => {
const deleteTodo = require("./todo.dao").deleteTodo;
const todoId = "123";
deleteTodo.mockResolvedValue();
const response = await request(app)
.delete(route.replace(":id", todoId))
.expect(400);
expect(response).toHaveProperty(
"body.message",
'request.params.id should match format "uuid"'
);
expect(deleteTodo).not.toHaveBeenCalled();
});
});

Serving the OpenAPI spec 🔗

So far, we only use the OpenAPI specification for input validation. Additionally, we can expose it via an endpoint GET /openapi.json. This requires a small adjustment in the pre-request-handlers/openapi.ts to introduce a function serveOpenapiSpec. This function serves the static file assets/openapi.json.

import path from "path";
import * as express from "express";
// ...

const spec = path.join("assets", "openapi.json");

export const serveOpenapiSpec = express.static(spec);

export const validateInputs = /* ... */;

In the application app.ts, the OpenAPI specification can now be served.

import express from "express";
// ...
import { serveOpenapiSpec } from "./pre-request-handlers/openapi";

const app = express();

// ...
app.use("/openapi.json", serveOpenapiSpec);
// ...

export default app;

This makes the OpenAPI specification available at GET /openapi.json. With tools like Swagger UI or ReDoc, this OpenAPI specification can be visualized.

Conclusion 🔗

We have set up input validation based on a declarative OpenAPI specification. The OpenAPI specification is tested within the controllers. It is exposed via an endpoint such that the API can be visually explored. In the next post, we take a deeper look at error handling and logging.

Become a GitHub sponsor to access the code of this post as a GitHub repo.