diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/mocks/index.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/mocks/index.ts index 104537c044..1b915b49f5 100644 --- a/packages/medusa/src/loaders/helpers/routing/__fixtures__/mocks/index.ts +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/mocks/index.ts @@ -1,3 +1,15 @@ export const customersGlobalMiddlewareMock = jest.fn() export const customersCreateMiddlewareMock = jest.fn() -export const storeCorsMiddlewareMock = jest.fn() +export const storeGlobalMiddlewareMock = jest.fn() + +export const config = { + projectConfig: { + store_cors: "http://localhost:8000", + admin_cors: "http://localhost:7001", + database_logging: false, + jwt_secret: "supersecret", + cookie_secret: "superSecret", + }, + featureFlags: {}, + plugins: [], +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-error-handler/middlewares.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-error-handler/middlewares.ts new file mode 100644 index 0000000000..1344a0190c --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-error-handler/middlewares.ts @@ -0,0 +1,39 @@ +import { MiddlewaresConfig } from "../../types" + +export const config: MiddlewaresConfig = { + errorHandler: (err, _req, res, _next) => { + const { code, message } = err + + switch (code) { + case "NOT_ALLOWED": + res.status(405).json({ + type: code.toLowerCase(), + message, + }) + break + case "INVALID_DATA": + res.status(400).json({ + type: code.toLowerCase(), + message, + }) + break + case "CONFLICT": + res.status(409).json({ + type: code.toLowerCase(), + message, + }) + break + case "TEAPOT": + res.status(418).json({ + type: code.toLowerCase(), + message, + }) + break + default: + res.status(500).json({ + type: "unknown_error", + message: "An unknown error occurred.", + }) + } + }, +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-error-handler/store/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-error-handler/store/route.ts new file mode 100644 index 0000000000..d9db1ddbc8 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-error-handler/store/route.ts @@ -0,0 +1,27 @@ +export const GET = async (req: Request, res: Response) => { + throw { + code: "NOT_ALLOWED", + message: "Not allowed to perform this action", + } +} + +export const POST = async (req: Request, res: Response) => { + throw { + code: "INVALID_DATA", + message: "Invalid data provided", + } +} + +export const PUT = async (req: Request, res: Response) => { + throw { + code: "CONFLICT", + message: "Conflict with another request", + } +} + +export const DELETE = async (req: Request, res: Response) => { + throw { + code: "TEAPOT", + message: "I'm a teapot", + } +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/admin/protected/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/admin/protected/route.ts new file mode 100644 index 0000000000..016c0b6335 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/admin/protected/route.ts @@ -0,0 +1,5 @@ +import { Request, Response } from "express" + +export const GET = (req: Request, res: Response) => { + res.send(`GET /admin/protected`) +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/admin/unprotected/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/admin/unprotected/route.ts new file mode 100644 index 0000000000..cdb7f1706c --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/admin/unprotected/route.ts @@ -0,0 +1,7 @@ +import { Request, Response } from "express" + +export const AUTHENTICATE = false + +export const GET = (req: Request, res: Response) => { + res.send(`GET /admin/unprotected`) +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/customers/error/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/customers/error/route.ts new file mode 100644 index 0000000000..f461026599 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/customers/error/route.ts @@ -0,0 +1,6 @@ +import { Request, Response } from "express" +import { MedusaError } from "medusa-core-utils" + +export const GET = async (req: Request, res: Response) => { + throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Not allowed") +} 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 index 4828c54877..d8a6b022b9 100644 --- a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/middlewares.ts +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/middlewares.ts @@ -1,9 +1,9 @@ -import { NextFunction, Request, Response } from "express" +import { NextFunction, Request, Response, raw } from "express" import { MiddlewaresConfig } from "../../types" import { customersCreateMiddlewareMock, customersGlobalMiddlewareMock, - storeCorsMiddlewareMock, + storeGlobalMiddlewareMock, } from "../mocks" const customersGlobalMiddleware = ( @@ -24,8 +24,8 @@ const customersCreateMiddleware = ( next() } -const storeCors = (req: Request, res: Response, next: NextFunction) => { - storeCorsMiddlewareMock() +const storeGlobal = (req: Request, res: Response, next: NextFunction) => { + storeGlobalMiddlewareMock() next() } @@ -42,7 +42,13 @@ export const config: MiddlewaresConfig = { }, { matcher: "/store/*", - middlewares: [storeCors], + middlewares: [storeGlobal], + }, + { + matcher: "/webhooks/*", + method: "POST", + bodyParser: false, + middlewares: [raw({ type: "application/json" })], }, ], } diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/store/me/protected/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/store/me/protected/route.ts new file mode 100644 index 0000000000..fccbda1829 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/store/me/protected/route.ts @@ -0,0 +1,5 @@ +import { Request, Response } from "express" + +export const GET = async (req: Request, res: Response) => { + res.send(`GET /store/protected`) +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/store/me/unprotected/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/store/me/unprotected/route.ts new file mode 100644 index 0000000000..a62f84c912 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/store/me/unprotected/route.ts @@ -0,0 +1,7 @@ +import { Request, Response } from "express" + +export const AUTHENTICATE = false + +export const GET = async (req: Request, res: Response) => { + res.send(`GET /store/unprotected`) +} diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/webhooks/payment/route.ts b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/webhooks/payment/route.ts new file mode 100644 index 0000000000..fbb7f8ac92 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/routers-middleware/webhooks/payment/route.ts @@ -0,0 +1,9 @@ +import { Request, Response } from "express" + +export const POST = (req: Request, res: Response) => { + if (!(req.body instanceof Buffer)) { + res.status(400).send("Invalid body") + } + + res.send("OK") +} 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 index 7286e15f8a..832977a594 100644 --- 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 @@ -1,9 +1,17 @@ import { Request, Response } from "express" export async function GET(req: Request, res: Response): Promise { - res.send(`GET order ${req.params.id}`) + try { + res.send(`GET order ${req.params.id}`) + } catch (err) { + res.status(400).send(err) + } } export async function POST(req: Request, res: Response): Promise { - res.send(`POST order ${req.params.id}`) + try { + res.send(`POST order ${req.params.id}`) + } catch (err) { + res.status(400).send(err) + } } diff --git a/packages/medusa/src/loaders/helpers/routing/__fixtures__/server/index.js b/packages/medusa/src/loaders/helpers/routing/__fixtures__/server/index.js new file mode 100644 index 0000000000..11f5934140 --- /dev/null +++ b/packages/medusa/src/loaders/helpers/routing/__fixtures__/server/index.js @@ -0,0 +1,177 @@ +import { + moduleLoader, + ModulesDefinition, + registerMedusaModule, +} from "@medusajs/modules-sdk" +import { asValue, createContainer } from "awilix" +import express from "express" +import jwt from "jsonwebtoken" +import { MockManager } from "medusa-test-utils" +import querystring from "querystring" +import supertest from "supertest" +import apiLoader from "../../../../api" +import featureFlagLoader, { featureFlagRouter } from "../../../../feature-flags" +import modelsLoader from "../../../../models" +import passportLoader from "../../../../passport" +import repositoriesLoader from "../../../../repositories" +import servicesLoader from "../../../../services" +import strategiesLoader from "../../../../strategies" + +import RoutesLoader from "../.." +import { config } from "../mocks" + +function asArray(resolvers) { + return { + resolve: (container) => + resolvers.map((resolver) => container.build(resolver)), + } +} + +/** + * Sets up a test server that injects API Routes using the RoutesLoader + * + * @param {String} rootDir - The root directory of the project + */ +export const createServer = async (rootDir) => { + const app = express() + + const moduleResolutions = {} + Object.entries(ModulesDefinition).forEach(([moduleKey, module]) => { + moduleResolutions[moduleKey] = registerMedusaModule( + moduleKey, + module.defaultModuleDeclaration, + undefined, + module + )[moduleKey] + }) + + const container = createContainer() + + container.registerAdd = function (name, registration) { + const storeKey = name + "_STORE" + + if (this.registrations[storeKey] === undefined) { + this.register(storeKey, asValue([])) + } + const store = this.resolve(storeKey) + + if (this.registrations[name] === undefined) { + this.register(name, asArray(store)) + } + store.unshift(registration) + + return this + }.bind(container) + + container.register("featureFlagRouter", asValue(featureFlagRouter)) + container.register("configModule", asValue(config)) + container.register({ + logger: asValue({ + error: () => {}, + }), + manager: asValue(MockManager), + }) + + app.set("trust proxy", 1) + app.use((req, _res, next) => { + req["session"] = {} + const data = req.get("Cookie") + if (data) { + req["session"] = { + ...req["session"], + ...JSON.parse(data), + } + } + next() + }) + + featureFlagLoader(config) + modelsLoader({ container, isTest: true }) + repositoriesLoader({ container, isTest: true }) + servicesLoader({ container, configModule: config }) + strategiesLoader({ container, configModule: config }) + await passportLoader({ app: app, container, configModule: config }) + await moduleLoader({ container, moduleResolutions }) + + app.use((req, res, next) => { + req.scope = container.createScope() + next() + }) + + // This where plugins normally load, but we simply load the routes + await new RoutesLoader({ + app, + rootDir, + configModule: config, + }).load() + + // the apiLoader needs to be called after plugins otherwise the core middleware bleads into the plugins + await apiLoader({ container, app: app, configModule: config }) + + const superRequest = supertest(app) + + return { + request: async (method, url, opts = {}) => { + const { payload, query, headers = {} } = opts + + const queryParams = query && querystring.stringify(query) + const req = superRequest[method.toLowerCase()]( + `${url}${queryParams ? "?" + queryParams : ""}` + ) + headers.Cookie = headers.Cookie || "" + if (opts.adminSession) { + const token = jwt.sign( + { + user_id: opts.adminSession.userId || opts.adminSession.jwt?.userId, + domain: "admin", + }, + config.projectConfig.jwt_secret + ) + + headers.Authorization = `Bearer ${token}` + } + if (opts.clientSession) { + const token = jwt.sign( + { + customer_id: + opts.clientSession.customer_id || + opts.clientSession.jwt?.customer_id, + domain: "store", + }, + config.projectConfig.jwt_secret + ) + + headers.Authorization = `Bearer ${token}` + } + + for (const name in headers) { + if ({}.hasOwnProperty.call(headers, name)) { + req.set(name, headers[name]) + } + } + + if (payload && !req.get("content-type")) { + req.set("Content-Type", "application/json") + } + + if (!req.get("accept")) { + req.set("Accept", "application/json") + } + + req.set("Host", "localhost") + + let res + try { + res = await req.send(JSON.stringify(payload)) + } catch (e) { + if (e.response) { + res = e.response + } else { + throw e + } + } + + return res + }, + } +} diff --git a/packages/medusa/src/loaders/helpers/routing/__tests__/index.spec.ts b/packages/medusa/src/loaders/helpers/routing/__tests__/index.spec.ts index 890024d138..a6e9fded42 100644 --- a/packages/medusa/src/loaders/helpers/routing/__tests__/index.spec.ts +++ b/packages/medusa/src/loaders/helpers/routing/__tests__/index.spec.ts @@ -1,23 +1,16 @@ import express from "express" -import http from "http" +import { IdMap } from "medusa-test-utils" import { resolve } from "path" -import request from "supertest" import { + config, customersCreateMiddlewareMock, customersGlobalMiddlewareMock, - storeCorsMiddlewareMock, + storeGlobalMiddlewareMock, } from "../__fixtures__/mocks" +import { createServer } from "../__fixtures__/server" import { RoutesLoader } from "../index" -const mockConfigModule = { - projectConfig: { - store_cors: "http://localhost:8000", - admin_cors: "http://localhost:7001", - database_logging: false, - }, - featureFlags: {}, - plugins: [], -} +jest.setTimeout(30000) describe("RoutesLoader", function () { afterEach(function () { @@ -25,44 +18,53 @@ describe("RoutesLoader", function () { }) describe("Routes", function () { - const app = express() - const server = http.createServer(app) + let request beforeAll(async function () { const rootDir = resolve(__dirname, "../__fixtures__/routers") - await new RoutesLoader({ - app, - rootDir, - configModule: mockConfigModule, - }).load() + const { request: request_ } = await createServer(rootDir) + + request = request_ }) 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") + const res = await request("GET", "/admin/orders/1000", { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + + expect(res.status).toBe(200) + expect(res.text).toBe("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") + const res = await request("POST", "/admin/orders/1000", { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + + expect(res.status).toBe(200) + expect(res.text).toBe("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"}' - ) + const res = await request("GET", "/customers/test-customer/orders/test") + + expect(res.status).toBe(200) + expect(res.text).toBe( + 'list customers {"customer_id":"test-customer","order_id":"test"}' + ) }) 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") + const res = await request("GET", "/_private") expect(res.status).toBe(404) expect(res.text).toContain("Cannot GET /_private") @@ -70,58 +72,179 @@ describe("RoutesLoader", function () { }) describe("Middlewares", function () { - const app = express() - const server = http.createServer(app) + let request beforeAll(async function () { const rootDir = resolve(__dirname, "../__fixtures__/routers-middleware") - await new RoutesLoader({ - app, - rootDir, - configModule: mockConfigModule, - }).load() + const { request: request_ } = await createServer(rootDir) + + request = request_ }) it("should call middleware applied to `/customers`", async function () { - await request(server) - .get("/customers") - .expect(200) - .expect("list customers") + const res = await request("GET", "/customers") + expect(res.status).toBe(200) + expect(res.text).toBe("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") + const res = await request("GET", "/customers") + expect(res.status).toBe(200) + expect(res.text).toBe("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") + const res = await request("POST", "/customers") + expect(res.status).toBe(200) + expect(res.text).toBe("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") + it("should call store global middleware on `/store/*` routes", async function () { + const res = await request("POST", "/store/products/1000/sync") + + expect(res.status).toBe(200) + expect(res.text).toBe("sync product 1000") + expect(storeGlobalMiddlewareMock).toHaveBeenCalled() expect(customersGlobalMiddlewareMock).not.toHaveBeenCalled() expect(customersCreateMiddlewareMock).not.toHaveBeenCalled() + }) - expect(storeCorsMiddlewareMock).toHaveBeenCalled() + it("should apply raw middleware on POST `/webhooks/payment` route", async function () { + const res = await request("POST", "/webhooks/payment", { + payload: { test: "test" }, + }) + + expect(res.status).toBe(200) + expect(res.text).toBe("OK") + }) + + it("should return 200 when admin is authenticated", async () => { + const res = await request("GET", "/admin/protected", { + adminSession: { + jwt: { + userId: IdMap.getId("admin_user"), + }, + }, + }) + + expect(res.status).toBe(200) + expect(res.text).toBe("GET /admin/protected") + }) + + it("should return 401 when admin is not authenticated", async () => { + const res = await request("GET", "/admin/protected") + + expect(res.status).toBe(401) + expect(res.text).toBe("Unauthorized") + }) + + it("should return 200 when admin route is opted out of authentication", async () => { + const res = await request("GET", "/admin/unprotected") + + expect(res.status).toBe(200) + expect(res.text).toBe("GET /admin/unprotected") + }) + + it("should return 200 when customer is authenticated", async () => { + const res = await request("GET", "/store/me/protected", { + clientSession: { + jwt: { + customer_id: IdMap.getId("lebron"), + }, + }, + }) + + expect(res.status).toBe(200) + expect(res.text).toBe("GET /store/protected") + }) + + it("should return 401 when customer is not authenticated", async () => { + const res = await request("GET", "/store/me/protected") + + expect(res.status).toBe(401) + expect(res.text).toBe("Unauthorized") + }) + + it("should return 200 when customer route is opted out of authentication", async () => { + const res = await request("GET", "/store/me/unprotected") + + expect(res.status).toBe(200) + expect(res.text).toBe("GET /store/unprotected") + }) + + it("should return the error as JSON when an error is thrown with default error handling", async () => { + const res = await request("GET", "/customers/error") + + expect(res.status).toBe(400) + expect(res.body).toEqual({ + message: "Not allowed", + type: "not_allowed", + }) + }) + }) + + describe("Custom error handling", function () { + let request + + beforeAll(async function () { + const rootDir = resolve( + __dirname, + "../__fixtures__/routers-error-handler" + ) + + const { request: request_ } = await createServer(rootDir) + + request = request_ + }) + + it("should return 405 when NOT_ALLOWED error is thrown", async () => { + const res = await request("GET", "/store") + + expect(res.status).toBe(405) + expect(res.body).toEqual({ + message: "Not allowed to perform this action", + type: "not_allowed", + }) + }) + + it("should return 400 when INVALID_DATA error is thrown", async () => { + const res = await request("POST", "/store") + + expect(res.status).toBe(400) + expect(res.body).toEqual({ + message: "Invalid data provided", + type: "invalid_data", + }) + }) + + it("should return 409 when CONFLICT error is thrown", async () => { + const res = await request("PUT", "/store") + + expect(res.status).toBe(409) + expect(res.body).toEqual({ + message: "Conflict with another request", + type: "conflict", + }) + }) + + it("should return 418 when TEAPOT error is thrown", async () => { + const res = await request("DELETE", "/store") + + expect(res.status).toBe(418) + expect(res.body).toEqual({ + message: "I'm a teapot", + type: "teapot", + }) }) }) @@ -136,7 +259,7 @@ describe("RoutesLoader", function () { const err = await new RoutesLoader({ app, rootDir, - configModule: mockConfigModule, + configModule: config, }) .load() .catch((e) => e) diff --git a/packages/medusa/src/loaders/helpers/routing/index.ts b/packages/medusa/src/loaders/helpers/routing/index.ts index 165f725fd8..fc3dd89b4f 100644 --- a/packages/medusa/src/loaders/helpers/routing/index.ts +++ b/packages/medusa/src/loaders/helpers/routing/index.ts @@ -1,19 +1,24 @@ import { promiseAll } from "@medusajs/utils" import cors from "cors" -import { Express, json, urlencoded } from "express" +import { Router, json, text, urlencoded, type Express } from "express" import { readdir } from "fs/promises" import { parseCorsOrigins } from "medusa-core-utils" import { extname, join, sep } from "path" import { authenticate, authenticateCustomer, + errorHandler, requireCustomerAuthentication, + wrapHandler, } from "../../../api/middlewares" import { ConfigModule } from "../../../types/global" import logger from "../../logger" import { + AsyncRouteHandler, GlobalMiddlewareDescriptor, HTTP_METHODS, + MiddlewareRoute, + MiddlewareVerb, MiddlewaresConfig, RouteConfig, RouteDescriptor, @@ -85,11 +90,81 @@ function calculatePriority(path: string): number { return depth + specifity + catchall } +function matchMethod( + method: RouteVerb, + configMethod: MiddlewareRoute["method"] +): boolean { + if (!configMethod || configMethod === "USE" || configMethod === "ALL") { + return true + } else if (Array.isArray(configMethod)) { + return ( + configMethod.includes(method) || + configMethod.includes("ALL") || + configMethod.includes("USE") + ) + } else { + return method === configMethod + } +} + +/** + * Function that looks though the global middlewares and returns the first + * complete match for the given path and method. + * + * @param path - The path to match + * @param method - The method to match + * @param routes - The routes to match against + * @returns The first complete match or undefined if no match is found + */ +function findMatch( + path: string, + method: RouteVerb, + routes: MiddlewareRoute[] +): MiddlewareRoute | undefined { + for (const route of routes) { + const { matcher, method: configMethod } = route + + if (matchMethod(method, configMethod)) { + let isMatch = false + + if (typeof matcher === "string") { + // Convert wildcard expressions to proper regex for matching entire path + // The '.*' will match any character sequence including '/' + const regex = new RegExp(`^${matcher.split("*").join(".*")}$`) + isMatch = regex.test(path) + } else if (matcher instanceof RegExp) { + // Ensure that the regex matches the entire path + const match = path.match(matcher) + isMatch = match !== null && match[0] === path + } + + if (isMatch) { + return route // Return the first complete match + } + } + } + + return undefined // Return undefined if no complete match is found +} + +/** + * Returns an array of body parser middlewares that are applied on routes + * out-of-the-box. + */ +function getBodyParserMiddleware(sizeLimit?: string | number | undefined) { + return [ + json({ limit: sizeLimit }), + text({ limit: sizeLimit }), + urlencoded({ limit: sizeLimit, extended: true }), + ] +} + export class RoutesLoader { protected routesMap = new Map() protected globalMiddlewaresDescriptor: GlobalMiddlewareDescriptor | undefined protected app: Express + protected router: Router protected activityId?: string protected rootDir: string protected configModule: ConfigModule @@ -113,6 +188,7 @@ export class RoutesLoader { excludes?: RegExp[] }) { this.app = app + this.router = Router() this.activityId = activityId this.rootDir = rootDir this.configModule = configModule @@ -133,16 +209,16 @@ export class RoutesLoader { }: { config?: MiddlewaresConfig }): void { - if (!config?.routes) { + if (!config?.routes && !config?.errorHandler) { log({ activityId: this.activityId, - message: `No middleware routes found. Skipping middleware application.`, + message: `Empty middleware config. Skipping middleware application.`, }) return } - for (const route of config.routes) { + 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.` @@ -211,6 +287,7 @@ export class RoutesLoader { await promiseAll( [...this.routesMap.values()].map(async (descriptor: RouteDescriptor) => { const absolutePath = descriptor.absolutePath + const route = descriptor.route return await import(absolutePath).then((import_) => { const map = this.routesMap @@ -223,30 +300,41 @@ export class RoutesLoader { } /** - * If the developer has not exported the authenticate flag - * we default to true. + * 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 the developer has not exported the + * CORS flag we default to true. + */ + const shouldAddCors = + import_["CORS"] !== undefined ? (import_["CORS"] as boolean) : true + + if (route.startsWith("/admin")) { + if (shouldAddCors) { + config.shouldAppendAdminCors = true + } + + if (shouldRequireAuth) { + config.shouldRequireAdminAuth = true + } } - if ( - shouldRequireAuth && - absolutePath.includes(join("api", "store", "me")) - ) { - config.shouldRequireCustomerAuth = shouldRequireAuth - } - - if (absolutePath.includes(join("api", "store"))) { + if (route.startsWith("/store")) { config.shouldAppendCustomer = true + + if (shouldAddCors) { + config.shouldAppendStoreCors = true + } + } + + if (shouldRequireAuth && route.startsWith("/store/me")) { + config.shouldRequireCustomerAuth = shouldRequireAuth } const handlers = Object.keys(import_).filter((key) => { @@ -446,7 +534,45 @@ export class RoutesLoader { ) } - protected async registerRoutes(): Promise { + /** + * Apply the most specific body parser middleware to the router + */ + applyBodyParserMiddleware(path: string, method: RouteVerb): void { + const middlewareDescriptor = this.globalMiddlewaresDescriptor + + const mostSpecificConfig = findMatch( + path, + method, + middlewareDescriptor?.config?.routes ?? [] + ) + + if (!mostSpecificConfig || mostSpecificConfig?.bodyParser === undefined) { + this.router[method.toLowerCase()](path, ...getBodyParserMiddleware()) + + return + } + + if (mostSpecificConfig?.bodyParser) { + const sizeLimit = mostSpecificConfig?.bodyParser?.sizeLimit + + this.router[method.toLowerCase()]( + path, + ...getBodyParserMiddleware(sizeLimit) + ) + + return + } + + return + } + + /** + * Apply the route specific middlewares to the router, + * this includes the cors, authentication and + * body parsing. These are applied first to ensure + * that they are applied before any other middleware. + */ + applyRouteSpecificMiddlewares(): void { const prioritizedRoutes = prioritize([...this.routesMap.values()]) for (const descriptor of prioritizedRoutes) { @@ -456,18 +582,122 @@ export class RoutesLoader { const routes = descriptor.config.routes - if (descriptor.config.shouldAppendCustomer) { - this.app.use(descriptor.route, authenticateCustomer()) + /** + * Apply default store and admin middlewares if + * not opted out of. + */ + + if (descriptor.config.shouldAppendAdminCors) { + /** + * Apply the admin cors + */ + this.router.use( + descriptor.route, + cors({ + origin: parseCorsOrigins( + this.configModule.projectConfig.admin_cors || "" + ), + credentials: true, + }) + ) } - if (descriptor.config.shouldRequireAdminAuth) { - this.app.use(descriptor.route, authenticate()) + if (descriptor.config.shouldAppendStoreCors) { + /** + * Apply the store cors + */ + this.router.use( + descriptor.route, + cors({ + origin: parseCorsOrigins( + this.configModule.projectConfig.store_cors || "" + ), + credentials: true, + }) + ) + } + + if (descriptor.config.shouldAppendCustomer) { + /** + * Add the customer to the request object + */ + this.router.use(descriptor.route, authenticateCustomer()) } if (descriptor.config.shouldRequireCustomerAuth) { - this.app.use(descriptor.route, requireCustomerAuthentication()) + /** + * Require the customer to be authenticated + */ + this.router.use(descriptor.route, requireCustomerAuthentication()) } + if (descriptor.config.shouldRequireAdminAuth) { + /** + * Require the admin to be authenticated + */ + this.router.use(descriptor.route, authenticate()) + } + + for (const route of routes) { + /** + * Apply the body parser middleware if the route + * has not opted out of it. + */ + this.applyBodyParserMiddleware(descriptor.route, route.method!) + } + } + } + + /** + * Apply the error handler middleware to the router + */ + applyErrorHandlerMiddleware(): void { + const middlewareDescriptor = this.globalMiddlewaresDescriptor + + if (!middlewareDescriptor) { + return + } + + const errorHandlerFn = middlewareDescriptor.config?.errorHandler + + /** + * If the user has opted out of the error handler then return + */ + if (errorHandlerFn === false) { + return + } + + /** + * If the user has provided a custom error handler then use it + */ + if (errorHandlerFn) { + this.router.use(errorHandlerFn) + return + } + + /** + * If the user has not provided a custom error handler then use the + * default one. + */ + this.router.use(errorHandler()) + } + + protected async registerRoutes(): Promise { + const middlewareDescriptor = this.globalMiddlewaresDescriptor + + const shouldWrapHandler = middlewareDescriptor?.config + ? middlewareDescriptor.config.errorHandler !== false + : true + + const prioritizedRoutes = prioritize([...this.routesMap.values()]) + + for (const descriptor of prioritizedRoutes) { + if (!descriptor.config?.routes?.length) { + continue + } + + const routes = descriptor.config.routes + for (const route of routes) { log({ activityId: this.activityId, @@ -475,7 +705,16 @@ export class RoutesLoader { descriptor.route }`, }) - this.app[route.method!.toLowerCase()](descriptor.route, route.handler) + + /** + * If the user hasn't opted out of error handling then + * we wrap the handler in a try/catch block. + */ + const handler = shouldWrapHandler + ? wrapHandler(route.handler as AsyncRouteHandler) + : route.handler + + this.router[route.method!.toLowerCase()](descriptor.route, handler) } } } @@ -493,51 +732,29 @@ export class RoutesLoader { 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}`, - }) + /** + * We don't prioritize the middlewares to preserve the order + * in which they are defined in the 'middlewares.ts'. This is to + * maintain the same behavior as how middleware is applied + * in Express. + */ - this.app[method.toLowerCase()](route.matcher, ...route.middlewares) - } - } else { + for (const route of routes) { + const methods = ( + Array.isArray(route.method) ? route.method : [route.method] + ).filter(Boolean) as MiddlewareVerb[] + + for (const method of methods) { log({ activityId: this.activityId, - message: `Registering middleware [${route.method}] - ${route.matcher}`, + message: `Registering middleware [${method}] - ${route.matcher}`, }) - this.app[route.method!.toLowerCase()]( - route.matcher, - ...route.middlewares - ) + this.router[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 && performance.mark("file-base-routing-start" + this.rootDir) @@ -561,10 +778,20 @@ export class RoutesLoader { await this.createRoutesMap({ dirPath: this.rootDir }) await this.createRoutesConfig() - this.applyGlobalMiddlewares() + this.applyRouteSpecificMiddlewares() await this.registerMiddlewares() await this.registerRoutes() + + this.applyErrorHandlerMiddleware() + + /** + * Apply the router to the app. + * + * This prevents middleware from a plugin from + * bleeding into the global middleware stack. + */ + this.app.use("/", this.router) } performance && performance.mark("file-base-routing-end" + this.rootDir) diff --git a/packages/medusa/src/loaders/helpers/routing/types.ts b/packages/medusa/src/loaders/helpers/routing/types.ts index 0a4a1edf2e..3a71c2fba1 100644 --- a/packages/medusa/src/loaders/helpers/routing/types.ts +++ b/packages/medusa/src/loaders/helpers/routing/types.ts @@ -1,4 +1,5 @@ import { + MedusaNextFunction, MedusaRequest, MedusaRequestHandler, MedusaResponse, @@ -18,12 +19,16 @@ export const HTTP_METHODS = [ ] as const export type RouteVerb = (typeof HTTP_METHODS)[number] -type MiddlewareVerb = "USE" | "ALL" | RouteVerb +export type MiddlewareVerb = "USE" | "ALL" | RouteVerb -type RouteHandler = ( +type SyncRouteHandler = (req: MedusaRequest, res: MedusaResponse) => void + +export type AsyncRouteHandler = ( req: MedusaRequest, res: MedusaResponse -) => Promise | void +) => Promise + +type RouteHandler = SyncRouteHandler | AsyncRouteHandler export type RouteImplementation = { method?: RouteVerb @@ -34,6 +39,8 @@ export type RouteConfig = { shouldRequireAdminAuth?: boolean shouldRequireCustomerAuth?: boolean shouldAppendCustomer?: boolean + shouldAppendAdminCors?: boolean + shouldAppendStoreCors?: boolean routes?: RouteImplementation[] } @@ -41,13 +48,28 @@ export type MiddlewareFunction = | MedusaRequestHandler | ((...args: any[]) => any) +export type MedusaErrorHandlerFunction = ( + error: any, + req: MedusaRequest, + res: MedusaResponse, + next: MedusaNextFunction +) => Promise | void + +type ParserConfig = + | false + | { + sizeLimit?: string | number | undefined + } + export type MiddlewareRoute = { method?: MiddlewareVerb | MiddlewareVerb[] matcher: string | RegExp + bodyParser?: ParserConfig middlewares: MiddlewareFunction[] } export type MiddlewaresConfig = { + errorHandler?: false | MedusaErrorHandlerFunction routes?: MiddlewareRoute[] } diff --git a/packages/medusa/src/loaders/passport.ts b/packages/medusa/src/loaders/passport.ts index f0988ade4e..e40764b00d 100644 --- a/packages/medusa/src/loaders/passport.ts +++ b/packages/medusa/src/loaders/passport.ts @@ -1,8 +1,8 @@ import { Express } from "express" import passport from "passport" -import { Strategy as JWTStrategy, ExtractJwt } from "passport-jwt" -import { Strategy as LocalStrategy } from "passport-local" import { Strategy as CustomStrategy } from "passport-custom" +import { ExtractJwt, Strategy as JWTStrategy } from "passport-jwt" +import { Strategy as LocalStrategy } from "passport-local" import { AuthService } from "../services" import { ConfigModule, MedusaContainer } from "../types/global" @@ -47,32 +47,28 @@ export default async ({ const { jwt_secret } = configModule.projectConfig passport.use( "admin-session", - new CustomStrategy( - async (req, done) => { + new CustomStrategy(async (req, done) => { + // @ts-ignore + if (req.session?.user_id) { // @ts-ignore - if(req.session?.user_id) { - // @ts-ignore - return done(null, { userId: req.session.user_id }) - } - - return done(null, false) + return done(null, { userId: req.session.user_id }) } - ) + + return done(null, false) + }) ) passport.use( "store-session", - new CustomStrategy( - async (req, done) => { + new CustomStrategy(async (req, done) => { + // @ts-ignore + if (req.session?.customer_id) { // @ts-ignore - if(req.session?.customer_id) { - // @ts-ignore - return done(null, { customer_id: req.session.customer_id }) - } - - return done(null, false) + return done(null, { customer_id: req.session.customer_id }) } - ) + + return done(null, false) + }) ) // Alternatively use API token to authenticate to the admin api @@ -80,7 +76,7 @@ export default async ({ "admin-api-token", new CustomStrategy(async (req, done) => { // extract the token from the header - const token = req.headers["x-medusa-access-token"]; + const token = req.headers["x-medusa-access-token"] // check if header exists and is string // typescript will complain if we don't check for type @@ -88,7 +84,7 @@ export default async ({ return done(null, false) } - const auth = await authService.authenticateAPIToken(token); + const auth = await authService.authenticateAPIToken(token) if (auth.success) { done(null, auth.user) } else { @@ -110,7 +106,7 @@ export default async ({ done(null, false) return } - + if (!token.user_id) { done(null, false) return @@ -134,7 +130,7 @@ export default async ({ done(null, false) return } - + if (!token.customer_id) { done(null, false) return diff --git a/packages/medusa/src/loaders/plugins.ts b/packages/medusa/src/loaders/plugins.ts index 715eee8cdf..d9f5178ce5 100644 --- a/packages/medusa/src/loaders/plugins.ts +++ b/packages/medusa/src/loaders/plugins.ts @@ -1,6 +1,16 @@ import { promiseAll, SearchUtils, upperCaseFirst } from "@medusajs/utils" import { aliasTo, asFunction, asValue, Lifetime } from "awilix" +import { Express } from "express" +import fs from "fs" +import { sync as existsSync } from "fs-exists-cached" +import glob from "glob" +import _ from "lodash" +import { createRequireFromPath } from "medusa-core-utils" import { FileService, OauthService } from "medusa-interfaces" +import { trackInstallation } from "medusa-telemetry" +import { EOL } from "os" +import path from "path" +import { EntitySchema } from "typeorm" import { AbstractTaxService, isBatchJobStrategy, @@ -10,6 +20,7 @@ import { isPriceSelectionStrategy, isTaxCalculationStrategy, } from "../interfaces" +import { MiddlewareService } from "../services" import { ClassConstructor, ConfigModule, @@ -20,24 +31,13 @@ import { formatRegistrationName, formatRegistrationNameWithoutNamespace, } from "../utils/format-registration-name" +import { getModelExtensionsMap } from "./helpers/get-model-extension-map" import { registerAbstractFulfillmentServiceFromClass, registerFulfillmentServiceFromClass, registerPaymentProcessorFromClass, registerPaymentServiceFromClass, } from "./helpers/plugins" - -import { Express } from "express" -import fs from "fs" -import { sync as existsSync } from "fs-exists-cached" -import glob from "glob" -import _ from "lodash" -import { createRequireFromPath } from "medusa-core-utils" -import { trackInstallation } from "medusa-telemetry" -import path from "path" -import { EntitySchema } from "typeorm" -import { MiddlewareService } from "../services" -import { getModelExtensionsMap } from "./helpers/get-model-extension-map" import { RoutesLoader } from "./helpers/routing" import logger from "./logger" @@ -325,7 +325,10 @@ function registerCoreRouters( const splat = descriptor.split("/") const path = `${splat[splat.length - 2]}/${splat[splat.length - 1]}` const loaded = require(fn).default - middlewareService.addRouter(path, loaded()) + + if (loaded && typeof loaded === "function") { + middlewareService.addRouter(path, loaded()) + } }) storeFiles.forEach((fn) => { @@ -333,7 +336,10 @@ function registerCoreRouters( const splat = descriptor.split("/") const path = `${splat[splat.length - 2]}/${splat[splat.length - 1]}` const loaded = require(fn).default - middlewareService.addRouter(path, loaded()) + + if (loaded && typeof loaded === "function") { + middlewareService.addRouter(path, loaded()) + } }) } @@ -358,7 +364,7 @@ async function registerApi( try { /** - * Register the plugin's api routes using the file based routing. + * Register the plugin's API routes using the file based routing. */ await new RoutesLoader({ app, @@ -366,7 +372,15 @@ async function registerApi( activityId: activityId, configModule: configmodule, }).load() + } catch (err) { + logger.warn( + `An error occurred while registering API Routes in ${projectName}${ + err.stack ? EOL + err.stack : "" + }` + ) + } + try { /** * For backwards compatibility we also support loading routes from * `/api/index` if the file exists. @@ -385,8 +399,6 @@ async function registerApi( app.use("/", routes(rootDirectory, pluginDetails.options)) } } - - return app } catch (err) { if (err.code !== "MODULE_NOT_FOUND") { logger.warn( @@ -397,9 +409,9 @@ async function registerApi( logger.warn(`${err.stack}`) } } - - return app } + + return app } /**