From 883a75c4f3837436f819a75285b190dfdf5656ef Mon Sep 17 00:00:00 2001 From: Stevche Radevski Date: Sat, 6 Apr 2024 15:45:52 +0200 Subject: [PATCH] feat: Switch to zod for the product API and implement missing primitives (#6978) --- .../api/__tests__/admin/product.js | 23 +- .../workflows/update-product-variants.ts | 2 +- .../[id]/options/[option_id]/route.ts | 8 +- .../admin/products/[id]/options/route.ts | 8 +- .../src/api-v2/admin/products/[id]/route.ts | 17 +- .../[id]/variants/[variant_id]/route.ts | 29 +- .../admin/products/[id]/variants/route.ts | 12 +- .../src/api-v2/admin/products/helpers.ts | 25 +- .../src/api-v2/admin/products/middlewares.ts | 94 +- .../src/api-v2/admin/products/query-config.ts | 6 +- .../medusa/src/api-v2/admin/products/route.ts | 25 +- .../utils/maybe-apply-price-lists-filter.ts | 4 +- .../src/api-v2/admin/products/validators.ts | 927 +++++------------- .../src/api-v2/admin/uploads/middlewares.ts | 3 - .../medusa/src/api-v2/utils/validate-query.ts | 67 ++ .../medusa/src/api-v2/utils/validators.ts | 70 ++ 16 files changed, 491 insertions(+), 829 deletions(-) create mode 100644 packages/medusa/src/api-v2/utils/validate-query.ts create mode 100644 packages/medusa/src/api-v2/utils/validators.ts diff --git a/integration-tests/api/__tests__/admin/product.js b/integration-tests/api/__tests__/admin/product.js index 4793bf0af1..c4acd89f97 100644 --- a/integration-tests/api/__tests__/admin/product.js +++ b/integration-tests/api/__tests__/admin/product.js @@ -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", }), ]) ) diff --git a/packages/core-flows/src/product/workflows/update-product-variants.ts b/packages/core-flows/src/product/workflows/update-product-variants.ts index 5d92ba9d12..b2e2bfd1d1 100644 --- a/packages/core-flows/src/product/workflows/update-product-variants.ts +++ b/packages/core-flows/src/product/workflows/update-product-variants.ts @@ -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[] } } diff --git a/packages/medusa/src/api-v2/admin/products/[id]/options/[option_id]/route.ts b/packages/medusa/src/api-v2/admin/products/[id]/options/[option_id]/route.ts index 0b58c88590..9404145f6d 100644 --- a/packages/medusa/src/api-v2/admin/products/[id]/options/[option_id]/route.ts +++ b/packages/medusa/src/api-v2/admin/products/[id]/options/[option_id]/route.ts @@ -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, + req: AuthenticatedMedusaRequest, 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 ( diff --git a/packages/medusa/src/api-v2/admin/products/[id]/options/route.ts b/packages/medusa/src/api-v2/admin/products/[id]/options/route.ts index 70eb2b65a8..a4f835eff7 100644 --- a/packages/medusa/src/api-v2/admin/products/[id]/options/route.ts +++ b/packages/medusa/src/api-v2/admin/products/[id]/options/route.ts @@ -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, + req: AuthenticatedMedusaRequest, 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) }) } diff --git a/packages/medusa/src/api-v2/admin/products/[id]/route.ts b/packages/medusa/src/api-v2/admin/products/[id]/route.ts index 91246123e0..894b990fef 100644 --- a/packages/medusa/src/api-v2/admin/products/[id]/route.ts +++ b/packages/medusa/src/api-v2/admin/products/[id]/route.ts @@ -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, + req: AuthenticatedMedusaRequest, 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 ( diff --git a/packages/medusa/src/api-v2/admin/products/[id]/variants/[variant_id]/route.ts b/packages/medusa/src/api-v2/admin/products/[id]/variants/[variant_id]/route.ts index 9ce02e80e1..3deb147ead 100644 --- a/packages/medusa/src/api-v2/admin/products/[id]/variants/[variant_id]/route.ts +++ b/packages/medusa/src/api-v2/admin/products/[id]/variants/[variant_id]/route.ts @@ -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, + req: AuthenticatedMedusaRequest, 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 ( diff --git a/packages/medusa/src/api-v2/admin/products/[id]/variants/route.ts b/packages/medusa/src/api-v2/admin/products/[id]/variants/route.ts index 28fe0c3905..887f3c8cbe 100644 --- a/packages/medusa/src/api-v2/admin/products/[id]/variants/route.ts +++ b/packages/medusa/src/api-v2/admin/products/[id]/variants/route.ts @@ -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, + req: AuthenticatedMedusaRequest, 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) }) } diff --git a/packages/medusa/src/api-v2/admin/products/helpers.ts b/packages/medusa/src/api-v2/admin/products/helpers.ts index bfc741cf4d..4d3987fe54 100644 --- a/packages/medusa/src/api-v2/admin/products/helpers.ts +++ b/packages/medusa/src/api-v2/admin/products/helpers.ts @@ -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, })), diff --git a/packages/medusa/src/api-v2/admin/products/middlewares.ts b/packages/medusa/src/api-v2/admin/products/middlewares.ts index c10812a860..08c4303ab4 100644 --- a/packages/medusa/src/api-v2/admin/products/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/products/middlewares.ts @@ -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 ), ], diff --git a/packages/medusa/src/api-v2/admin/products/query-config.ts b/packages/medusa/src/api-v2/admin/products/query-config.ts index d5c7e26930..d49664ec6b 100644 --- a/packages/medusa/src/api-v2/admin/products/query-config.ts +++ b/packages/medusa/src/api-v2/admin/products/query-config.ts @@ -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, } diff --git a/packages/medusa/src/api-v2/admin/products/route.ts b/packages/medusa/src/api-v2/admin/products/route.ts index fe876946fd..f06f7af6e2 100644 --- a/packages/medusa/src/api-v2/admin/products/route.ts +++ b/packages/medusa/src/api-v2/admin/products/route.ts @@ -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, + req: AuthenticatedMedusaRequest, 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, + req: AuthenticatedMedusaRequest, 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) }) } diff --git a/packages/medusa/src/api-v2/admin/products/utils/maybe-apply-price-lists-filter.ts b/packages/medusa/src/api-v2/admin/products/utils/maybe-apply-price-lists-filter.ts index 0ae69cae5c..679248ffe9 100644 --- a/packages/medusa/src/api-v2/admin/products/utils/maybe-apply-price-lists-filter.ts +++ b/packages/medusa/src/api-v2/admin/products/utils/maybe-apply-price-lists-filter.ts @@ -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() diff --git a/packages/medusa/src/api-v2/admin/products/validators.ts b/packages/medusa/src/api-v2/admin/products/validators.ts index d50a79941d..b0649760f9 100644 --- a/packages/medusa/src/api-v2/admin/products/validators.ts +++ b/packages/medusa/src/api-v2/admin/products/validators.ts @@ -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 +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 - - // /** - // * 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 - - @IsOptional() - @ValidateNested() - @Type(() => OperatorMapValidator) - updated_at?: OperatorMap - - @ValidateNested() - @IsOptional() - @Type(() => OperatorMapValidator) - deleted_at?: OperatorMap - - // 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 - - @IsOptional() - @ValidateNested() - @Type(() => OperatorMapValidator) - updated_at?: OperatorMap - - @ValidateNested() - @IsOptional() - @Type(() => OperatorMapValidator) - deleted_at?: OperatorMap - - // 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 -} - -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 -} - -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 - - @IsArray() - @IsOptional() - @ValidateNested({ each: true }) - @Type(() => ProductVariantPricesCreateReq) - prices?: ProductVariantPricesCreateReq[] - - @IsOptional() - @IsObject() - options?: Record -} - -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 - - @IsArray() - @IsOptional() - @ValidateNested({ each: true }) - @Type(() => ProductVariantPricesUpdateReq) - prices?: ProductVariantPricesUpdateReq[] - - @IsOptional() - @IsObject() - options?: Record -} - -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 +export const AdminCreateProductTag = z.object({ + value: z.string().optional(), +}) + +export type AdminUpdateProductTagType = z.infer +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 +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 +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 +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[] diff --git a/packages/medusa/src/api-v2/admin/uploads/middlewares.ts b/packages/medusa/src/api-v2/admin/uploads/middlewares.ts index fce8e52281..bad0080cf8 100644 --- a/packages/medusa/src/api-v2/admin/uploads/middlewares.ts +++ b/packages/medusa/src/api-v2/admin/uploads/middlewares.ts @@ -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/" }) diff --git a/packages/medusa/src/api-v2/utils/validate-query.ts b/packages/medusa/src/api-v2/utils/validate-query.ts new file mode 100644 index 0000000000..ed78f5ad70 --- /dev/null +++ b/packages/medusa/src/api-v2/utils/validate-query.ts @@ -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 = (obj: T): T => { + const result = omit(obj, ["limit", "offset", "fields", "order"]) as T + return removeUndefinedProperties(result) +} + +export function validateAndTransformQuery( + zodSchema: z.ZodObject, + queryConfig: QueryConfig, + config?: { + strict?: boolean + } +): ( + req: MedusaRequest, + res: MedusaResponse, + next: NextFunction +) => Promise { + 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) + } + } +} diff --git a/packages/medusa/src/api-v2/utils/validators.ts b/packages/medusa/src/api-v2/utils/validators.ts new file mode 100644 index 0000000000..6c7e92c882 --- /dev/null +++ b/packages/medusa/src/api-v2/utils/validators.ts @@ -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(), + }) +}