Building Node.js REST API servers from OpenAPI specifications

; Date: Tue May 24 2022

Tags: OpenAPI »»»» Node.js

OpenAPI specifications are meant to assist creating either server or client API implementations. In this article we will explore quickly implementing server code from an OpenAPI spec, running on Node.js, preferably using TypeScript.

Our goal in this article is to create a maintainable Node.js/JavaScript/TypeScript server implementing a REST service defined in an OpenAPI specification. In this case we will not generate the OpenAPI spec file from the code, but to use the OpenAPI spec to drive the server. Some frameworks go the other way, and generate the OpenAPI spec from the code. That model is useful in some cases. But, there are many scenarios where we need to implement a server compatible with an existing API where there is a published OpenAPI specification. In such a case, we need to generate the server from the OpenAPI spec, rather than generate the OpenAPI spec from the code.

What do we mean by maintanable server code? APIs change. What happens to your server implementation when you need to update to a later version of the API? A maintainable scenario is where you can update the OpenAPI spec being used without having to recreate your implementation from scratch. In some cases, regenerating source code from a changed API spec overwrites existing code. If you've customized code that was overwritten you'll need to recreate the customization. Instead, we must be able to quickly update our server code with very little or no fuss. This is because we must have a tight loop between iterating the OpenAPI spec and testing server implementation changes.

The core of an OpenAPI spec is the paths structure, where each item is a URL path in our API. These URL's clearly correspond to route handlers like these:

router.get('/todos/all', ...)
router.put('/todos', ... )
router.delete('/todos/123', ...)

This means an OpenAPI server framework must somehow generate these route handlers. The server framework should ride on top of existing frameworks like Express or Fastify. The OpenAPI framework can either generate source code, that we somehow customize with our business logic, or it somehow handles internally matching the requests against the specified API. The operationId attribute in OpenAPI path declarations must connect to a JavaScript function (we're targeting Node.js) that contains our code. And then there are things like object schema's, that must correspond to data types, and so on.

For each combination of a URL path, and a request verb, there is a corresponding operationId. The operationId, for a Node.js server, would correspond to a JavaScript function. Therefore the OpenAPI server framework should handle matching against URL's and request verbs, ensure that incoming data matches whatever is in the OpenAPI spec, then invoke the function corresponding to the operationId.

Repository with example code: (github.com) https://github.com/robogeek/typescript-todo-service

The repository contains a sample OpenAPI file for a TODO service. As of this writing the TODO service has not been tested in a real application. It is designed from an earlier tutorial, Single page multi-user application with Express, Bootstrap v5, Socket.IO, Sequelize, and should therefore this API should work. However, the the purpose of this article is not to create a working application, but only to demonstrate how to start from an OpenAPI specification and quickly scaffold the starting point for a server implementation. All we need is to witness operation handler functions being executed.

This repository also contains examples of using the spec to build servers.

Creating an OpenAPI server using Exegesis and Exegesis-Express

The Exegesis library implements OpenAPI 3, but you'll want to use either exegesis-express or exegesis-koa. Let's take a spin using the Express version.

Setup a Node.js project directory by installing these packages

$ npm init -y
$ npm install express exegesis-express cookie-parser body-parser --save
$ npm install @types/cookie-parser @types/errorhandler \
    @types/express @types/method-override @types/morgan \
    @types/node typescript nodemon --save-dev

This is a modestly useful set of packages for a typical ExpressJS server. As of this writing, Express v4 is still the current version, and Express v5 is in Beta 1. This set of Express middleware are useful if you desire to support more than the OpenAPI paths.

We are installing TypeScript, along with several types packages. Finally, Nodemon is useful for monitoring source code and reloading the server for a better developer workflow.

The todo.yml file must have a couple small changes made. So, copy it into the project directory, then for every entry under paths make this change

paths:
  /todo:
    post:
      ...
      x-exegesis-controller: todoController
      ...

In OpenAPI, x- is used for vendor extensions. In this case, Exegesis is the vendor, hence x-exegesis, and this particular extension is to name a module where a controller function will be found. In OpenAPI, each paths entry has an operationId parameter which is a human-readable name for the operation corresponding to the path.

For example, the POST on /todo corresponds to the createTodo operation. The x-exegesis-controller parameter names a module where Exegesis looks for a function whose name matches the operationID parameter. Hence, Exegesis will invoke todoController.createTodo upon matching a request to this paths entry.

You might have noticed that we installed the TypeScript compiler and some Types packages. This means we need to initialize a tsconfig.json as so:

{
    "include": [ "lib/**/*" ],
    "compilerOptions": {
        "lib": [ "es6", "es2021", "esnext" ],
        "target": "es2022",
        "module": "commonjs",
        "moduleResolution": "Node",
        "outDir": "dist",
        "rootDir": "./lib",
        "experimentalDecorators": true,
        "emitDecoratorMetadata": true,
        "declaration": true,
        "declarationMap": true,
        "inlineSourceMap": true,
        "inlineSources": true
    }
}

This says to look in the lib directory for source files, and compile into dist, compiling to the CommonJS module format. I started to use the ES6 module format, but Exegesis uses require to load the controller modules, which necessitated compiling the TypeScript to CommonJS format.

In lib/index.ts create a script to wire together the Express server, and to use Exegesis for handling OpenAPI requests. Normally in an Express application we'd use a Router object to handle routes, but Exegesis can handle all the routes corresponding to the OpenAPI spec.

This function is modified from the tutorial sample offered by the Exegesis team:

async function createServer() {
    // See https://github.com/exegesis-js/exegesis/blob/master/docs/Options.md
    const options = {
        controllers: path.resolve(__dirname, 'controllers'),
        allowMissingControllers: false,
    };

    // This creates an exegesis middleware, which can be used with express,
    // connect, or even just by itself.
    const exegesisMiddleware = await exegesisExpress.middleware(
        path.resolve(__dirname, '../todo.yml'),
        options
    );

    const app: express.Express = express();

    // If you have any body parsers, this should go before them.
    app.use(exegesisMiddleware);

    app.use(bodyParser.json());
    app.use(bodyParser.urlencoded({
        extended: true
    }));
    app.use(cookieParser('keyboard mouse'));

    // Return a 404
    app.use((req, res) => {
        res.status(404).json({ message: `Not found` });
    });

    // Handle any unexpected errors
    app.use((err, req, res, next) => {
        res.status(500).json({ message: `Internal error: ${err.message}` });
    });

    const server = http.createServer(app);

    return server;
}

The options object, as the name implies, configures Exegesis. In this case we configure a directory in which to look for controller modules that contain functions corresponding to operations. It will turn around and invoke require('moduleName') to load the module, then invoke MODULE['operationID'] to handle the operation. Therefore, to load it using require the controller modules must be in CommonJS format.

Calling exegesisExpress.middleware generates a middleware function to hand to Express. It contains the OpenAPI spec, plus the options object. We then pass this function to Express using app.use.

The rest of this function is typical setup of an Express application. Notice that you can use other Express middleware alongside Exegesis. For error handling, there is a catch-all handler that will execute for any requests that are not otherwise handled, and there is an error handler configured. To start the server running, of course you call server.listen().

In lib/controllers create a file named todoController.ts containing the following:

import * as util from 'util';

export function createTodo(context) {
    return { message: `Hello createTodo ${util.inspect(context.params)}` };
}

export function listTodos(context) {
    return { message: `Hello listTodos ${util.inspect(context.params)}` };
}

export function getTodoById(context) {
    return { message: `Hello getTodoById ${util.inspect(context.params)}` };
}

export function patchTodoById(context) {
    return { message: `Hello patchTodoById ${util.inspect(context.params)}` };
}

export function deleteTodo(context) {
    return { message: `Hello deleteTodo ${util.inspect(context.params)}` };
}

These function names match the operationID values in todo.yml. In other words, these functions will be executed for requests matching the corresponding entries in the specification.

The result is that you can create an OpenAPI server via configuring an Express application, then creating these operation handler functions.

The context object is specific to Exegesis, and it contains all information about the request.

To ease development, in package.json use these scripts:

"scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "serve": "node ./dist/index.js",
    "monitor": "nodemon --config nodemon.json dist/index.js"
},

The build and watch scripts handle building the source code into the dist directory. The serve script will start the server, while the monitor script uses nodemon so that the server will automatically restart whenever the code is rebuilt. Unit tests can be automatically executed using nodemon as well.

Once the server is running you can exercise it as so:

$ curl -f http://localhost:3000/todos/all
{"message":"Hello listTodos { 
     query: {}, header: {}, server: {}, path: {}, cookie: {} 
}"}
$ curl -f http://localhost:3000/todos/111
{"message":"Hello getTodoById { 
     query: {}, header: {}, server: {}, path: { id: '111' }, cookie: {} 
}","complete":false}
$ curl -X DELETE -f http://localhost:3000/todos/111
{"message":"Hello deleteTodo { 
     query: {}, header: {}, server: {}, path: { id: '111' }, cookie: {} 
}"}

The difference between getTodoById and deleteTodo is that the latter is an HTTP DELETE request. We invoke that by passing -X DELETE on the CURL command line.

Exegesis is therefore an attractive choice because we are free to fully customize the Express application to serve other purposes in addition to the API.

Creating an OpenAPI server using OpenAPI-Backend

The next too for creating REST servers using OpenAPI uses a similar approach. With openapi-backend we give an OpenAPI specification to the package, and it handles requests for us. We then provide some handler functions which are called by the framework.

Setup a Node.js project directory by installing these packages

$ npm init -y
$ npm install express openapi-backend cookie-parser body-parser --save
$ npm install @types/cookie-parser @types/errorhandler \
    @types/express @types/method-override @types/morgan \
    @types/node typescript nodemon --save-dev

This is a modestly useful set of packages for a typical ExpressJS server. As of this writing, Express v4 is still current, but Express v5 is in Beta 1. The code shown below is therefore written against Express v4. The setup is similar to what we did for Exegesis, with a different package name.

With openapi-backend it is not required to modify todo.yml, because it does not use any vendor extensions.

Like with Exegesis we supply handler functions to openapi-backend. When the framework matches a request, it invokes the handler function.

For TypeScript configuration the same tsconfig.json can be used, but with one addition:

    "esModuleInterop": true,

This improves interoperability between ES6 and CommonJS modules.

In the lib directory create a file named index.ts. Because there are few handler functions to implement, we'll be able to fit everything into one source file.

import 'source-map-support/register';
import OpenAPIBackend from 'openapi-backend';
import Express from 'express';
import morgan from 'morgan';

import type { Request } from 'openapi-backend';

const app = Express();
app.use(Express.json());

// create api with your definition file or object
const api = new OpenAPIBackend({ 
    definition: '../../todo.yml',
});

// register your framework specific request handlers here
api.register({
    createTodo: (c, req, res) => {
        return res.status(200).json({
            operationId: `createTodo`,
            method: c.request.method,
            path: c.request.path,
            params: c.request.params,
            headers: c.request.headers,
            query: c.request.query,
            body: c.request.body,
            requestBody: c.request.requestBody,
            result: 'ok'
        });
    },
    listTodos: (c, req, res) => {
        return res.status(200).json({
            operationId: `listTodos`,
            // Ditto
            result: 'ok'
        });
    },
    getTodoById: (c, req, res) => {
        return res.status(200).json({
            operationId: `getTodoById`,
            // Ditto
            result: 'ok'
        });
    },
    patchTodoById: (c, req, res) => {
        return res.status(200).json({
            operationId: `patchTodoById`,
            // Ditto
            result: 'ok'
        });
    },
    deleteTodo: (c, req, res) => {
        return res.status(200).json({
            operationId: `deleteTodo`,
            // Ditto
            result: 'ok' 
        });
    },
    validationFail: (c, req, res) => res.status(400).json({ err: c.validation.errors }),
    notFound: (c, req, res) => res.status(404).json({ err: 'not found' }),
});

// initalize the backend
api.init();

// logging
app.use(morgan('combined'));

// use as express middleware
app.use((req, res) => api.handleRequest(req as Request, req, res));

// start server
app.listen(9000, () => console.info('api listening at http://localhost:9000'));

This is a normal Express application setup. The OpenAPIBackend object consumes an OpenAPI specification, and contains a number of useful functions. One of those functions, handleRequest, is how inbound requests are matched and handled.

Think about the line marked use as express middleware. Technically, (expressjs.com) according to the Express documentation on Middleware, this is not middleware usage, but that's being nitpicky. Middleware functions have three parameter, the third being next, and next is called to pass the request further down the chain. To continue being nitpicky, this is a catch-all route handler, meaning all inbound requests are handled by this line. Now that I've expressed that piece of nitpickiness, let's move on.

We pass to api.register an object containing what are called Operation Handlers. The name expresses what they do, because they are handler functions for operation's you declare in the OpenAPI spec. These functions receive three arguments, a context as well as the normal request and response objects. The context object contains a lot of useful data about the request.

You'll also notice two additional handler functions. There are several special handlers to cover error handling requirements:

  • validationFail: Gets called when input data does not validate against the specification.
  • notFound: Gets called if the path does not match anything in the specification.
  • methodNotAllowed: Gets called if the request method (GET, POST, PATCH, etc) does not match the operations allowed in the specification for that path.
  • notImplemented: Gets called if no operation handler has been registered for the operation ID.
  • unauthorizedHandler: Gets called if security checks failed.
  • postResponseHandler: Post-processes responses, and allows you to check whether the response is valid.

Think about how this is structured, and how your Express app might handle other routes than ones listed in the specification. Clearly, for those other routes to be recognized, they should be attached prior to adding api.handleRequest. Another approach is that a OpenAPIBackend options object has a parameter, apiRoot, for setting the root of the tree handled by the API.

There's much more you can do with the OpenAPIBackend object, so it's worthwhile to read the documentation.

$ curl -f http://localhost:9000/todos/all
{"operationId":"listTodos","method":"get","path":"/todos/all",
  "params":{},"headers":{"host":"localhost:9000",
  "user-agent":"curl/7.81.0","accept":"*/*"},
  "query":{},"body":{},"requestBody":{},"result":"ok"}

$ curl -X PATCH -f http://localhost:9000/todos/111
{"operationId":"patchTodoById","method":"patch","path":"/todos/111",
  "params":{"id":"111"},"headers":{"host":"localhost:9000","
  user-agent":"curl/7.81.0","accept":"*/*"},
  "query":{},"body":{},"requestBody":{},"result":"ok"}

$ curl -X DELETE -f http://localhost:9000/todos/111
{"operationId":"deleteTodo","method":"delete","path":"/todos/111",
  "params":{"id":"111"},"headers":{"host":"localhost:9000",
  "user-agent":"curl/7.81.0","accept":"*/*"},
  "query":{},"body":{},"requestBody":{},"result":"ok"}

$ curl -X PATCH -f http://localhost:9000/todoz
curl: (22) The requested URL returned error: 404

These are a few example queries against the server. These prove that we're executing the operation handler functions, as expected.

Like Exegesis, OpenAPI-Backend is attractive because we can fully customize the Express application.

Creating an OpenAPI server using Swagger-Codegen-v3 and OAS3-Tools

Smart Bear, the creator of the Swagger specification and tools, created one named Swagger Codegen. It handles generating code from OpenAPI specifications, and does so for several languages. The generated code for Node.js/Express uses OAS3-Tools

Neither of these are well documented, so the following is gleaned from experimentation and reading the source code.

For example, running it this way generates code for Node.js/Express from an OpenAPI 3 specification:

$ docker run --rm -v ${PWD}:/local swaggerapi/swagger-codegen-cli-v3  \
    generate \
    -i /local/todo.yml \
    -l nodejs-server \
    -o /local/servers/swagger-codegen

This mounts the current directory as /local in a Docker container which contains version 3.x of Swagger Codegen. The command line says to generate code from todo.yml, using the nodejs-server language, to the named output directory.

That's easy enough. But, studying the generated code there does not seem to be a way to avoid overwriting customizations if we regenerate the code. It seems we're expected to treat this as a method of generating a starter project. But, I noticed that this simply uses the oas3-tools package, and it's easy enough to implement a server using that package. So, instead of further exploring Swagger Codegen, let's move on to oas3-tools.

OAS3-Tools is described as being forked from apigee-127/swagger-tools in order to implement OpenAPI3. There isn't much documentation, but there is example code which is straight-forward. Unfortunately, implementing a server in TypeScript using OAS3-Tools ran into multiple problems with type definitions. It is therefore not recommended to use OAS3-Tools with TypeScript.

Let's examine the code generated for the TODO service:

var path = require('path');
var http = require('http');

var oas3Tools = require('oas3-tools');
var serverPort = 8080;

// swaggerRouter configuration
var options = {
    routing: {
        controllers: path.join(__dirname, './controllers')
    },
};

var expressAppConfig = oas3Tools.expressAppConfig(path.join(__dirname, 'api/openapi.yaml'), options);
var app = expressAppConfig.getApp();

// Initialize the Swagger middleware
http.createServer(app).listen(serverPort, function () {
    console.log('Your server is listening on port %d (http://localhost:%d)', serverPort, serverPort);
    console.log('Swagger-ui is available on http://localhost:%d/docs', serverPort);
});

The file todo.yml was copied into the generated source tree as api/openapi.yaml. Several vendor extensions are used in this file.

The options object is used to configure OAS3-Tools. There are a lot more fields available, and it can configure Swagger UI or data validation, among other things. The controllers setting here gives a directory that is to be searched for modules containing operation handler functions.

The functions oas3Tools.expressAppConfig and expressAppConfig.getApp consume the settings in todo.yml and produce an Express application. The only ability to customize the Express application is via the options object.

In the controllers directory a file named Default.js was created. Among the vendor extensions in openapi.yaml is one referring to Default. This file contains operation handler functions:

module.exports.createTodo = function createTodo (req, res, next, body) {
  ...
};

module.exports.deleteTodo = function deleteTodo (req, res, next, id) {
  ...
};

module.exports.getTodoById = function getTodoById (req, res, next, id) {
  ...
};

module.exports.listTodos = function listTodos (req, res, next) {
  ...
};

module.exports.patchTodoById = function patchTodoById (req, res, next, body, id) {
  ...
};

These are clearly meant to be Express middleware functions, but there are additional arguments, because the signatures mostly match Express middleware. The lack of OAS3-Tools documentation means we do not know how to determine which handler functions require additional arguments, nor what those additional arguments would be.

Any iteration of the OpenAPI spec will overwrite this file. That means any customization you do to this file will be lost when you regenerate the source code. The default implementation does invoke code in another file, DefaultService.js, but that file is also overwritten when regenerating source code.

In other words, we cannot recommend using Swagger Codegen, because it overwrites source code that you are likely to customize.

OAS3-Tools, however, can be recommended. It is an easy-to-use framework. Unfortunately, as just discussed, the method signatures are not documented. That will make it hard to create the operation handler functions.

Another problem with using OAS3-Tools is that the options object is not defined in a way that it was easy to use from TypeScript. Not all of us use TypeScript, but in my opinion it is highly preferable over straight JavaScript. The options object is defined in a way where TypeScript demands that we fill in every field, whereas most of the fields are clearly optional.

A big problem with OAS3-Tools is that you are not in control of configuring the Express application object. You're out of luck if your needs are not met with its preconfigured application.

In other words, this is an interesting tool, but we cannot recommend Swagger-Condegen. OAS3-Tools is more interesting, but is still not usable. Two easy fixes for the OAS3-Tools team is to add documentation, and to define the options object such that it is easy to use from TypeScript.

Ditto for OpenAPI-Generator

The OpenAPI-Generator project is large, covering code generation for OpenAPI specifications on both client and server in a long list of languages. It looks like an interesting project, that could be very useful. But an initial trial run shows many of the problems just described with OAS3-Tools.

Github: (github.com) https://github.com/OpenAPITools/openapi-generator

This tool is implemented in Java, and the best way to execute it seems to be with the Docker container as so:

$ docker run --rm -v "${PWD}:/local" openapitools/openapi-generator-cli \
      generate \
      -i /local/todo.yml \
      -o /local/servers/openapi-generator \
      -g nodejs-express-server

This usage is very similar to Swagger-Codegen tool.

The code which is generated is more extensive than with Swagger-Codegen. But, it has the same issue as just mentioned. Some of the files it generates will be desirable to customize, but will be overwritten when generating new code.

This tool has a long list of options, and perhaps there is a solution to this problem among those options.

Summary

The packages shown here were found by reviewing many pages of search results on the npmjs.com website for OpenAPI packages. They are the most promising for the purpose of developing servers from an OpenAPI specification.

Some other packages let you write a server implementation, and use tags in JSDOC comments, or using TypeScript decorators, describing API particulars. Tools provided by these packages are then used to generate the OpenAPI specification. The idea is saving you the effort of creating the specification yourself, since creating a large OpenAPI specification is a significant undertaking.

Of these, the most promising are OpenAPI-Backend and Exegesis. Because they do not generate code, they will not overwrite files you have customized. Instead, you create an implementation, and the framework internally configures itself based on the OpenAPI specificaton. For development this is very convenient.

About the Author(s)

(davidherron.com) David Herron : David Herron is a writer and software engineer focusing on the wise use of technology. He is especially interested in clean energy technologies like solar power, wind power, and electric cars. David worked for nearly 30 years in Silicon Valley on software ranging from electronic mail systems, to video streaming, to the Java programming language, and has published several books on Node.js programming and electric vehicles.