diff --git a/.changeset/grumpy-pumas-occur.md b/.changeset/grumpy-pumas-occur.md new file mode 100644 index 0000000000..1abf923f33 --- /dev/null +++ b/.changeset/grumpy-pumas-occur.md @@ -0,0 +1,5 @@ +--- +"@medusajs/framework": patch +--- + +feat: add middleware-file-loader diff --git a/packages/core/framework/src/http/__fixtures__/routers-middleware/middlewares.ts b/packages/core/framework/src/http/__fixtures__/routers-middleware/middlewares.ts index e6d8b26d2e..47d3632fd4 100644 --- a/packages/core/framework/src/http/__fixtures__/routers-middleware/middlewares.ts +++ b/packages/core/framework/src/http/__fixtures__/routers-middleware/middlewares.ts @@ -43,6 +43,12 @@ export default defineMiddlewares([ matcher: "/store/*", middlewares: [storeGlobal], }, + { + matcher: "/webhooks", + bodyParser: { + preserveRawBody: true, + }, + }, { matcher: "/webhooks/*", method: "POST", diff --git a/packages/core/framework/src/http/__tests__/middleware-file-loader.spec.ts b/packages/core/framework/src/http/__tests__/middleware-file-loader.spec.ts new file mode 100644 index 0000000000..3fd2aad88a --- /dev/null +++ b/packages/core/framework/src/http/__tests__/middleware-file-loader.spec.ts @@ -0,0 +1,51 @@ +import { resolve } from "path" +import { MiddlewareFileLoader } from "../middleware-file-loader" + +describe("Middleware file loader", () => { + it("should load routes from the filesystem", async () => { + const BASE_DIR = resolve(__dirname, "../__fixtures__/routers-middleware") + const loader = new MiddlewareFileLoader({}) + await loader.scanDir(BASE_DIR) + + expect(loader.getBodyParserConfigRoutes()).toMatchInlineSnapshot(` + [ + { + "config": { + "preserveRawBody": true, + }, + "matcher": "/webhooks", + "method": undefined, + }, + { + "config": false, + "matcher": "/webhooks/*", + "method": "POST", + }, + ] + `) + expect(loader.getMiddlewares()).toMatchInlineSnapshot(` + [ + { + "handler": [Function], + "matcher": "/customers", + "method": undefined, + }, + { + "handler": [Function], + "matcher": "/customers", + "method": "POST", + }, + { + "handler": [Function], + "matcher": "/store/*", + "method": undefined, + }, + { + "handler": [Function], + "matcher": "/webhooks/*", + "method": "POST", + }, + ] + `) + }) +}) diff --git a/packages/core/framework/src/http/middleware-file-loader.ts b/packages/core/framework/src/http/middleware-file-loader.ts new file mode 100644 index 0000000000..74cd6fee91 --- /dev/null +++ b/packages/core/framework/src/http/middleware-file-loader.ts @@ -0,0 +1,154 @@ +import { join } from "path" +import { dynamicImport, FileSystem } from "@medusajs/utils" + +import { logger } from "../logger" +import type { + MiddlewaresConfig, + BodyParserConfigRoute, + ScannedMiddlewareDescriptor, +} from "./types" + +/** + * File name that is used to indicate that the file is a middleware file + */ +const MIDDLEWARE_FILE_NAME = "middlewares" + +const log = ({ + activityId, + message, +}: { + activityId?: string + message: string +}) => { + if (activityId) { + logger.progress(activityId, message) + return + } + + logger.debug(message) +} + +/** + * Exposes the API to scan a directory and load the `middleware.ts` file. This file contains + * the configuration for certain global middlewares and core routes validators. Also, it may + * contain custom middlewares. + */ +export class MiddlewareFileLoader { + /** + * Middleware collected manually or by scanning directories + */ + #middleware: ScannedMiddlewareDescriptor[] = [] + #bodyParserConfigRoutes: BodyParserConfigRoute[] = [] + + /** + * An eventual activity id for information tracking + */ + readonly #activityId?: string + + constructor({ activityId }: { activityId?: string }) { + this.#activityId = activityId + } + + /** + * Processes the middleware file and returns the middleware and the + * routes config exported by it. + */ + async #processMiddlewareFile(absolutePath: string): Promise { + const middlewareExports = await dynamicImport(absolutePath) + + const middlewareConfig = middlewareExports.default + if (!middlewareConfig) { + log({ + activityId: this.#activityId, + message: `No middleware configuration found in ${absolutePath}. Skipping middleware configuration.`, + }) + return + } + + const routes = middlewareConfig.routes as MiddlewaresConfig["routes"] + if (!routes || !Array.isArray(routes)) { + log({ + activityId: this.#activityId, + message: `Invalid default export found in ${absolutePath}. Make sure to use "defineMiddlewares" function and export its output.`, + }) + return + } + + const result = routes.reduce<{ + bodyParserConfigRoutes: BodyParserConfigRoute[] + middleware: ScannedMiddlewareDescriptor[] + }>( + (result, route) => { + if (!route.matcher) { + throw new Error( + `Middleware is missing a \`matcher\` field. The 'matcher' field is required when applying middleware. ${JSON.stringify( + route, + null, + 2 + )}` + ) + } + + const matcher = String(route.matcher) + + if ("bodyParser" in route && route.bodyParser !== undefined) { + result.bodyParserConfigRoutes.push({ + matcher: matcher, + method: route.method, + config: route.bodyParser, + }) + } + + if (route.middlewares) { + route.middlewares.forEach((middleware) => { + result.middleware.push({ + handler: middleware, + matcher: matcher, + method: route.method, + }) + }) + } + return result + }, + { + bodyParserConfigRoutes: [], + middleware: [], + } + ) + + this.#middleware = result.middleware + this.#bodyParserConfigRoutes = result.bodyParserConfigRoutes + } + + /** + * Scans a given directory for the "middleware.ts" or "middleware.js" files and + * imports them for reading the registered middleware and configuration for + * existing routes/middleware. + */ + async scanDir(sourceDir: string) { + const fs = new FileSystem(sourceDir) + if (await fs.exists(`${MIDDLEWARE_FILE_NAME}.ts`)) { + await this.#processMiddlewareFile( + join(sourceDir, `${MIDDLEWARE_FILE_NAME}.ts`) + ) + } else if (await fs.exists(`${MIDDLEWARE_FILE_NAME}.js`)) { + await this.#processMiddlewareFile( + join(sourceDir, `${MIDDLEWARE_FILE_NAME}.ts`) + ) + } + } + + /** + * Returns a collection of registered middleware + */ + getMiddlewares() { + return this.#middleware + } + + /** + * Returns routes that have bodyparser config on them + */ + getBodyParserConfigRoutes() { + return this.#bodyParserConfigRoutes + } +} diff --git a/packages/core/framework/src/http/routes-sorter.ts b/packages/core/framework/src/http/routes-sorter.ts index 4d86574fa8..fa453539b8 100644 --- a/packages/core/framework/src/http/routes-sorter.ts +++ b/packages/core/framework/src/http/routes-sorter.ts @@ -25,7 +25,7 @@ type Route = { /** * The HTTP methods this route is supposed to handle. */ - methods?: MiddlewareVerb[] + methods?: MiddlewareVerb | MiddlewareVerb[] } /** @@ -108,7 +108,6 @@ export class RoutesSorter { constructor(routes: Route[]) { this.#routesToProcess = routes - console.log("Processing routes", this.#routesToProcess) } /** diff --git a/packages/core/framework/src/http/types.ts b/packages/core/framework/src/http/types.ts index 717c628d6b..1a062a1bd4 100644 --- a/packages/core/framework/src/http/types.ts +++ b/packages/core/framework/src/http/types.ts @@ -111,6 +111,18 @@ export type FileSystemRouteDescriptor = ScannedRouteDescriptor & { relativePath: string } +export type ScannedMiddlewareDescriptor = { + matcher: string + method?: MiddlewareVerb | MiddlewareVerb[] + handler: MiddlewareFunction +} + +export type BodyParserConfigRoute = { + matcher: string + method?: MiddlewareVerb | MiddlewareVerb[] + config?: ParserConfig +} + export type GlobalMiddlewareDescriptor = { config?: MiddlewaresConfig }