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

* chore(framework): Move and improve routes loader

* cleanup

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

View File

@@ -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"
}
}

View File

@@ -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: [],
}

View File

@@ -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")
}

View File

@@ -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.",
})
}
},
})

View File

@@ -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",
}
}

View File

@@ -0,0 +1,5 @@
import { Request, Response } from "express"
export const GET = (req: Request, res: Response) => {
res.send(`GET /admin/protected`)
}

View File

@@ -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`)
}

View File

@@ -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")
}

View File

@@ -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")
}

View File

@@ -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" })],
},
])

View File

@@ -0,0 +1,5 @@
import { Request, Response } from "express"
export const GET = async (req: Request, res: Response) => {
res.send(`GET /store/protected`)
}

View File

@@ -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`)
}

View File

@@ -0,0 +1,5 @@
import { Request, Response } from "express"
export const POST = (req: Request, res: Response) => {
res.send(`sync product ${req.params.id}`)
}

View File

@@ -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")
}

View File

@@ -0,0 +1,5 @@
import { Request, Response } from "express"
export const GET = async (req: Request, res: Response): Promise<void> => {
res.send(`GET private route`)
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,5 @@
import { Request, Response } from "express"
export function GET(req: Request, res: Response) {
res.send("hello world")
}

View File

@@ -0,0 +1,5 @@
import { Request, Response } from "express"
export async function GET(req: Request, res: Response): Promise<void> {
console.log("hello world")
}

View File

@@ -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")
}

View File

@@ -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")
}

View File

@@ -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))
}

View File

@@ -0,0 +1,5 @@
import { NextFunction, Request, Response } from "express"
export function GET(req: Request, res: Response) {
res.send("list customers")
}

View File

@@ -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
},
}
}

View 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."
)
})
})
})

View File

@@ -1 +1,5 @@
export * from "./express-loader"
export * from "./router"
export * from "./types"
export * from "./middlewares"
export * from "./utils/define-middlewares"

View File

@@ -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
}

View File

@@ -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]
*/

View File

@@ -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
}
}

View File

@@ -0,0 +1,3 @@
export * from "./authenticate-middleware"
export * from "./error-handler"
export * from "./exception-formatter"

View 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)
}
}

View 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

View File

@@ -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 || []),
}
}),
}
}