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:
Riqwan Thamir
2024-04-29 15:14:41 +00:00
committed by GitHub
parent 32c2a9d76b
commit 11517f0faf
24 changed files with 980 additions and 48 deletions
@@ -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,
],
}
@@ -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 })
}
@@ -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)
}
@@ -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(),
],
},
]
@@ -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,
}
@@ -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,
})
}
@@ -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"
@@ -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()
}
}
@@ -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)
+10 -2
View File
@@ -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"
@@ -0,0 +1,6 @@
export type MedusaPricingContext = {
region_id?: string
currency_code?: string
customer_id?: string
customer_group_id?: string[]
}