Express server with OpenAPI specification with ES6+ on Windows doesn't accept nested routes

66 Views Asked by At

A couple of weeks ago I finally had the time to start experimenting with NodeJS API building and after some research decided to go with a combination of Express + Sequelize + PostgresSql + OpenAPI. At this time I skipped using TypeScript and opted for going with JS ES6+. After reading official documentation for the OpenAPI Specification and the npm package I'm using (express-openapi), I've been going back and forward from openapi 3.x to swagger 2.0 and switching the api-doc from JSON to yml but in the end I always get stuck at the same point with this error:

SyntaxError: Invalid regular expression: /^\/v2\/users\(?:([^\/]+?))\/?$/i: Unmatched ')'
    at new RegExp (<anonymous>)
    at pathtoRegexp (...\express-sequelize-openapi\node_modules\path-to-regexp\index.js:128:10)
    at new Layer (...\express-sequelize-openapi\node_modules\express\lib\router\layer.js:45:17)
    at Function.route (...\express-sequelize-openapi\node_modules\express\lib\router\index.js:505:15)
    at app.<computed> [as get] (...\express-sequelize-openapi\node_modules\express\lib\application.js:498:30)
    at Object.visitOperation (...\express-sequelize-openapi\node_modules\express-openapi\dist\index.js:131:33)
    at ...\express-sequelize-openapi\node_modules\openapi-framework\dist\index.js:370:29
    at Set.forEach (<anonymous>)
    at ...\express-sequelize-openapi\node_modules\openapi-framework\dist\index.js:233:100
    at Array.forEach (<anonymous>)

These are some of the dependencies I'm using with the current versions on my package.json:

    "body-parser": "^1.20.2",
    "cors": "^2.8.5",
    "express": "^4.19.1",
    "express-openapi": "^12.1.3",
    "helmet": "^7.1.0",
    "pg": "^8.11.3",
    "pg-hstore": "^2.3.4",
    "sequelize": "^6.37.1",
    "sequelize-cli": "^6.6.2",
    "swagger-ui-express": "^5.0.0",

Then this is the content of app.js:

import express from "express";
import bodyParser from "body-parser";
import cors from "cors";
import helmet from "helmet";
import path from "path";
import swaggerUi from "swagger-ui-express";
import { initialize as initializeOpenApi } from "express-openapi";
import { fileURLToPath } from "url";
import v1ApiDoc from "./doc/api-doc-v2.js";

// Defining the Express app
const app = express();

// Adding Helmet to enhance your Rest API's security
app.use(helmet());

// Using bodyParser to parse JSON bodies into JS objects
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

// Enabling CORS for all requests
app.use(cors());

const docsPath = "/openapi.json";

// OpenAPI UI
app.use("/api-documentation", swaggerUi.serve, swaggerUi.setup(v1ApiDoc));

export const startApp = async (port) => {
  // OpenAPI routes
  const openApiDoc = await initializeOpenApi({
    apiDoc: v1ApiDoc,
    app,
    docsPath,
    paths: "./api/routes/v2",
  });

  app.use(function (err, req, res, next) {
    res.status(err.status).json(err);
  });

  // Starting the server
  app.listen(port => {
      console.log(`Listening on port ${port}`);
  });
};

Here's the current api-doc v2 I've created:

const apiDoc = {
  swagger: "2.0",
  info: {
    title: "Node Express API.",
    version: "0.1.0",
  },
  basePath: "/v2",
  paths: {},
  tags: [
    {
      name: "Users",
    },
  ],
  definitions: {
    GeneralSuccess: {
      type: "object",
      properties: {
        success: {
          type: "boolean",
        },
        data: {
          type: "object",
        },
      },
    },
    GeneralError: {
      type: "object",
      properties: {
        success: {
          type: "boolean",
        },
        error: {
          type: "object",
          properties: {
            message: {
              type: "string",
            },
            error: {
              type: "object",
            },
          },
        },
      },
    },
    User: {
      required: ["id"],
      type: "object",
      properties: {
        id: {
          type: "string",
          description: "User's unique identifier",
        },
        email: {
          type: "string",
          description: "User's email address",
        },
        password: {
          type: "string",
          description: "User's password",
        },
        firstName: {
          type: "string",
          description: "User's firstname",
        },
        lastName: {
          type: "string",
          description: "User's lastname",
        },
      },
    },
  },
  responses: {
    200: {
      // Success
      description: "Successful request.",
      schema: {
        $ref: "#/definitions/GeneralSuccess",
      },
    },
    404: {
      // Not Found
      description: "Entity not found.",
      schema: {
        $ref: "#/definitions/GeneralError",
      },
    },
    422: {
      // Illegal Input
      description: "Illegal input for operation.",
      schema: {
        $ref: "#/definitions/GeneralError",
      },
    },
    500: {
      // Internal Server Error
      description: "An unexpected error occurred",
      schema: {
        $ref: "#/definitions/GeneralError",
      },
    },
    default: {
      description: "Unexpected error",
      schema: {
        $ref: "#/definitions/GeneralError",
      },
    },
  },
};

export default apiDoc;

And the routes are being specified under folder /routes/v2. I have two routes so far, users.js directly under the trunk that works (get, post) and users/{id}.js that triggers the same error every time. Content at this point is irrelevant as I copied/pasted the same code into a different file with a different name directly under v2 and it works.

So I'm getting at this point is more of an issue of configuration, some stupid detail I'm missing, or a package version conflict.

I appreciate any advice I can get to have this project running.

PS: one secondary issue, I think is a consequence, is that I'm not able to load any of the endpoints into the local SwaggerUI api-documentation page.

3

There are 3 best solutions below

1
Narretz On BEST ANSWER

This is a bug in the open-api code: https://github.com/kogosoftwarellc/open-api/issues/896

The problem is that the dependency glob after a certain version works differently, and that surfaces this bug.

Easiest solution is probably to set version 7 of glob: npm equivalent of yarn resolutions?

1
Jeremy Fiel On

I'm not sure if swagger-ui-express and express-openapi are supposed to be used together but here's a sample app serving from swagger-ui-express

index.js

const express = require('express')
const swaggerUI = require('swagger-ui-express')


const app = express()
app.use(express.json())
const PORT = process.env.PORT || 3000


const OASdescription = {

    openapi: "3.1.0",
    info: {
        title: "test api for stack",
        version: "1.0.0"
    },
    servers: [
        {
            "url": "https://localhost.com:3000/v1"
        }
    ],
    tags: [{
        "name": "test api",
        "description": "test api description"
    }],
    paths: {
        "/hello": {
            "get": {
                "description": "description",
                "tags": ["test api"],
                "operationId": "sayHello",
                "parameters": [
                    {
                        "$ref": "#/components/parameters/header_accept-language"
                    }
                ],
                "responses": {
                    "200": {
                        "description": "response",
                        "content": {
                            "application/json": {
                                "schema": {
                                    "type": "string"

                                }
                            }
                        }
                    },
                    "400": {
                        "$ref": "#/components/responses/400"
                    }
                }
            }
        }
    },
    components: {
        "parameters": {
            "header_accept-language": {
                "name": "accept-language",
                "in": "header",
                "schema": {
                    "type": "string",
                    "enum": ["en-US"],
                    "default": "en-US"
                }
            }
        },
        "responses": {
            "400": {
                "description": "Bad Request",
                "content": {
                    "application/problem+json": {
                        "schema": {
                            "description": "Problem JSON as defined in RFC9457",
                            "type": "object",
                            "properties": {
                                "type": {
                                    "description": "The \"type\" member is a JSON string containing a URI reference [URI] that identifies the problem type",
                                    "type": "string",
                                    "format": "uri-reference"
                                },
                                "status": {
                                    "description": "The \"status\" member is a JSON number indicating the HTTP status code ([HTTP], <a href=\"https://www.rfc-editor.org/rfc/rfc9110#section-15\">Section 15</a>) generated by the origin server for this occurrence of the problem.",
                                    "type": "number"
                                },
                                "title": {
                                    "description": "The \"title\" member is a JSON string containing a short, human-readable summary of the problem type.",
                                    "type": "string"
                                },
                                "detail": {
                                    "description": "The \"detail\" member is a JSON string containing a human-readable explanation specific to this occurrence of the problem.",
                                    "type": "string"
                                },
                                "instance": {
                                    "description": "The \"instance\" member is a JSON string containing a URI reference that identifies the specific occurrence of the problem.",
                                    "type": "string",
                                    "format": "uri-reference"
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}


app.use('/docs', swaggerUI.serve, swaggerUI.setup(OASdescription))


app.listen(PORT, () => console.log(`listening on port ${PORT}`))

pacakage.json

{
    "dependencies": {
        "swagger-ui-express": "^5.0.0",
        "express": "^4.18.2"
    }
}
2
djbowen95 On

I have two routes so far, users.js directly under the trunk that works (get, post) and users/{id}.js that triggers the same error every time.

Assuming these - /users.js and /users/{id}.js are the filenames for your modules - this is likely your problem. Your application simply will not parse a filename with special characters ({ }) in it.

Many operating systems - ie. Windows - do support filenames with special characters, but it's best practice to avoid them (anything except letters, numbers and -/_) unless you have a really good reason why.

https://www.mtu.edu/umc/services/websites/writing/characters-avoid/

The error you are recieving is because Open API has a security feature utility that uses regular expression pattern matching (RegEx) to make sure the path name fits rules. I didn't go through every line, but I think it's the one called at line 170 not happy with you.

https://github.com/kogosoftwarellc/open-api/blob/main/packages/openapi-framework/index.ts

https://github.com/kogosoftwarellc/open-api/blob/main/packages/openapi-framework/src/util.ts

I'm wondering if perhaps - if you're new to both modular ES6+ and to Node/Express for RESTful API development in general - whether you've confused the path in the file system, with the URL path defined in your HTTP request method. Curly braces are used here in the path - but it's in the code itself - and it's used to indicate a parameter.

https://swagger.io/docs/specification/basic-structure/#parameters

The curly braces here indicate / stand in for a variable parameter. If you've done some string interpolation you should should recognize this ES6+ syntax - ie. it's how you add something stored in a variable into a string. The curly braces are not being sent as part of every HTTP request, they represent a replaceable part of the string being sent and parsed that varies each time; this essentially tells Express / Open API where to look for the user's 'id'.

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals

I hope this sorts your problem - if you just rename the file it might start working! But as a tiny last point -

It's great to break things in ES6+ down into modules - it makes the code more navigable, and it often is good practice to separate out your routes (ie. user routes, product routes) - but a user route with a parameter (ie. users/{id}) is generally still considered a user route. It isn't really necessary to have one module for your GET/POST requests on all users, and a separate one for the GET/POST requests that look up a single user. You are still querying the same table in your database, the same data overall, even if you are selecting differently from it.

Here's an example:

https://medium.com/@zachcaceres/child-routers-in-express-56f904597b1b

So while you could rename your users/{id} file, you might be better just moving the code into your users file and exporting it all together.

Anyway, hope this is all helpful! First time I've done an answer here, and not sure if it's overkill with the information!