feat: Switch to zod for the product API and implement missing primitives (#6978)

This commit is contained in:
Stevche Radevski
2024-04-06 15:45:52 +02:00
committed by GitHub
parent 65794f4bb5
commit 883a75c4f3
16 changed files with 491 additions and 829 deletions

View File

@@ -2742,7 +2742,12 @@ medusaIntegrationTestRunner({
await api.post("/admin/collections", payload, adminHeaders)
} catch (error) {
expect(error.response.data.message).toMatch(
`Product_collection with handle ${baseCollection.handle} already exists.`
breaking(
() =>
`Product_collection with handle ${baseCollection.handle} already exists.`,
() =>
`Product collection with handle: ${baseCollection.handle} already exists.`
)
)
}
})
@@ -2764,10 +2769,10 @@ medusaIntegrationTestRunner({
const payload = {
title: "Second variant",
sku: variant.sku,
ean: variant.ean,
upc: variant.upc,
barcode: variant.barcode,
sku: "new-sku",
ean: "new-ean",
upc: "new-upc",
barcode: "new-barcode",
...breaking(
() => ({
options: [
@@ -2803,10 +2808,10 @@ medusaIntegrationTestRunner({
expect.arrayContaining([
expect.objectContaining({
title: "Second variant",
sku: variant.sku,
ean: variant.ean,
upc: variant.upc,
barcode: variant.barcode,
sku: "new-sku",
ean: "new-ean",
upc: "new-upc",
barcode: "new-barcode",
}),
])
)

View File

@@ -11,7 +11,7 @@ import { getVariantPricingLinkStep } from "../steps/get-variant-pricing-link"
type UpdateProductVariantsStepInput = {
selector: ProductTypes.FilterableProductVariantProps
update: ProductTypes.UpdateProductVariantDTO & {
prices?: PricingTypes.CreateMoneyAmountDTO[]
prices?: Partial<PricingTypes.CreateMoneyAmountDTO>[]
}
}

View File

@@ -8,8 +8,8 @@ import {
} from "@medusajs/core-flows"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import { UpdateProductOptionDTO } from "../../../../../../../../types/dist"
import { refetchProduct, remapProduct } from "../../../helpers"
import { refetchProduct, remapProductResponse } from "../../../helpers"
import { AdminUpdateProductOptionType } from "../../../validators"
export const GET = async (
req: AuthenticatedMedusaRequest,
@@ -33,7 +33,7 @@ export const GET = async (
}
export const POST = async (
req: AuthenticatedMedusaRequest<UpdateProductOptionDTO>,
req: AuthenticatedMedusaRequest<AdminUpdateProductOptionType>,
res: MedusaResponse
) => {
const productId = req.params.id
@@ -56,7 +56,7 @@ export const POST = async (
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ product: remapProduct(product) })
res.status(200).json({ product: remapProductResponse(product) })
}
export const DELETE = async (

View File

@@ -3,10 +3,10 @@ import {
MedusaResponse,
} from "../../../../../types/routing"
import { CreateProductOptionDTO } from "@medusajs/types"
import { createProductOptionsWorkflow } from "@medusajs/core-flows"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import { refetchProduct, remapProduct } from "../../helpers"
import { refetchProduct, remapProductResponse } from "../../helpers"
import { AdminCreateProductOptionType } from "../../validators"
export const GET = async (
req: AuthenticatedMedusaRequest,
@@ -37,7 +37,7 @@ export const GET = async (
}
export const POST = async (
req: AuthenticatedMedusaRequest<CreateProductOptionDTO>,
req: AuthenticatedMedusaRequest<AdminCreateProductOptionType>,
res: MedusaResponse
) => {
const productId = req.params.id
@@ -62,5 +62,5 @@ export const POST = async (
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ product: remapProduct(product) })
res.status(200).json({ product: remapProductResponse(product) })
}

View File

@@ -7,9 +7,14 @@ import {
updateProductsWorkflow,
} from "@medusajs/core-flows"
import { UpdateProductDTO } from "@medusajs/types"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import { refetchProduct, remapKeysForProduct, remapProduct } from "../helpers"
import {
refetchProduct,
remapKeysForProduct,
remapProductResponse,
} from "../helpers"
import { AdminUpdateProductType } from "../validators"
import { UpdateProductDTO } from "@medusajs/types"
export const GET = async (
req: AuthenticatedMedusaRequest,
@@ -28,17 +33,17 @@ export const GET = async (
const [product] = await remoteQuery(queryObject)
res.status(200).json({ product: remapProduct(product) })
res.status(200).json({ product: remapProductResponse(product) })
}
export const POST = async (
req: AuthenticatedMedusaRequest<UpdateProductDTO>,
req: AuthenticatedMedusaRequest<AdminUpdateProductType>,
res: MedusaResponse
) => {
const { result, errors } = await updateProductsWorkflow(req.scope).run({
input: {
selector: { id: req.params.id },
update: req.validatedBody,
update: req.validatedBody as UpdateProductDTO,
},
throwOnError: false,
})
@@ -52,7 +57,7 @@ export const POST = async (
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ product: remapProduct(product) })
res.status(200).json({ product: remapProductResponse(product) })
}
export const DELETE = async (

View File

@@ -7,14 +7,14 @@ import {
updateProductVariantsWorkflow,
} from "@medusajs/core-flows"
import { UpdateProductVariantDTO } from "@medusajs/types"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import {
refetchProduct,
remapKeysForVariant,
remapProduct,
remapVariant,
remapProductResponse,
remapVariantResponse,
} from "../../../helpers"
import { AdminUpdateProductVariantType } from "../../../validators"
export const GET = async (
req: AuthenticatedMedusaRequest,
@@ -35,26 +35,24 @@ export const GET = async (
})
const [variant] = await remoteQuery(queryObject)
res.status(200).json({ variant: remapVariant(variant) })
res.status(200).json({ variant: remapVariantResponse(variant) })
}
export const POST = async (
req: AuthenticatedMedusaRequest<UpdateProductVariantDTO>,
req: AuthenticatedMedusaRequest<AdminUpdateProductVariantType>,
res: MedusaResponse
) => {
// TODO: Should we allow fetching a variant without knowing the product ID? In such case we'll need to change the route to /admin/products/variants/:id
const productId = req.params.id
const variantId = req.params.variant_id
const { result, errors } = await updateProductVariantsWorkflow(req.scope).run(
{
input: {
selector: { id: variantId, product_id: productId },
update: req.validatedBody,
},
throwOnError: false,
}
)
const { errors } = await updateProductVariantsWorkflow(req.scope).run({
input: {
selector: { id: variantId, product_id: productId },
update: req.validatedBody,
},
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
@@ -65,7 +63,8 @@ export const POST = async (
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ product: remapProduct(product) })
res.status(200).json({ product: remapProductResponse(product) })
Response
}
export const DELETE = async (

View File

@@ -3,15 +3,15 @@ import {
MedusaResponse,
} from "../../../../../types/routing"
import { CreateProductVariantDTO } from "@medusajs/types"
import { createProductVariantsWorkflow } from "@medusajs/core-flows"
import { remoteQueryObjectFromString } from "@medusajs/utils"
import {
refetchProduct,
remapKeysForVariant,
remapProduct,
remapVariant,
remapProductResponse,
remapVariantResponse,
} from "../../helpers"
import { AdminCreateProductVariantType } from "../../validators"
export const GET = async (
req: AuthenticatedMedusaRequest,
@@ -34,7 +34,7 @@ export const GET = async (
const { rows: variants, metadata } = await remoteQuery(queryObject)
res.json({
variants: variants.map(remapVariant),
variants: variants.map(remapVariantResponse),
count: metadata.count,
offset: metadata.skip,
limit: metadata.take,
@@ -42,7 +42,7 @@ export const GET = async (
}
export const POST = async (
req: AuthenticatedMedusaRequest<CreateProductVariantDTO>,
req: AuthenticatedMedusaRequest<AdminCreateProductVariantType>,
res: MedusaResponse
) => {
const productId = req.params.id
@@ -69,5 +69,5 @@ export const POST = async (
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ product: remapProduct(product) })
res.status(200).json({ product: remapProductResponse(product) })
}

View File

@@ -1,9 +1,4 @@
import {
CreateProductDTO,
MedusaContainer,
ProductDTO,
ProductVariantDTO,
} from "@medusajs/types"
import { MedusaContainer, ProductDTO, ProductVariantDTO } from "@medusajs/types"
import { remoteQueryObjectFromString } from "@medusajs/utils"
const isPricing = (fieldName: string) =>
@@ -39,27 +34,27 @@ export const remapKeysForVariant = (selectFields: string[]) => {
return [...variantFields, ...pricingFields]
}
export const remapProduct = (p: ProductDTO) => {
export const remapProductResponse = (product: ProductDTO) => {
return {
...p,
variants: p.variants?.map(remapVariant),
...product,
variants: product.variants?.map(remapVariantResponse),
}
}
export const remapVariant = (v: ProductVariantDTO) => {
if (!v) {
return v
export const remapVariantResponse = (variant: ProductVariantDTO) => {
if (!variant) {
return variant
}
return {
...v,
prices: (v as any).price_set?.prices?.map((price) => ({
...variant,
prices: (variant as any).price_set?.prices?.map((price) => ({
id: price.id,
amount: price.amount,
currency_code: price.currency_code,
min_quantity: price.min_quantity,
max_quantity: price.max_quantity,
variant_id: v.id,
variant_id: variant.id,
created_at: price.created_at,
updated_at: price.updated_at,
})),

View File

@@ -2,21 +2,23 @@ import { transformBody, transformQuery } from "../../../api/middlewares"
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import { authenticate } from "../../../utils/authenticate-middleware"
import { maybeApplyLinkFilter } from "../../utils/maybe-apply-link-filter"
import { validateAndTransformBody } from "../../utils/validate-body"
import { validateAndTransformQuery } from "../../utils/validate-query"
import * as QueryConfig from "./query-config"
import { maybeApplyPriceListsFilter } from "./utils"
import {
AdminGetProductsOptionsParams,
AdminGetProductsParams,
AdminGetProductsProductOptionsOptionParams,
AdminGetProductsProductParams,
AdminGetProductsProductVariantsVariantParams,
AdminGetProductsVariantsParams,
AdminPostProductsProductOptionsOptionReq,
AdminPostProductsProductOptionsReq,
AdminPostProductsProductReq,
AdminPostProductsProductVariantsReq,
AdminPostProductsProductVariantsVariantReq,
AdminPostProductsReq,
AdminCreateProduct,
AdminCreateProductOption,
AdminCreateProductVariant,
AdminUpdateProduct,
AdminUpdateProductOption,
AdminGetProductParams,
AdminGetProductVariantsParams,
AdminGetProductVariantParams,
AdminUpdateProductVariant,
AdminGetProductOptionsParams,
AdminGetProductOptionParams,
} from "./validators"
export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
@@ -29,7 +31,7 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["GET"],
matcher: "/admin/products",
middlewares: [
transformQuery(
validateAndTransformQuery(
AdminGetProductsParams,
QueryConfig.listProductQueryConfig
),
@@ -45,8 +47,8 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["GET"],
matcher: "/admin/products/:id",
middlewares: [
transformQuery(
AdminGetProductsProductParams,
validateAndTransformQuery(
AdminGetProductParams,
QueryConfig.retrieveProductQueryConfig
),
],
@@ -55,9 +57,9 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["POST"],
matcher: "/admin/products",
middlewares: [
transformBody(AdminPostProductsReq),
transformQuery(
AdminGetProductsProductParams,
validateAndTransformBody(AdminCreateProduct),
validateAndTransformQuery(
AdminGetProductParams,
QueryConfig.retrieveProductQueryConfig
),
],
@@ -66,9 +68,9 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["POST"],
matcher: "/admin/products/:id",
middlewares: [
transformBody(AdminPostProductsProductReq),
transformQuery(
AdminGetProductsProductParams,
validateAndTransformBody(AdminUpdateProduct),
validateAndTransformQuery(
AdminGetProductParams,
QueryConfig.retrieveProductQueryConfig
),
],
@@ -77,8 +79,8 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["DELETE"],
matcher: "/admin/products/:id",
middlewares: [
transformQuery(
AdminGetProductsProductParams,
validateAndTransformQuery(
AdminGetProductParams,
QueryConfig.retrieveProductQueryConfig
),
],
@@ -88,8 +90,8 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["GET"],
matcher: "/admin/products/:id/variants",
middlewares: [
transformQuery(
AdminGetProductsVariantsParams,
validateAndTransformQuery(
AdminGetProductVariantsParams,
QueryConfig.listVariantConfig
),
],
@@ -99,8 +101,8 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["GET"],
matcher: "/admin/products/:id/variants/:variant_id",
middlewares: [
transformQuery(
AdminGetProductsProductVariantsVariantParams,
validateAndTransformQuery(
AdminGetProductVariantParams,
QueryConfig.retrieveVariantConfig
),
],
@@ -109,9 +111,9 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["POST"],
matcher: "/admin/products/:id/variants",
middlewares: [
transformBody(AdminPostProductsProductVariantsReq),
transformQuery(
AdminGetProductsProductParams,
validateAndTransformBody(AdminCreateProductVariant),
validateAndTransformQuery(
AdminGetProductParams,
QueryConfig.retrieveProductQueryConfig
),
],
@@ -120,9 +122,9 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["POST"],
matcher: "/admin/products/:id/variants/:variant_id",
middlewares: [
transformBody(AdminPostProductsProductVariantsVariantReq),
transformQuery(
AdminGetProductsProductParams,
validateAndTransformBody(AdminUpdateProductVariant),
validateAndTransformQuery(
AdminGetProductParams,
QueryConfig.retrieveProductQueryConfig
),
],
@@ -131,8 +133,8 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["DELETE"],
matcher: "/admin/products/:id/variants/:variant_id",
middlewares: [
transformQuery(
AdminGetProductsProductParams,
validateAndTransformQuery(
AdminGetProductParams,
QueryConfig.retrieveProductQueryConfig
),
],
@@ -143,8 +145,8 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["GET"],
matcher: "/admin/products/:id/options",
middlewares: [
transformQuery(
AdminGetProductsOptionsParams,
validateAndTransformQuery(
AdminGetProductOptionsParams,
QueryConfig.listOptionConfig
),
],
@@ -154,8 +156,8 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["GET"],
matcher: "/admin/products/:id/options/:option_id",
middlewares: [
transformQuery(
AdminGetProductsProductOptionsOptionParams,
validateAndTransformQuery(
AdminGetProductOptionParams,
QueryConfig.retrieveOptionConfig
),
],
@@ -164,9 +166,9 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["POST"],
matcher: "/admin/products/:id/options",
middlewares: [
transformBody(AdminPostProductsProductOptionsReq),
transformQuery(
AdminGetProductsProductParams,
validateAndTransformBody(AdminCreateProductOption),
validateAndTransformQuery(
AdminGetProductParams,
QueryConfig.retrieveProductQueryConfig
),
],
@@ -175,9 +177,9 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["POST"],
matcher: "/admin/products/:id/options/:option_id",
middlewares: [
transformBody(AdminPostProductsProductOptionsOptionReq),
transformQuery(
AdminGetProductsProductParams,
validateAndTransformBody(AdminUpdateProductOption),
validateAndTransformQuery(
AdminGetProductParams,
QueryConfig.retrieveProductQueryConfig
),
],
@@ -186,8 +188,8 @@ export const adminProductRoutesMiddlewares: MiddlewareRoute[] = [
method: ["DELETE"],
matcher: "/admin/products/:id/options/:option_id",
middlewares: [
transformQuery(
AdminGetProductsProductParams,
validateAndTransformQuery(
AdminGetProductParams,
QueryConfig.retrieveProductQueryConfig
),
],

View File

@@ -27,9 +27,7 @@ export const defaultAdminProductsVariantFields = [
]
export const retrieveVariantConfig = {
defaultFields: defaultAdminProductsVariantFields,
defaultRelations: [],
allowedRelations: [],
defaults: defaultAdminProductsVariantFields,
isList: false,
}
@@ -42,7 +40,7 @@ export const listVariantConfig = {
export const defaultAdminProductsOptionFields = ["id", "title"]
export const retrieveOptionConfig = {
defaultFields: defaultAdminProductsOptionFields,
defaults: defaultAdminProductsOptionFields,
isList: false,
}

View File

@@ -1,22 +1,29 @@
import { createProductsWorkflow } from "@medusajs/core-flows"
import { CreateProductDTO } from "@medusajs/types"
import {
ContainerRegistrationKeys,
ProductStatus,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from "../../../types/routing"
import { refetchProduct, remapKeysForProduct, remapProduct } from "./helpers"
import { AdminGetProductsParams } from "./validators"
import {
refetchProduct,
remapKeysForProduct,
remapProductResponse,
} from "./helpers"
import {
AdminCreateProductType,
AdminGetProductsParamsType,
} from "./validators"
import { CreateProductDTO } from "@medusajs/types"
export const GET = async (
req: AuthenticatedMedusaRequest<AdminGetProductsParams>,
req: AuthenticatedMedusaRequest<AdminGetProductsParamsType>,
res: MedusaResponse
) => {
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const selectFields = remapKeysForProduct(req.remoteQueryConfig.fields ?? [])
const queryObject = remoteQueryObjectFromString({
@@ -31,7 +38,7 @@ export const GET = async (
const { rows: products, metadata } = await remoteQuery(queryObject)
res.json({
products: products.map(remapProduct),
products: products.map(remapProductResponse),
count: metadata.count,
offset: metadata.skip,
limit: metadata.take,
@@ -39,10 +46,10 @@ export const GET = async (
}
export const POST = async (
req: AuthenticatedMedusaRequest<CreateProductDTO>,
req: AuthenticatedMedusaRequest<AdminCreateProductType>,
res: MedusaResponse
) => {
const input = [req.validatedBody]
const input = [req.validatedBody as CreateProductDTO]
const { result, errors } = await createProductsWorkflow(req.scope).run({
input: { products: input },
@@ -58,5 +65,5 @@ export const POST = async (
req.scope,
req.remoteQueryConfig.fields
)
res.status(200).json({ product: remapProduct(product) })
res.status(200).json({ product: remapProductResponse(product) })
}

View File

@@ -4,11 +4,11 @@ import {
} from "@medusajs/utils"
import { NextFunction } from "express"
import { MedusaRequest } from "../../../../types/routing"
import { AdminGetProductsParams } from "../validators"
import { AdminGetProductsParamsType } from "../validators"
export function maybeApplyPriceListsFilter() {
return async (req: MedusaRequest, _, next: NextFunction) => {
const filterableFields: AdminGetProductsParams = req.filterableFields
const filterableFields: AdminGetProductsParamsType = req.filterableFields
if (!filterableFields.price_list_id) {
return next()

View File

@@ -1,725 +1,242 @@
import { OperatorMap } from "@medusajs/types"
import { ProductStatus } from "@medusajs/utils"
import { Transform, Type } from "class-transformer"
import { z } from "zod"
import {
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsNumber,
IsObject,
IsOptional,
IsString,
NotEquals,
ValidateIf,
ValidateNested,
} from "class-validator"
import { FindParams, extendedFindParamsMixin } from "../../../types/common"
import { OperatorMapValidator } from "../../../types/validators/operator-map"
import { IsType } from "../../../utils"
createFindParams,
createOperatorMap,
createSelectParams,
} from "../../utils/validators"
import { optionalBooleanMapper } from "../../../utils/validators/is-boolean"
export class AdminGetProductsProductParams extends FindParams {}
export class AdminGetProductsProductVariantsVariantParams extends FindParams {}
export class AdminGetProductsProductOptionsOptionParams extends FindParams {}
const statusEnum = z.nativeEnum(ProductStatus)
/**
* Parameters used to filter and configure the pagination of the retrieved regions.
*/
export class AdminGetProductsParams extends extendedFindParamsMixin({
limit: 50,
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,
}) {
// TODO: Will search be handled the same way? Should it be part of the `findParams` class instead, or the mixin?
/**
* Search term to search products' title, description, variants' title and sku, and collections' title.
*/
@IsString()
@IsOptional()
q?: string
/**
* IDs to filter products by.
*/
@IsOptional()
@IsType([String, [String]])
id?: string | string[]
/**
* Statuses to filter products by.
*/
@IsOptional()
@IsEnum(ProductStatus, { each: true })
status?: ProductStatus[]
/**
* Title to filter products by.
*/
@IsString()
@IsOptional()
title?: string
/**
* Handle to filter products by.
*/
@IsString()
@IsOptional()
handle?: string
/**
* Filter products by whether they're gift cards.
*/
@IsBoolean()
@IsOptional()
@Transform(({ value }) => optionalBooleanMapper.get(value.toLowerCase()))
is_giftcard?: boolean
/**
* Filter products by their associated price lists' ID.
*/
@IsOptional()
@IsArray()
price_list_id?: string[]
/**
* Filter products by associated sales channel IDs.
*/
@IsOptional()
@IsArray()
sales_channel_id?: string[]
/**
* Filter products by their associated product collection's ID.
*/
@IsArray()
@IsOptional()
collection_id?: string[]
/**
* Filter products by their associated tags' value.
*/
@IsArray()
@IsOptional()
tags?: string[]
/**
* Filter products by their associated product type's ID.
*/
@IsArray()
@IsOptional()
type_id?: string[]
// TODO: Replace this with AdminGetProductVariantsParams when its available
@IsOptional()
@IsObject()
variants?: Record<any, any>
// /**
// * Filter products by their associated discount condition's ID.
// */
// @IsString()
// @IsOptional()
// discount_condition_id?: string
// /**
// * Filter products by their associated product category's ID.
// */
// @IsArray()
// @IsOptional()
// category_id?: string[]
// /**
// * Whether to include product category children in the response.
// *
// * @featureFlag product_categories
// */
// @IsBoolean()
// @IsOptional()
// @Transform(({ value }) => optionalBooleanMapper.get(value.toLowerCase()))
// include_category_children?: boolean
@IsOptional()
@ValidateNested()
@Type(() => OperatorMapValidator)
created_at?: OperatorMap<string>
@IsOptional()
@ValidateNested()
@Type(() => OperatorMapValidator)
updated_at?: OperatorMap<string>
@ValidateNested()
@IsOptional()
@Type(() => OperatorMapValidator)
deleted_at?: OperatorMap<string>
// Note: These are new in v2
// Additional filters from BaseFilterable
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AdminGetProductsParams)
$and?: AdminGetProductsParams[]
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AdminGetProductsParams)
$or?: AdminGetProductsParams[]
}
export class AdminGetProductsVariantsParams extends extendedFindParamsMixin({
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()
),
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
>
export const AdminGetProductVariantsParams = createFindParams({
offset: 0,
}) {
// TODO: Will search be handled the same way? Should it be part of the `findParams` class instead, or the mixin?
/**
* Search term to search product variants' title, sku, and products' title.
*/
@IsString()
@IsOptional()
q?: string
/**
* IDs to filter product variants by.
*/
@IsOptional()
@IsType([String, [String]])
id?: string | string[]
/**
* Filter product variants by whether their inventory is managed or not.
*/
@IsBoolean()
@IsOptional()
@Transform(({ value }) => optionalBooleanMapper.get(value.toLowerCase()))
manage_inventory?: boolean
/**
* Filter product variants by whether they are allowed to be backordered or not.
*/
@IsBoolean()
@IsOptional()
@Transform(({ value }) => optionalBooleanMapper.get(value.toLowerCase()))
allow_backorder?: boolean
// TODO: The OperatorMap and DateOperator are slightly different, so the date comparisons is a breaking change.
@IsOptional()
@ValidateNested()
@Type(() => OperatorMapValidator)
created_at?: OperatorMap<string>
@IsOptional()
@ValidateNested()
@Type(() => OperatorMapValidator)
updated_at?: OperatorMap<string>
@ValidateNested()
@IsOptional()
@Type(() => OperatorMapValidator)
deleted_at?: OperatorMap<string>
// Note: These are new in v2
// Additional filters from BaseFilterable
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AdminGetProductsVariantsParams)
$and?: AdminGetProductsVariantsParams[]
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AdminGetProductsVariantsParams)
$or?: AdminGetProductsVariantsParams[]
}
// Note: This model and endpoint are new in v2
export class AdminGetProductsOptionsParams extends extendedFindParamsMixin({
limit: 50,
}).merge(
z.object({
q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
manage_inventory: z.boolean().optional(),
allow_backorder: z.boolean().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(),
})
)
export type AdminGetProductOptionsParamsType = z.infer<
typeof AdminGetProductOptionsParams
>
export const AdminGetProductOptionsParams = createFindParams({
offset: 0,
}) {
@IsOptional()
@IsString()
title?: string
// Note: These are new in v2
// Additional filters from BaseFilterable
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AdminGetProductsOptionsParams)
$and?: AdminGetProductsOptionsParams[]
@IsOptional()
@ValidateNested({ each: true })
@Type(() => AdminGetProductsOptionsParams)
$or?: AdminGetProductsOptionsParams[]
}
export class AdminPostProductsReq {
@IsString()
title: string
@IsString()
@IsOptional()
subtitle?: string
@IsString()
@IsOptional()
description?: string
@IsBoolean()
is_giftcard = false
@IsBoolean()
discountable = true
@IsArray()
@IsOptional()
images?: string[]
@IsString()
@IsOptional()
thumbnail?: string
@IsString()
@IsOptional()
handle?: string
@IsOptional()
@IsEnum(ProductStatus)
status?: ProductStatus = ProductStatus.DRAFT
@IsOptional()
@IsString()
type_id?: string
@IsOptional()
@IsString()
collection_id?: string
@IsOptional()
@Type(() => ProductTagReq)
@ValidateNested({ each: true })
@IsArray()
tags?: ProductTagReq[]
// @IsOptional()
// @Type(() => ProductProductCategoryReq)
// @ValidateNested({ each: true })
// @IsArray()
// categories?: ProductProductCategoryReq[]
// TODO: Deal with in next iteration
// @FeatureFlagDecorators(SalesChannelFeatureFlag.key, [
// IsOptional(),
// Type(() => ProductSalesChannelReq),
// ValidateNested({ each: true }),
// IsArray(),
// ])
// sales_channels?: ProductSalesChannelReq[]
@IsOptional()
@Type(() => AdminPostProductsProductOptionsReq)
@ValidateNested({ each: true })
@IsArray()
options?: AdminPostProductsProductOptionsReq[]
@IsOptional()
@Type(() => AdminPostProductsProductVariantsReq)
@ValidateNested({ each: true })
@IsArray()
variants?: AdminPostProductsProductVariantsReq[]
@IsNumber()
@IsOptional()
weight?: number
@IsNumber()
@IsOptional()
length?: number
@IsNumber()
@IsOptional()
height?: number
@IsNumber()
@IsOptional()
width?: number
@IsString()
@IsOptional()
hs_code?: string
@IsString()
@IsOptional()
origin_country?: string
@IsString()
@IsOptional()
mid_code?: string
@IsString()
@IsOptional()
material?: string
@IsObject()
@IsOptional()
metadata?: Record<string, unknown>
}
export class AdminPostProductsProductReq {
@IsString()
@IsOptional()
title?: string
@IsString()
@IsOptional()
subtitle?: string
@IsString()
@IsOptional()
description?: string
@IsBoolean()
@IsOptional()
discountable?: boolean
@IsArray()
@IsOptional()
images?: string[]
@IsString()
@IsOptional()
thumbnail?: string
@IsString()
@IsOptional()
handle?: string
@IsEnum(ProductStatus)
@NotEquals(null)
@ValidateIf((_, value) => value !== undefined)
status?: ProductStatus
@IsOptional()
@IsString()
type_id?: string
@IsOptional()
@IsString()
collection_id?: string
@IsOptional()
@Type(() => ProductTagReq)
@ValidateNested({ each: true })
@IsArray()
tags?: ProductTagReq[]
// @IsOptional()
// @Type(() => ProductProductCategoryReq)
// @ValidateNested({ each: true })
// @IsArray()
// categories?: ProductProductCategoryReq[]
// @FeatureFlagDecorators(SalesChannelFeatureFlag.key, [
// IsOptional(),
// Type(() => ProductSalesChannelReq),
// ValidateNested({ each: true }),
// IsArray(),
// ])
// sales_channels?: ProductSalesChannelReq[] | null
@IsOptional()
@Type(() => ProductVariantReq)
@ValidateNested({ each: true })
@IsArray()
variants?: ProductVariantReq[]
@IsNumber()
@IsOptional()
weight?: number
@IsNumber()
@IsOptional()
length?: number
@IsNumber()
@IsOptional()
height?: number
@IsNumber()
@IsOptional()
width?: number
@IsString()
@IsOptional()
hs_code?: string
@IsString()
@IsOptional()
origin_country?: string
@IsString()
@IsOptional()
mid_code?: string
@IsString()
@IsOptional()
material?: string
@IsObject()
@IsOptional()
metadata?: Record<string, unknown>
}
export class AdminPostProductsProductVariantsReq {
@IsString()
title: string
@IsString()
@IsOptional()
sku?: string
@IsString()
@IsOptional()
ean?: string
@IsString()
@IsOptional()
upc?: string
@IsString()
@IsOptional()
barcode?: string
@IsString()
@IsOptional()
hs_code?: string
@IsString()
@IsOptional()
mid_code?: string
@IsNumber()
@IsOptional()
inventory_quantity?: number = 0
@IsBoolean()
@IsOptional()
allow_backorder?: boolean
@IsBoolean()
@IsOptional()
manage_inventory?: boolean = true
@IsNumber()
@IsOptional()
variant_rank?: number
@IsNumber()
@IsOptional()
weight?: number
@IsNumber()
@IsOptional()
length?: number
@IsNumber()
@IsOptional()
height?: number
@IsNumber()
@IsOptional()
width?: number
@IsString()
@IsOptional()
origin_country?: string
@IsString()
@IsOptional()
material?: string
@IsObject()
@IsOptional()
metadata?: Record<string, unknown>
@IsArray()
@IsOptional()
@ValidateNested({ each: true })
@Type(() => ProductVariantPricesCreateReq)
prices?: ProductVariantPricesCreateReq[]
@IsOptional()
@IsObject()
options?: Record<string, string>
}
export class AdminPostProductsProductVariantsVariantReq {
@IsString()
@IsOptional()
title?: string
@IsString()
@IsOptional()
sku?: string
@IsString()
@IsOptional()
ean?: string
@IsString()
@IsOptional()
upc?: string
@IsString()
@IsOptional()
barcode?: string
@IsString()
@IsOptional()
hs_code?: string
@IsString()
@IsOptional()
mid_code?: string
@IsNumber()
@IsOptional()
inventory_quantity?: number
@IsBoolean()
@IsOptional()
allow_backorder?: boolean
@IsBoolean()
@IsOptional()
manage_inventory?: boolean
@IsNumber()
@IsOptional()
variant_rank?: number
@IsNumber()
@IsOptional()
weight?: number
@IsNumber()
@IsOptional()
length?: number
@IsNumber()
@IsOptional()
height?: number
@IsNumber()
@IsOptional()
width?: number
@IsString()
@IsOptional()
origin_country?: string
@IsString()
@IsOptional()
material?: string
@IsObject()
@IsOptional()
metadata?: Record<string, unknown>
@IsArray()
@IsOptional()
@ValidateNested({ each: true })
@Type(() => ProductVariantPricesUpdateReq)
prices?: ProductVariantPricesUpdateReq[]
@IsOptional()
@IsObject()
options?: Record<string, string>
}
export class AdminPostProductsProductOptionsReq {
@IsString()
title: string
@IsArray()
values: string[]
}
export class AdminPostProductsProductOptionsOptionReq {
@IsString()
title: string
@IsArray()
values: string[]
}
// eslint-disable-next-line max-len
export class ProductVariantReq extends AdminPostProductsProductVariantsVariantReq {
@IsString()
@IsOptional()
id?: string
}
export class ProductTagReq {
@IsString()
@IsOptional()
id?: string
@IsString()
value: string
}
/**
* The details of a product type, used to create or update an existing product type.
*/
export class ProductTypeReq {
/**
* The ID of the product type. It's only required when referring to an existing product type.
*/
@IsString()
@IsOptional()
id?: string
/**
* The value of the product type.
*/
@IsString()
value: string
}
limit: 50,
}).merge(
z.object({
id: z.union([z.string(), z.array(z.string())]).optional(),
title: z.string().optional(),
$and: z.lazy(() => AdminGetProductsParams.array()).optional(),
$or: z.lazy(() => AdminGetProductsParams.array()).optional(),
})
)
export type AdminCreateProductTagType = z.infer<typeof AdminCreateProductTag>
export const AdminCreateProductTag = z.object({
value: z.string().optional(),
})
export type AdminUpdateProductTagType = z.infer<typeof AdminUpdateProductTag>
export const AdminUpdateProductTag = z.object({
id: z.string().optional(),
value: z.string().optional(),
})
export type AdminCreateProductOptionType = z.infer<
typeof AdminCreateProductOption
>
export const AdminCreateProductOption = z.object({
title: z.string(),
values: z.array(z.string()),
})
export type AdminUpdateProductOptionType = z.infer<
typeof AdminUpdateProductOption
>
export const AdminUpdateProductOption = z.object({
id: z.string().optional(),
title: z.string().optional(),
values: z.array(z.string()).optional(),
})
// TODO: Add support for rules
export class ProductVariantPricesCreateReq {
@IsString()
currency_code: string
export type AdminCreateVariantPriceType = z.infer<
typeof AdminCreateVariantPrice
>
export const AdminCreateVariantPrice = z.object({
currency_code: z.string(),
amount: z.number(),
min_quantity: z.number().optional(),
max_quantity: z.number().optional(),
})
@IsInt()
amount: number
// TODO: Add support for rules
export type AdminUpdateVariantPriceType = z.infer<
typeof AdminUpdateVariantPrice
>
export const AdminUpdateVariantPrice = z.object({
id: z.string().optional(),
currency_code: z.string().optional(),
amount: z.number().optional(),
min_quantity: z.number().optional(),
max_quantity: z.number().optional(),
})
@IsOptional()
@IsInt()
min_quantity?: number
export type AdminCreateProductTypeType = z.infer<typeof AdminCreateProductType>
export const AdminCreateProductType = z.object({
value: z.string(),
})
@IsOptional()
@IsInt()
max_quantity?: number
}
export type AdminCreateProductVariantType = z.infer<
typeof AdminCreateProductVariant
>
export const AdminCreateProductVariant = z.object({
title: z.string(),
sku: z.string().optional(),
ean: z.string().optional(),
upc: z.string().optional(),
barcode: z.string().optional(),
hs_code: z.string().optional(),
mid_code: z.string().optional(),
inventory_quantity: z.number().optional().default(0),
allow_backorder: z.boolean().optional().default(false),
manage_inventory: z.boolean().optional().default(true),
variant_rank: z.number().optional(),
weight: z.number().optional(),
length: z.number().optional(),
height: z.number().optional(),
width: z.number().optional(),
origin_country: z.string().optional(),
material: z.string().optional(),
metadata: z.record(z.unknown()).optional(),
prices: z.array(AdminCreateVariantPrice),
options: z.record(z.string()).optional(),
})
export class ProductVariantPricesUpdateReq {
@IsString()
@IsOptional()
id?: string
export type AdminUpdateProductVariantType = z.infer<
typeof AdminUpdateProductVariant
>
export const AdminUpdateProductVariant = AdminCreateProductVariant.extend({
id: z.string().optional(),
title: z.string().optional(),
prices: z.array(AdminUpdateVariantPrice).optional(),
inventory_quantity: z.number().optional(),
allow_backorder: z.boolean().optional(),
manage_inventory: z.boolean().optional(),
}).strict()
@IsString()
@IsOptional()
currency_code?: string
export type AdminCreateProductType = z.infer<typeof AdminCreateProduct>
export const AdminCreateProduct = z
.object({
title: z.string(),
subtitle: z.string().optional(),
description: z.string().optional(),
is_giftcard: z.boolean().optional().default(false),
discountable: z.boolean().optional().default(true),
images: z.array(z.object({ url: z.string() })).optional(),
thumbnail: z.string().optional(),
handle: z.string().optional(),
status: statusEnum.optional().default(ProductStatus.DRAFT),
type_id: z.string().nullable().optional(),
collection_id: z.string().nullable().optional(),
tags: z.array(AdminUpdateProductTag).optional(),
options: z.array(AdminCreateProductOption).optional(),
variants: z.array(AdminCreateProductVariant).optional(),
weight: z.number().optional(),
length: z.number().optional(),
height: z.number().optional(),
width: z.number().optional(),
hs_code: z.string().optional(),
mid_code: z.string().optional(),
origin_country: z.string().optional(),
material: z.string().optional(),
metadata: z.record(z.unknown()).optional(),
})
.strict()
@IsInt()
amount: number
export type AdminUpdateProductType = z.infer<typeof AdminUpdateProduct>
export const AdminUpdateProduct = AdminCreateProduct.omit({ is_giftcard: true })
.extend({
title: z.string().optional(),
discountable: z.boolean().optional(),
options: z.array(AdminUpdateProductOption).optional(),
variants: z.array(AdminUpdateProductVariant).optional(),
status: statusEnum.optional(),
})
.strict()
@IsOptional()
@IsInt()
min_quantity?: number
// TODO: Handle in create and update product once ready
// @IsOptional()
// @Type(() => ProductProductCategoryReq)
// @ValidateNested({ each: true })
// @IsArray()
// categories?: ProductProductCategoryReq[]
@IsOptional()
@IsInt()
max_quantity?: number
}
// TODO: Deal with in next iteration
// @FeatureFlagDecorators(SalesChannelFeatureFlag.key, [
// IsOptional(),
// Type(() => ProductSalesChannelReq),
// ValidateNested({ each: true }),
// IsArray(),
// ])
// sales_channels?: ProductSalesChannelReq[]

View File

@@ -1,9 +1,6 @@
import multer from "multer"
import { transformQuery } from "../../../api/middlewares"
import { MiddlewareRoute } from "../../../loaders/helpers/routing/types"
import { authenticate } from "../../../utils/authenticate-middleware"
import * as QueryConfig from "./query-config"
import { AdminGetProductsProductParams } from "../products/validators"
const upload = multer({ dest: "uploads/" })

View File

@@ -0,0 +1,67 @@
import { NextFunction } from "express"
import { MedusaRequest, MedusaResponse } from "../../types/routing"
import { zodValidator } from "./validate-body"
import { z } from "zod"
import { removeUndefinedProperties } from "../../utils"
import { omit } from "lodash"
import { BaseEntity, QueryConfig, RequestQueryFields } from "@medusajs/types"
import {
prepareListQuery,
prepareRetrieveQuery,
} from "../../utils/get-query-config"
import { FindConfig } from "@medusajs/types"
/**
* 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"] }
*/
const normalizeQuery = (req: MedusaRequest) => {
return Object.entries(req.query).reduce((acc, [key, val]) => {
if (Array.isArray(val) && val.length === 1) {
acc[key] = (val as string[])[0].split(",")
} else {
acc[key] = val
}
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>,
queryConfig: QueryConfig<TEntity>,
config?: {
strict?: boolean
}
): (
req: MedusaRequest,
res: MedusaResponse,
next: NextFunction
) => Promise<void> {
return async (req: MedusaRequest, _: MedusaResponse, next: NextFunction) => {
try {
const query = normalizeQuery(req)
const validated = await zodValidator(zodSchema, query, config)
const cnf = queryConfig.isList
? prepareListQuery(validated, queryConfig)
: prepareRetrieveQuery(validated, queryConfig)
req.validatedQuery = validated
req.filterableFields = getFilterableFields(req.validatedQuery)
req.remoteQueryConfig = cnf.remoteQueryConfig
req.listConfig = (cnf as any).listConfig
req.retrieveConfig = (cnf as any).retrieveConfig
next()
} catch (e) {
next(e)
}
}
}

View File

@@ -0,0 +1,70 @@
import { z } from "zod"
export const createSelectParams = () => {
return z.object({
fields: z.string().optional(),
})
}
export 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(),
})
)
}
export const createOperatorMap = (type?: z.ZodType) => {
if (!type) {
type = z.string()
}
return z.object({
$eq: z.union([type, z.array(type)]).optional(),
$ne: z.union([type, z.array(type)]).optional(),
$in: z.array(type).optional(),
$nin: z.array(type).optional(),
$like: type.optional(),
$re: type.optional(),
$contains: type.optional(),
$gt: type.optional(),
$gte: type.optional(),
$lt: type.optional(),
$lte: type.optional(),
})
}