chore(framework): Move and improve routes loader (#8392)

* chore(framework): Move and improve routes loader

* cleanup

* fix(framework): import
This commit is contained in:
Adrien de Peretti authored and GitHub committed 2024-08-01 16:18:42 +02:00
1 parent 4081b3359d
commit f81652bf6e
102 files changed
+925 -802

No files matched your search

+13 -20
View File
@@ -1,8 +1,7 @@
import { ContainerRegistrationKeys } from "@medusajs/utils"
import { Express } from "express"
import path from "path"
import { join } from "path"
import qs from "qs"
import { RoutesLoader } from "./helpers/routing"
import { RoutesLoader } from "@medusajs/framework"
import { MedusaContainer, PluginDetails } from "@medusajs/types"
type Options = {
@@ -24,9 +23,7 @@ export default async ({ app, container, plugins }: Options) => {
next()
})
const configModule = container.resolve(
ContainerRegistrationKeys.CONFIG_MODULE
)
const sourcePaths: string[] = []
/**
* Always load plugin routes before the Medusa core routes, since it
@@ -40,31 +37,27 @@ export default async ({ app, container, plugins }: Options) => {
* route will never be resolved, because it will be handled by the
* "/products/:id" route.
*/
await Promise.all(
plugins.map(async (pluginDetails) => {
return new RoutesLoader({
app: app,
configModule,
rootDir: path.join(pluginDetails.resolve, "api"),
}).load()
})
sourcePaths.push(
...plugins.map((pluginDetails) => {
return join(pluginDetails.resolve, "api")
}),
/**
* Register the Medusa CORE API routes using the file based routing.
*/
join(__dirname, "../api")
)
// TODO: Figure out why this is causing issues with test when placed inside ./api.ts
// Adding this here temporarily
// Test: (packages/medusa/src/api/routes/admin/currencies/update-currency.ts)
try {
/**
* Register the Medusa CORE API routes using the file based routing.
*/
await new RoutesLoader({
app: app,
rootDir: path.join(__dirname, "../api"),
configModule,
sourceDir: sourcePaths,
}).load()
} catch (err) {
throw Error(
"An error occurred while registering Medusa Core API Routes. See error in logs for more details.",
"An error occurred while registering API Routes. See error in logs for more details.",
{ cause: err }
)
}
@@ -1,4 +1,4 @@
import { FlagSettings } from "../../types/feature-flags"
import { FlagSettings } from "@medusajs/framework"
const AnalyticsFeatureFlag: FlagSettings = {
key: "analytics",
@@ -1,4 +1,4 @@
import { FlagSettings } from "../../types/feature-flags"
import { FlagSettings } from "@medusajs/framework"
const OrderEditingFeatureFlag: FlagSettings = {
key: "order_editing",
@@ -1,4 +1,4 @@
import { FlagSettings } from "../../types/feature-flags"
import { FlagSettings } from "@medusajs/framework"
const ProductCategoryFeatureFlag: FlagSettings = {
key: "product_categories",
@@ -1,4 +1,4 @@
import { FlagSettings } from "../../types/feature-flags"
import { FlagSettings } from "@medusajs/framework"
const PublishableAPIKeysFeatureFlag: FlagSettings = {
key: "publishable_api_keys",
@@ -1,4 +1,4 @@
import { FlagSettings } from "../../types/feature-flags"
import { FlagSettings } from "@medusajs/framework"
const SalesChannelFeatureFlag: FlagSettings = {
key: "sales_channels",
@@ -1,4 +1,4 @@
import { FlagSettings } from "../../types/feature-flags"
import { FlagSettings } from "@medusajs/framework"
const TaxInclusivePricingFeatureFlag: FlagSettings = {
key: "tax_inclusive_pricing",
@@ -1,20 +0,0 @@
import { ConfigModule } from "@medusajs/types"
export const customersGlobalMiddlewareMock = jest.fn()
export const customersCreateMiddlewareMock = jest.fn()
export const storeGlobalMiddlewareMock = jest.fn()
export const config: ConfigModule = {
projectConfig: {
databaseLogging: false,
http: {
authCors: "http://localhost:9000",
storeCors: "http://localhost:8000",
adminCors: "http://localhost:7001",
jwtSecret: "supersecret",
cookieSecret: "superSecret",
},
},
featureFlags: {},
plugins: [],
}
@@ -1,9 +0,0 @@
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")
}
@@ -1,39 +0,0 @@
import { defineMiddlewares } from "../../../../../utils/define-middlewares"
export default defineMiddlewares({
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.",
})
}
},
})
@@ -1,27 +0,0 @@
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",
}
}
@@ -1,5 +0,0 @@
import { Request, Response } from "express"
export const GET = (req: Request, res: Response) => {
res.send(`GET /admin/protected`)
}
@@ -1,7 +0,0 @@
import { Request, Response } from "express"
export const AUTHENTICATE = false
export const GET = (req: Request, res: Response) => {
res.send(`GET /admin/unprotected`)
}
@@ -1,6 +0,0 @@
import { Request, Response } from "express"
import { MedusaError } from "@medusajs/utils"
export const GET = async (req: Request, res: Response) => {
throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Not allowed")
}
@@ -1,9 +0,0 @@
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")
}
@@ -1,52 +0,0 @@
import { NextFunction, Request, Response, raw } from "express"
import {
customersCreateMiddlewareMock,
customersGlobalMiddlewareMock,
storeGlobalMiddlewareMock,
} from "../mocks"
import { defineMiddlewares } from "../../../../../utils/define-middlewares"
const customersGlobalMiddleware = (
req: Request,
res: Response,
next: NextFunction
) => {
customersGlobalMiddlewareMock()
next()
}
const customersCreateMiddleware = (
req: Request,
res: Response,
next: NextFunction
) => {
customersCreateMiddlewareMock()
next()
}
const storeGlobal = (req: Request, res: Response, next: NextFunction) => {
storeGlobalMiddlewareMock()
next()
}
export default defineMiddlewares([
{
matcher: "/customers",
middlewares: [customersGlobalMiddleware],
},
{
method: "POST",
matcher: "/customers",
middlewares: [customersCreateMiddleware],
},
{
matcher: "/store/*",
middlewares: [storeGlobal],
},
{
matcher: "/webhooks/*",
method: "POST",
bodyParser: false,
middlewares: [raw({ type: "application/json" })],
},
])
@@ -1,5 +0,0 @@
import { Request, Response } from "express"
export const GET = async (req: Request, res: Response) => {
res.send(`GET /store/protected`)
}
@@ -1,7 +0,0 @@
import { Request, Response } from "express"
export const AUTHENTICATE = false
export const GET = async (req: Request, res: Response) => {
res.send(`GET /store/unprotected`)
}
@@ -1,5 +0,0 @@
import { Request, Response } from "express"
export const POST = (req: Request, res: Response) => {
res.send(`sync product ${req.params.id}`)
}
@@ -1,9 +0,0 @@
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")
}
@@ -1,5 +0,0 @@
import { Request, Response } from "express"
export const GET = async (req: Request, res: Response): Promise<void> => {
res.send(`GET private route`)
}
@@ -1,17 +0,0 @@
import { Request, Response } from "express"
export async function GET(req: Request, res: Response): Promise<void> {
try {
res.send(`GET order ${req.params.id}`)
} catch (err) {
res.status(400).send(err)
}
}
export async function POST(req: Request, res: Response): Promise<void> {
try {
res.send(`POST order ${req.params.id}`)
} catch (err) {
res.status(400).send(err)
}
}
@@ -1,5 +0,0 @@
import { Request, Response } from "express"
export function GET(req: Request, res: Response) {
res.send("hello world")
}
@@ -1,5 +0,0 @@
import { Request, Response } from "express"
export async function GET(req: Request, res: Response): Promise<void> {
console.log("hello world")
}
@@ -1,29 +0,0 @@
import { Request, Response } from "express"
export async function GET(req: Request, res: Response): Promise<void> {
console.log("hello world")
}
export async function POST(req: Request, res: Response): Promise<void> {
console.log("hello world")
}
export async function PUT(req: Request, res: Response): Promise<void> {
console.log("hello world")
}
export async function DELETE(req: Request, res: Response): Promise<void> {
console.log("hello world")
}
export async function PATCH(req: Request, res: Response): Promise<void> {
console.log("hello world")
}
export async function OPTIONS(req: Request, res: Response): Promise<void> {
console.log("hello world")
}
export async function HEAD(req: Request, res: Response): Promise<void> {
console.log("hello world")
}
@@ -1,9 +0,0 @@
import { Request, Response } from "express"
export async function GET(req: Request, res: Response): Promise<void> {
console.log("hello world")
}
export async function POST(req: Request, res: Response): Promise<void> {
console.log("hello world")
}
@@ -1,7 +0,0 @@
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))
}
@@ -1,5 +0,0 @@
import { NextFunction, Request, Response } from "express"
export function GET(req: Request, res: Response) {
res.send("list customers")
}
@@ -1,196 +0,0 @@
import {
moduleLoader,
ModulesDefinition,
registerMedusaModule,
} from "@medusajs/modules-sdk"
import {
configManager,
ConfigModule,
container,
featureFlagsLoader,
logger,
} from "@medusajs/framework"
import { ContainerRegistrationKeys, generateJwtToken } from "@medusajs/utils"
import { asValue } from "awilix"
import express from "express"
import querystring from "querystring"
import supertest from "supertest"
import apiLoader from "../../../../api"
import { getResolvedPlugins } from "../../../../helpers/resolve-plugins"
import RoutesLoader from "../.."
import { config } from "../mocks"
import { MedusaContainer } from "@medusajs/types"
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]
})
configManager.loadConfig({
projectConfig: config as ConfigModule,
baseDir: rootDir,
})
container.registerAdd = function (this: MedusaContainer, name, registration) {
const storeKey = name + "_STORE"
if (this.registrations[storeKey] === undefined) {
this.register(storeKey, asValue([]))
}
const store = this.resolve(storeKey) as Array<any>
if (this.registrations[name] === undefined) {
this.register(name, asArray(store))
}
store.unshift(registration)
return this
}.bind(container)
container.register(ContainerRegistrationKeys.PG_CONNECTION, asValue({}))
container.register("configModule", asValue(config))
container.register({
logger: asValue({
error: () => {},
}),
manager: asValue({}),
})
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()
})
const plugins = getResolvedPlugins(rootDir, config) || []
await featureFlagsLoader()
await moduleLoader({ container, moduleResolutions, logger })
app.use((req, res, next) => {
req.scope = container.createScope() as MedusaContainer
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,
plugins,
})
const superRequest = supertest(app)
return {
request: async (method, url, opts: any = {}) => {
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 = generateJwtToken(
{
actor_id: opts.adminSession.userId || opts.adminSession.jwt?.userId,
actor_type: "user",
app_metadata: {
user_id:
opts.adminSession.userId || opts.adminSession.jwt?.userId,
},
},
{
secret: config.projectConfig.http.jwtSecret!,
expiresIn: "1d",
}
)
headers.Authorization = `Bearer ${token}`
}
if (opts.clientSession) {
const token = generateJwtToken(
{
actor_id:
opts.clientSession.customer_id ||
opts.clientSession.jwt?.customer_id,
actor_type: "customer",
app_metadata: {
customer_id:
opts.clientSession.customer_id ||
opts.clientSession.jwt?.customer_id,
},
},
{ secret: config.projectConfig.http.jwtSecret!, expiresIn: "1d" }
)
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
},
}
}
@@ -1,245 +0,0 @@
import express from "express"
import { resolve } from "path"
import {
config,
customersCreateMiddlewareMock,
customersGlobalMiddlewareMock,
storeGlobalMiddlewareMock,
} from "../__fixtures__/mocks"
import { createServer } from "../__fixtures__/server"
import { RoutesLoader } from "../index"
jest.setTimeout(30000)
describe("RoutesLoader", function () {
afterEach(function () {
jest.clearAllMocks()
})
describe("Routes", function () {
let request
beforeAll(async function () {
const rootDir = resolve(__dirname, "../__fixtures__/routers")
const { request: request_ } = await createServer(rootDir)
request = request_
})
it("should return a status 200 on GET admin/order/:id", async function () {
const res = await request("GET", "/admin/orders/1000", {
adminSession: {
jwt: {
userId: "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 () {
const res = await request("POST", "/admin/orders/1000", {
adminSession: {
jwt: {
userId: "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 () {
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("GET", "/_private")
expect(res.status).toBe(404)
expect(res.text).toContain("Cannot GET /_private")
})
})
describe("Middlewares", function () {
let request
beforeAll(async function () {
const rootDir = resolve(__dirname, "../__fixtures__/routers-middleware")
const { request: request_ } = await createServer(rootDir)
request = request_
})
it("should call middleware applied to `/customers`", async function () {
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 () {
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 () {
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 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()
})
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: "admin_user",
},
},
})
expect(res.status).toBe(200)
expect(res.text).toBe("GET /admin/protected")
})
it.skip("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 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",
})
})
})
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: config,
})
.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."
)
})
})
})
@@ -1,829 +0,0 @@
import { ConfigModule } from "@medusajs/types"
import { parseCorsOrigins, promiseAll, wrapHandler } from "@medusajs/utils"
import cors from "cors"
import { type Express, json, Router, text, urlencoded } from "express"
import { readdir } from "fs/promises"
import { extname, join, sep } from "path"
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
import { authenticate, errorHandler } from "../../../utils/middlewares"
import { logger } from "@medusajs/framework"
import {
AsyncRouteHandler,
GlobalMiddlewareDescriptor,
HTTP_METHODS,
MiddlewareRoute,
MiddlewaresConfig,
MiddlewareVerb,
ParserConfigArgs,
RouteConfig,
RouteDescriptor,
RouteVerb,
} from "./types"
const log = ({
activityId,
message,
}: {
activityId?: string
message: string
}) => {
if (activityId) {
logger.progress(activityId, message)
return
}
logger.debug(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
}
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(args?: ParserConfigArgs) {
const sizeLimit = args?.sizeLimit
const preserveRawBody = args?.preserveRawBody
return [
json({
limit: sizeLimit,
verify: preserveRawBody
? (req: MedusaRequest, res: MedusaResponse, buf: Buffer) => {
req.rawBody = buf
}
: undefined,
}),
text({ limit: sizeLimit }),
urlencoded({ limit: sizeLimit, extended: true }),
]
}
export class RoutesLoader {
protected routesMap = new Map<string, RouteDescriptor>()
protected globalMiddlewaresDescriptor: GlobalMiddlewareDescriptor | undefined
protected app: Express
protected router: Router
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.router = Router()
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 && !config?.errorHandler) {
log({
activityId: this.activityId,
message: `Empty middleware config. 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]/route.ts => "/admin/orders/:id/route.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<void>}
*/
protected async createRoutesConfig(): Promise<void> {
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
const config: RouteConfig = {
routes: [],
}
/**
* If the developer has not exported the
* AUTHENTICATE flag we default to true.
*/
const shouldRequireAuth =
import_[AUTHTHENTICATE] !== undefined
? (import_[AUTHTHENTICATE] as boolean)
: true
config.optedOutOfAuth = !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")) {
config.routeType = "admin"
if (shouldAddCors) {
config.shouldAppendAdminCors = true
}
}
if (route.startsWith("/store")) {
config.routeType = "store"
if (shouldAddCors) {
config.shouldAppendStoreCors = true
}
}
if (route.startsWith("/auth") && shouldAddCors) {
config.routeType = "auth"
if (shouldAddCors) {
config.shouldAppendAuthCors = 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(sep)
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_.default 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<void> {
await promiseAll(
await readdir(dirPath, { withFileTypes: true }).then((entries) => {
return entries
.filter((entry) => {
if (
this.excludes.length &&
this.excludes.some((exclude) => exclude.test(entry.name))
) {
return false
}
let name = entry.name
const extension = extname(name)
if (extension) {
name = name.replace(extension, "")
}
if (entry.isFile() && name !== ROUTE_NAME) {
return false
}
return true
})
.map(async (entry) => {
const childPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
return await this.createRoutesMap({
dirPath: childPath,
parentPath: parentPath ?? dirPath,
})
}
return this.createRoutesDescriptor({
childPath,
parentPath,
})
})
.flat(Infinity)
})
)
}
/**
* 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) {
this.router[method.toLowerCase()](
path,
...getBodyParserMiddleware(mostSpecificConfig?.bodyParser)
)
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) {
if (!descriptor.config?.routes?.length) {
continue
}
const config = descriptor.config
const routes = descriptor.config.routes
/**
* Apply default store and admin middlewares if
* not opted out of.
*/
if (config.shouldAppendAdminCors) {
/**
* Apply the admin cors
*/
this.router.use(
descriptor.route,
cors({
origin: parseCorsOrigins(
this.configModule.projectConfig.http.adminCors
),
credentials: true,
})
)
}
if (config.shouldAppendAuthCors) {
/**
* Apply the auth cors
*/
this.router.use(
descriptor.route,
cors({
origin: parseCorsOrigins(
this.configModule.projectConfig.http.authCors
),
credentials: true,
})
)
}
if (config.shouldAppendStoreCors) {
/**
* Apply the store cors
*/
this.router.use(
descriptor.route,
cors({
origin: parseCorsOrigins(
this.configModule.projectConfig.http.storeCors
),
credentials: true,
})
)
}
// We only apply the auth middleware to store routes to populate the auth context. For actual authentication, users can just reapply the middleware.
if (!config.optedOutOfAuth && config.routeType === "store") {
this.router.use(
descriptor.route,
authenticate("customer", ["bearer", "session"], {
allowUnauthenticated: true,
})
)
}
if (!config.optedOutOfAuth && config.routeType === "admin") {
// We probably don't want to allow access to all endpoints using an api key, but it will do until we revamp our routing.
this.router.use(
descriptor.route,
authenticate("user", ["bearer", "session", "api-key"])
)
}
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
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<void> {
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,
message: `Registering route [${route.method?.toUpperCase()}] - ${
descriptor.route
}`,
})
/**
* 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)
}
}
}
protected async registerMiddlewares(): Promise<void> {
const descriptor = this.globalMiddlewaresDescriptor
if (!descriptor) {
return
}
if (!descriptor.config?.routes?.length) {
return
}
const routes = descriptor.config.routes
/**
* 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.
*/
for (const route of routes) {
if (!route.middlewares || !route.middlewares.length) {
continue
}
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 [${method}] - ${route.matcher}`,
})
this.router[method.toLowerCase()](route.matcher, ...route.middlewares)
}
}
}
async load() {
performance && performance.mark("file-base-routing-start" + this.rootDir)
let apiExists = true
/**
* Since the file based routing does not require a index file
* we can't 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.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)
const timeSpent =
performance &&
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
@@ -1,87 +0,0 @@
import {
MedusaNextFunction,
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]
export type MiddlewareVerb = "USE" | "ALL" | RouteVerb
type SyncRouteHandler = (req: MedusaRequest, res: MedusaResponse) => void
export type AsyncRouteHandler = (
req: MedusaRequest,
res: MedusaResponse
) => Promise<void>
type RouteHandler = SyncRouteHandler | AsyncRouteHandler
export type RouteImplementation = {
method?: RouteVerb
handler: RouteHandler
}
export type RouteConfig = {
optedOutOfAuth?: boolean
routeType?: "admin" | "store" | "auth"
shouldAppendAdminCors?: boolean
shouldAppendStoreCors?: boolean
shouldAppendAuthCors?: boolean
routes?: RouteImplementation[]
}
export type MiddlewareFunction =
| MedusaRequestHandler
| ((...args: any[]) => any)
export type MedusaErrorHandlerFunction = (
error: any,
req: MedusaRequest,
res: MedusaResponse,
next: MedusaNextFunction
) => Promise<void> | void
export type ParserConfigArgs = {
sizeLimit?: string | number | undefined
preserveRawBody?: boolean
}
export type ParserConfig = false | ParserConfigArgs
export type MiddlewareRoute = {
method?: MiddlewareVerb | MiddlewareVerb[]
matcher: string | RegExp
bodyParser?: ParserConfig
middlewares?: MiddlewareFunction[]
}
export type MiddlewaresConfig = {
errorHandler?: false | MedusaErrorHandlerFunction
routes?: MiddlewareRoute[]
}
export type RouteDescriptor = {
absolutePath: string
relativePath: string
route: string
priority: number
config?: RouteConfig
}
export type GlobalMiddlewareDescriptor = {
config?: MiddlewaresConfig
}