chore(framework): Move and improve routes loader (#8392)
* chore(framework): Move and improve routes loader * cleanup * fix(framework): import
This commit is contained in:
committed by
GitHub
parent
4081b3359d
commit
f81652bf6e
@@ -41,8 +41,10 @@
|
||||
"devDependencies": {
|
||||
"@medusajs/types": "^1.11.16",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"cross-env": "^7.0.3",
|
||||
"rimraf": "^3.0.2",
|
||||
"supertest": "^4.0.2",
|
||||
"tsc-alias": "^1.8.6",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^5.2.11"
|
||||
@@ -55,11 +57,14 @@
|
||||
"@medusajs/workflows-sdk": "^0.1.6",
|
||||
"awilix": "^8.0.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"ioredis": "^5.2.5",
|
||||
"ioredis-mock": "8.4.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"medusa-telemetry": "^0.0.17",
|
||||
"morgan": "^1.9.1"
|
||||
"morgan": "^1.9.1",
|
||||
"zod": "3.22.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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: [],
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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.",
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export const GET = (req: Request, res: Response) => {
|
||||
res.send(`GET /admin/protected`)
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextFunction, raw, Request, Response } 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" })],
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export const GET = async (req: Request, res: Response) => {
|
||||
res.send(`GET /store/protected`)
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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,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")
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export const GET = async (req: Request, res: Response): Promise<void> => {
|
||||
res.send(`GET private route`)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export function GET(req: Request, res: Response) {
|
||||
res.send("hello world")
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Request, Response } from "express"
|
||||
|
||||
export async function GET(req: Request, res: Response): Promise<void> {
|
||||
console.log("hello world")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,181 @@
|
||||
import {
|
||||
moduleLoader,
|
||||
ModulesDefinition,
|
||||
registerMedusaModule,
|
||||
} from "@medusajs/modules-sdk"
|
||||
import { ContainerRegistrationKeys, generateJwtToken } from "@medusajs/utils"
|
||||
import { asValue } from "awilix"
|
||||
import express from "express"
|
||||
import querystring from "querystring"
|
||||
import supertest from "supertest"
|
||||
|
||||
import { config } from "../mocks"
|
||||
import { MedusaContainer } from "@medusajs/types"
|
||||
import { configManager, ConfigModule } from "../../../config"
|
||||
import { container } from "../../../container"
|
||||
import { featureFlagsLoader } from "../../../feature-flags"
|
||||
import { logger } from "../../../logger"
|
||||
import { MedusaRequest } from "../../types"
|
||||
import { RoutesLoader } from "../../router"
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
await featureFlagsLoader()
|
||||
await moduleLoader({ container, moduleResolutions, logger })
|
||||
|
||||
app.use((req, res, next) => {
|
||||
;(req as MedusaRequest).scope = container.createScope() as MedusaContainer
|
||||
next()
|
||||
})
|
||||
|
||||
await new RoutesLoader({
|
||||
app,
|
||||
sourceDir: rootDir,
|
||||
}).load()
|
||||
|
||||
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
|
||||
},
|
||||
}
|
||||
}
|
||||
243
packages/framework/framework/src/http/__tests__/index.spec.ts
Normal file
243
packages/framework/framework/src/http/__tests__/index.spec.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import express from "express"
|
||||
import { resolve } from "path"
|
||||
import {
|
||||
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,
|
||||
sourceDir: rootDir,
|
||||
})
|
||||
.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 +1,5 @@
|
||||
export * from "./express-loader"
|
||||
export * from "./router"
|
||||
export * from "./types"
|
||||
export * from "./middlewares"
|
||||
export * from "./utils/define-middlewares"
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { ApiKeyDTO, IApiKeyModuleService } from "@medusajs/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
ModuleRegistrationName,
|
||||
} from "@medusajs/utils"
|
||||
import { NextFunction, RequestHandler } from "express"
|
||||
import { JwtPayload, verify } from "jsonwebtoken"
|
||||
import {
|
||||
AuthContext,
|
||||
AuthenticatedMedusaRequest,
|
||||
MedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "../types"
|
||||
import { ConfigModule } from "../../config"
|
||||
|
||||
const SESSION_AUTH = "session"
|
||||
const BEARER_AUTH = "bearer"
|
||||
const API_KEY_AUTH = "api-key"
|
||||
|
||||
// This is the only hard-coded actor type, as API keys have special handling for now. We could also generalize API keys to carry the actor type with them.
|
||||
const ADMIN_ACTOR_TYPE = "user"
|
||||
|
||||
type AuthType = typeof SESSION_AUTH | typeof BEARER_AUTH | typeof API_KEY_AUTH
|
||||
|
||||
type MedusaSession = {
|
||||
auth_context: AuthContext
|
||||
}
|
||||
|
||||
export const authenticate = (
|
||||
actorType: string | string[],
|
||||
authType: AuthType | AuthType[],
|
||||
options: { allowUnauthenticated?: boolean; allowUnregistered?: boolean } = {}
|
||||
): RequestHandler => {
|
||||
const handler = async (
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
const authTypes = Array.isArray(authType) ? authType : [authType]
|
||||
const actorTypes = Array.isArray(actorType) ? actorType : [actorType]
|
||||
const req_ = req as AuthenticatedMedusaRequest
|
||||
|
||||
// We only allow authenticating using a secret API key on the admin
|
||||
const isExclusivelyUser =
|
||||
actorTypes.length === 1 && actorTypes[0] === ADMIN_ACTOR_TYPE
|
||||
|
||||
if (authTypes.includes(API_KEY_AUTH) && isExclusivelyUser) {
|
||||
const apiKey = await getApiKeyInfo(req)
|
||||
if (apiKey) {
|
||||
req_.auth_context = {
|
||||
actor_id: apiKey.id,
|
||||
actor_type: "api-key",
|
||||
auth_identity_id: "",
|
||||
app_metadata: {},
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
// We try to extract the auth context either from the session or from a JWT token
|
||||
let authContext: AuthContext | null = getAuthContextFromSession(
|
||||
req.session,
|
||||
authTypes,
|
||||
actorTypes
|
||||
)
|
||||
|
||||
if (!authContext) {
|
||||
const {
|
||||
projectConfig: { http },
|
||||
} = req.scope.resolve<ConfigModule>(
|
||||
ContainerRegistrationKeys.CONFIG_MODULE
|
||||
)
|
||||
|
||||
authContext = getAuthContextFromJwtToken(
|
||||
req.headers.authorization,
|
||||
http.jwtSecret!,
|
||||
authTypes,
|
||||
actorTypes
|
||||
)
|
||||
}
|
||||
|
||||
// If the entity is authenticated, and it is a registered actor we can continue
|
||||
if (authContext?.actor_id) {
|
||||
req_.auth_context = authContext
|
||||
return next()
|
||||
}
|
||||
|
||||
// If the entity is authenticated, but there is no registered actor yet, we can continue (eg. in the case of a user invite) if allow unregistered is set
|
||||
if (authContext?.auth_identity_id && options.allowUnregistered) {
|
||||
req_.auth_context = authContext
|
||||
return next()
|
||||
}
|
||||
|
||||
// If we allow unauthenticated requests (i.e public endpoints), just continue
|
||||
if (options.allowUnauthenticated) {
|
||||
return next()
|
||||
}
|
||||
|
||||
res.status(401).json({ message: "Unauthorized" })
|
||||
}
|
||||
|
||||
return handler as unknown as RequestHandler
|
||||
}
|
||||
|
||||
const getApiKeyInfo = async (req: MedusaRequest): Promise<ApiKeyDTO | null> => {
|
||||
const authHeader = req.headers.authorization
|
||||
if (!authHeader) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [tokenType, token] = authHeader.split(" ")
|
||||
if (tokenType.toLowerCase() !== "basic" || !token) {
|
||||
return null
|
||||
}
|
||||
|
||||
// The token could have been base64 encoded, we want to decode it first.
|
||||
let normalizedToken = token
|
||||
if (!token.startsWith("sk_")) {
|
||||
normalizedToken = Buffer.from(token, "base64").toString("utf-8")
|
||||
}
|
||||
|
||||
// Basic auth is defined as a username:password set, and since the token is set to the username we need to trim the colon
|
||||
if (normalizedToken.endsWith(":")) {
|
||||
normalizedToken = normalizedToken.slice(0, -1)
|
||||
}
|
||||
|
||||
// Secret tokens start with 'sk_', and if it doesn't it could be a user JWT or a malformed token
|
||||
if (!normalizedToken.startsWith("sk_")) {
|
||||
return null
|
||||
}
|
||||
|
||||
const apiKeyModule = req.scope.resolve(
|
||||
ModuleRegistrationName.API_KEY
|
||||
) as IApiKeyModuleService
|
||||
try {
|
||||
const apiKey = await apiKeyModule.authenticate(normalizedToken)
|
||||
if (!apiKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
return apiKey
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getAuthContextFromSession = (
|
||||
session: Partial<MedusaSession> = {},
|
||||
authTypes: AuthType[],
|
||||
actorTypes: string[]
|
||||
): AuthContext | null => {
|
||||
if (!authTypes.includes(SESSION_AUTH)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
session.auth_context &&
|
||||
(actorTypes.includes("*") ||
|
||||
actorTypes.includes(session.auth_context.actor_type))
|
||||
) {
|
||||
return session.auth_context
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const getAuthContextFromJwtToken = (
|
||||
authHeader: string | undefined,
|
||||
jwtSecret: string,
|
||||
authTypes: AuthType[],
|
||||
actorTypes: string[]
|
||||
): AuthContext | null => {
|
||||
if (!authTypes.includes(BEARER_AUTH)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!authHeader) {
|
||||
return null
|
||||
}
|
||||
|
||||
const re = /(\S+)\s+(\S+)/
|
||||
const matches = authHeader.match(re)
|
||||
|
||||
// TODO: figure out how to obtain token (and store correct data in token)
|
||||
if (matches) {
|
||||
const tokenType = matches[1]
|
||||
const token = matches[2]
|
||||
if (tokenType.toLowerCase() === BEARER_AUTH) {
|
||||
// get config jwt secret
|
||||
// verify token and set authUser
|
||||
try {
|
||||
const verified = verify(token, jwtSecret) as JwtPayload
|
||||
if (
|
||||
actorTypes.includes("*") ||
|
||||
actorTypes.includes(verified.actor_type)
|
||||
) {
|
||||
return verified as AuthContext
|
||||
}
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { NextFunction, Response } from "express"
|
||||
|
||||
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/utils"
|
||||
import { formatException } from "./exception-formatter"
|
||||
import { MedusaRequest } from "../types"
|
||||
import { logger as logger_ } from "../../logger"
|
||||
|
||||
const QUERY_RUNNER_RELEASED = "QueryRunnerAlreadyReleasedError"
|
||||
const TRANSACTION_STARTED = "TransactionAlreadyStartedError"
|
||||
const TRANSACTION_NOT_STARTED = "TransactionNotStartedError"
|
||||
|
||||
const API_ERROR = "api_error"
|
||||
const INVALID_REQUEST_ERROR = "invalid_request_error"
|
||||
const INVALID_STATE_ERROR = "invalid_state_error"
|
||||
|
||||
export function errorHandler() {
|
||||
return (
|
||||
err: MedusaError,
|
||||
req: MedusaRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
const logger: typeof logger_ = req.scope.resolve(
|
||||
ContainerRegistrationKeys.LOGGER
|
||||
)
|
||||
|
||||
err = formatException(err)
|
||||
|
||||
logger.error(err)
|
||||
|
||||
const errorType = err.type || err.name
|
||||
|
||||
const errObj = {
|
||||
code: err.code,
|
||||
type: err.type,
|
||||
message: err.message,
|
||||
}
|
||||
|
||||
let statusCode = 500
|
||||
switch (errorType) {
|
||||
case QUERY_RUNNER_RELEASED:
|
||||
case TRANSACTION_STARTED:
|
||||
case TRANSACTION_NOT_STARTED:
|
||||
case MedusaError.Types.CONFLICT:
|
||||
statusCode = 409
|
||||
errObj.code = INVALID_STATE_ERROR
|
||||
errObj.message =
|
||||
"The request conflicted with another request. You may retry the request with the provided Idempotency-Key."
|
||||
break
|
||||
case MedusaError.Types.UNAUTHORIZED:
|
||||
statusCode = 401
|
||||
break
|
||||
case MedusaError.Types.PAYMENT_AUTHORIZATION_ERROR:
|
||||
statusCode = 422
|
||||
break
|
||||
case MedusaError.Types.DUPLICATE_ERROR:
|
||||
statusCode = 422
|
||||
errObj.code = INVALID_REQUEST_ERROR
|
||||
break
|
||||
case MedusaError.Types.NOT_ALLOWED:
|
||||
case MedusaError.Types.INVALID_DATA:
|
||||
statusCode = 400
|
||||
break
|
||||
case MedusaError.Types.NOT_FOUND:
|
||||
statusCode = 404
|
||||
break
|
||||
case MedusaError.Types.DB_ERROR:
|
||||
statusCode = 500
|
||||
errObj.code = API_ERROR
|
||||
break
|
||||
case MedusaError.Types.UNEXPECTED_STATE:
|
||||
case MedusaError.Types.INVALID_ARGUMENT:
|
||||
break
|
||||
default:
|
||||
errObj.code = "unknown_error"
|
||||
errObj.message = "An unknown error occurred."
|
||||
errObj.type = "unknown_error"
|
||||
break
|
||||
}
|
||||
|
||||
res.status(statusCode).json(errObj)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @schema Error
|
||||
* title: "Response Error"
|
||||
* type: object
|
||||
* properties:
|
||||
* code:
|
||||
* type: string
|
||||
* description: A slug code to indicate the type of the error.
|
||||
* enum: [invalid_state_error, invalid_request_error, api_error, unknown_error]
|
||||
* message:
|
||||
* type: string
|
||||
* description: Description of the error that occurred.
|
||||
* example: "first_name must be a string"
|
||||
* type:
|
||||
* type: string
|
||||
* description: A slug indicating the type of the error.
|
||||
* enum: [QueryRunnerAlreadyReleasedError, TransactionAlreadyStartedError, TransactionNotStartedError, conflict, unauthorized, payment_authorization_error, duplicate_error, not_allowed, invalid_data, not_found, database_error, unexpected_state, invalid_argument, unknown_error]
|
||||
*/
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MedusaError } from "@medusajs/utils"
|
||||
|
||||
export enum PostgresError {
|
||||
DUPLICATE_ERROR = "23505",
|
||||
FOREIGN_KEY_ERROR = "23503",
|
||||
SERIALIZATION_FAILURE = "40001",
|
||||
NULL_VIOLATION = "23502",
|
||||
}
|
||||
|
||||
export const formatException = (err): MedusaError => {
|
||||
switch (err.code) {
|
||||
case PostgresError.DUPLICATE_ERROR:
|
||||
return new MedusaError(
|
||||
MedusaError.Types.DUPLICATE_ERROR,
|
||||
`${err.table.charAt(0).toUpperCase()}${err.table.slice(
|
||||
1
|
||||
)} with ${err.detail.slice(4).replace(/[()=]/g, (s) => {
|
||||
return s === "=" ? " " : ""
|
||||
})}`
|
||||
)
|
||||
case PostgresError.FOREIGN_KEY_ERROR: {
|
||||
const matches =
|
||||
/Key \(([\w-\d]+)\)=\(([\w-\d]+)\) is not present in table "(\w+)"/g.exec(
|
||||
err.detail
|
||||
)
|
||||
|
||||
if (matches?.length !== 4) {
|
||||
return new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
JSON.stringify(matches)
|
||||
)
|
||||
}
|
||||
|
||||
return new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`${matches[3]?.charAt(0).toUpperCase()}${matches[3]?.slice(1)} with ${
|
||||
matches[1]
|
||||
} ${matches[2]} does not exist.`
|
||||
)
|
||||
}
|
||||
case PostgresError.SERIALIZATION_FAILURE: {
|
||||
return new MedusaError(
|
||||
MedusaError.Types.CONFLICT,
|
||||
err?.detail ?? err?.message
|
||||
)
|
||||
}
|
||||
case PostgresError.NULL_VIOLATION: {
|
||||
return new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Can't insert null value in field ${err?.column} on insert in table ${err?.table}`
|
||||
)
|
||||
}
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./authenticate-middleware"
|
||||
export * from "./error-handler"
|
||||
export * from "./exception-formatter"
|
||||
874
packages/framework/framework/src/http/router.ts
Normal file
874
packages/framework/framework/src/http/router.ts
Normal file
@@ -0,0 +1,874 @@
|
||||
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, parse, sep } from "path"
|
||||
import {
|
||||
GlobalMiddlewareDescriptor,
|
||||
HTTP_METHODS,
|
||||
MedusaRequest,
|
||||
MedusaResponse,
|
||||
MiddlewareRoute,
|
||||
MiddlewaresConfig,
|
||||
MiddlewareVerb,
|
||||
ParserConfigArgs,
|
||||
RouteConfig,
|
||||
RouteDescriptor,
|
||||
RouteVerb,
|
||||
} from "./types"
|
||||
import { authenticate, errorHandler } from "./middlewares"
|
||||
import { configManager } from "../config"
|
||||
import { logger } from "../logger"
|
||||
|
||||
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 }),
|
||||
]
|
||||
}
|
||||
|
||||
// TODO this router would need a proper rework, but it is out of scope right now
|
||||
|
||||
class ApiRoutesLoader {
|
||||
/**
|
||||
* Map of router path and its descriptor
|
||||
* @private
|
||||
*/
|
||||
#routesMap = new Map<string, RouteDescriptor>()
|
||||
|
||||
/**
|
||||
* Global middleware descriptors
|
||||
* @private
|
||||
*/
|
||||
#globalMiddlewaresDescriptor: GlobalMiddlewareDescriptor | undefined
|
||||
|
||||
/**
|
||||
* An express instance
|
||||
* @private
|
||||
*/
|
||||
readonly #app: Express
|
||||
|
||||
/**
|
||||
* A router to assign the route to
|
||||
* @private
|
||||
*/
|
||||
readonly #router: Router
|
||||
|
||||
/**
|
||||
* An eventual activity id for information tracking
|
||||
* @private
|
||||
*/
|
||||
readonly #activityId?: string
|
||||
|
||||
/**
|
||||
* The list of file names to exclude from the routes scan
|
||||
* @private
|
||||
*/
|
||||
#excludes: RegExp[] = [
|
||||
/\.DS_Store/,
|
||||
/(\.ts\.map|\.js\.map|\.d\.ts|\.md)/,
|
||||
/^_[^/\\]*(\.[^/\\]+)?$/,
|
||||
]
|
||||
|
||||
/**
|
||||
* Path from where to load the routes from
|
||||
* @private
|
||||
*/
|
||||
readonly #sourceDir: string
|
||||
|
||||
constructor({
|
||||
app,
|
||||
activityId,
|
||||
sourceDir,
|
||||
}: {
|
||||
app: Express
|
||||
activityId?: string
|
||||
sourceDir: string
|
||||
}) {
|
||||
this.#app = app
|
||||
this.#router = Router()
|
||||
this.#activityId = activityId
|
||||
this.#sourceDir = sourceDir
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(path: string) {
|
||||
const descriptor: RouteDescriptor = {
|
||||
absolutePath: path,
|
||||
relativePath: path,
|
||||
route: "",
|
||||
priority: Infinity,
|
||||
}
|
||||
|
||||
const childPath = path.replace(this.#sourceDir, "")
|
||||
descriptor.relativePath = childPath
|
||||
|
||||
let routeToParse = childPath
|
||||
|
||||
const pathSegments = routeToParse.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(path, descriptor)
|
||||
}
|
||||
|
||||
protected async createMiddlewaresDescriptor() {
|
||||
const filePaths = await readdir(this.#sourceDir)
|
||||
|
||||
const filteredFilePaths = filePaths.filter((path) => {
|
||||
const pathToCheck = path.replace(this.#sourceDir, "")
|
||||
return !pathToCheck
|
||||
.split(sep)
|
||||
.some((segment) =>
|
||||
this.#excludes.some((exclude) => exclude.test(segment))
|
||||
)
|
||||
})
|
||||
|
||||
const middlewareFilePath = filteredFilePaths.find((file) => {
|
||||
return file.replace(/\.[^/.]+$/, "") === MIDDLEWARES_NAME
|
||||
})
|
||||
|
||||
if (!middlewareFilePath) {
|
||||
log({
|
||||
activityId: this.#activityId,
|
||||
message: `No middleware files found in ${
|
||||
this.#sourceDir
|
||||
}. Skipping middleware configuration.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const absolutePath = join(this.#sourceDir, 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(): Promise<void> {
|
||||
await promiseAll(
|
||||
await readdir(this.#sourceDir, {
|
||||
recursive: true,
|
||||
withFileTypes: true,
|
||||
}).then((entries) => {
|
||||
const fileEntries = entries.filter((entry) => {
|
||||
const fullPathFromSource = join(entry.path, entry.name).replace(
|
||||
this.#sourceDir,
|
||||
""
|
||||
)
|
||||
const isExcluded = fullPathFromSource
|
||||
.split(sep)
|
||||
.some((segment) =>
|
||||
this.#excludes.some((exclude) => exclude.test(segment))
|
||||
)
|
||||
|
||||
return (
|
||||
!entry.isDirectory() &&
|
||||
!isExcluded &&
|
||||
parse(entry.name).name === ROUTE_NAME
|
||||
)
|
||||
})
|
||||
|
||||
return fileEntries.map(async (entry) => {
|
||||
const path = join(entry.path, entry.name)
|
||||
return this.createRoutesDescriptor(path)
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
configManager.config.projectConfig.http.adminCors
|
||||
),
|
||||
credentials: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (config.shouldAppendAuthCors) {
|
||||
/**
|
||||
* Apply the auth cors
|
||||
*/
|
||||
this.#router.use(
|
||||
descriptor.route,
|
||||
cors({
|
||||
origin: parseCorsOrigins(
|
||||
configManager.config.projectConfig.http.authCors
|
||||
),
|
||||
credentials: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (config.shouldAppendStoreCors) {
|
||||
/**
|
||||
* Apply the store cors
|
||||
*/
|
||||
this.#router.use(
|
||||
descriptor.route,
|
||||
cors({
|
||||
origin: parseCorsOrigins(
|
||||
configManager.config.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 as any)
|
||||
return
|
||||
}
|
||||
|
||||
/**
|
||||
* If the user has not provided a custom error handler then use the
|
||||
* default one.
|
||||
*/
|
||||
this.#router.use(errorHandler() as any)
|
||||
}
|
||||
|
||||
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 Parameters<typeof wrapHandler>[0])
|
||||
: 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.#sourceDir)
|
||||
|
||||
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.#sourceDir)
|
||||
} catch (_error) {
|
||||
apiExists = false
|
||||
}
|
||||
|
||||
if (apiExists) {
|
||||
await this.createMiddlewaresDescriptor()
|
||||
|
||||
await this.createRoutesMap()
|
||||
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.#sourceDir)
|
||||
const timeSpent =
|
||||
performance &&
|
||||
performance
|
||||
.measure(
|
||||
"file-base-routing-measure" + this.#sourceDir,
|
||||
"file-base-routing-start" + this.#sourceDir,
|
||||
"file-base-routing-end" + this.#sourceDir
|
||||
)
|
||||
?.duration?.toFixed(2)
|
||||
|
||||
log({
|
||||
activityId: this.#activityId,
|
||||
message: `Routes loaded in ${timeSpent} ms`,
|
||||
})
|
||||
|
||||
this.#routesMap.clear()
|
||||
this.#globalMiddlewaresDescriptor = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export class RoutesLoader {
|
||||
/**
|
||||
* An express instance
|
||||
* @private
|
||||
*/
|
||||
readonly #app: Express
|
||||
|
||||
/**
|
||||
* An eventual activity id for information tracking
|
||||
* @private
|
||||
*/
|
||||
readonly #activityId?: string
|
||||
|
||||
/**
|
||||
* Path from where to load the routes from
|
||||
* @private
|
||||
*/
|
||||
readonly #sourceDir: string | string[]
|
||||
|
||||
constructor({
|
||||
app,
|
||||
activityId,
|
||||
sourceDir,
|
||||
}: {
|
||||
app: Express
|
||||
activityId?: string
|
||||
sourceDir: string | string[]
|
||||
}) {
|
||||
this.#app = app
|
||||
this.#activityId = activityId
|
||||
this.#sourceDir = sourceDir
|
||||
}
|
||||
|
||||
async load() {
|
||||
const normalizedSourcePath = Array.isArray(this.#sourceDir)
|
||||
? this.#sourceDir
|
||||
: [this.#sourceDir]
|
||||
|
||||
const promises = normalizedSourcePath.map(async (sourcePath) => {
|
||||
const apiRoutesLoader = new ApiRoutesLoader({
|
||||
app: this.#app,
|
||||
activityId: this.#activityId,
|
||||
sourceDir: sourcePath,
|
||||
})
|
||||
|
||||
await apiRoutesLoader.load()
|
||||
})
|
||||
|
||||
await promiseAll(promises)
|
||||
}
|
||||
}
|
||||
180
packages/framework/framework/src/http/types.ts
Normal file
180
packages/framework/framework/src/http/types.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { ZodObject } from "zod"
|
||||
import type { NextFunction, Request, Response } from "express"
|
||||
|
||||
import { MedusaPricingContext, RequestQueryFields } from "@medusajs/types"
|
||||
import * as core from "express-serve-static-core"
|
||||
import { MedusaContainer } from "../container"
|
||||
|
||||
export interface FindConfig<Entity> {
|
||||
select?: (keyof Entity)[]
|
||||
skip?: number
|
||||
take?: number
|
||||
relations?: string[]
|
||||
order?: { [K: string]: "ASC" | "DESC" }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
export interface MedusaRequest<Body = unknown>
|
||||
extends Request<core.ParamsDictionary, any, Body> {
|
||||
validatedBody: Body
|
||||
validatedQuery: RequestQueryFields & Record<string, unknown>
|
||||
/**
|
||||
* TODO: shouldn't this correspond to returnable fields instead of allowed fields? also it is used by the cleanResponseData util
|
||||
*/
|
||||
allowedProperties: string[]
|
||||
/**
|
||||
* An object containing the select, relation, skip, take and order to be used with medusa internal services
|
||||
*/
|
||||
listConfig: FindConfig<unknown>
|
||||
/**
|
||||
* An object containing the select, relation to be used with medusa internal services
|
||||
*/
|
||||
retrieveConfig: FindConfig<unknown>
|
||||
/**
|
||||
* An object containing fields and variables to be used with the remoteQuery
|
||||
*/
|
||||
remoteQueryConfig: {
|
||||
fields: string[]
|
||||
pagination: { order?: Record<string, string>; skip?: number; take?: number }
|
||||
}
|
||||
/**
|
||||
* An object containing the fields that are filterable e.g `{ id: Any<String> }`
|
||||
*/
|
||||
filterableFields: Record<string, unknown>
|
||||
includes?: Record<string, boolean>
|
||||
/**
|
||||
* An array of fields and relations that are allowed to be queried, this can be set by the
|
||||
* consumer as part of a middleware and it will take precedence over the defaultAllowedFields
|
||||
* @deprecated use `allowed` instead
|
||||
*/
|
||||
allowedFields?: string[]
|
||||
/**
|
||||
* An array of fields and relations that are allowed to be queried, this can be set by the
|
||||
* consumer as part of a middleware and it will take precedence over the defaultAllowedFields set
|
||||
* by the api
|
||||
*/
|
||||
allowed?: string[]
|
||||
errors: string[]
|
||||
scope: MedusaContainer
|
||||
session?: any
|
||||
rawBody?: any
|
||||
requestId?: string
|
||||
/**
|
||||
* An object that carries the context that is used to calculate prices for variants
|
||||
*/
|
||||
pricingContext?: MedusaPricingContext
|
||||
/**
|
||||
* A generic context object that can be used across the request lifecycle
|
||||
*/
|
||||
context?: Record<string, any>
|
||||
/**
|
||||
* Custom validators for the request body and query params that will be
|
||||
* merged with the original validator of the route.
|
||||
*/
|
||||
extendedValidators?: {
|
||||
body?: ZodObject<any, any>
|
||||
queryParams?: ZodObject<any, any>
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthContext {
|
||||
actor_id: string
|
||||
actor_type: string
|
||||
auth_identity_id: string
|
||||
app_metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface AuthenticatedMedusaRequest<Body = never>
|
||||
extends MedusaRequest<Body> {
|
||||
auth_context: AuthContext
|
||||
}
|
||||
|
||||
export type MedusaResponse<Body = unknown> = Response<Body>
|
||||
|
||||
export type MedusaNextFunction = NextFunction
|
||||
|
||||
export type MedusaRequestHandler<Body = unknown, Res = unknown> = (
|
||||
req: MedusaRequest<Body>,
|
||||
res: MedusaResponse<Res>,
|
||||
next: MedusaNextFunction
|
||||
) => Promise<void> | void
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
MedusaNextFunction,
|
||||
MedusaRequest,
|
||||
MedusaRequestHandler,
|
||||
MedusaResponse,
|
||||
MiddlewaresConfig,
|
||||
MiddlewareVerb,
|
||||
ParserConfig,
|
||||
} from "../types"
|
||||
import { ZodObject } from "zod"
|
||||
|
||||
/**
|
||||
* A helper function to configure the routes by defining custom middleware,
|
||||
* bodyparser config and validators to be merged with the pre-existing
|
||||
* route validators.
|
||||
*/
|
||||
export function defineMiddlewares<
|
||||
Route extends {
|
||||
method?: MiddlewareVerb | MiddlewareVerb[]
|
||||
matcher: string | RegExp
|
||||
bodyParser?: ParserConfig
|
||||
extendedValidators?: {
|
||||
body?: ZodObject<any, any>
|
||||
queryParams?: ZodObject<any, any>
|
||||
}
|
||||
// eslint-disable-next-line space-before-function-paren
|
||||
middlewares?: (<Req extends MedusaRequest>(
|
||||
req: Req,
|
||||
res: MedusaResponse,
|
||||
next: MedusaNextFunction
|
||||
) => any)[]
|
||||
}
|
||||
>(
|
||||
config:
|
||||
| Route[]
|
||||
| { routes?: Route[]; errorHandler?: MiddlewaresConfig["errorHandler"] }
|
||||
): MiddlewaresConfig {
|
||||
const routes = Array.isArray(config) ? config : config.routes || []
|
||||
const errorHandler = Array.isArray(config) ? undefined : config.errorHandler
|
||||
|
||||
return {
|
||||
errorHandler,
|
||||
routes: routes.map((route) => {
|
||||
const { middlewares, extendedValidators, ...rest } = route
|
||||
const customMiddleware: MedusaRequestHandler[] = []
|
||||
|
||||
/**
|
||||
* Define a custom validator when "extendedValidators.body" or
|
||||
* "extendedValidators.queryParams" validation schema is
|
||||
* provided.
|
||||
*/
|
||||
if (extendedValidators?.body || extendedValidators?.queryParams) {
|
||||
customMiddleware.push((req, _, next) => {
|
||||
req.extendedValidators = extendedValidators
|
||||
next()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
middlewares: customMiddleware.concat(middlewares || []),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user