diff --git a/.changeset/strange-chefs-refuse.md b/.changeset/strange-chefs-refuse.md new file mode 100644 index 0000000000..dff4523f38 --- /dev/null +++ b/.changeset/strange-chefs-refuse.md @@ -0,0 +1,5 @@ +--- +"@medusajs/framework": patch +--- + +feat: add middleware and routes sorter diff --git a/packages/core/framework/src/http/__tests__/routes-sorter.spec.ts b/packages/core/framework/src/http/__tests__/routes-sorter.spec.ts new file mode 100644 index 0000000000..cec010fb57 --- /dev/null +++ b/packages/core/framework/src/http/__tests__/routes-sorter.spec.ts @@ -0,0 +1,289 @@ +import { RoutesSorter } from "../routes-sorter" + +describe("Routes sorter", () => { + test("sort the given routes", () => { + const sorter = new RoutesSorter([ + { + matcher: "/v1", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/products", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/products", + methods: ["GET"], + isAppRoute: true, + handler: () => {}, + }, + { + matcher: "/admin/products/export", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/products", + isAppRoute: true, + methods: ["POST"], + handler: () => {}, + }, + { + matcher: "/admin/products/:id", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/products/batch", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/products/*", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/(products|collections)", + methods: ["POST"], + handler: () => {}, + }, + { + matcher: "/admin/*", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/:id/export", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/*/export", + methods: ["GET"], + handler: () => {}, + }, + ]) + + expect(sorter.sort()).toMatchInlineSnapshot(` + [ + { + "handler": [Function], + "matcher": "/v1", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "matcher": "/admin/*", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "matcher": "/admin/*/export", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "matcher": "/admin/(products|collections)", + "methods": [ + "POST", + ], + }, + { + "handler": [Function], + "matcher": "/admin/products", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "isAppRoute": true, + "matcher": "/admin/products", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "isAppRoute": true, + "matcher": "/admin/products", + "methods": [ + "POST", + ], + }, + { + "handler": [Function], + "matcher": "/admin/products/*", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "matcher": "/admin/products/export", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "matcher": "/admin/products/batch", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "matcher": "/admin/products/:id", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "matcher": "/admin/:id/export", + "methods": [ + "GET", + ], + }, + ] + `) + }) + + test("handle all regex based routes", () => { + const sorter = new RoutesSorter([ + { + matcher: "/admin/:id/export", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/*/export", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/products", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/pr*ts", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/product(collection)?s", + methods: ["GET"], + handler: () => {}, + }, + ]) + + expect(sorter.sort()).toMatchInlineSnapshot(` + [ + { + "handler": [Function], + "matcher": "/admin/*/export", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "matcher": "/admin/pr*ts", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "matcher": "/admin/product(collection)?s", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "matcher": "/admin/products", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "matcher": "/admin/:id/export", + "methods": [ + "GET", + ], + }, + ] + `) + }) + + test("handle routes with multiple params", () => { + const sorter = new RoutesSorter([ + { + matcher: "/admin/customers/:id/addresses", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/customers/:id", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/customers/:id/addresses/:addressId/switch", + methods: ["GET"], + handler: () => {}, + }, + { + matcher: "/admin/customers/:id/addresses/:addressId", + methods: ["GET"], + handler: () => {}, + }, + ]) + + expect(sorter.sort()).toMatchInlineSnapshot(` + [ + { + "handler": [Function], + "matcher": "/admin/customers/:id", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "matcher": "/admin/customers/:id/addresses", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "matcher": "/admin/customers/:id/addresses/:addressId", + "methods": [ + "GET", + ], + }, + { + "handler": [Function], + "matcher": "/admin/customers/:id/addresses/:addressId/switch", + "methods": [ + "GET", + ], + }, + ] + `) + }) +}) diff --git a/packages/core/framework/src/http/routes-sorter.ts b/packages/core/framework/src/http/routes-sorter.ts new file mode 100644 index 0000000000..4d86574fa8 --- /dev/null +++ b/packages/core/framework/src/http/routes-sorter.ts @@ -0,0 +1,263 @@ +import { MiddlewareVerb } from "./types" + +/** + * Route represents both the middleware/routes defined via the + * "defineMiddlewares" method and the routes scanned from + * the filesystem. The later one's must be marked with "isAppRoute = true". + */ +type Route = { + /** + * The route path. + */ + matcher: string + + /** + * Function to handle the request + */ + handler?: any + + /** + * Must be true when the route is discovered via the fileystem + * scanning. + */ + isAppRoute?: boolean + + /** + * The HTTP methods this route is supposed to handle. + */ + methods?: MiddlewareVerb[] +} + +/** + * RoutesBranch represents a branch in a tree of routes. All routes are + * part of a node in a branch. Following is the list of nodes a branch + * can have. + * + * - global: Global routes for the current branch + * - regex: Routes using Regex patterns for the current branch + * - wildcard: Routes using the wildcard character for the current branch + * - params: Routes using params for the current branch + * - static: Routes with no dynamic segments for the current branch + * + * We separate routes under these nodes, so that we can register them with + * their priority. Priorities are as follows: + * + * - global + * - regex + * - wildcard + * - static + * - params + */ +type RoutesBranch = { + global: { + routes: Route[] + children?: { + [segment: string]: RoutesBranch + } + } + + regex: { + routes: Route[] + children?: { + [segment: string]: RoutesBranch + } + } + + wildcard: { + routes: Route[] + children?: { + [segment: string]: RoutesBranch + } + } + + params: { + routes: Route[] + children?: { + [segment: string]: RoutesBranch + } + } + + static: { + routes: Route[] + children?: { + [segment: string]: RoutesBranch + } + } +} + +/** + * RoutesSorter exposes the API to sort middleware and inApp route handlers + * by their priorities. The class converts an array of matchers to a tree + * like structure and then sort them back to a flat array based upon the + * priorities of different types of nodes. + */ +export class RoutesSorter { + /** + * Input routes + */ + #routesToProcess: Route[] + + /** + * Intermediate tree representation for sorting routes + */ + #routesTree: { + [segment: string]: RoutesBranch + } = { + root: this.#createBranch(), + } + + constructor(routes: Route[]) { + this.#routesToProcess = routes + console.log("Processing routes", this.#routesToProcess) + } + + /** + * Creates an empty branch with known nodes + */ + #createBranch(): RoutesBranch { + return { + global: { + routes: [], + }, + regex: { + routes: [], + }, + wildcard: { + routes: [], + }, + static: { + routes: [], + }, + params: { + routes: [], + }, + } + } + + /** + * Processes the route by splitting it with a "/" and then placing + * each segment into a specific node of a specific branch. + * + * The tree structure for a "/admin/products/:id" will look as follows + * + * ``` + * root: { + * static: { + * routes: [], + * children: { + * admin: { + * static: { + * routes: [], + * children: { + * products: { + * routes: [], + * params: { + * routes: [{ matcher: '/admin/products/:id', ... }] + * } + * } + * } + * } + * } + * } + * } + * } + * ``` + */ + #processRoute(route: Route) { + const segments = route.matcher.split("/").filter((s) => s.length) + let parent = this.#routesTree["root"] + + segments.forEach((segment, index) => { + let bucket: keyof RoutesBranch = "static" + + if (!route.methods) { + bucket = "global" + } else if (segment.startsWith("*")) { + bucket = "wildcard" + } else if (segment.startsWith(":")) { + bucket = "params" + } else if (/[\(\+\*\[\]\)\!]/.test(segment)) { + bucket = "regex" + } + + if (index + 1 === segments.length) { + parent[bucket].routes.push(route) + return + } + + parent[bucket].children = parent[bucket].children ?? {} + parent[bucket].children![segment] = + parent[bucket].children![segment] ?? this.#createBranch() + parent = parent[bucket].children![segment] + }) + } + + /** + * Returns an array of sorted routes for a given branch. + */ + #sortBranch(routeBranch: { [segment: string]: RoutesBranch }) { + const branchRoutes = Object.keys(routeBranch).reduce<{ + global: Route[] + wildcard: Route[] + regex: Route[] + params: Route[] + static: Route[] + }>( + (result, branchKey) => { + const node = routeBranch[branchKey] + + result.global.push(...node.global.routes) + if (node.global.children) { + result.global.push(...this.#sortBranch(node.global.children)) + } + + result.wildcard.push(...node.wildcard.routes) + if (node.wildcard.children) { + result.wildcard.push(...this.#sortBranch(node.wildcard.children)) + } + + result.regex.push(...node.regex.routes) + if (node.regex.children) { + result.regex.push(...this.#sortBranch(node.regex.children)) + } + + result.static.push(...node.static.routes) + if (node.static.children) { + result.static.push(...this.#sortBranch(node.static.children)) + } + + result.params.push(...node.params.routes) + if (node.params.children) { + result.params.push(...this.#sortBranch(node.params.children)) + } + + return result + }, + { + global: [], + wildcard: [], + regex: [], + params: [], + static: [], + } + ) + + /** + * Concatenating routes as per their priority. + */ + const routes: Route[] = branchRoutes.global + .concat(branchRoutes.wildcard) + .concat(branchRoutes.regex) + .concat(branchRoutes.static) + .concat(branchRoutes.params) + return routes + } + + /** + * Sort the input routes + */ + sort() { + this.#routesToProcess.map((route) => this.#processRoute(route)) + return this.#sortBranch(this.#routesTree) + } +}