feat(medusa): support file based routing (#5365)
* feat(medusa): Implement file base routing mechanism * cleanup * cleanup and improvements * improve tests * update tests * Create tame-trains-grin.md * fix tests, move fixtures * fix tests * lint * init work * work on supported methods * add config generator * progress * progress * rework middleware registartion * progress * progress * export MiddlewareConfig type * add type extensions * work on improving logs * work on loggers * apply cors, json parsing, and admin authentication per default * address feedback * rm changeset --------- Co-authored-by: adrien2p <adrien.deperetti@gmail.com> Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
co-authored by
adrien2p
Oli Juhl
parent
49ed7de752
commit
ddff919655
@@ -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"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const customersGlobalMiddlewareMock = jest.fn()
|
||||
export const customersCreateMiddlewareMock = jest.fn()
|
||||
export const storeCorsMiddlewareMock = jest.fn()
|
||||
+9
@@ -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")
|
||||
}
|
||||
+9
@@ -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")
|
||||
}
|
||||
+48
@@ -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],
|
||||
},
|
||||
],
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export const POST = (req: Request, res: Response) => {
|
||||
res.send(`sync product ${req.params.id}`)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export const GET = async (req: Request, res: Response): Promise<void> => {
|
||||
res.send(`GET private route`)
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export async function GET(req: Request, res: Response): Promise<void> {
|
||||
res.send(`GET order ${req.params.id}`)
|
||||
}
|
||||
|
||||
export async function POST(req: Request, res: Response): Promise<void> {
|
||||
res.send(`POST order ${req.params.id}`)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export function GET(req: Request, res: Response) {
|
||||
res.send("hello world")
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export async function GET(req: Request, res: Response): Promise<void> {
|
||||
console.log("hello world")
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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")
|
||||
}
|
||||
+7
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextFunction, Request, Response } from "express"
|
||||
|
||||
export function GET(req: Request, res: Response) {
|
||||
res.send("list customers")
|
||||
}
|
||||
@@ -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."
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<string, RouteDescriptor>()
|
||||
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<void>}
|
||||
*/
|
||||
protected async createRoutesConfig(): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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
|
||||
@@ -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> | 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
|
||||
}
|
||||
@@ -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<Express> {
|
||||
const logger = container.resolve<Logger>("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) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
MiddlewareFunction,
|
||||
MiddlewareRoute,
|
||||
MiddlewaresConfig,
|
||||
} from "../loaders/helpers/routing/types"
|
||||
@@ -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> | void
|
||||
Reference in New Issue
Block a user