diff --git a/.changeset/long-wasps-mix.md b/.changeset/long-wasps-mix.md new file mode 100644 index 0000000000..c0beed1f6c --- /dev/null +++ b/.changeset/long-wasps-mix.md @@ -0,0 +1,5 @@ +--- +"@medusajs/medusa": patch +--- + +feat(medusa): Introduces a new file based routing system as an alternative to the current approach. File based routing is optional, and the previous approach can still be used. The two approaches can also be used together allowing for incremental adoption. diff --git a/packages/medusa/setupTests.js b/packages/medusa/setupTests.js index d94d2eb824..828683d29a 100644 --- a/packages/medusa/setupTests.js +++ b/packages/medusa/setupTests.js @@ -1,3 +1,5 @@ +global.performance = require("perf_hooks").performance + global.afterEach(async () => { await new Promise((resolve) => setImmediate(resolve)) }) diff --git a/packages/medusa/src/index.js b/packages/medusa/src/index.js index 280e52f785..b171cba753 100644 --- a/packages/medusa/src/index.js +++ b/packages/medusa/src/index.js @@ -5,6 +5,8 @@ export * from "./models" export * from "./services" export * from "./types/batch-job" export * from "./types/common" +export * from "./types/middlewares" +export * from "./types/routing" export * from "./types/global" export * from "./types/price-list" export * from "./utils" diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/mocks/index.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/mocks/index.ts new file mode 100644 index 0000000000..104537c044 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/mocks/index.ts @@ -0,0 +1,3 @@ +export const customersGlobalMiddlewareMock = jest.fn() +export const customersCreateMiddlewareMock = jest.fn() +export const storeCorsMiddlewareMock = jest.fn() diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-duplicate-parameter/admin/customers/[id]/orders/[id]/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-duplicate-parameter/admin/customers/[id]/orders/[id]/route.ts new file mode 100644 index 0000000000..b9e83f2e0b --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-duplicate-parameter/admin/customers/[id]/orders/[id]/route.ts @@ -0,0 +1,9 @@ +import { Request, Response } from "express" + +export const GET = (req: Request, res: Response) => { + res.send("get customer order") +} + +export const POST = (req: Request, res: Response) => { + res.send("update customer order") +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/customers/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/customers/route.ts new file mode 100644 index 0000000000..02c39b29a0 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/customers/route.ts @@ -0,0 +1,9 @@ +import { Request, Response } from "express" + +export function GET(req: Request, res: Response) { + res.send("list customers") +} + +export function POST(req: Request, res: Response) { + res.send("create customer") +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/middlewares.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/middlewares.ts new file mode 100644 index 0000000000..4828c54877 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/middlewares.ts @@ -0,0 +1,48 @@ +import { NextFunction, Request, Response } from "express" +import { MiddlewaresConfig } from "../../types" +import { + customersCreateMiddlewareMock, + customersGlobalMiddlewareMock, + storeCorsMiddlewareMock, +} from "../mocks" + +const customersGlobalMiddleware = ( + req: Request, + res: Response, + next: NextFunction +) => { + customersGlobalMiddlewareMock() + next() +} + +const customersCreateMiddleware = ( + req: Request, + res: Response, + next: NextFunction +) => { + customersCreateMiddlewareMock() + next() +} + +const storeCors = (req: Request, res: Response, next: NextFunction) => { + storeCorsMiddlewareMock() + next() +} + +export const config: MiddlewaresConfig = { + routes: [ + { + matcher: "/customers", + middlewares: [customersGlobalMiddleware], + }, + { + method: "POST", + matcher: "/customers", + middlewares: [customersCreateMiddleware], + }, + { + matcher: "/store/*", + middlewares: [storeCors], + }, + ], +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/store/products/[id]/sync/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/store/products/[id]/sync/route.ts new file mode 100644 index 0000000000..01c9a8aa73 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/store/products/[id]/sync/route.ts @@ -0,0 +1,5 @@ +import { Request, Response } from "express" + +export const POST = (req: Request, res: Response) => { + res.send(`sync product ${req.params.id}`) +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/_private/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/_private/route.ts new file mode 100644 index 0000000000..6392a61686 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/_private/route.ts @@ -0,0 +1,5 @@ +import { Request, Response } from "express" + +export const GET = async (req: Request, res: Response): Promise => { + res.send(`GET private route`) +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/orders/[id]/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/orders/[id]/route.ts new file mode 100644 index 0000000000..7286e15f8a --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/orders/[id]/route.ts @@ -0,0 +1,9 @@ +import { Request, Response } from "express" + +export async function GET(req: Request, res: Response): Promise { + res.send(`GET order ${req.params.id}`) +} + +export async function POST(req: Request, res: Response): Promise { + res.send(`POST order ${req.params.id}`) +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/orders/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/orders/route.ts new file mode 100644 index 0000000000..75326875cb --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/orders/route.ts @@ -0,0 +1,5 @@ +import { Request, Response } from "express" + +export function GET(req: Request, res: Response) { + res.send("hello world") +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/products/[id]/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/products/[id]/route.ts new file mode 100644 index 0000000000..c899bd4222 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/products/[id]/route.ts @@ -0,0 +1,5 @@ +import { Request, Response } from "express" + +export async function GET(req: Request, res: Response): Promise { + console.log("hello world") +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/products/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/products/route.ts new file mode 100644 index 0000000000..79749a05e9 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/products/route.ts @@ -0,0 +1,29 @@ +import { Request, Response } from "express" + +export async function GET(req: Request, res: Response): Promise { + console.log("hello world") +} + +export async function POST(req: Request, res: Response): Promise { + console.log("hello world") +} + +export async function PUT(req: Request, res: Response): Promise { + console.log("hello world") +} + +export async function DELETE(req: Request, res: Response): Promise { + console.log("hello world") +} + +export async function PATCH(req: Request, res: Response): Promise { + console.log("hello world") +} + +export async function OPTIONS(req: Request, res: Response): Promise { + console.log("hello world") +} + +export async function HEAD(req: Request, res: Response): Promise { + console.log("hello world") +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/route.ts new file mode 100644 index 0000000000..4239d6fa3f --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/admin/route.ts @@ -0,0 +1,9 @@ +import { Request, Response } from "express" + +export async function GET(req: Request, res: Response): Promise { + console.log("hello world") +} + +export async function POST(req: Request, res: Response): Promise { + console.log("hello world") +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/customers/[customer_id]/orders/[order_id]/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/customers/[customer_id]/orders/[order_id]/route.ts new file mode 100644 index 0000000000..855de749a7 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/customers/[customer_id]/orders/[order_id]/route.ts @@ -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)) +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/customers/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/customers/route.ts new file mode 100644 index 0000000000..c3e8918f8b --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers/customers/route.ts @@ -0,0 +1,5 @@ +import { NextFunction, Request, Response } from "express" + +export function GET(req: Request, res: Response) { + res.send("list customers") +} diff --git a/packages/medusa/src/loaders/helpers/routing/__tests__/index.spec.ts b/packages/medusa/src/loaders/helpers/routing/__tests__/index.spec.ts new file mode 100644 index 0000000000..890024d138 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__tests__/index.spec.ts @@ -0,0 +1,150 @@ +import express from "express" +import http from "http" +import { resolve } from "path" +import request from "supertest" +import { + customersCreateMiddlewareMock, + customersGlobalMiddlewareMock, + storeCorsMiddlewareMock, +} from "../__fixtures__/mocks" +import { RoutesLoader } from "../index" + +const mockConfigModule = { + projectConfig: { + store_cors: "http://localhost:8000", + admin_cors: "http://localhost:7001", + database_logging: false, + }, + featureFlags: {}, + plugins: [], +} + +describe("RoutesLoader", function () { + afterEach(function () { + jest.clearAllMocks() + }) + + describe("Routes", function () { + const app = express() + const server = http.createServer(app) + + beforeAll(async function () { + const rootDir = resolve(__dirname, "../__fixtures__/routers") + + await new RoutesLoader({ + app, + rootDir, + configModule: mockConfigModule, + }).load() + }) + + it("should return a status 200 on GET admin/order/:id", async function () { + await request(server) + .get("/admin/orders/1000") + .expect(200) + .expect("GET order 1000") + }) + + it("should return a status 200 on POST admin/order/:id", async function () { + await request(server) + .post("/admin/orders/1000") + .expect(200) + .expect("POST order 1000") + }) + + it("should call GET /customers/[customer_id]/orders/[order_id]", async function () { + await request(server) + .get("/customers/test-customer/orders/test-order") + .expect(200) + .expect( + 'list customers {"customer_id":"test-customer","order_id":"test-order"}' + ) + }) + + it("should not be able to GET /_private as the folder is prefixed with an underscore", async function () { + const res = await request(server).get("/_private") + + expect(res.status).toBe(404) + expect(res.text).toContain("Cannot GET /_private") + }) + }) + + describe("Middlewares", function () { + const app = express() + const server = http.createServer(app) + + beforeAll(async function () { + const rootDir = resolve(__dirname, "../__fixtures__/routers-middleware") + + await new RoutesLoader({ + app, + rootDir, + configModule: mockConfigModule, + }).load() + }) + + it("should call middleware applied to `/customers`", async function () { + await request(server) + .get("/customers") + .expect(200) + .expect("list customers") + + expect(customersGlobalMiddlewareMock).toHaveBeenCalled() + }) + + it("should not call middleware applied to POST `/customers` when GET `/customers`", async function () { + await request(server) + .get("/customers") + .expect(200) + .expect("list customers") + + expect(customersGlobalMiddlewareMock).toHaveBeenCalled() + expect(customersCreateMiddlewareMock).not.toHaveBeenCalled() + }) + + it("should call middleware applied to POST `/customers` when POST `/customers`", async function () { + await request(server) + .post("/customers") + .expect(200) + .expect("create customer") + + expect(customersGlobalMiddlewareMock).toHaveBeenCalled() + expect(customersCreateMiddlewareMock).toHaveBeenCalled() + }) + + it("should call store cors middleware on `/store/*` routes", async function () { + await request(server) + .post("/store/products/1000/sync") + .expect(200) + .expect("sync product 1000") + + expect(customersGlobalMiddlewareMock).not.toHaveBeenCalled() + expect(customersCreateMiddlewareMock).not.toHaveBeenCalled() + + expect(storeCorsMiddlewareMock).toHaveBeenCalled() + }) + }) + + describe("Duplicate parameters", function () { + const app = express() + + it("should throw if a route contains the same parameter multiple times", async function () { + const rootDir = resolve( + __dirname, + "../__fixtures__/routers-duplicate-parameter" + ) + const err = await new RoutesLoader({ + app, + rootDir, + configModule: mockConfigModule, + }) + .load() + .catch((e) => e) + + expect(err).toBeDefined() + expect(err.message).toBe( + "Duplicate parameters found in route /admin/customers/[id]/orders/[id] (id). Make sure that all parameters are unique." + ) + }) + }) +}) diff --git a/packages/medusa/src/loaders/helpers/routing/index.ts b/packages/medusa/src/loaders/helpers/routing/index.ts new file mode 100644 index 0000000000..3ccade4621 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/index.ts @@ -0,0 +1,585 @@ +import cors from "cors" +import { Express, json, urlencoded } from "express" +import { readdir } from "fs/promises" +import { parseCorsOrigins } from "medusa-core-utils" +import { extname, join } from "path" +import { + authenticate, + authenticateCustomer, + requireCustomerAuthentication, +} from "../../../api/middlewares" +import { ConfigModule } from "../../../types/global" +import logger from "../../logger" +import { + GlobalMiddlewareDescriptor, + HTTP_METHODS, + MiddlewaresConfig, + RouteConfig, + RouteDescriptor, + RouteVerb, +} from "./types" + +const log = ({ + activityId, + message, +}: { + activityId?: string + message: string +}) => { + if (activityId) { + logger.progress(activityId, message) + return + } + + logger.info(message) +} + +/** + * 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 route should be authenticated or not. + */ +const AUTHTHENTICATE = "AUTHENTICATE" + +/** + * File name for the global middlewares file + */ +const MIDDLEWARES_NAME = "middlewares" + +const pathSegmentReplacer = { + "\\[\\.\\.\\.\\]": () => `*`, + "\\[(\\w+)?": (param?: string) => `:${param}`, + "\\]": () => ``, +} + +/** + * @param routes - The routes to prioritize + * + * @return An array of sorted + * routes based on their priority + */ +const prioritize = (routes: RouteDescriptor[]): RouteDescriptor[] => { + return routes.sort((a, b) => { + return a.priority - b.priority + }) +} + +/** + * The smaller the number the higher the priority with zero indicating + * highest priority + * + * @param path - The path to calculate the priority for + * + * @return An integer ranging from `0` to `Infinity` + */ +function calculatePriority(path: string): number { + const depth = path.match(/\/.+?/g)?.length || 0 + const specifity = path.match(/\/:.+?/g)?.length || 0 + const catchall = (path.match(/\/\*/g)?.length || 0) > 0 ? Infinity : 0 + + return depth + specifity + catchall +} + +export class RoutesLoader { + protected routesMap = new Map() + protected globalMiddlewaresDescriptor: GlobalMiddlewareDescriptor | undefined + + protected app: Express + protected activityId?: string + protected rootDir: string + protected configModule: ConfigModule + protected excludes: RegExp[] = [ + /\.DS_Store/, + /(\.ts\.map|\.js\.map|\.d\.ts)/, + /^_/, + ] + + constructor({ + app, + activityId, + rootDir, + configModule, + excludes = [], + }: { + app: Express + activityId?: string + rootDir: string + configModule: ConfigModule + excludes?: RegExp[] + }) { + this.app = app + this.activityId = activityId + this.rootDir = rootDir + this.configModule = configModule + this.excludes.push(...(excludes ?? [])) + } + + /** + * Validate the route config and display a log info if + * it should be ignored or skipped. + * + * @param {GlobalMiddlewareDescriptor} descriptor + * @param {MiddlewaresConfig} config + * + * @return {void} + */ + protected validateMiddlewaresConfig({ + config, + }: { + config?: MiddlewaresConfig + }): void { + if (!config?.routes) { + log({ + activityId: this.activityId, + message: `No middleware routes found. Skipping middleware application.`, + }) + + return + } + + for (const route of config.routes) { + if (!route.matcher) { + throw new Error( + `Route is missing a \`matcher\` field. The 'matcher' field is required when applying middleware to this route.` + ) + } + } + } + + /** + * Take care of replacing the special path segments + * to an express specific path segment + * + * @param route - The route to parse + * + * @example + * "/admin/orders/[id]/index.ts" => "/admin/orders/:id/index.ts" + */ + protected parseRoute(route: string): string { + let route_ = route + + for (const config of Object.entries(pathSegmentReplacer)) { + const [searchFor, replacedByFn] = config + const replacer = new RegExp(searchFor, "g") + + const matches = [...route_.matchAll(replacer)] + + const parameters = new Set() + + for (const match of matches) { + if (match?.[1] && !Number.isInteger(match?.[1])) { + if (parameters.has(match?.[1])) { + log({ + activityId: this.activityId, + message: `Duplicate parameters found in route ${route} (${match?.[1]})`, + }) + + throw new Error( + `Duplicate parameters found in route ${route} (${match?.[1]}). Make sure that all parameters are unique.` + ) + } + + parameters.add(match?.[1]) + } + + route_ = route_.replace(match[0], replacedByFn(match?.[1])) + } + + const extension = extname(route_) + if (extension) { + route_ = route_.replace(extension, "") + } + } + + route = route_ + + return route + } + + /** + * Load the file content from a descriptor and retrieve the verbs and handlers + * to be assigned to the descriptor + * + * @return {Promise} + */ + protected async createRoutesConfig(): Promise { + await Promise.all( + [...this.routesMap.values()].map(async (descriptor: RouteDescriptor) => { + const absolutePath = descriptor.absolutePath + + return await import(absolutePath).then((import_) => { + const map = this.routesMap + + const config: RouteConfig = { + routes: [], + shouldRequireAdminAuth: false, + shouldRequireCustomerAuth: false, + shouldAppendCustomer: false, + } + + /** + * If the developer has not exported the authenticate flag + * we default to true. + */ + const shouldRequireAuth = + import_[AUTHTHENTICATE] !== undefined + ? (import_[AUTHTHENTICATE] as boolean) + : true + + if ( + shouldRequireAuth && + absolutePath.includes(join("api", "admin")) + ) { + config.shouldRequireAdminAuth = shouldRequireAuth + } + + if ( + shouldRequireAuth && + absolutePath.includes(join("api", "store", "me")) + ) { + config.shouldRequireCustomerAuth = shouldRequireAuth + } + + if (absolutePath.includes(join("api", "store"))) { + config.shouldAppendCustomer = true + } + + const handlers = Object.keys(import_).filter((key) => { + /** + * Filter out any export that is not a function + */ + return typeof import_[key] === "function" + }) + + for (const handler of handlers) { + if (HTTP_METHODS.includes(handler as RouteVerb)) { + config.routes?.push({ + method: handler as RouteVerb, + handler: import_[handler], + }) + } else { + log({ + activityId: this.activityId, + message: `Skipping handler ${handler} in ${absolutePath}. Invalid HTTP method: ${handler}.`, + }) + } + } + + if (!config.routes?.length) { + log({ + activityId: this.activityId, + message: `No valid route handlers detected in ${absolutePath}. Skipping route configuration.`, + }) + + map.delete(absolutePath) + return + } + + descriptor.config = config + map.set(absolutePath, descriptor) + }) + }) + ) + } + + protected createRoutesDescriptor({ + childPath, + parentPath, + }: { + childPath: string + parentPath?: string + }) { + const descriptor: RouteDescriptor = { + absolutePath: childPath, + relativePath: "", + route: "", + priority: Infinity, + } + + if (parentPath) { + childPath = childPath.replace(parentPath, "") + } + + descriptor.relativePath = childPath + + let routeToParse = childPath + + const pathSegments = childPath.split("/") + const lastSegment = pathSegments[pathSegments.length - 1] + + if (lastSegment.startsWith("route")) { + pathSegments.pop() + routeToParse = pathSegments.join("/") + } + + descriptor.route = this.parseRoute(routeToParse) + descriptor.priority = calculatePriority(descriptor.route) + + this.routesMap.set(childPath, descriptor) + } + + protected async createMiddlewaresDescriptor({ + dirPath, + }: { + dirPath: string + }) { + const files = await readdir(dirPath) + + const middlewareFilePath = files + .filter((path) => { + if ( + this.excludes.length && + this.excludes.some((exclude) => exclude.test(path)) + ) { + return false + } + + return true + }) + .find((file) => { + return file.replace(/\.[^/.]+$/, "") === MIDDLEWARES_NAME + }) + + if (!middlewareFilePath) { + log({ + activityId: this.activityId, + message: `No middleware files found in ${dirPath}. Skipping middleware configuration.`, + }) + return + } + + const absolutePath = join(dirPath, middlewareFilePath) + + try { + await import(absolutePath).then((import_) => { + const middlewaresConfig = import_.config as + | MiddlewaresConfig + | undefined + + if (!middlewaresConfig) { + log({ + activityId: this.activityId, + message: `No middleware configuration found in ${absolutePath}. Skipping middleware configuration.`, + }) + return + } + + middlewaresConfig.routes = middlewaresConfig.routes?.map((route) => { + return { + ...route, + method: route.method ?? "USE", + } + }) + + const descriptor: GlobalMiddlewareDescriptor = { + config: middlewaresConfig, + } + + this.validateMiddlewaresConfig(descriptor) + + this.globalMiddlewaresDescriptor = descriptor + }) + } catch (error) { + log({ + activityId: this.activityId, + message: `Failed to load middleware configuration in ${absolutePath}. Skipping middleware configuration.`, + }) + + return + } + } + + protected async createRoutesMap({ + dirPath, + parentPath, + }: { + dirPath: string + parentPath?: string + }): Promise { + await Promise.all( + await readdir(dirPath, { withFileTypes: true }).then((entries) => { + return entries + .filter((entry) => { + const fullPath = join(dirPath, entry.name) + + if ( + this.excludes.length && + this.excludes.some((exclude) => exclude.test(entry.name)) + ) { + return false + } + + // Get entry name without extension + const name = entry.name.replace(/\.[^/.]+$/, "") + + if (entry.isFile() && name !== ROUTE_NAME) { + return false + } + + return true + }) + .map(async (entry) => { + const childPath = join(dirPath, entry.name) + + if (entry.isDirectory()) { + return this.createRoutesMap({ + dirPath: childPath, + parentPath: parentPath ?? dirPath, + }) + } + + return this.createRoutesDescriptor({ + childPath, + parentPath, + }) + }) + .flat(Infinity) + }) + ) + } + + protected async registerRoutes(): Promise { + const prioritizedRoutes = prioritize([...this.routesMap.values()]) + + for (const descriptor of prioritizedRoutes) { + if (!descriptor.config?.routes?.length) { + continue + } + + const routes = descriptor.config.routes + + if (descriptor.config.shouldAppendCustomer) { + this.app.use(descriptor.route, authenticateCustomer()) + } + + if (descriptor.config.shouldRequireAdminAuth) { + this.app.use(descriptor.route, authenticate()) + } + + if (descriptor.config.shouldRequireCustomerAuth) { + this.app.use(descriptor.route, requireCustomerAuthentication()) + } + + for (const route of routes) { + log({ + activityId: this.activityId, + message: `Registering route [${route.method?.toUpperCase()}] - ${ + descriptor.route + }`, + }) + this.app[route.method!.toLowerCase()](descriptor.route, route.handler) + } + } + } + + protected async registerMiddlewares(): Promise { + const descriptor = this.globalMiddlewaresDescriptor + + if (!descriptor) { + return + } + + if (!descriptor.config?.routes?.length) { + return + } + + const routes = descriptor.config.routes + + for (const route of routes) { + if (Array.isArray(route.method)) { + for (const method of route.method) { + log({ + activityId: this.activityId, + message: `Registering middleware [${method}] - ${route.matcher}`, + }) + + this.app[method.toLowerCase()](route.matcher, ...route.middlewares) + } + } else { + log({ + activityId: this.activityId, + message: `Registering middleware [${route.method}] - ${route.matcher}`, + }) + + this.app[route.method!.toLowerCase()]( + route.matcher, + ...route.middlewares + ) + } + } + } + + applyGlobalMiddlewares() { + if (this.routesMap.size > 0) { + this.app.use(json(), urlencoded({ extended: true })) + + const adminCors = this.configModule.projectConfig.admin_cors || "" + this.app.use( + "/admin", + cors({ + origin: parseCorsOrigins(adminCors), + credentials: true, + }) + ) + + const storeCors = this.configModule.projectConfig.store_cors || "" + this.app.use( + "/store", + cors({ origin: parseCorsOrigins(storeCors), credentials: true }) + ) + } + } + + async load() { + performance.mark("file-base-routing-start" + this.rootDir) + + let apiExists = true + + /** + * Since the file based routing does not require a index file + * we can check if it exists using require. Instead we try + * to read the directory and if it fails we know that the + * directory does not exist. + */ + try { + await readdir(this.rootDir) + } catch (_error) { + apiExists = false + } + + if (apiExists) { + await this.createMiddlewaresDescriptor({ dirPath: this.rootDir }) + + await this.createRoutesMap({ dirPath: this.rootDir }) + await this.createRoutesConfig() + + this.applyGlobalMiddlewares() + + await this.registerMiddlewares() + await this.registerRoutes() + } + + performance.mark("file-base-routing-end" + this.rootDir) + const timeSpent = performance + .measure( + "file-base-routing-measure" + this.rootDir, + "file-base-routing-start" + this.rootDir, + "file-base-routing-end" + this.rootDir + ) + ?.duration?.toFixed(2) + + log({ + activityId: this.activityId, + message: `Routes loaded in ${timeSpent} ms`, + }) + + this.routesMap.clear() + this.globalMiddlewaresDescriptor = undefined + } +} + +export default RoutesLoader diff --git a/packages/medusa/src/loaders/helpers/routing/types.ts b/packages/medusa/src/loaders/helpers/routing/types.ts new file mode 100644 index 0000000000..0a4a1edf2e --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/types.ts @@ -0,0 +1,64 @@ +import { + MedusaRequest, + MedusaRequestHandler, + MedusaResponse, +} from "../../../types/routing" + +/** + * List of all the supported HTTP methods + */ +export const HTTP_METHODS = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", + "HEAD", +] as const + +export type RouteVerb = (typeof HTTP_METHODS)[number] +type MiddlewareVerb = "USE" | "ALL" | RouteVerb + +type RouteHandler = ( + req: MedusaRequest, + res: MedusaResponse +) => Promise | void + +export type RouteImplementation = { + method?: RouteVerb + handler: RouteHandler +} + +export type RouteConfig = { + shouldRequireAdminAuth?: boolean + shouldRequireCustomerAuth?: boolean + shouldAppendCustomer?: boolean + routes?: RouteImplementation[] +} + +export type MiddlewareFunction = + | MedusaRequestHandler + | ((...args: any[]) => any) + +export type MiddlewareRoute = { + method?: MiddlewareVerb | MiddlewareVerb[] + matcher: string | RegExp + middlewares: MiddlewareFunction[] +} + +export type MiddlewaresConfig = { + routes?: MiddlewareRoute[] +} + +export type RouteDescriptor = { + absolutePath: string + relativePath: string + route: string + priority: number + config?: RouteConfig +} + +export type GlobalMiddlewareDescriptor = { + config?: MiddlewaresConfig +} diff --git a/packages/medusa/src/loaders/plugins.ts b/packages/medusa/src/loaders/plugins.ts index d174c87574..45527f4702 100644 --- a/packages/medusa/src/loaders/plugins.ts +++ b/packages/medusa/src/loaders/plugins.ts @@ -39,6 +39,7 @@ import { EntitySchema } from "typeorm" import { MiddlewareService } from "../services" import { getModelExtensionsMap } from "./helpers/get-model-extension-map" import logger from "./logger" +import { RoutesLoader } from "./helpers/routing" type Options = { rootDirectory: string @@ -83,7 +84,14 @@ export default async ({ registerRepositories(pluginDetails, container) await registerServices(pluginDetails, container) await registerMedusaApi(pluginDetails, container) - registerApi(pluginDetails, app, rootDirectory, container, activityId) + await registerApi( + pluginDetails, + app, + rootDirectory, + container, + configModule, + activityId + ) registerCoreRouters(pluginDetails, container) registerSubscribers(pluginDetails, container) }) @@ -332,13 +340,14 @@ function registerCoreRouters( /** * Registers the plugin's api routes. */ -function registerApi( +async function registerApi( pluginDetails: PluginDetails, app: Express, rootDirectory = "", container: MedusaContainer, + configmodule: ConfigModule, activityId: string -): Express { +): Promise { const logger = container.resolve("logger") const projectName = pluginDetails.name === MEDUSA_PROJECT_NAME @@ -346,16 +355,42 @@ function registerApi( : `${pluginDetails.name}` logger.progress(activityId, `Registering custom endpoints for ${projectName}`) + try { - const routes = require(`${pluginDetails.resolve}/api`).default - if (routes) { - app.use("/", routes(rootDirectory, pluginDetails.options)) + /** + * Register the plugin's api routes using the file based routing. + */ + await new RoutesLoader({ + app, + rootDir: `${pluginDetails.resolve}/api`, + activityId: activityId, + configModule: configmodule, + }).load() + + /** + * For backwards compatibility we also support loading routes from + * `/api/index` if the file exists. + */ + let apiFolderExists = true + + try { + require.resolve(`${pluginDetails.resolve}/api`) + } catch (e) { + apiFolderExists = false } + + if (apiFolderExists) { + const routes = require(`${pluginDetails.resolve}/api`).default + if (routes) { + app.use("/", routes(rootDirectory, pluginDetails.options)) + } + } + return app } catch (err) { if (err.code !== "MODULE_NOT_FOUND") { logger.warn( - `An error occured while registering endpoints in ${projectName}` + `An error occurred while registering endpoints in ${projectName}` ) if (err.stack) { diff --git a/packages/medusa/src/types/middlewares.ts b/packages/medusa/src/types/middlewares.ts new file mode 100644 index 0000000000..d90ca38974 --- /dev/null +++ b/packages/medusa/src/types/middlewares.ts @@ -0,0 +1,5 @@ +export type { + MiddlewareFunction, + MiddlewareRoute, + MiddlewaresConfig, +} from "../loaders/helpers/routing/types" diff --git a/packages/medusa/src/types/routing.ts b/packages/medusa/src/types/routing.ts new file mode 100644 index 0000000000..54b0051f0b --- /dev/null +++ b/packages/medusa/src/types/routing.ts @@ -0,0 +1,19 @@ +import type { NextFunction, Request, Response } from "express" + +import type { Customer, User } from "../models" +import type { MedusaContainer } from "./global" + +export interface MedusaRequest extends Request { + user?: (User | Customer) & { customer_id?: string; userId?: string } + scope: MedusaContainer +} + +export type MedusaResponse = Response + +export type MedusaNextFunction = NextFunction + +export type MedusaRequestHandler = ( + req: MedusaRequest, + res: MedusaResponse, + next: MedusaNextFunction +) => Promise | void