feat: add routes loader (#11592)

Fixes: FRMW-2919

This PR adds a new routes loader with a single responsibility of scanning the filesystem and collecting routes. Sorting of routes, merging middleware and registering them with express are going to separate implementations.

The new `RoutesLoader` class allows overriding routes as-well (not recommended though) and this is how routes are de-duplicated.

- When two routes for the exact route pattern/matcher are discovered, the routes loader will only keep the last one.

- Routes files can also override handlers for specific HTTP methods. For example, the original route file exported handlers for `GET` and `POST`, but the overriding one only defines `GET`. In that case, we will continue using the original implementation for the `POST` handler.

- If an overriding route file exports additional configuration like `export const AUTHENTICATION=false`, then this will only impact the handlers exported from this file and not the original handlers.

Routes sorting has been already been implemented in a separate PR and you can visualize it using this URL. https://routes-visualizer.fly.dev/
This commit is contained in:
Harminder Virk
2025-02-26 12:54:10 +05:30
committed by GitHub
parent b42f151be3
commit 9e2af4801d
9 changed files with 708 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
import { Request, Response } from "express"
export async function GET(req: Request, res: Response): Promise<void> {
console.log("hello world")
}
export const AUTHENTICATE = false

View File

@@ -0,0 +1,11 @@
import { Request, Response } from "express"
export async function GET(req: Request, res: Response): Promise<void> {
console.log("hello world")
}
export async function POST(req: Request, res: Response): Promise<void> {
console.log("hello world")
}
export const AUTHENTICATE = false

View File

@@ -0,0 +1,7 @@
import { Request, Response } from "express"
export function GET(req: Request, res: Response) {
/* const customerId = req.params.id;
const orderId = req.params.id;*/
res.send("list customers " + JSON.stringify(req.params))
}

View File

@@ -0,0 +1,384 @@
import { resolve } from "path"
import { RoutesLoader } from "../routes-loader"
describe("Routes loader", () => {
it("should load routes from the filesystem", async () => {
const BASE_DIR = resolve(__dirname, "../__fixtures__/routers")
const loader = new RoutesLoader({})
await loader.scanDir(BASE_DIR)
expect(loader.getRoutes()).toMatchInlineSnapshot(`
[
{
"absolutePath": "${BASE_DIR}/admin/orders/[id]/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": false,
"relativePath": "/admin/orders/[id]/route.ts",
"route": "/admin/orders/:id",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/orders/[id]/route.ts",
"handler": [Function],
"method": "POST",
"optedOutOfAuth": false,
"relativePath": "/admin/orders/[id]/route.ts",
"route": "/admin/orders/:id",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/orders/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": false,
"relativePath": "/admin/orders/route.ts",
"route": "/admin/orders",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/products/[id]/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": false,
"relativePath": "/admin/products/[id]/route.ts",
"route": "/admin/products/:id",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/products/route.ts",
"handler": [Function],
"method": "DELETE",
"optedOutOfAuth": false,
"relativePath": "/admin/products/route.ts",
"route": "/admin/products",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/products/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": false,
"relativePath": "/admin/products/route.ts",
"route": "/admin/products",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/products/route.ts",
"handler": [Function],
"method": "HEAD",
"optedOutOfAuth": false,
"relativePath": "/admin/products/route.ts",
"route": "/admin/products",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/products/route.ts",
"handler": [Function],
"method": "OPTIONS",
"optedOutOfAuth": false,
"relativePath": "/admin/products/route.ts",
"route": "/admin/products",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/products/route.ts",
"handler": [Function],
"method": "PATCH",
"optedOutOfAuth": false,
"relativePath": "/admin/products/route.ts",
"route": "/admin/products",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/products/route.ts",
"handler": [Function],
"method": "POST",
"optedOutOfAuth": false,
"relativePath": "/admin/products/route.ts",
"route": "/admin/products",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/products/route.ts",
"handler": [Function],
"method": "PUT",
"optedOutOfAuth": false,
"relativePath": "/admin/products/route.ts",
"route": "/admin/products",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": false,
"relativePath": "/admin/route.ts",
"route": "/admin",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/route.ts",
"handler": [Function],
"method": "POST",
"optedOutOfAuth": false,
"relativePath": "/admin/route.ts",
"route": "/admin",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/customers/[customer_id]/orders/[order_id]/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": false,
"relativePath": "/customers/[customer_id]/orders/[order_id]/route.ts",
"route": "/customers/:customer_id/orders/:order_id",
"shouldAppendAdminCors": false,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/customers/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": false,
"relativePath": "/customers/route.ts",
"route": "/customers",
"shouldAppendAdminCors": false,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
]
`)
})
it("should override existing routes when duplicates are found while scanning additional directories", async () => {
const BASE_DIR = resolve(__dirname, "../__fixtures__/routers")
const BASE_DIR_2 = resolve(
__dirname,
"../__fixtures__/routers-with-duplicates"
)
const loader = new RoutesLoader({})
await loader.scanDir(BASE_DIR)
await loader.scanDir(BASE_DIR_2)
expect(loader.getRoutes()).toMatchInlineSnapshot(`
[
{
"absolutePath": "${BASE_DIR}/admin/orders/[id]/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": false,
"relativePath": "/admin/orders/[id]/route.ts",
"route": "/admin/orders/:id",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/orders/[id]/route.ts",
"handler": [Function],
"method": "POST",
"optedOutOfAuth": false,
"relativePath": "/admin/orders/[id]/route.ts",
"route": "/admin/orders/:id",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/orders/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": false,
"relativePath": "/admin/orders/route.ts",
"route": "/admin/orders",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR_2}/admin/products/[id]/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": true,
"relativePath": "/admin/products/[id]/route.ts",
"route": "/admin/products/:id",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/products/route.ts",
"handler": [Function],
"method": "DELETE",
"optedOutOfAuth": false,
"relativePath": "/admin/products/route.ts",
"route": "/admin/products",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR_2}/admin/products/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": true,
"relativePath": "/admin/products/route.ts",
"route": "/admin/products",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/products/route.ts",
"handler": [Function],
"method": "HEAD",
"optedOutOfAuth": false,
"relativePath": "/admin/products/route.ts",
"route": "/admin/products",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/products/route.ts",
"handler": [Function],
"method": "OPTIONS",
"optedOutOfAuth": false,
"relativePath": "/admin/products/route.ts",
"route": "/admin/products",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/products/route.ts",
"handler": [Function],
"method": "PATCH",
"optedOutOfAuth": false,
"relativePath": "/admin/products/route.ts",
"route": "/admin/products",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR_2}/admin/products/route.ts",
"handler": [Function],
"method": "POST",
"optedOutOfAuth": true,
"relativePath": "/admin/products/route.ts",
"route": "/admin/products",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/products/route.ts",
"handler": [Function],
"method": "PUT",
"optedOutOfAuth": false,
"relativePath": "/admin/products/route.ts",
"route": "/admin/products",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": false,
"relativePath": "/admin/route.ts",
"route": "/admin",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/admin/route.ts",
"handler": [Function],
"method": "POST",
"optedOutOfAuth": false,
"relativePath": "/admin/route.ts",
"route": "/admin",
"shouldAppendAdminCors": true,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/customers/[customer_id]/orders/[order_id]/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": false,
"relativePath": "/customers/[customer_id]/orders/[order_id]/route.ts",
"route": "/customers/:customer_id/orders/:order_id",
"shouldAppendAdminCors": false,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR}/customers/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": false,
"relativePath": "/customers/route.ts",
"route": "/customers",
"shouldAppendAdminCors": false,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": false,
},
{
"absolutePath": "${BASE_DIR_2}/store/[customer_id]/orders/[order_id]/route.ts",
"handler": [Function],
"method": "GET",
"optedOutOfAuth": false,
"relativePath": "/store/[customer_id]/orders/[order_id]/route.ts",
"route": "/store/:customer_id/orders/:order_id",
"shouldAppendAdminCors": false,
"shouldAppendAuthCors": false,
"shouldAppendStoreCors": true,
},
]
`)
})
it("should throw error for duplicate params", async () => {
const BASE_DIR = resolve(
__dirname,
"../__fixtures__/routers-duplicate-parameter"
)
const loader = new RoutesLoader({})
await expect(() => loader.scanDir(BASE_DIR)).rejects.toThrow(
"Duplicate parameters found in route /admin/customers/[id]/orders/[id]/route.ts (id). Make sure that all parameters are unique."
)
})
})

View File

@@ -1,7 +1,7 @@
import { RoutesSorter } from "../routes-sorter"
describe("Routes sorter", () => {
test("sort the given routes", () => {
it("should sort the given routes", () => {
const sorter = new RoutesSorter([
{
matcher: "/v1",
@@ -159,7 +159,7 @@ describe("Routes sorter", () => {
`)
})
test("handle all regex based routes", () => {
it("should handle all regex based routes", () => {
const sorter = new RoutesSorter([
{
matcher: "/admin/:id/export",
@@ -229,7 +229,7 @@ describe("Routes sorter", () => {
`)
})
test("handle routes with multiple params", () => {
it("should handle routes with multiple params", () => {
const sorter = new RoutesSorter([
{
matcher: "/admin/customers/:id/addresses",

View File

@@ -0,0 +1,265 @@
import { join, parse, sep } from "path"
import { dynamicImport, readDirRecursive } from "@medusajs/utils"
import { logger } from "../logger"
import {
type RouteVerb,
HTTP_METHODS,
type ScannedRouteDescriptor,
type FileSystemRouteDescriptor,
} from "./types"
/**
* File name that is used to indicate that the file is a route file
*/
const ROUTE_NAME = "route"
/**
* Flag that developers can export from their route files to indicate
* whether or not the routes from this file should be authenticated.
*/
const AUTHTHENTICATION_FLAG = "AUTHENTICATE"
/**
* Flag that developers can export from their route files to indicate
* whether or not the routes from this file should implement CORS
* policy.
*/
const CORS_FLAG = "CORS"
/**
* The matcher to use to convert the dynamic params from the filesystem
* identifier to the express identifier.
*
* We capture all words under opening and closing brackets `[]` and mark
* it as a param via `:`.
*/
const PARAM_SEGMENT_MATCHER = /\[(\w+)\]/
/**
* Regexes to use to identify if a route is prefixed
* with "/admin", "/store", or "/auth".
*/
const ADMIN_ROUTE_MATCH = /(\/admin$|\/admin\/)/
const STORE_ROUTE_MATCH = /(\/store$|\/store\/)/
const AUTH_ROUTE_MATCH = /(\/auth$|\/auth\/)/
const log = ({
activityId,
message,
}: {
activityId?: string
message: string
}) => {
if (activityId) {
logger.progress(activityId, message)
return
}
logger.debug(message)
}
/**
* Exposes to API to register routes manually or by scanning the filesystem from a
* source directory.
*
* In case of duplicates routes, the route registered afterwards will override the
* one registered first.
*/
export class RoutesLoader {
/**
* Routes collected manually or by scanning directories
*/
#routes: Record<
string,
Record<string, ScannedRouteDescriptor | FileSystemRouteDescriptor>
> = {}
/**
* An eventual activity id for information tracking
*/
readonly #activityId?: string
constructor({ activityId }: { activityId?: string }) {
this.#activityId = activityId
}
/**
* Creates the route path from its relative file path.
*/
#createRoutePath(relativePath: string): string {
const segments = relativePath.replace(/route(\.js|\.ts)$/, "").split(sep)
const params: Record<string, boolean> = {}
return `/${segments
.filter((segment) => !!segment)
.map((segment) => {
if (segment.startsWith("[")) {
segment = segment.replace(PARAM_SEGMENT_MATCHER, (_, group) => {
if (params[group]) {
log({
activityId: this.#activityId,
message: `Duplicate parameters found in route ${relativePath} (${group})`,
})
throw new Error(
`Duplicate parameters found in route ${relativePath} (${group}). Make sure that all parameters are unique.`
)
}
params[group] = true
return `:${group}`
})
}
return segment
})
.join("/")}`
}
/**
* Returns the route config by exporting the route file and parsing
* its exports
*/
async #getRoutesForFile(
routePath: string,
absolutePath: string
): Promise<ScannedRouteDescriptor[]> {
const routeExports = await dynamicImport(absolutePath)
/**
* Find the route type based upon its prefix.
*/
const routeType = ADMIN_ROUTE_MATCH.test(routePath)
? "admin"
: STORE_ROUTE_MATCH.test(routePath)
? "store"
: AUTH_ROUTE_MATCH.test(routePath)
? "auth"
: undefined
/**
* Check if the route file has decided to opt-out of authentication
*/
const shouldAuthenticate =
AUTHTHENTICATION_FLAG in routeExports
? !!routeExports[AUTHTHENTICATION_FLAG]
: true
/**
* Check if the route file has decided to opt-out of CORS
*/
const shouldApplyCors =
CORS_FLAG in routeExports ? !!routeExports[CORS_FLAG] : true
/**
* Loop over all the exports and collect functions that are exported
* with names after HTTP methods.
*/
return Object.keys(routeExports)
.filter((key) => {
if (typeof routeExports[key] !== "function") {
return false
}
if (!HTTP_METHODS.includes(key as RouteVerb)) {
log({
activityId: this.#activityId,
message: `Skipping handler ${key} in ${absolutePath}. Invalid HTTP method: ${key}.`,
})
return false
}
return true
})
.map((key) => {
return {
route: routePath,
method: key as RouteVerb,
handler: routeExports[key],
optedOutOfAuth: !shouldAuthenticate,
shouldAppendAdminCors: shouldApplyCors && routeType === "admin",
shouldAppendAuthCors: shouldApplyCors && routeType === "auth",
shouldAppendStoreCors: shouldApplyCors && routeType === "store",
} satisfies ScannedRouteDescriptor
})
}
/**
* Scans a given directory and loads all routes from it. You can access the loaded
* routes via "getRoutes" method
*/
async scanDir(sourceDir: string) {
const entries = await readDirRecursive(sourceDir, {
ignoreMissing: true,
})
await Promise.all(
entries
.filter((entry) => {
if (entry.isDirectory()) {
return false
}
const { name, ext } = parse(entry.name)
if (name === ROUTE_NAME && [".js", ".ts"].includes(ext)) {
const routeFilePathSegment = join(entry.path, entry.name)
.replace(sourceDir, "")
.split(sep)
return !routeFilePathSegment.some((segment) =>
segment.startsWith("_")
)
}
return false
})
.map(async (entry) => {
const absolutePath = join(entry.path, entry.name)
const relativePath = absolutePath.replace(sourceDir, "")
const route = this.#createRoutePath(relativePath)
const routes = await this.#getRoutesForFile(route, absolutePath)
routes.forEach((routeConfig) => {
this.registerRoute({
absolutePath,
relativePath,
...routeConfig,
})
})
})
)
}
/**
* Register a route
*/
registerRoute(route: ScannedRouteDescriptor | FileSystemRouteDescriptor) {
this.#routes[route.route] = this.#routes[route.route] ?? {}
const trackedRoute = this.#routes[route.route]
trackedRoute[route.method] = route
}
/**
* Register one or more routes
*/
registerRoutes(
routes: (ScannedRouteDescriptor | FileSystemRouteDescriptor)[]
) {
routes.forEach((route) => this.registerRoute(route))
}
/**
* Returns an array of routes scanned by the routes loader or registered
* manually.
*/
getRoutes() {
return Object.keys(this.#routes).reduce<
(ScannedRouteDescriptor | FileSystemRouteDescriptor)[]
>((result, routePattern) => {
const methodsRoutes = this.#routes[routePattern]
Object.keys(methodsRoutes).forEach((method) => {
result.push(methodsRoutes[method])
})
return result
}, [])
}
}

View File

@@ -86,6 +86,31 @@ export type RouteDescriptor = {
config?: RouteConfig
}
/**
* Route descriptor refers represents a route either scanned
* from the filesystem or registered manually. It does not
* represent a middleware
*/
export type ScannedRouteDescriptor = {
route: string
method: RouteVerb
handler: RouteHandler
optedOutOfAuth: boolean
routeType?: "admin" | "store" | "auth"
shouldAppendAdminCors: boolean
shouldAppendStoreCors: boolean
shouldAppendAuthCors: boolean
}
/**
* FileSystem route description represents a route scanned from
* the filesystem
*/
export type FileSystemRouteDescriptor = ScannedRouteDescriptor & {
absolutePath: string
relativePath: string
}
export type GlobalMiddlewareDescriptor = {
config?: MiddlewaresConfig
}
@@ -111,7 +136,7 @@ export interface MedusaRequest<
/**
* An object containing fields and variables to be used with the remoteQuery
*
*
* @version 2.2.0
*/
queryConfig: {