Choosing an Unreleased API Over the One Already There
express-openapi is Wesley Todd’s library, not mine. It generates an OpenAPI document from an Express app by reading the routes the app actually registered, which beats maintaining a spec by hand and hoping it still matches the code.
I opened a pull request to add Express 5 support, and I had hoped it would be small. It came to +244/-259 across eight files. That was not the mistake. The mistake came later and took two lines.
How the routes are found
Express does not offer a list of registered routes. It has a router, the router has a stack of layers, and each layer holds a handler plus a compiled regular expression for the path it matches. The library walks that stack directly.
Walking is the easy half; the hard half is that a layer knows its path as a regular expression and an OpenAPI document needs it as a string. There is no inverse for “compile this path”, so the library recovers the path by stringifying the regular expression and deleting the parts the router put in:
const match = thing
.toString()
.replace('\\/?', '')
.replace('(?=\\/|$)', '$')
// Added this line to catch the express v5 case after the v4 part is stripped off
.replace('(?:\\/(?=$))?$', '$')
.match(/^\/\^((?:\\[.*+?^${}()|[\]\\/]|[^.*+?^${}()|[\]\\/])*)\$\//)
That comment is not mine and it predates my patch, which tells you this had already caught someone. Express 5 compiles paths differently again, so the tests that pinned particular regular expressions to particular strings had nothing left to say. That is why test/_regexRoutes.js lost 117 lines instead of gaining a case, and why lib/generate-doc.js came to +87/-122.
The line that needed an API that did not exist
Rewriting the walk left one problem. When a sub-router is mounted, the document needs the prefix it was mounted at, and my rewrite asked the router for it:
const p = router.getRoutes()[0].path.split('/')[1]
getRoutes() is not part of the router. It is pillarjs/router#174, an open pull request by bjohansebas that adds a method for listing registered routes, which is exactly what a consumer needs so that nobody has to un-compile a regular expression again. It is the right feature and I wanted it.
So my package.json pointed at a branch:
"router": "github:bjohansebas/router#maproutes"
and I said in the thread, on the day I opened it, that this should not merge until getRoutes() was released. That was August 2025.
Four days later I did not need it
On 2 September I replaced that call with a property the layer was already carrying:
- const p = router.getRoutes()[0].path.split('/')[1]
+ const p = routeLayer.pathPatterns
- "router": "github:bjohansebas/router#maproutes",
+ "router": "^1.3.8",
Two lines, and the dependency on an unreleased feature was gone. The commit is titled “remove dependency on the yet to be released feature getRoutes()”. It passed CI on Node 20, 22 and 24.
Twenty minutes after the last of those pushes, I force-pushed the branch back to the version that calls getRoutes(), and that is what the pull request contains today.
Why I put it back
Two reasons, and I still think the first one is right.
routeLayer.pathPatterns is another internal. It is an undocumented property on a layer object, subject to exactly the same drift as the regular expression I had just finished being burned by. getRoutes() is a public method whose entire purpose is to answer this question. Given a choice between reading one more private field and calling an API designed for the job, the API is the better code, and it is still the better code now.
The second reason was an estimate, and the estimate was wrong. I assumed getRoutes() was weeks away. It was opened in July 2025, it is +350/-3, it carries twenty-nine reviews, and it is still open. None of that is unusual or anyone’s fault: adding a method to the router that sits under Express is exactly the kind of change that should collect twenty-nine reviews, and the people doing that review are doing it for free, around whatever else their lives contain. A careful API takes as long as it takes.
What I got wrong was treating someone else’s review cycle as a schedule I could plan against. I did not decide to wait a year; I decided to wait a few weeks, and then never revisited it, because a pull request that is blocked does not remind you that it is blocked.
What generalises
Choosing the cleaner interface over the available one is not really a code decision, it is a scheduling bet on people who never agreed to your schedule. That bet is often worth making. What made it expensive here is that I placed it once and never priced it again, and nothing in my setup would ever have prompted me to.
The version without the dependency still exists. It is unreferenced now, reachable only because GitHub keeps orphaned commits after a force-push, and it is green. My patch has sat still for a year on top of a two-line change that was already written, already tested, and already passing.