feat(medusa,types): added store apis for products (#7144)
what: - adds products list api - adds products retrieve api RESOLVES CORE-2016
This commit is contained in:
6
.changeset/khaki-mice-peel.md
Normal file
6
.changeset/khaki-mice-peel.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@medusajs/medusa": patch
|
||||
"@medusajs/types": patch
|
||||
---
|
||||
|
||||
feat(medusa,types): added store apis for products
|
||||
408
integration-tests/modules/__tests__/product/store/index.spec.ts
Normal file
408
integration-tests/modules/__tests__/product/store/index.spec.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
|
||||
import { ApiKeyType, ProductStatus } from "@medusajs/utils"
|
||||
import { medusaIntegrationTestRunner } from "medusa-test-utils"
|
||||
import { createAdminUser } from "../../../../helpers/create-admin-user"
|
||||
import { createDefaultRuleTypes } from "../../../helpers/create-default-rule-types"
|
||||
|
||||
jest.setTimeout(50000)
|
||||
|
||||
const env = { MEDUSA_FF_MEDUSA_V2: true }
|
||||
const adminHeaders = { headers: { "x-medusa-access-token": "test_token" } }
|
||||
|
||||
medusaIntegrationTestRunner({
|
||||
env,
|
||||
testSuite: ({ dbConnection, getContainer, api }) => {
|
||||
describe("Store: Products API", () => {
|
||||
let appContainer
|
||||
let product
|
||||
let product2
|
||||
let product3
|
||||
let product4
|
||||
let variant
|
||||
let variant2
|
||||
let variant3
|
||||
let variant4
|
||||
|
||||
const createProducts = async (data) => {
|
||||
const response = await api.post(
|
||||
"/admin/products?fields=*variants",
|
||||
data,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
return [response.data.product, response.data.product.variants || []]
|
||||
}
|
||||
|
||||
const createSalesChannel = async (data, productIds) => {
|
||||
const response = await api.post(
|
||||
"/admin/sales-channels",
|
||||
data,
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
const salesChannel = response.data.sales_channel
|
||||
|
||||
await api.post(
|
||||
`/admin/sales-channels/${salesChannel.id}/products`,
|
||||
{ add: productIds },
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
return salesChannel
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
appContainer = getContainer()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await createAdminUser(dbConnection, adminHeaders, appContainer)
|
||||
await createDefaultRuleTypes(appContainer)
|
||||
})
|
||||
|
||||
describe("GET /store/products", () => {
|
||||
beforeEach(async () => {
|
||||
;[product, [variant]] = await createProducts({
|
||||
title: "test product 1",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
variants: [
|
||||
{
|
||||
title: "test variant 1",
|
||||
prices: [{ amount: 3000, currency_code: "usd" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
;[product2, [variant2]] = await createProducts({
|
||||
title: "test product 2 uniquely",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
variants: [{ title: "test variant 2", prices: [] }],
|
||||
})
|
||||
;[product3, [variant3]] = await createProducts({
|
||||
title: "product not in price list",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
variants: [{ title: "test variant 3", prices: [] }],
|
||||
})
|
||||
;[product4, [variant4]] = await createProducts({
|
||||
title: "draft product",
|
||||
status: ProductStatus.DRAFT,
|
||||
variants: [{ title: "test variant 4", prices: [] }],
|
||||
})
|
||||
|
||||
const defaultSalesChannel = await createSalesChannel(
|
||||
{ name: "default sales channel" },
|
||||
[product.id, product2.id, product3.id, product4.id]
|
||||
)
|
||||
|
||||
const service = appContainer.resolve(ModuleRegistrationName.STORE)
|
||||
const [store] = await service.list()
|
||||
|
||||
if (store) {
|
||||
await service.delete(store.id)
|
||||
}
|
||||
|
||||
await service.create({
|
||||
supported_currency_codes: ["usd", "dkk"],
|
||||
default_currency_code: "usd",
|
||||
default_sales_channel_id: defaultSalesChannel.id,
|
||||
})
|
||||
})
|
||||
|
||||
it("should list all published products", async () => {
|
||||
let response = await api.get(`/store/products`)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.count).toEqual(3)
|
||||
expect(response.data.products).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: product.id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: product2.id,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: product3.id,
|
||||
}),
|
||||
])
|
||||
)
|
||||
|
||||
response = await api.get(`/store/products?q=uniquely`)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.count).toEqual(1)
|
||||
expect(response.data.products).toEqual([
|
||||
expect.objectContaining({
|
||||
id: product2.id,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("should list all products for a sales channel", async () => {
|
||||
const salesChannel = await createSalesChannel(
|
||||
{ name: "sales channel test" },
|
||||
[product.id]
|
||||
)
|
||||
|
||||
let response = await api.get(
|
||||
`/store/products?sales_channel_id[]=${salesChannel.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", () => {
|
||||
let salesChannel1
|
||||
let salesChannel2
|
||||
let publishableKey1
|
||||
|
||||
beforeEach(async () => {
|
||||
salesChannel1 = await createSalesChannel(
|
||||
{ name: "sales channel test" },
|
||||
[product.id]
|
||||
)
|
||||
|
||||
salesChannel2 = await createSalesChannel(
|
||||
{ name: "sales channel test 2" },
|
||||
[product2.id]
|
||||
)
|
||||
|
||||
const api1Res = await api.post(
|
||||
`/admin/api-keys`,
|
||||
{ title: "Test publishable KEY", type: ApiKeyType.PUBLISHABLE },
|
||||
adminHeaders
|
||||
)
|
||||
|
||||
publishableKey1 = api1Res.data.api_key
|
||||
|
||||
await api.post(
|
||||
`/admin/api-keys/${publishableKey1.id}/sales-channels`,
|
||||
{ add: [salesChannel1.id] },
|
||||
adminHeaders
|
||||
)
|
||||
})
|
||||
|
||||
it("should list all products for a sales channel", async () => {
|
||||
let response = await api.get(
|
||||
`/store/products?sales_channel_id[]=${salesChannel1.id}`,
|
||||
{ headers: { "x-publishable-api-key": publishableKey1.token } }
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.count).toEqual(1)
|
||||
expect(response.data.products).toEqual([
|
||||
expect.objectContaining({
|
||||
id: product.id,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it("should throw error when publishable key is invalid", async () => {
|
||||
let error = await api
|
||||
.get(`/store/products?sales_channel_id[]=does-not-exist`, {
|
||||
headers: { "x-publishable-api-key": "does-not-exist" },
|
||||
})
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.response.status).toEqual(400)
|
||||
expect(error.response.data).toEqual({
|
||||
message: `Publishable API key not found`,
|
||||
type: "invalid_data",
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw error when sales channel does not exist", async () => {
|
||||
let error = await api
|
||||
.get(`/store/products?sales_channel_id[]=does-not-exist`, {
|
||||
headers: { "x-publishable-api-key": publishableKey1.token },
|
||||
})
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.response.status).toEqual(400)
|
||||
expect(error.response.data).toEqual({
|
||||
message: `Invalid sales channel filters provided - does-not-exist`,
|
||||
type: "invalid_data",
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw error when sales channel not in publishable key", async () => {
|
||||
let error = await api
|
||||
.get(`/store/products?sales_channel_id[]=${salesChannel2.id}`, {
|
||||
headers: { "x-publishable-api-key": publishableKey1.token },
|
||||
})
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.response.status).toEqual(400)
|
||||
expect(error.response.data).toEqual({
|
||||
message: `Invalid sales channel filters provided - ${salesChannel2.id}`,
|
||||
type: "invalid_data",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("should throw error when calculating prices without context", async () => {
|
||||
let error = await api
|
||||
.get(`/store/products?fields=*variants.calculated_price`)
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.response.status).toEqual(400)
|
||||
expect(error.response.data).toEqual({
|
||||
message:
|
||||
"Pricing parameters (currency_code or region_id) are required to calculate prices",
|
||||
type: "invalid_data",
|
||||
})
|
||||
})
|
||||
|
||||
it("should list products with prices when context is present", async () => {
|
||||
let response = await api.get(
|
||||
`/store/products?fields=*variants.calculated_price¤cy_code=usd`
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.count).toEqual(3)
|
||||
expect(response.data.products).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: product.id,
|
||||
variants: [
|
||||
expect.objectContaining({
|
||||
calculated_price: {
|
||||
id: expect.any(String),
|
||||
is_calculated_price_price_list: false,
|
||||
calculated_amount: 3000,
|
||||
is_original_price_price_list: false,
|
||||
original_amount: 3000,
|
||||
currency_code: "usd",
|
||||
calculated_price: {
|
||||
id: expect.any(String),
|
||||
price_list_id: null,
|
||||
price_list_type: null,
|
||||
min_quantity: null,
|
||||
max_quantity: null,
|
||||
},
|
||||
original_price: {
|
||||
id: expect.any(String),
|
||||
price_list_id: null,
|
||||
price_list_type: null,
|
||||
min_quantity: null,
|
||||
max_quantity: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /store/products/:id", () => {
|
||||
beforeEach(async () => {
|
||||
;[product, [variant]] = await createProducts({
|
||||
title: "test product 1",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
variants: [
|
||||
{
|
||||
title: "test variant 1",
|
||||
prices: [{ amount: 3000, currency_code: "usd" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const defaultSalesChannel = await createSalesChannel(
|
||||
{ name: "default sales channel" },
|
||||
[product.id]
|
||||
)
|
||||
|
||||
const service = appContainer.resolve(ModuleRegistrationName.STORE)
|
||||
const [store] = await service.list()
|
||||
|
||||
if (store) {
|
||||
await service.delete(store.id)
|
||||
}
|
||||
|
||||
await service.create({
|
||||
supported_currency_codes: ["usd", "dkk"],
|
||||
default_currency_code: "usd",
|
||||
default_sales_channel_id: defaultSalesChannel.id,
|
||||
})
|
||||
})
|
||||
|
||||
it("should retrieve product successfully", async () => {
|
||||
let response = await api.get(`/store/products/${product.id}`)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.product).toEqual(
|
||||
expect.objectContaining({
|
||||
id: product.id,
|
||||
variants: [
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should throw error when calculating prices without context", async () => {
|
||||
let error = await api
|
||||
.get(
|
||||
`/store/products/${product.id}?fields=*variants.calculated_price`
|
||||
)
|
||||
.catch((e) => e)
|
||||
|
||||
expect(error.response.status).toEqual(400)
|
||||
expect(error.response.data).toEqual({
|
||||
message:
|
||||
"Pricing parameters (currency_code or region_id) are required to calculate prices",
|
||||
type: "invalid_data",
|
||||
})
|
||||
})
|
||||
|
||||
it("should get product with prices when context is present", async () => {
|
||||
let response = await api.get(
|
||||
`/store/products/${product.id}?fields=*variants.calculated_price¤cy_code=usd`
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.product).toEqual(
|
||||
expect.objectContaining({
|
||||
id: product.id,
|
||||
variants: [
|
||||
expect.objectContaining({
|
||||
calculated_price: {
|
||||
id: expect.any(String),
|
||||
is_calculated_price_price_list: false,
|
||||
calculated_amount: 3000,
|
||||
is_original_price_price_list: false,
|
||||
original_amount: 3000,
|
||||
currency_code: "usd",
|
||||
calculated_price: {
|
||||
id: expect.any(String),
|
||||
price_list_id: null,
|
||||
price_list_type: null,
|
||||
min_quantity: null,
|
||||
max_quantity: null,
|
||||
},
|
||||
original_price: {
|
||||
id: expect.any(String),
|
||||
price_list_id: null,
|
||||
price_list_type: null,
|
||||
min_quantity: null,
|
||||
max_quantity: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ProductStatus } from "@medusajs/utils"
|
||||
import { z } from "zod"
|
||||
import { optionalBooleanMapper } from "../../../utils/validators/is-boolean"
|
||||
import { GetProductsParams } from "../../utils/common-validators"
|
||||
import {
|
||||
createFindParams,
|
||||
createOperatorMap,
|
||||
@@ -13,50 +13,6 @@ export const AdminGetProductParams = createSelectParams()
|
||||
export const AdminGetProductVariantParams = createSelectParams()
|
||||
export const AdminGetProductOptionParams = createSelectParams()
|
||||
|
||||
export type AdminGetProductsParamsType = z.infer<typeof AdminGetProductsParams>
|
||||
export const AdminGetProductsParams = createFindParams({
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
}).merge(
|
||||
z.object({
|
||||
q: z.string().optional(),
|
||||
id: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
status: statusEnum.array().optional(),
|
||||
title: z.string().optional(),
|
||||
handle: z.string().optional(),
|
||||
is_giftcard: z.preprocess(
|
||||
(val: any) => optionalBooleanMapper.get(val?.toLowerCase()),
|
||||
z.boolean().optional()
|
||||
),
|
||||
category_id: z.string().array().optional(),
|
||||
price_list_id: z.string().array().optional(),
|
||||
sales_channel_id: z.string().array().optional(),
|
||||
collection_id: z.string().array().optional(),
|
||||
tags: z.string().array().optional(),
|
||||
type_id: z.string().array().optional(),
|
||||
// TODO: Replace this with AdminGetProductVariantsParams when its available
|
||||
variants: z.record(z.unknown()).optional(),
|
||||
created_at: createOperatorMap().optional(),
|
||||
updated_at: createOperatorMap().optional(),
|
||||
deleted_at: createOperatorMap().optional(),
|
||||
$and: z.lazy(() => AdminGetProductsParams.array()).optional(),
|
||||
$or: z.lazy(() => AdminGetProductsParams.array()).optional(),
|
||||
})
|
||||
)
|
||||
// TODO: These were part of the products find query, add them once supported
|
||||
// @IsString()
|
||||
// @IsOptional()
|
||||
// discount_condition_id?: string
|
||||
|
||||
// @IsArray()
|
||||
// @IsOptional()
|
||||
// category_id?: string[]
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
// @Transform(({ value }) => optionalBooleanMapper.get(value.toLowerCase()))
|
||||
// include_category_children?: boolean
|
||||
|
||||
export type AdminGetProductVariantsParamsType = z.infer<
|
||||
typeof AdminGetProductVariantsParams
|
||||
>
|
||||
@@ -77,6 +33,21 @@ export const AdminGetProductVariantsParams = createFindParams({
|
||||
})
|
||||
)
|
||||
|
||||
export type AdminGetProductsParamsType = z.infer<typeof AdminGetProductsParams>
|
||||
export const AdminGetProductsParams = createFindParams({
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
}).merge(
|
||||
z
|
||||
.object({
|
||||
variants: AdminGetProductVariantsParams.optional(),
|
||||
price_list_id: z.string().array().optional(),
|
||||
$and: z.lazy(() => AdminGetProductsParams.array()).optional(),
|
||||
$or: z.lazy(() => AdminGetProductsParams.array()).optional(),
|
||||
})
|
||||
.merge(GetProductsParams)
|
||||
)
|
||||
|
||||
export type AdminGetProductOptionsParamsType = z.infer<
|
||||
typeof AdminGetProductOptionsParams
|
||||
>
|
||||
|
||||
@@ -36,6 +36,7 @@ import { hooksRoutesMiddlewares } from "./hooks/middlewares"
|
||||
import { storeCartRoutesMiddlewares } from "./store/carts/middlewares"
|
||||
import { storeCurrencyRoutesMiddlewares } from "./store/currencies/middlewares"
|
||||
import { storeCustomerRoutesMiddlewares } from "./store/customers/middlewares"
|
||||
import { storeProductRoutesMiddlewares } from "./store/products/middlewares"
|
||||
import { storeRegionRoutesMiddlewares } from "./store/regions/middlewares"
|
||||
|
||||
export const config: MiddlewaresConfig = {
|
||||
@@ -80,5 +81,6 @@ export const config: MiddlewaresConfig = {
|
||||
...adminShippingProfilesMiddlewares,
|
||||
...adminFulfillmentsRoutesMiddlewares,
|
||||
...adminFulfillmentProvidersRoutesMiddlewares,
|
||||
...storeProductRoutesMiddlewares,
|
||||
],
|
||||
}
|
||||
|
||||
26
packages/medusa/src/api-v2/store/products/[id]/route.ts
Normal file
26
packages/medusa/src/api-v2/store/products/[id]/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { isPresent } from "@medusajs/utils"
|
||||
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
|
||||
import { refetchProduct } from "../helpers"
|
||||
import { StoreGetProductsParamsType } from "../validators"
|
||||
|
||||
export const GET = async (
|
||||
req: MedusaRequest<StoreGetProductsParamsType>,
|
||||
res: MedusaResponse
|
||||
) => {
|
||||
const context = isPresent(req.pricingContext)
|
||||
? {
|
||||
"variants.calculated_price": { context: req.pricingContext },
|
||||
}
|
||||
: undefined
|
||||
|
||||
const product = await refetchProduct(
|
||||
{
|
||||
id: req.params.id,
|
||||
context,
|
||||
},
|
||||
req.scope,
|
||||
req.remoteQueryConfig.fields
|
||||
)
|
||||
|
||||
res.json({ product })
|
||||
}
|
||||
34
packages/medusa/src/api-v2/store/products/helpers.ts
Normal file
34
packages/medusa/src/api-v2/store/products/helpers.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { MedusaContainer } from "@medusajs/types"
|
||||
import { isPresent } from "@medusajs/utils"
|
||||
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 (
|
||||
idOrFilter: string | object,
|
||||
scope: MedusaContainer,
|
||||
fields: string[]
|
||||
) => {
|
||||
return await refetchEntity("product", idOrFilter, scope, fields)
|
||||
}
|
||||
67
packages/medusa/src/api-v2/store/products/middlewares.ts
Normal file
67
packages/medusa/src/api-v2/store/products/middlewares.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ProductStatus } from "@medusajs/utils"
|
||||
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
|
||||
import { authenticate } from "../../../utils/authenticate-middleware"
|
||||
import { maybeApplyLinkFilter } from "../../utils/maybe-apply-link-filter"
|
||||
import {
|
||||
applyDefaultFilters,
|
||||
filterByValidSalesChannels,
|
||||
setPricingContext,
|
||||
} from "../../utils/middlewares"
|
||||
import { validateAndTransformQuery } from "../../utils/validate-query"
|
||||
import * as QueryConfig from "./query-config"
|
||||
import {
|
||||
StoreGetProductsParams,
|
||||
StoreGetProductsParamsType,
|
||||
} from "./validators"
|
||||
|
||||
export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [
|
||||
{
|
||||
method: "ALL",
|
||||
matcher: "/store/products*",
|
||||
middlewares: [
|
||||
authenticate("store", ["session", "bearer"], {
|
||||
allowUnauthenticated: true,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
method: ["GET"],
|
||||
matcher: "/store/products",
|
||||
middlewares: [
|
||||
validateAndTransformQuery(
|
||||
StoreGetProductsParams,
|
||||
QueryConfig.listProductQueryConfig
|
||||
),
|
||||
filterByValidSalesChannels(),
|
||||
maybeApplyLinkFilter({
|
||||
entryPoint: "product_sales_channel",
|
||||
resourceId: "product_id",
|
||||
filterableField: "sales_channel_id",
|
||||
}),
|
||||
applyDefaultFilters<StoreGetProductsParamsType>({
|
||||
status: ProductStatus.PUBLISHED,
|
||||
}),
|
||||
setPricingContext(),
|
||||
],
|
||||
},
|
||||
{
|
||||
method: ["GET"],
|
||||
matcher: "/store/products/:id",
|
||||
middlewares: [
|
||||
validateAndTransformQuery(
|
||||
StoreGetProductsParams,
|
||||
QueryConfig.retrieveProductQueryConfig
|
||||
),
|
||||
filterByValidSalesChannels(),
|
||||
maybeApplyLinkFilter({
|
||||
entryPoint: "product_sales_channel",
|
||||
resourceId: "product_id",
|
||||
filterableField: "sales_channel_id",
|
||||
}),
|
||||
applyDefaultFilters<StoreGetProductsParamsType>({
|
||||
status: ProductStatus.PUBLISHED,
|
||||
}),
|
||||
setPricingContext(),
|
||||
],
|
||||
},
|
||||
]
|
||||
41
packages/medusa/src/api-v2/store/products/query-config.ts
Normal file
41
packages/medusa/src/api-v2/store/products/query-config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export const defaultStoreProductFields = [
|
||||
"id",
|
||||
"title",
|
||||
"subtitle",
|
||||
"description",
|
||||
"handle",
|
||||
"is_giftcard",
|
||||
"discountable",
|
||||
"thumbnail",
|
||||
"collection_id",
|
||||
"type_id",
|
||||
"weight",
|
||||
"length",
|
||||
"height",
|
||||
"width",
|
||||
"hs_code",
|
||||
"origin_country",
|
||||
"mid_code",
|
||||
"material",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"*type",
|
||||
"*collection",
|
||||
"*options",
|
||||
"*options.values",
|
||||
"*tags",
|
||||
"*images",
|
||||
"*variants",
|
||||
"*variants.options",
|
||||
]
|
||||
|
||||
export const retrieveProductQueryConfig = {
|
||||
defaults: defaultStoreProductFields,
|
||||
isList: false,
|
||||
}
|
||||
|
||||
export const listProductQueryConfig = {
|
||||
...retrieveProductQueryConfig,
|
||||
defaultLimit: 50,
|
||||
isList: true,
|
||||
}
|
||||
39
packages/medusa/src/api-v2/store/products/route.ts
Normal file
39
packages/medusa/src/api-v2/store/products/route.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
isPresent,
|
||||
remoteQueryObjectFromString,
|
||||
} from "@medusajs/utils"
|
||||
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
|
||||
import { wrapWithCategoryFilters } from "./helpers"
|
||||
import { StoreGetProductsParamsType } from "./validators"
|
||||
|
||||
export const GET = async (
|
||||
req: MedusaRequest<StoreGetProductsParamsType>,
|
||||
res: MedusaResponse
|
||||
) => {
|
||||
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
const context = isPresent(req.pricingContext)
|
||||
? {
|
||||
"variants.calculated_price": { context: req.pricingContext },
|
||||
}
|
||||
: undefined
|
||||
|
||||
const queryObject = remoteQueryObjectFromString({
|
||||
entryPoint: "product",
|
||||
variables: {
|
||||
filters: wrapWithCategoryFilters(req.filterableFields),
|
||||
...context,
|
||||
...req.remoteQueryConfig.pagination,
|
||||
},
|
||||
fields: req.remoteQueryConfig.fields,
|
||||
})
|
||||
|
||||
const { rows: products, metadata } = await remoteQuery(queryObject)
|
||||
|
||||
res.json({
|
||||
products,
|
||||
count: metadata.count,
|
||||
offset: metadata.skip,
|
||||
limit: metadata.take,
|
||||
})
|
||||
}
|
||||
48
packages/medusa/src/api-v2/store/products/validators.ts
Normal file
48
packages/medusa/src/api-v2/store/products/validators.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod"
|
||||
import {
|
||||
GetProductsParams,
|
||||
ProductStatusEnum,
|
||||
} from "../../utils/common-validators"
|
||||
import {
|
||||
createFindParams,
|
||||
createOperatorMap,
|
||||
createSelectParams,
|
||||
} from "../../utils/validators"
|
||||
|
||||
export const StoreGetProductParams = createSelectParams()
|
||||
|
||||
export type StoreGetProductVariantsParamsType = z.infer<
|
||||
typeof StoreGetProductVariantsParams
|
||||
>
|
||||
export const StoreGetProductVariantsParams = createFindParams({
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
}).merge(
|
||||
z.object({
|
||||
q: z.string().optional(),
|
||||
id: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
status: ProductStatusEnum.array().optional(),
|
||||
created_at: createOperatorMap().optional(),
|
||||
updated_at: createOperatorMap().optional(),
|
||||
deleted_at: createOperatorMap().optional(),
|
||||
$and: z.lazy(() => StoreGetProductsParams.array()).optional(),
|
||||
$or: z.lazy(() => StoreGetProductsParams.array()).optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export type StoreGetProductsParamsType = z.infer<typeof StoreGetProductsParams>
|
||||
export const StoreGetProductsParams = createFindParams({
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
}).merge(
|
||||
z
|
||||
.object({
|
||||
region_id: z.string().optional(),
|
||||
currency_code: z.string().optional(),
|
||||
variants: StoreGetProductVariantsParams.optional(),
|
||||
$and: z.lazy(() => StoreGetProductsParams.array()).optional(),
|
||||
$or: z.lazy(() => StoreGetProductsParams.array()).optional(),
|
||||
})
|
||||
.merge(GetProductsParams)
|
||||
.strict()
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod"
|
||||
import { optionalBooleanMapper } from "../../../utils/validators/is-boolean"
|
||||
|
||||
export const AddressPayload = z
|
||||
.object({
|
||||
@@ -24,3 +25,8 @@ export const BigNumberInput = z.union([
|
||||
precision: z.number(),
|
||||
}),
|
||||
])
|
||||
|
||||
export const OptionalBooleanValidator = z.preprocess(
|
||||
(val: any) => optionalBooleanMapper.get(val?.toLowerCase()),
|
||||
z.boolean().optional()
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./common"
|
||||
export * from "./products"
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ProductStatus } from "@medusajs/utils"
|
||||
import { z } from "zod"
|
||||
import { createOperatorMap } from "../../validators"
|
||||
import { OptionalBooleanValidator } from "../common"
|
||||
|
||||
export const ProductStatusEnum = z.nativeEnum(ProductStatus)
|
||||
|
||||
export const GetProductsParams = z.object({
|
||||
q: z.string().optional(),
|
||||
id: z.union([z.string(), z.array(z.string())]).optional(),
|
||||
title: z.string().optional(),
|
||||
handle: z.string().optional(),
|
||||
is_giftcard: OptionalBooleanValidator,
|
||||
category_id: z.string().array().optional(),
|
||||
sales_channel_id: z.string().array().optional(),
|
||||
collection_id: z.string().array().optional(),
|
||||
tags: z.string().array().optional(),
|
||||
type_id: z.string().array().optional(),
|
||||
created_at: createOperatorMap().optional(),
|
||||
updated_at: createOperatorMap().optional(),
|
||||
deleted_at: createOperatorMap().optional(),
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { isObject } from "@medusajs/utils"
|
||||
import { NextFunction } from "express"
|
||||
import { MedusaRequest } from "../../../../types/routing"
|
||||
|
||||
export function applyDefaultFilters<TFilter extends object>(filters: TFilter) {
|
||||
return async (req: MedusaRequest, _, next: NextFunction) => {
|
||||
const filterableFields = req.filterableFields || {}
|
||||
|
||||
for (const [filter, filterValue] of Object.entries(filters)) {
|
||||
let existingFilter = filterableFields[filter]
|
||||
|
||||
if (existingFilter && isObject(existingFilter)) {
|
||||
// If an existing filter is found, append to it
|
||||
filterableFields[filter] = {
|
||||
...existingFilter,
|
||||
[filter]: filterValue,
|
||||
}
|
||||
} else {
|
||||
filterableFields[filter] = filterValue
|
||||
}
|
||||
}
|
||||
|
||||
req.filterableFields = filterableFields
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./apply-default-filters"
|
||||
2
packages/medusa/src/api-v2/utils/middlewares/index.ts
Normal file
2
packages/medusa/src/api-v2/utils/middlewares/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./common"
|
||||
export * from "./products"
|
||||
@@ -0,0 +1,79 @@
|
||||
import { isPresent, MedusaError } from "@medusajs/utils"
|
||||
import { NextFunction } from "express"
|
||||
import { AuthenticatedMedusaRequest } from "../../../../types/routing"
|
||||
import { refetchEntity } from "../../refetch-entity"
|
||||
|
||||
export function filterByValidSalesChannels() {
|
||||
return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => {
|
||||
const publishableApiKey = req.get("x-publishable-api-key")
|
||||
const salesChannelIds = req.filterableFields.sales_channel_id as
|
||||
| string[]
|
||||
| undefined
|
||||
|
||||
const store = await refetchEntity("stores", {}, req.scope, [
|
||||
"default_sales_channel_id",
|
||||
])
|
||||
|
||||
if (!store) {
|
||||
try {
|
||||
throw new MedusaError(MedusaError.Types.INVALID_DATA, `Store not found`)
|
||||
} catch (e) {
|
||||
return next(e)
|
||||
}
|
||||
}
|
||||
// Always set sales channels in the following priority
|
||||
// - any existing sales chennel ids passed through query params
|
||||
// - if none, we set the default sales channel
|
||||
req.filterableFields.sales_channel_id = salesChannelIds ?? [
|
||||
store.default_sales_channel_id,
|
||||
]
|
||||
|
||||
// Return early if no publishable keys are found
|
||||
if (!isPresent(publishableApiKey)) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// When publishable keys are present, we fetch for all sales chennels attached
|
||||
// to the publishable key and validate the sales channel filter against it
|
||||
const apiKey = await refetchEntity(
|
||||
"api_key",
|
||||
{ token: publishableApiKey },
|
||||
req.scope,
|
||||
["id", "sales_channels_link.sales_channel_id"]
|
||||
)
|
||||
|
||||
if (!apiKey) {
|
||||
try {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Publishable API key not found`
|
||||
)
|
||||
} catch (e) {
|
||||
return next(e)
|
||||
}
|
||||
}
|
||||
|
||||
const validSalesChannelIds = apiKey.sales_channels_link.map(
|
||||
(link) => link.sales_channel_id
|
||||
)
|
||||
|
||||
const invalidSalesChannelIds = (salesChannelIds || []).filter(
|
||||
(id) => !validSalesChannelIds.includes(id)
|
||||
)
|
||||
|
||||
if (invalidSalesChannelIds.length) {
|
||||
try {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Invalid sales channel filters provided - ${invalidSalesChannelIds.join(
|
||||
", "
|
||||
)}`
|
||||
)
|
||||
} catch (e) {
|
||||
return next(e)
|
||||
}
|
||||
}
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./filter-by-valid-sales-channels"
|
||||
export * from "./set-pricing-context"
|
||||
@@ -0,0 +1,89 @@
|
||||
import { MedusaPricingContext } from "@medusajs/types"
|
||||
import { isPresent, MedusaError } from "@medusajs/utils"
|
||||
import { NextFunction } from "express"
|
||||
import { AuthenticatedMedusaRequest } from "../../../../types/routing"
|
||||
import { refetchEntities, refetchEntity } from "../../refetch-entity"
|
||||
|
||||
export function setPricingContext() {
|
||||
return async (req: AuthenticatedMedusaRequest, _, next: NextFunction) => {
|
||||
// If the endpoint doesn't request prices, we can exit early
|
||||
if (
|
||||
!req.remoteQueryConfig.fields.some((field) =>
|
||||
field.startsWith("variants.calculated_price")
|
||||
)
|
||||
) {
|
||||
delete req.filterableFields.region_id
|
||||
delete req.filterableFields.currency_code
|
||||
|
||||
return next()
|
||||
}
|
||||
|
||||
const query = req.filterableFields || {}
|
||||
const pricingContext: MedusaPricingContext = {}
|
||||
const customerId = req.user?.customer_id
|
||||
|
||||
if (query.region_id) {
|
||||
const region = await refetchEntity("region", query.region_id, req.scope, [
|
||||
"id",
|
||||
"currency_code",
|
||||
])
|
||||
|
||||
if (region) {
|
||||
pricingContext.region_id = region.id
|
||||
}
|
||||
|
||||
if (region?.currency_code) {
|
||||
pricingContext.currency_code = region.currency_code
|
||||
}
|
||||
|
||||
delete req.filterableFields.region_id
|
||||
}
|
||||
|
||||
// If a currency code is explicitly passed, we should be using that instead of the
|
||||
// regions currency code
|
||||
if (query.currency_code) {
|
||||
const currency = await refetchEntity(
|
||||
"currency",
|
||||
{ code: query.currency_code },
|
||||
req.scope,
|
||||
["code"]
|
||||
)
|
||||
|
||||
if (currency) {
|
||||
pricingContext.currency_code = currency.code
|
||||
}
|
||||
|
||||
delete req.filterableFields.currency_code
|
||||
}
|
||||
|
||||
// Find all the customer groups the customer is a part of and set
|
||||
if (customerId) {
|
||||
const customerGroups = await refetchEntities(
|
||||
"customer_group",
|
||||
{ customer_id: customerId },
|
||||
req.scope,
|
||||
["id"]
|
||||
)
|
||||
|
||||
pricingContext.customer_group_id = customerGroups.map((cg) => cg.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 (!isPresent(pricingContext)) {
|
||||
try {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Pricing parameters (currency_code or region_id) are required to calculate prices`
|
||||
)
|
||||
} catch (e) {
|
||||
return next(e)
|
||||
}
|
||||
}
|
||||
|
||||
req.pricingContext = pricingContext
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
46
packages/medusa/src/api-v2/utils/refetch-entity.ts
Normal file
46
packages/medusa/src/api-v2/utils/refetch-entity.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { MedusaContainer } from "@medusajs/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
isString,
|
||||
remoteQueryObjectFromString,
|
||||
} from "@medusajs/utils"
|
||||
|
||||
export const refetchEntities = async (
|
||||
entryPoint: string,
|
||||
idOrFilter: string | object,
|
||||
scope: MedusaContainer,
|
||||
fields: string[]
|
||||
) => {
|
||||
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
|
||||
}
|
||||
|
||||
let variables = { filters, ...context }
|
||||
|
||||
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
|
||||
}
|
||||
@@ -44,7 +44,6 @@ export function validateAndTransformQuery<TEntity extends BaseEntity>(
|
||||
return async (req: MedusaRequest, _: MedusaResponse, next: NextFunction) => {
|
||||
try {
|
||||
const query = normalizeQuery(req)
|
||||
|
||||
const validated = await zodValidator(zodSchema, query)
|
||||
const cnf = queryConfig.isList
|
||||
? prepareListQuery(validated, queryConfig)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { NextFunction, Request, Response } from "express"
|
||||
import type { Customer, User } from "../models"
|
||||
|
||||
import { MedusaContainer, RequestQueryFields } from "@medusajs/types"
|
||||
import { FindConfig } from "./common"
|
||||
import {
|
||||
MedusaContainer,
|
||||
MedusaPricingContext,
|
||||
RequestQueryFields,
|
||||
} from "@medusajs/types"
|
||||
import * as core from "express-serve-static-core"
|
||||
import { FindConfig } from "./common"
|
||||
|
||||
export interface MedusaRequest<Body = unknown>
|
||||
extends Request<core.ParamsDictionary, any, Body> {
|
||||
@@ -50,6 +54,10 @@ export interface MedusaRequest<Body = unknown>
|
||||
session?: any
|
||||
rawBody?: any
|
||||
requestId?: string
|
||||
/**
|
||||
* An object that carries the context that is used to calculate prices for variants
|
||||
*/
|
||||
pricingContext?: MedusaPricingContext
|
||||
}
|
||||
|
||||
export interface AuthenticatedMedusaRequest<Body = never>
|
||||
|
||||
@@ -4,4 +4,5 @@ export * from "./price-list"
|
||||
export * from "./price-rule"
|
||||
export * from "./price-set"
|
||||
export * from "./price-set-rule-type"
|
||||
export * from "./pricing-context"
|
||||
export * from "./rule-type"
|
||||
|
||||
6
packages/types/src/pricing/common/pricing-context.ts
Normal file
6
packages/types/src/pricing/common/pricing-context.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type MedusaPricingContext = {
|
||||
region_id?: string
|
||||
currency_code?: string
|
||||
customer_id?: string
|
||||
customer_group_id?: string[]
|
||||
}
|
||||
Reference in New Issue
Block a user