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:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@medusajs/framework": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
feat: add routes loader
|
||||||
+7
@@ -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
|
||||||
+11
@@ -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
|
||||||
+7
@@ -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))
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { RoutesSorter } from "../routes-sorter"
|
import { RoutesSorter } from "../routes-sorter"
|
||||||
|
|
||||||
describe("Routes sorter", () => {
|
describe("Routes sorter", () => {
|
||||||
test("sort the given routes", () => {
|
it("should sort the given routes", () => {
|
||||||
const sorter = new RoutesSorter([
|
const sorter = new RoutesSorter([
|
||||||
{
|
{
|
||||||
matcher: "/v1",
|
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([
|
const sorter = new RoutesSorter([
|
||||||
{
|
{
|
||||||
matcher: "/admin/:id/export",
|
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([
|
const sorter = new RoutesSorter([
|
||||||
{
|
{
|
||||||
matcher: "/admin/customers/:id/addresses",
|
matcher: "/admin/customers/:id/addresses",
|
||||||
|
|||||||
@@ -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
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,6 +86,31 @@ export type RouteDescriptor = {
|
|||||||
config?: RouteConfig
|
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 = {
|
export type GlobalMiddlewareDescriptor = {
|
||||||
config?: MiddlewaresConfig
|
config?: MiddlewaresConfig
|
||||||
}
|
}
|
||||||
@@ -111,7 +136,7 @@ export interface MedusaRequest<
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* An object containing fields and variables to be used with the remoteQuery
|
* An object containing fields and variables to be used with the remoteQuery
|
||||||
*
|
*
|
||||||
* @version 2.2.0
|
* @version 2.2.0
|
||||||
*/
|
*/
|
||||||
queryConfig: {
|
queryConfig: {
|
||||||
|
|||||||
Reference in New Issue
Block a user