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:
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
21
packages/core/framework/src/http/middlewares/set-context.ts
Normal file
21
packages/core/framework/src/http/middlewares/set-context.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
232
packages/core/framework/src/http/utils/get-query-config.ts
Normal file
232
packages/core/framework/src/http/utils/get-query-config.ts
Normal 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: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
43
packages/core/framework/src/http/utils/http-compression.ts
Normal file
43
packages/core/framework/src/http/utils/http-compression.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
48
packages/core/framework/src/http/utils/refetch-entities.ts
Normal file
48
packages/core/framework/src/http/utils/refetch-entities.ts
Normal 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
|
||||
}
|
||||
22
packages/core/framework/src/http/utils/unless-path.ts
Normal file
22
packages/core/framework/src/http/utils/unless-path.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
36
packages/core/framework/src/http/utils/validate-body.ts
Normal file
36
packages/core/framework/src/http/utils/validate-body.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
91
packages/core/framework/src/http/utils/validate-query.ts
Normal file
91
packages/core/framework/src/http/utils/validate-query.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from './job-loader'
|
||||
export * from "./job-loader"
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "@medusajs/workflows-sdk/composer"
|
||||
export * from "@medusajs/workflows-sdk/composer"
|
||||
|
||||
1
packages/core/framework/src/zod/index.ts
Normal file
1
packages/core/framework/src/zod/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./zod-helpers"
|
||||
134
packages/core/framework/src/zod/zod-helpers.ts
Normal file
134
packages/core/framework/src/zod/zod-helpers.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user