breaking: move shared HTTP utils to the framework (#9402)

Fixes: FRMW-2728, FRMW-2729

After this PR gets merged the following middleware will be exported from the `@medusajs/framework/http` import path.

- applyParamsAsFilters
- clearFiltersByKey
- applyDefaultFilters
- setContext
- getQueryConfig
- httpCompression
- maybeApplyLinkFilter
- refetchEntities
- unlessPath
- validateBody
- validateQuery

Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com>
This commit is contained in:
Harminder Virk
2024-10-03 15:12:00 +05:30
committed by GitHub
parent 193f93464f
commit 48e00169d2
557 changed files with 2365 additions and 3499 deletions

View File

@@ -1,18 +1,13 @@
import { createMedusaContainer } from "@medusajs/utils"
import { AwilixContainer, ResolveOptions } from "awilix"
import { TransformObjectMethodToAsync } from "@medusajs/types";
/**
* The following interface acts as a bucket that other modules or the
* utils package can fill using declaration merging
*/
export interface ModuleImplementations {}
import { ModuleImplementations } from "@medusajs/types"
/**
* The Medusa Container extends [Awilix](https://github.com/jeffijoe/awilix) to
* provide dependency injection functionalities.
*/
export type MedusaContainer<Cradle extends object = TransformObjectMethodToAsync<ModuleImplementations>> =
// export type MedusaContainer<Cradle extends object = TransformObjectMethodToAsync<ModuleImplementations>> =
export type MedusaContainer<Cradle extends object = ModuleImplementations> =
Omit<AwilixContainer, "resolve"> & {
resolve<K extends keyof Cradle>(
key: K,

View File

@@ -0,0 +1,45 @@
import zod from "zod"
import { MedusaError } from "@medusajs/utils"
import { validateAndTransformBody } from "../utils/validate-body"
import { MedusaRequest, MedusaResponse } from "../types"
const createLinkBody = () => {
return zod.object({
add: zod.array(zod.string()).optional(),
remove: zod.array(zod.string()).optional(),
})
}
describe("validateAndTransformBody", () => {
afterEach(() => {
jest.clearAllMocks()
})
it("should pass additionalDataValidator to validator factory", async () => {
let mockRequest = {
query: {},
body: {},
} as MedusaRequest
const mockResponse = {} as MedusaResponse
const nextFunction = jest.fn()
mockRequest.additionalDataValidator = zod.object({
brand_id: zod.number(),
})
const validatorFactory = (schema?: Zod.ZodObject<any, any>) => {
return schema ? createLinkBody().merge(schema) : createLinkBody()
}
let middleware = validateAndTransformBody(validatorFactory)
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenCalledWith(
new MedusaError(
"invalid_data",
`Invalid request: Field 'brand_id' is required`
)
)
})
})

View File

@@ -0,0 +1,725 @@
import z from "zod"
import { MedusaError } from "@medusajs/utils"
import { validateAndTransformQuery } from "../utils/validate-query"
import { MedusaRequest, MedusaResponse, MedusaNextFunction } from "../types"
export const createSelectParams = () => {
return z.object({
fields: z.string().optional(),
})
}
const createFindParams = ({
offset,
limit,
order,
}: {
offset?: number
limit?: number
order?: string
} = {}) => {
const selectParams = createSelectParams()
return selectParams.merge(
z.object({
offset: z.preprocess(
(val) => {
if (val && typeof val === "string") {
return parseInt(val)
}
return val
},
z
.number()
.optional()
.default(offset ?? 0)
),
limit: z.preprocess(
(val) => {
if (val && typeof val === "string") {
return parseInt(val)
}
return val
},
z
.number()
.optional()
.default(limit ?? 20)
),
order: order
? z.string().optional().default(order)
: z.string().optional(),
})
)
}
describe("validateAndTransformQuery", () => {
afterEach(() => {
jest.clearAllMocks()
})
it("should transform the input query", async () => {
let mockRequest = {
query: {},
} as MedusaRequest
const mockResponse = {} as MedusaResponse
const nextFunction: MedusaNextFunction = jest.fn()
const expectations = ({
offset,
limit,
inputOrder,
transformedOrder,
}: {
offset: number
limit: number
inputOrder: string | undefined
transformedOrder?: Record<string, "ASC" | "DESC">
relations?: string[]
}) => {
expect(mockRequest.validatedQuery).toEqual({
offset,
limit,
order: inputOrder,
})
expect(mockRequest.filterableFields).toEqual({})
expect(mockRequest.listConfig).toEqual({
take: limit,
skip: offset,
select: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
relations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
],
order: transformedOrder,
})
expect(mockRequest.remoteQueryConfig).toEqual({
fields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
pagination: {
order: transformedOrder,
skip: offset,
take: limit,
},
})
}
let queryConfig: any = {
defaultFields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
defaultRelations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
],
isList: true,
}
let middleware = validateAndTransformQuery(createFindParams(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expectations({
limit: 20,
offset: 0,
inputOrder: undefined,
})
mockRequest = {
query: {
limit: "10",
offset: "5",
order: "created_at",
},
} as unknown as MedusaRequest
middleware = validateAndTransformQuery(createFindParams(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expectations({
limit: 10,
offset: 5,
inputOrder: "created_at",
transformedOrder: { created_at: "ASC" },
})
mockRequest = {
query: {
limit: "10",
offset: "5",
order: "created_at",
},
} as unknown as MedusaRequest
queryConfig = {
defaults: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
isList: true,
}
middleware = validateAndTransformQuery(createFindParams(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expectations({
limit: 10,
offset: 5,
inputOrder: "created_at",
transformedOrder: { created_at: "ASC" },
})
})
it("should transform the input query taking into account the fields symbols (+,- or no symbol)", async () => {
let mockRequest = {
query: {
fields: "id",
},
} as unknown as MedusaRequest
const mockResponse = {} as MedusaResponse
const nextFunction: MedusaNextFunction = jest.fn()
let queryConfig: any = {
defaultFields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
defaultRelations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
],
isList: true,
}
let middleware = validateAndTransformQuery(createFindParams(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(mockRequest.listConfig).toEqual(
expect.objectContaining({
select: ["id"],
})
)
mockRequest = {
query: {
fields: "+test_prop,-prop-test-something",
},
} as unknown as MedusaRequest
queryConfig = {
defaultFields: [
"id",
"prop-test-something",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
defaultRelations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
],
isList: true,
}
middleware = validateAndTransformQuery(createFindParams(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(mockRequest.listConfig).toEqual(
expect.objectContaining({
select: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"test_prop",
],
})
)
mockRequest = {
query: {
fields: "+test_prop,-updated_at",
},
} as unknown as MedusaRequest
queryConfig = {
defaults: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
isList: true,
}
middleware = validateAndTransformQuery(createFindParams(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(mockRequest.listConfig).toEqual(
expect.objectContaining({
select: [
"id",
"created_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"test_prop",
],
})
)
})
it(`should transform the input and manage the allowed fields and relations properly without error`, async () => {
let mockRequest = {
query: {
fields: "*product.variants,+product.id",
},
} as unknown as MedusaRequest
const mockResponse = {} as MedusaResponse
const nextFunction: MedusaNextFunction = jest.fn()
let queryConfig: any = {
defaults: [
"id",
"created_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
allowed: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"product",
"product.variants",
],
isList: true,
}
let middleware = validateAndTransformQuery(createFindParams(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(mockRequest.listConfig).toEqual(
expect.objectContaining({
select: [
"id",
"created_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"product.id",
],
relations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
"product",
"product.variants",
],
})
)
expect(mockRequest.remoteQueryConfig).toEqual(
expect.objectContaining({
fields: [
"id",
"created_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"product.id",
"product.variants.*",
],
})
)
mockRequest = {
query: {
fields: "store.name",
},
} as unknown as MedusaRequest
queryConfig = {
defaultFields: [
"id",
"created_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
allowed: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"product",
"product.variants",
"store.name",
],
isList: true,
}
middleware = validateAndTransformQuery(createFindParams(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(mockRequest.listConfig).toEqual(
expect.objectContaining({
select: ["store.name", "id"],
relations: ["store"],
})
)
expect(mockRequest.remoteQueryConfig).toEqual(
expect.objectContaining({
fields: ["store.name", "id"],
})
)
})
it("should throw when attempting to transform the input if disallowed fields are requested", async () => {
let mockRequest = {
query: {
fields: "+test_prop",
},
} as unknown as MedusaRequest
const mockResponse = {} as MedusaResponse
const nextFunction: MedusaNextFunction = jest.fn()
let queryConfig: any = {
defaultFields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
defaultRelations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
],
allowed: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
isList: true,
}
let middleware = validateAndTransformQuery(createFindParams(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenLastCalledWith(
new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [test_prop] are not valid`
)
)
mockRequest = {
query: {
fields: "product",
},
} as unknown as MedusaRequest
queryConfig = {
defaultFields: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
defaultRelations: [
"metadata",
"metadata.parent",
"metadata.children",
"metadata.product",
],
allowed: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
isList: true,
}
middleware = validateAndTransformQuery(createFindParams(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenLastCalledWith(
new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [product] are not valid`
)
)
mockRequest = {
query: {
fields: "store",
},
} as unknown as MedusaRequest
queryConfig = {
defaultFields: [
"id",
"created_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
allowed: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"product",
"product.variants",
"store.name",
],
isList: true,
}
middleware = validateAndTransformQuery(createFindParams(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenLastCalledWith(
new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [store] are not valid`
)
)
mockRequest = {
query: {
fields: "*product",
},
} as unknown as MedusaRequest
queryConfig = {
defaults: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
allowed: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
isList: true,
}
middleware = validateAndTransformQuery(createFindParams(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenLastCalledWith(
new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [product] are not valid`
)
)
mockRequest = {
query: {
fields: "*product.variants",
},
} as unknown as MedusaRequest
queryConfig = {
defaults: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
allowed: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"product",
],
isList: true,
}
middleware = validateAndTransformQuery(createFindParams(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenLastCalledWith(
new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [product.variants] are not valid`
)
)
mockRequest = {
query: {
fields: "*product",
},
} as unknown as MedusaRequest
queryConfig = {
defaults: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
],
allowed: [
"id",
"created_at",
"updated_at",
"deleted_at",
"metadata.id",
"metadata.parent.id",
"metadata.children.id",
"metadata.product.id",
"product.title",
],
isList: true,
}
middleware = validateAndTransformQuery(createFindParams(), queryConfig)
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenLastCalledWith(
new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [product] are not valid`
)
)
})
})

View File

@@ -2,4 +2,11 @@ export * from "./express-loader"
export * from "./router"
export * from "./types"
export * from "./middlewares"
export * from "./utils/http-compression"
export * from "./utils/validate-body"
export * from "./utils/validate-query"
export * from "./utils/get-query-config"
export * from "./utils/define-middlewares"
export * from "./utils/maybe-apply-link-filter"
export * from "./utils/refetch-entities"
export * from "./utils/unless-path"

View File

@@ -0,0 +1,37 @@
import { isObject, isPresent } from "@medusajs/utils"
import { MedusaNextFunction, MedusaRequest } from "../types"
export function applyDefaultFilters<TFilter extends object>(
filtersToApply: TFilter
) {
return async (req: MedusaRequest, _, next: MedusaNextFunction) => {
for (const [filter, filterValue] of Object.entries(filtersToApply)) {
let valueToApply = filterValue
// If certain manipulations need to be done on a middleware level, we can provide a simple
// function that mutates the data based on any custom requirement
if (typeof filterValue === "function") {
// pass the actual filterable fields so that the function can mutate the original object.
// Currently we only need it to delete filter keys from the request filter object, but this could
// be used for other purposes. If we can't find other purposes, we can refactor to accept an array
// of strings to delete after filters have been applied.
valueToApply = filterValue(
req.filterableFields,
req.remoteQueryConfig.fields
)
}
// If the value to apply is an object, we add it to any existing filters thats already applied
if (isObject(valueToApply)) {
req.filterableFields[filter] = {
...(req.filterableFields[filter] || {}),
...valueToApply,
}
} else if (isPresent(valueToApply)) {
req.filterableFields[filter] = valueToApply
}
}
return next()
}
}

View File

@@ -0,0 +1,13 @@
import { MedusaNextFunction, MedusaRequest } from "../types"
export function applyParamsAsFilters(mappings: { [param: string]: string }) {
return async (req: MedusaRequest, _, next: MedusaNextFunction) => {
for (const [param, paramValue] of Object.entries(req.params)) {
if (mappings[param]) {
req.filterableFields[mappings[param]] = paramValue
}
}
return next()
}
}

View File

@@ -0,0 +1,11 @@
import { MedusaNextFunction, MedusaRequest } from "../types"
export function clearFiltersByKey(keys: string[]) {
return async (req: MedusaRequest, _, next: MedusaNextFunction) => {
keys.forEach((key) => {
delete req.filterableFields[key]
})
return next()
}
}

View File

@@ -1,4 +1,3 @@
import { Query } from "@medusajs/types"
import {
ApiKeyType,
ContainerRegistrationKeys,
@@ -31,7 +30,7 @@ export async function ensurePublishableApiKeyMiddleware(
}
let apiKey
const query: Query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
try {
const { data } = await query.graph(

View File

@@ -3,7 +3,6 @@ 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"
@@ -20,9 +19,7 @@ export function errorHandler() {
res: Response,
next: NextFunction
) => {
const logger: typeof logger_ = req.scope.resolve(
ContainerRegistrationKeys.LOGGER
)
const logger = req.scope.resolve(ContainerRegistrationKeys.LOGGER)
err = formatException(err)

View File

@@ -1,3 +1,7 @@
export * from "./authenticate-middleware"
export * from "./error-handler"
export * from "./exception-formatter"
export * from "./apply-default-filters"
export * from "./apply-params-as-filters"
export * from "./clear-filters-by-key"
export * from "./set-context"

View File

@@ -0,0 +1,21 @@
import { MedusaNextFunction, MedusaRequest } from "../types"
export function setContext(context: Record<string, any>) {
return async (req: MedusaRequest, _, next: MedusaNextFunction) => {
const ctx: Record<string, any> = { ...(req.context || {}) }
for (const [contextKey, contextValue] of Object.entries(context || {})) {
let valueToApply = contextValue
if (typeof contextValue === "function") {
valueToApply = await contextValue(req, ctx)
}
ctx[contextKey] = valueToApply
}
req.context = ctx
return next()
}
}

View File

@@ -1,17 +1,13 @@
import type { NextFunction, Request, Response } from "express"
import { ZodObject } from "zod"
import { MedusaPricingContext, RequestQueryFields } from "@medusajs/types"
import {
FindConfig,
MedusaPricingContext,
RequestQueryFields,
} from "@medusajs/types"
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
*/

View File

@@ -0,0 +1,232 @@
import { pick } from "lodash"
import { RequestQueryFields, FindConfig, QueryConfig } from "@medusajs/types"
import {
isDefined,
isPresent,
MedusaError,
getSetDifference,
stringToSelectRelationObject,
} from "@medusajs/utils"
export function pickByConfig<TModel>(
obj: TModel | TModel[],
config: FindConfig<TModel>
): Partial<TModel> | Partial<TModel>[] {
const fields = [...(config.select ?? []), ...(config.relations ?? [])]
if (fields.length) {
if (Array.isArray(obj)) {
return obj.map((o) => pick(o, fields))
} else {
return pick(obj, fields)
}
}
return obj
}
export function prepareListQuery<T extends RequestQueryFields, TEntity>(
validated: T,
queryConfig: QueryConfig<TEntity> = {}
) {
// TODO: this function will be simplified a lot once we drop support for the old api
const { order, fields, limit = 50, expand, offset = 0 } = validated
let {
allowed = [],
defaults = [],
defaultFields = [],
defaultLimit,
allowedFields = [],
allowedRelations = [],
defaultRelations = [],
isList,
} = queryConfig
allowedFields = allowed.length ? allowed : allowedFields
defaultFields = defaults.length ? defaults : defaultFields
// e.g *product.variants meaning that we want all fields from the product.variants
// in that case it wont be part of the select but it will be part of the relations.
// For the remote query we will have to add the fields to the fields array as product.variants.*
const starFields: Set<string> = new Set()
let allFields = new Set(defaultFields) as Set<string>
if (isDefined(fields)) {
const customFields = fields.split(",").filter(Boolean)
const shouldReplaceDefaultFields =
!customFields.length ||
customFields.some((field) => {
return !(
field.startsWith("-") ||
field.startsWith("+") ||
field.startsWith(" ") ||
field.startsWith("*")
)
})
if (shouldReplaceDefaultFields) {
allFields = new Set(customFields.map((f) => f.replace(/^[+ -]/, "")))
} else {
customFields.forEach((field) => {
if (field.startsWith("+") || field.startsWith(" ")) {
allFields.add(field.trim().replace(/^\+/, ""))
} else if (field.startsWith("-")) {
allFields.delete(field.replace(/^-/, ""))
} else {
allFields.add(field)
}
})
}
allFields.add("id")
}
allFields.forEach((field) => {
if (field.startsWith("*")) {
starFields.add(field.replace(/^\*/, ""))
allFields.delete(field)
}
})
const notAllowedFields: string[] = []
if (allowedFields.length) {
;[...allFields, ...Array.from(starFields)].forEach((field) => {
const hasAllowedField = allowedFields.includes(field)
if (hasAllowedField) {
return
}
// Select full relation in that case it must match an allowed field fully
// e.g product.variants in that case we must have a product.variants in the allowedFields
if (starFields.has(field)) {
if (hasAllowedField) {
return
}
notAllowedFields.push(field)
return
}
const fieldStartsWithAllowedField = allowedFields.some((allowedField) =>
field.startsWith(allowedField)
)
if (!fieldStartsWithAllowedField) {
notAllowedFields.push(field)
return
}
})
}
if (allFields.size && notAllowedFields.length) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [${Array.from(notAllowedFields).join(
", "
)}] are not valid`
)
}
// TODO: maintain backward compatibility, remove in the future
const { select, relations } = stringToSelectRelationObject(
Array.from(allFields)
)
let allRelations = new Set([
...relations,
...defaultRelations,
...Array.from(starFields),
])
if (isDefined(expand)) {
allRelations = new Set(expand.split(",").filter(Boolean))
}
if (allowedRelations.length && expand) {
const allAllowedRelations = new Set([...allowedRelations])
const notAllowedRelations = getSetDifference(
allRelations,
allAllowedRelations
)
if (allRelations.size && notAllowedRelations.size) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Requested fields [${Array.from(notAllowedRelations).join(
", "
)}] are not valid`
)
}
}
// End of expand compatibility
let orderBy: { [k: symbol]: "DESC" | "ASC" } | undefined = {}
if (isDefined(order)) {
let orderField = order
if (order.startsWith("-")) {
const [, field] = order.split("-")
orderField = field
orderBy = { [field]: "DESC" }
} else {
orderBy = { [order]: "ASC" }
}
if (
queryConfig?.allowedFields?.length &&
!queryConfig?.allowedFields.includes(orderField)
) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Order field ${orderField} is not valid`
)
}
}
const finalOrder = isPresent(orderBy) ? orderBy : undefined
return {
listConfig: {
select: select.length ? select : undefined,
relations: Array.from(allRelations),
skip: offset,
take: limit ?? defaultLimit,
order: finalOrder,
},
remoteQueryConfig: {
// Add starFields that are relations only on which we want all properties with a dedicated format to the remote query
fields: [
...Array.from(allFields),
...Array.from(starFields).map((f) => `${f}.*`),
],
pagination: isList
? {
skip: offset,
take: limit ?? defaultLimit,
order: finalOrder,
}
: {},
},
}
}
export function prepareRetrieveQuery<T extends RequestQueryFields, TEntity>(
validated: T,
queryConfig?: QueryConfig<TEntity>
) {
const { listConfig, remoteQueryConfig } = prepareListQuery(
validated,
queryConfig
)
return {
retrieveConfig: {
select: listConfig.select,
relations: listConfig.relations,
},
remoteQueryConfig: {
fields: remoteQueryConfig.fields,
pagination: {},
},
}
}

View File

@@ -0,0 +1,43 @@
import compression from "compression"
import type { ConfigModule } from "@medusajs/types"
import { ContainerRegistrationKeys } from "@medusajs/utils"
import { HttpCompressionOptions, ProjectConfigOptions } from "../../config"
import type { MedusaRequest, MedusaResponse } from "../types"
export function shouldCompressResponse(
req: MedusaRequest,
res: MedusaResponse
) {
const { projectConfig } = req.scope.resolve<ConfigModule>(
ContainerRegistrationKeys.CONFIG_MODULE
)
const { enabled } = compressionOptions(projectConfig)
if (!enabled) {
return false
}
if (req.headers["x-no-compression"]) {
// don't compress responses with this request header
return false
}
// fallback to standard filter function
return compression.filter(req, res)
}
export function compressionOptions(
config: ProjectConfigOptions
): HttpCompressionOptions {
const responseCompressionOptions = config.http.compression ?? {}
responseCompressionOptions.enabled =
responseCompressionOptions.enabled ?? false
responseCompressionOptions.level = responseCompressionOptions.level ?? 6
responseCompressionOptions.memLevel = responseCompressionOptions.memLevel ?? 8
responseCompressionOptions.threshold =
responseCompressionOptions.threshold ?? 1024
return responseCompressionOptions
}

View File

@@ -0,0 +1,104 @@
import {
arrayIntersection,
ContainerRegistrationKeys,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { MedusaNextFunction, MedusaRequest } from "../types"
export function maybeApplyLinkFilter({
entryPoint,
resourceId,
filterableField,
filterByField = "id",
}) {
return async function linkFilter(
req: MedusaRequest,
_,
next: MedusaNextFunction
) {
const filterableFields = req.filterableFields
if (!filterableFields?.[filterableField]) {
return next()
}
const filterFields = filterableFields[filterableField]
const idsToFilterBy = Array.isArray(filterFields)
? filterFields
: [filterFields]
delete filterableFields[filterableField]
const remoteQuery = req.scope.resolve(
ContainerRegistrationKeys.REMOTE_QUERY
)
const queryObject = remoteQueryObjectFromString({
entryPoint,
fields: [resourceId],
variables: { filters: { [filterableField]: idsToFilterBy } },
})
const resources = await remoteQuery(queryObject)
let existingFilters = filterableFields[filterByField] as
| string[]
| string
| undefined
if (existingFilters) {
if (typeof existingFilters === "string") {
existingFilters = [existingFilters]
}
filterableFields[filterByField] = arrayIntersection(
existingFilters,
resources.map((p) => p[resourceId])
)
} else {
filterableFields[filterByField] = resources.map((p) => p[resourceId])
}
req.filterableFields = transformFilterableFields(filterableFields)
return next()
}
}
/*
Transforms an object key string into nested objects
before = {
"test.something.another": []
}
after = {
test: {
something: {
another: []
}
}
}
*/
function transformFilterableFields(filterableFields: Record<string, unknown>) {
const result = {}
for (const key of Object.keys(filterableFields)) {
const value = filterableFields[key]
const keys = key.split(".")
let current = result
// Iterate over the keys, creating nested objects as needed
for (let i = 0; i < keys.length; i++) {
const part = keys[i]
current[part] ??= {}
if (i === keys.length - 1) {
// If its the last key, assign the value
current[part] = value
break
}
current = current[part]
}
}
return result
}

View File

@@ -0,0 +1,48 @@
import { MedusaContainer } from "@medusajs/types"
import {
isString,
ContainerRegistrationKeys,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { MedusaRequest } from "../types"
export const refetchEntities = async (
entryPoint: string,
idOrFilter: string | object,
scope: MedusaContainer,
fields: string[],
pagination: MedusaRequest["remoteQueryConfig"]["pagination"] = {}
) => {
const remoteQuery = scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const filters = isString(idOrFilter) ? { id: idOrFilter } : idOrFilter
let context: object = {}
if ("context" in filters) {
if (filters.context) {
context = filters.context!
}
delete filters.context
}
const variables = { filters, ...context, ...pagination }
const queryObject = remoteQueryObjectFromString({
entryPoint,
variables,
fields,
})
return await remoteQuery(queryObject)
}
export const refetchEntity = async (
entryPoint: string,
idOrFilter: string | object,
scope: MedusaContainer,
fields: string[]
) => {
const [entity] = await refetchEntities(entryPoint, idOrFilter, scope, fields)
return entity
}

View File

@@ -0,0 +1,22 @@
import {
MedusaNextFunction,
MedusaRequest,
MedusaResponse,
MiddlewareFunction,
} from "../types"
/**
* Due to how our route loader works, where we load all middlewares before routes, ambiguous routes * end up having all middlewares on different routes executed before the route handler is.
*/
/**
* This function allows us to skip middlewares for particular routes, so we can temporarily solve * * this without completely breaking the route loader for everyone.
*/
export const unlessPath =
(onPath: RegExp, middleware: MiddlewareFunction) =>
(req: MedusaRequest, res: MedusaResponse, next: MedusaNextFunction) => {
if (onPath.test(req.path)) {
return next()
} else {
return middleware(req, res, next)
}
}

View File

@@ -0,0 +1,36 @@
import { z } from "zod"
import { NextFunction } from "express"
import { MedusaRequest, MedusaResponse } from "../types"
import { zodValidator } from "../../zod/zod-helpers"
export function validateAndTransformBody(
zodSchema:
| z.ZodObject<any, any>
| ((
customSchema?: z.ZodObject<any, any>
) => z.ZodObject<any, any> | z.ZodEffects<any, any>)
): (
req: MedusaRequest,
res: MedusaResponse,
next: NextFunction
) => Promise<void> {
return async function validateBody(
req: MedusaRequest,
_: MedusaResponse,
next: NextFunction
) {
try {
let schema: z.ZodObject<any, any> | z.ZodEffects<any, any>
if (typeof zodSchema === "function") {
schema = zodSchema(req.additionalDataValidator)
} else {
schema = zodSchema
}
req.validatedBody = await zodValidator(schema, req.body)
next()
} catch (e) {
next(e)
}
}
}

View File

@@ -0,0 +1,91 @@
import { z } from "zod"
import { omit } from "lodash"
import { NextFunction } from "express"
import { removeUndefinedProperties, MedusaError } from "@medusajs/utils"
import { BaseEntity, QueryConfig, RequestQueryFields } from "@medusajs/types"
import { zodValidator } from "../../zod/zod-helpers"
import { MedusaRequest, MedusaResponse } from "../types"
import { prepareListQuery, prepareRetrieveQuery } from "./get-query-config"
/**
* Normalize an input query, especially from array like query params to an array type
* e.g: /admin/orders/?fields[]=id,status,cart_id becomes { fields: ["id", "status", "cart_id"] }
*
* We only support up to 2 levels of depth for query params in order to have a somewhat readable query param, and limit possible performance issues
*/
const normalizeQuery = (req: MedusaRequest) => {
return Object.entries(req.query).reduce((acc, [key, val]) => {
let normalizedValue = val
if (Array.isArray(val) && val.length === 1 && typeof val[0] === "string") {
normalizedValue = val[0].split(",")
}
if (key.includes(".")) {
const [parent, child, ...others] = key.split(".")
if (others.length > 0) {
throw new MedusaError(
MedusaError.Types.INVALID_ARGUMENT,
`Key accessor more than 2 levels deep: ${key}`
)
}
if (!acc[parent]) {
acc[parent] = {}
}
acc[parent] = {
...acc[parent],
[child]: normalizedValue,
}
} else {
acc[key] = normalizedValue
}
return acc
}, {})
}
/**
* Omit the non filterable config from the validated object
* @param obj
*/
const getFilterableFields = <T extends RequestQueryFields>(obj: T): T => {
const result = omit(obj, ["limit", "offset", "fields", "order"]) as T
return removeUndefinedProperties(result)
}
export function validateAndTransformQuery<TEntity extends BaseEntity>(
zodSchema: z.ZodObject<any, any> | z.ZodEffects<any, any>,
queryConfig: QueryConfig<TEntity>
): (
req: MedusaRequest,
res: MedusaResponse,
next: NextFunction
) => Promise<void> {
return async function validateQuery(
req: MedusaRequest,
_: MedusaResponse,
next: NextFunction
) {
try {
const allowed = (req.allowed ?? queryConfig.allowed ?? []) as string[]
delete req.allowed
const query = normalizeQuery(req)
const validated = await zodValidator(zodSchema, query)
const cnf = queryConfig.isList
? prepareListQuery(validated, { ...queryConfig, allowed })
: prepareRetrieveQuery(validated, { ...queryConfig, allowed })
req.validatedQuery = validated
req.filterableFields = getFilterableFields(req.validatedQuery)
req.remoteQueryConfig = cnf.remoteQueryConfig
req.listConfig = (cnf as any).listConfig
req.retrieveConfig = (cnf as any).retrieveConfig
next()
} catch (e) {
next(e)
}
}
}

View File

@@ -10,6 +10,7 @@ export * from "./medusa-app-loader"
export * from "./subscribers"
export * from "./workflows"
export * from "./telemetry"
export * from "./zod"
export const MEDUSA_CLI_PATH = require.resolve("@medusajs/medusa-cli")

View File

@@ -1 +1 @@
export * from './job-loader'
export * from "./job-loader"

View File

@@ -33,6 +33,7 @@ import {
container as mainContainer,
MedusaContainer,
} from "./container"
import type { Knex } from "@mikro-orm/knex"
export class MedusaAppLoader {
/**
@@ -106,9 +107,9 @@ export class MedusaAppLoader {
protected prepareSharedResourcesAndDeps() {
const injectedDependencies = {
[ContainerRegistrationKeys.PG_CONNECTION]: this.#container.resolve(
ContainerRegistrationKeys.PG_CONNECTION
),
[ContainerRegistrationKeys.PG_CONNECTION]: this.#container.resolve<
Knex<any>
>(ContainerRegistrationKeys.PG_CONNECTION),
[ContainerRegistrationKeys.LOGGER]: this.#container.resolve(
ContainerRegistrationKeys.LOGGER
),

View File

@@ -1,11 +1,11 @@
/**
* Custom wrapper on top of MikroORM CLI to override the issue
* they have when importing TypeScript files.
*
*
* They have hardcoded the module system of TypeScript to CommonJS
* and that makes it impossible to use any other module system
* like Node16 or NodeNext and so on.
*
*
* With this wrapper, we monkey patch the code responsible for register
* ts-node and then boot their CLI. Since, the code footprint is
* small, we should be okay with managing this wrapper.
@@ -51,7 +51,6 @@ require("@jercle/yargonaut")
.style("yellow", "required")
.helpStyle("green")
.errorsStyle("red")
;(async () => {
const argv = await CLIConfigurator.configure()
const args = await argv.parse(process.argv.slice(2))

View File

@@ -1 +1 @@
export * from "@medusajs/workflows-sdk/composer"
export * from "@medusajs/workflows-sdk/composer"

View File

@@ -0,0 +1 @@
export * from "./zod-helpers"

View File

@@ -0,0 +1,134 @@
import { MedusaError } from "@medusajs/framework/utils"
import {
z,
ZodError,
ZodInvalidTypeIssue,
ZodInvalidUnionIssue,
ZodIssue,
} from "zod"
const formatPath = (issue: ZodIssue) => {
return issue.path.join(", ")
}
const formatInvalidType = (issues: ZodIssue[]) => {
const expected = issues
.map((i) => {
// Unforutnately the zod library doesn't distinguish between a wrong type and a required field, which we want to handle differently
if (i.code === "invalid_type" && i.message !== "Required") {
return i.expected
}
return
})
.filter(Boolean)
if (!expected.length) {
return
}
const received = (issues?.[0] as ZodInvalidTypeIssue)?.received
return `Expected type: '${expected.join(", ")}' for field '${formatPath(
issues[0]
)}', got: '${received}'`
}
const formatRequiredField = (issues: ZodIssue[]) => {
const expected = issues
.map((i) => {
if (i.code === "invalid_type" && i.message === "Required") {
return i.expected
}
return
})
.filter(Boolean)
if (!expected.length) {
return
}
return `Field '${formatPath(issues[0])}' is required`
}
const formatUnionError = (issue: ZodInvalidUnionIssue) => {
const issues = issue.unionErrors.flatMap((e) => e.issues)
return (
formatInvalidType(issues) || formatRequiredField(issues) || issue.message
)
}
const formatError = (err: ZodError) => {
const issueMessages = err.issues.slice(0, 3).map((issue) => {
switch (issue.code) {
case "invalid_type":
return (
formatInvalidType([issue]) ||
formatRequiredField([issue]) ||
issue.message
)
case "invalid_literal":
return `Expected literal: '${issue.expected}' for field '${formatPath(
issue
)}', but got: '${issue.received}'`
case "invalid_union":
return formatUnionError(issue)
case "invalid_enum_value":
return `Expected: '${issue.options.join(", ")}' for field '${formatPath(
issue
)}', but got: '${issue.received}'`
case "unrecognized_keys":
return `Unrecognized fields: '${issue.keys.join(", ")}'`
case "invalid_arguments":
return `Invalid arguments for '${issue.path.join(", ")}'`
case "too_small":
return `Value for field '${formatPath(
issue
)}' too small, expected at least: '${issue.minimum}'`
case "too_big":
return `Value for field '${formatPath(
issue
)}' too big, expected at most: '${issue.maximum}'`
case "not_multiple_of":
return `Value for field '${formatPath(issue)}' not multiple of: '${
issue.multipleOf
}'`
case "not_finite":
return `Value for field '${formatPath(issue)}' not finite: '${
issue.message
}'`
case "invalid_union_discriminator":
case "invalid_return_type":
case "invalid_date":
case "invalid_string":
case "invalid_intersection_types":
default:
return issue.message
}
})
return issueMessages.join("; ")
}
export async function zodValidator<T>(
zodSchema: z.ZodObject<any, any> | z.ZodEffects<any, any>,
body: T
): Promise<z.ZodRawShape> {
let strictSchema = zodSchema
// ZodEffects doesn't support setting as strict, for all other schemas we want to enforce strictness.
if ("strict" in zodSchema) {
strictSchema = zodSchema.strict()
}
try {
return await strictSchema.parseAsync(body)
} catch (err) {
if (err instanceof ZodError) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Invalid request: ${formatError(err)}`
)
}
throw err
}
}