feat(medusa): add middleware filters + scope products (#7178)

* chore: add middleware filters + scope products

* chore: fix spec + add changeset

* chore: add internal category to list test
This commit is contained in:
Riqwan Thamir authored and GitHub committed 2024-04-30 16:59:43 +02:00
1 parent 1eeb1e9de3
commit efa3308d78
8 files changed
+164 -63

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/medusa": patch
---
feat(medusa): add middleware filters + scope products
@@ -33,6 +33,27 @@ medusaIntegrationTestRunner({
return [response.data.product, response.data.product.variants || []] return [response.data.product, response.data.product.variants || []]
} }
const createCategory = async (data, productIds) => {
const response = await api.post(
"/admin/product-categories",
data,
adminHeaders
)
await api.post(
`/admin/product-categories/${response.data.product_category.id}/products`,
{ add: productIds },
adminHeaders
)
const response2 = await api.get(
`/admin/product-categories/${response.data.product_category.id}?fields=*products`,
adminHeaders
)
return response2.data.product_category
}
const createSalesChannel = async (data, productIds) => { const createSalesChannel = async (data, productIds) => {
const response = await api.post( const response = await api.post(
"/admin/sales-channels", "/admin/sales-channels",
@@ -156,6 +177,30 @@ medusaIntegrationTestRunner({
]) ])
}) })
it("should list all products for a category", async () => {
const category = await createCategory(
{ name: "test", is_internal: false, is_active: true },
[product.id]
)
const category2 = await createCategory(
{ name: "test2", is_internal: true, is_active: true },
[product4.id]
)
const response = await api.get(
`/store/products?category_id[]=${category.id}&category_id[]=${category2.id}`
)
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(1)
expect(response.data.products).toEqual([
expect.objectContaining({
id: product.id,
}),
])
})
describe("with publishable keys", () => { describe("with publishable keys", () => {
let salesChannel1 let salesChannel1
let salesChannel2 let salesChannel2
@@ -349,6 +394,34 @@ medusaIntegrationTestRunner({
) )
}) })
// TODO: There are 2 problems that need to be solved to enable this test
// 1. When adding product to another category, the product is being removed from earlier assigned categories
// 2. MikroORM seems to be doing a join strategy to load relationships, we need to send a separate query to fetch relationships
// to scope the relationships
it.skip("should list only categories that are public and active", async () => {
const category = await createCategory(
{ name: "test 1", is_internal: true, is_active: true },
[product.id]
)
await createCategory(
{ name: "test 2", is_internal: false, is_active: true },
[product.id]
)
const response = await api.get(
`/store/products/${product.id}?fields=*categories`
)
expect(response.status).toEqual(200)
expect(response.data.product).toEqual(
expect.objectContaining({
id: product.id,
categories: [expect.objectContaining({ id: category.id })],
})
)
})
it("should throw error when calculating prices without context", async () => { it("should throw error when calculating prices without context", async () => {
let error = await api let error = await api
.get( .get(
@@ -7,17 +7,19 @@ export const GET = async (
req: MedusaRequest<StoreGetProductsParamsType>, req: MedusaRequest<StoreGetProductsParamsType>,
res: MedusaResponse res: MedusaResponse
) => { ) => {
const context = isPresent(req.pricingContext) const filters: object = {
? { id: req.params.id,
...req.filterableFields,
}
if (isPresent(req.pricingContext)) {
filters["context"] = {
"variants.calculated_price": { context: req.pricingContext }, "variants.calculated_price": { context: req.pricingContext },
} }
: undefined }
const product = await refetchProduct( const product = await refetchProduct(
{ filters,
id: req.params.id,
context,
},
req.scope, req.scope,
req.remoteQueryConfig.fields req.remoteQueryConfig.fields
) )
@@ -1,29 +1,5 @@
import { MedusaContainer } from "@medusajs/types" import { MedusaContainer } from "@medusajs/types"
import { isPresent } from "@medusajs/utils"
import { refetchEntity } from "../../utils/refetch-entity" import { refetchEntity } from "../../utils/refetch-entity"
import { StoreGetProductsParamsType } from "./validators"
// For category filters, we only allow showcasing public and active categories
// TODO: This should ideally be done in the middleware, write a generic filter to conditionally
// map these values or normalize the filters to the ones expected by remote query
export function wrapWithCategoryFilters(filters: StoreGetProductsParamsType) {
const categoriesFilter = isPresent(filters.category_id)
? {
categories: {
...filters.category_id,
is_internal: false,
is_active: true,
},
}
: {}
delete filters.category_id
return {
...filters,
...categoriesFilter,
}
}
export const refetchProduct = async ( export const refetchProduct = async (
idOrFilter: string | object, idOrFilter: string | object,
@@ -1,4 +1,4 @@
import { ProductStatus } from "@medusajs/utils" import { isPresent, ProductStatus } from "@medusajs/utils"
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types" import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import { authenticate } from "../../../utils/authenticate-middleware" import { authenticate } from "../../../utils/authenticate-middleware"
import { maybeApplyLinkFilter } from "../../utils/maybe-apply-link-filter" import { maybeApplyLinkFilter } from "../../utils/maybe-apply-link-filter"
@@ -38,8 +38,18 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [
resourceId: "product_id", resourceId: "product_id",
filterableField: "sales_channel_id", filterableField: "sales_channel_id",
}), }),
applyDefaultFilters<StoreGetProductsParamsType>({ applyDefaultFilters({
status: ProductStatus.PUBLISHED, status: ProductStatus.PUBLISHED,
categories: (filters: StoreGetProductsParamsType, fields: string[]) => {
const categoryIds = filters.category_id
delete filters.category_id
if (!isPresent(categoryIds)) {
return
}
return { id: categoryIds, is_internal: false, is_active: true }
},
}), }),
setPricingContext(), setPricingContext(),
], ],
@@ -58,8 +68,15 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [
resourceId: "product_id", resourceId: "product_id",
filterableField: "sales_channel_id", filterableField: "sales_channel_id",
}), }),
applyDefaultFilters<StoreGetProductsParamsType>({ applyDefaultFilters({
status: ProductStatus.PUBLISHED, status: ProductStatus.PUBLISHED,
categories: (_filters, fields: string[]) => {
if (!fields.some((field) => field.startsWith("categories"))) {
return
}
return { is_internal: false, is_active: true }
},
}), }),
setPricingContext(), setPricingContext(),
], ],
@@ -4,7 +4,6 @@ import {
remoteQueryObjectFromString, remoteQueryObjectFromString,
} from "@medusajs/utils" } from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../types/routing" import { MedusaRequest, MedusaResponse } from "../../../types/routing"
import { wrapWithCategoryFilters } from "./helpers"
import { StoreGetProductsParamsType } from "./validators" import { StoreGetProductsParamsType } from "./validators"
export const GET = async ( export const GET = async (
@@ -12,18 +11,20 @@ export const GET = async (
res: MedusaResponse res: MedusaResponse
) => { ) => {
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY) const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const context = isPresent(req.pricingContext) const context: object = {}
? {
"variants.calculated_price": { context: req.pricingContext }, if (isPresent(req.pricingContext)) {
context["variants.calculated_price"] = {
context: req.pricingContext,
}
} }
: undefined
const queryObject = remoteQueryObjectFromString({ const queryObject = remoteQueryObjectFromString({
entryPoint: "product", entryPoint: "product",
variables: { variables: {
filters: wrapWithCategoryFilters(req.filterableFields), filters: req.filterableFields,
...context,
...req.remoteQueryConfig.pagination, ...req.remoteQueryConfig.pagination,
...context,
}, },
fields: req.remoteQueryConfig.fields, fields: req.remoteQueryConfig.fields,
}) })
@@ -1,26 +1,37 @@
import { isObject } from "@medusajs/utils" import { isObject, isPresent } from "@medusajs/utils"
import { NextFunction } from "express" import { NextFunction } from "express"
import { MedusaRequest } from "../../../../types/routing" import { MedusaRequest } from "../../../../types/routing"
export function applyDefaultFilters<TFilter extends object>(filters: TFilter) { export function applyDefaultFilters<TFilter extends object>(
filtersToApply: TFilter
) {
return async (req: MedusaRequest, _, next: NextFunction) => { return async (req: MedusaRequest, _, next: NextFunction) => {
const filterableFields = req.filterableFields || {} for (const [filter, filterValue] of Object.entries(filtersToApply)) {
let valueToApply = filterValue
for (const [filter, filterValue] of Object.entries(filters)) { // If certain manipulations need to be done on a middleware level, we can provide a simple
let existingFilter = filterableFields[filter] // function that mutates the data based on any custom requirement
if (typeof filterValue === "function") {
if (existingFilter && isObject(existingFilter)) { // pass the actual filterable fields so that the function can mutate the original object.
// If an existing filter is found, append to it // Currently we only need it to delete filter keys from the request filter object, but this could
filterableFields[filter] = { // be used for other purposes. If we can't find other purposes, we can refactor to accept an array
...existingFilter, // of strings to delete after filters have been applied.
[filter]: filterValue, valueToApply = filterValue(
} req.filterableFields,
} else { req.remoteQueryConfig.fields
filterableFields[filter] = filterValue )
}
} }
req.filterableFields = filterableFields // 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() return next()
} }
@@ -10,14 +10,30 @@ export function setPricingContext() {
if ( if (
!req.remoteQueryConfig.fields.some((field) => !req.remoteQueryConfig.fields.some((field) =>
field.startsWith("variants.calculated_price") field.startsWith("variants.calculated_price")
) ) &&
!req.filterableFields.region_id &&
!req.filterableFields.currency_code
) { ) {
delete req.filterableFields.region_id
delete req.filterableFields.currency_code
return next() return next()
} }
// If pricing parameters are passed, but pricing fields are not passed, throw an error
if (
!req.remoteQueryConfig.fields.some((field) =>
field.startsWith("variants.calculated_price")
) &&
(req.filterableFields.region_id || req.filterableFields.currency_code)
) {
try {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Missing required pricing fields to calculate prices`
)
} catch (e) {
return next(e)
}
}
const query = req.filterableFields || {} const query = req.filterableFields || {}
const pricingContext: MedusaPricingContext = {} const pricingContext: MedusaPricingContext = {}
const customerId = req.user?.customer_id const customerId = req.user?.customer_id
@@ -70,8 +86,8 @@ export function setPricingContext() {
delete req.filterableFields.customer_id delete req.filterableFields.customer_id
} }
// If a region or currency_code is not present in the context, we will not be able to calculate prices // If a currency_code is not present in the context, we will not be able to calculate prices
if (!isPresent(pricingContext)) { if (!isPresent(pricingContext.currency_code)) {
try { try {
throw new MedusaError( throw new MedusaError(
MedusaError.Types.INVALID_DATA, MedusaError.Types.INVALID_DATA,