fix: API validation management issues (#9693)

**What**
Currently, the API validation layer is broken in both responsibilities and validation itself.
This pr introduce the following fixes and patterns:
- Always create a `*Fields` schema that only takes care of defining the schema validation without `effect`
- Use the previous point into the API schema validator including `$and` and `$or` capabilities plus the recursive effects
- remove `normalizeArray` which does not have to exists since array are already treated as they should
- Add recursive transformation to take into account `$and` and `$or` as well or any other similar operators
- New util `applyAndAndOrOperators` to wrap the management of those operators and to be merged to an existing schema

Tasks
- [x] store domain
- [ ] admin domain
This commit is contained in:
Adrien de Peretti
2024-10-22 17:16:36 +02:00
committed by GitHub
parent 7b147aa651
commit 6b989353ac
12 changed files with 198 additions and 126 deletions

View File

@@ -8,6 +8,7 @@ import {
generateStoreHeaders, generateStoreHeaders,
} from "../../../../helpers/create-admin-user" } from "../../../../helpers/create-admin-user"
import { getProductFixture } from "../../../../helpers/fixtures" import { getProductFixture } from "../../../../helpers/fixtures"
import qs from "qs"
jest.setTimeout(30000) jest.setTimeout(30000)
@@ -639,6 +640,35 @@ medusaIntegrationTestRunner({
]) ])
}) })
it("should list all products for a category using $and filters", async () => {
const category = await createCategory(
{ name: "test", is_internal: false, is_active: true },
[product.id]
)
const category2 = await createCategory(
{ name: "test2", is_internal: true, is_active: true },
[product4.id]
)
const searchParam = qs.stringify({
$and: [{ category_id: [category.id, category2.id] }],
})
const response = await api.get(
`/store/products?${searchParam}`,
storeHeaders
)
expect(response.status).toEqual(200)
expect(response.data.count).toEqual(1)
expect(response.data.products).toEqual([
expect.objectContaining({
id: product.id,
}),
])
})
it("returns a list of ordered products by id ASC", async () => { it("returns a list of ordered products by id ASC", async () => {
const response = await api.get("/store/products?order=id", storeHeaders) const response = await api.get("/store/products?order=id", storeHeaders)
expect(response.status).toEqual(200) expect(response.status).toEqual(200)

View File

@@ -4,9 +4,18 @@ import {
createOperatorMap, createOperatorMap,
createSelectParams, createSelectParams,
} from "../../utils/validators" } from "../../utils/validators"
import { applyAndAndOrOperators } from "../../utils/common-validators"
export const StoreGetCollectionParams = createSelectParams() export const StoreGetCollectionParams = createSelectParams()
export const StoreGetCollectionsParamsFields = z.object({
q: z.string().optional(),
title: z.union([z.string(), z.array(z.string())]).optional(),
handle: z.union([z.string(), z.array(z.string())]).optional(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
})
export type StoreGetCollectionsParamsType = z.infer< export type StoreGetCollectionsParamsType = z.infer<
typeof StoreGetCollectionsParams typeof StoreGetCollectionsParams
> >
@@ -14,14 +23,6 @@ export const StoreGetCollectionsParams = createFindParams({
offset: 0, offset: 0,
limit: 10, limit: 10,
order: "-created_at", order: "-created_at",
}).merge( })
z.object({ .merge(StoreGetCollectionsParamsFields)
q: z.string().optional(), .merge(applyAndAndOrOperators(StoreGetCollectionsParamsFields))
title: z.union([z.string(), z.array(z.string())]).optional(),
handle: z.union([z.string(), z.array(z.string())]).optional(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
$and: z.lazy(() => StoreGetCollectionsParams.array()).optional(),
$or: z.lazy(() => StoreGetCollectionsParams.array()).optional(),
})
)

View File

@@ -1,19 +1,20 @@
import { z } from "zod" import { z } from "zod"
import { createFindParams, createSelectParams } from "../../utils/validators" import { createFindParams, createSelectParams } from "../../utils/validators"
import { applyAndAndOrOperators } from "../../utils/common-validators"
export const StoreGetCurrencyParams = createSelectParams() export const StoreGetCurrencyParams = createSelectParams()
export const StoreGetCurrenciesParamsFields = z.object({
q: z.string().optional(),
code: z.union([z.string(), z.array(z.string())]).optional(),
})
export type StoreGetCurrenciesParamsType = z.infer< export type StoreGetCurrenciesParamsType = z.infer<
typeof StoreGetCurrenciesParams typeof StoreGetCurrenciesParams
> >
export const StoreGetCurrenciesParams = createFindParams({ export const StoreGetCurrenciesParams = createFindParams({
offset: 0, offset: 0,
limit: 50, limit: 50,
}).merge( })
z.object({ .merge(StoreGetCurrenciesParamsFields)
q: z.string().optional(), .merge(applyAndAndOrOperators(StoreGetCurrenciesParamsFields))
code: z.union([z.string(), z.array(z.string())]).optional(),
$and: z.lazy(() => StoreGetCurrenciesParams.array()).optional(),
$or: z.lazy(() => StoreGetCurrenciesParams.array()).optional(),
})
)

View File

@@ -1,18 +1,20 @@
import { z } from "zod" import { z } from "zod"
import { createFindParams, createSelectParams } from "../../utils/validators" import { createFindParams, createSelectParams } from "../../utils/validators"
import { applyAndAndOrOperators } from "../../utils/common-validators"
export const StoreGetOrderParams = createSelectParams() export const StoreGetOrderParams = createSelectParams()
export type StoreGetOrderParamsType = z.infer<typeof StoreGetOrderParams> export type StoreGetOrderParamsType = z.infer<typeof StoreGetOrderParams>
export const StoreGetOrdersParamsFields = z.object({
id: z.union([z.string(), z.array(z.string())]).optional(),
status: z.union([z.string(), z.array(z.string())]).optional(),
})
export const StoreGetOrdersParams = createFindParams({ export const StoreGetOrdersParams = createFindParams({
offset: 0, offset: 0,
limit: 50, limit: 50,
}).merge( })
z.object({ .merge(StoreGetOrdersParamsFields)
id: z.union([z.string(), z.array(z.string())]).optional(), .merge(applyAndAndOrOperators(StoreGetOrdersParamsFields))
status: z.union([z.string(), z.array(z.string())]).optional(),
$and: z.lazy(() => StoreGetOrdersParams.array()).optional(),
$or: z.lazy(() => StoreGetOrdersParams.array()).optional(),
})
)
export type StoreGetOrdersParamsType = z.infer<typeof StoreGetOrdersParams> export type StoreGetOrdersParamsType = z.infer<typeof StoreGetOrdersParams>

View File

@@ -1,5 +1,8 @@
import { z } from "zod" import { z } from "zod"
import { booleanString } from "../../utils/common-validators" import {
applyAndAndOrOperators,
booleanString,
} from "../../utils/common-validators"
import { import {
createFindParams, createFindParams,
createOperatorMap, createOperatorMap,
@@ -16,26 +19,26 @@ export const StoreProductCategoryParams = createSelectParams().merge(
}) })
) )
export const StoreProductCategoriesParamsFields = z.object({
q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
name: z.union([z.string(), z.array(z.string())]).optional(),
description: z.union([z.string(), z.array(z.string())]).optional(),
handle: z.union([z.string(), z.array(z.string())]).optional(),
parent_category_id: z.union([z.string(), z.array(z.string())]).optional(),
include_ancestors_tree: booleanString().optional(),
include_descendants_tree: booleanString().optional(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(),
})
export type StoreProductCategoriesParamsType = z.infer< export type StoreProductCategoriesParamsType = z.infer<
typeof StoreProductCategoriesParams typeof StoreProductCategoriesParams
> >
export const StoreProductCategoriesParams = createFindParams({ export const StoreProductCategoriesParams = createFindParams({
offset: 0, offset: 0,
limit: 50, limit: 50,
}).merge( })
z.object({ .merge(StoreProductCategoriesParamsFields)
q: z.string().optional(), .merge(applyAndAndOrOperators(StoreProductCategoriesParamsFields))
id: z.union([z.string(), z.array(z.string())]).optional(),
name: z.union([z.string(), z.array(z.string())]).optional(),
description: z.union([z.string(), z.array(z.string())]).optional(),
handle: z.union([z.string(), z.array(z.string())]).optional(),
parent_category_id: z.union([z.string(), z.array(z.string())]).optional(),
include_ancestors_tree: booleanString().optional(),
include_descendants_tree: booleanString().optional(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(),
$and: z.lazy(() => StoreProductCategoriesParams.array()).optional(),
$or: z.lazy(() => StoreProductCategoriesParams.array()).optional(),
})
)

View File

@@ -16,10 +16,7 @@ import {
import { validateAndTransformQuery } from "@medusajs/framework" import { validateAndTransformQuery } from "@medusajs/framework"
import { maybeApplyStockLocationId } from "./helpers" import { maybeApplyStockLocationId } from "./helpers"
import * as QueryConfig from "./query-config" import * as QueryConfig from "./query-config"
import { import { StoreGetProductsParams } from "./validators"
StoreGetProductsParams,
StoreGetProductsParamsType,
} from "./validators"
export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [
{ {
@@ -41,7 +38,8 @@ export const storeProductRoutesMiddlewares: MiddlewareRoute[] = [
}), }),
applyDefaultFilters({ applyDefaultFilters({
status: ProductStatus.PUBLISHED, status: ProductStatus.PUBLISHED,
categories: (filters: StoreGetProductsParamsType, fields: string[]) => { // TODO: the type here seems off and the implementation does not take into account $and and $or possible filters. Might be worth re working (original type used here was StoreGetProductsParamsType)
categories: (filters: any, fields: string[]) => {
const categoryIds = filters.category_id const categoryIds = filters.category_id
delete filters.category_id delete filters.category_id

View File

@@ -1,6 +1,9 @@
import { z } from "zod" import { z } from "zod"
import { import {
applyAndAndOrOperators,
GetProductsParams, GetProductsParams,
recursivelyNormalizeSchema,
StoreGetProductParamsDirectFields,
transformProductParams, transformProductParams,
} from "../../utils/common-validators" } from "../../utils/common-validators"
import { import {
@@ -9,64 +12,68 @@ import {
createSelectParams, createSelectParams,
} from "../../utils/validators" } from "../../utils/validators"
export const StoreGetProductParamsFields = z.object({
region_id: z.string().optional(),
country_code: z.string().optional(),
province: z.string().optional(),
cart_id: z.string().optional(),
})
export type StoreGetProductParamsType = z.infer<typeof StoreGetProductParams> export type StoreGetProductParamsType = z.infer<typeof StoreGetProductParams>
export const StoreGetProductParams = createSelectParams().merge( export const StoreGetProductParams = createSelectParams().merge(
// These are used to populate the tax and pricing context StoreGetProductParamsFields
z.object({
region_id: z.string().optional(),
country_code: z.string().optional(),
province: z.string().optional(),
cart_id: z.string().optional(),
})
) )
export const StoreGetProductVariantsParamsFields = z.object({
q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
options: z.object({ value: z.string(), option_id: z.string() }).optional(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(),
})
export type StoreGetProductVariantsParamsType = z.infer< export type StoreGetProductVariantsParamsType = z.infer<
typeof StoreGetProductVariantsParams typeof StoreGetProductVariantsParams
> >
export const StoreGetProductVariantsParams = createFindParams({ export const StoreGetProductVariantsParams = createFindParams({
offset: 0, offset: 0,
limit: 50, limit: 50,
}).merge( })
z.object({ .merge(StoreGetProductVariantsParamsFields)
q: z.string().optional(), .merge(applyAndAndOrOperators(StoreGetProductVariantsParamsFields))
id: z.union([z.string(), z.array(z.string())]).optional(),
options: z.object({ value: z.string(), option_id: z.string() }).optional(), export const StoreGetProductsParamsFields = z
created_at: createOperatorMap().optional(), .object({
updated_at: createOperatorMap().optional(), region_id: z.string().optional(),
deleted_at: createOperatorMap().optional(), country_code: z.string().optional(),
$and: z.lazy(() => StoreGetProductsParams.array()).optional(), province: z.string().optional(),
$or: z.lazy(() => StoreGetProductsParams.array()).optional(), cart_id: z.string().optional(),
}) })
) .merge(GetProductsParams)
.strict()
export type StoreGetProductsParamsType = z.infer<typeof StoreGetProductsParams> export type StoreGetProductsParamsType = z.infer<typeof StoreGetProductsParams>
export const StoreGetProductsParams = createFindParams({ export const StoreGetProductsParams = createFindParams({
offset: 0, offset: 0,
limit: 50, limit: 50,
}) })
.merge(StoreGetProductsParamsFields)
.merge( .merge(
z z
.object({ .object({
// These are used to populate the tax and pricing context
region_id: z.string().optional(),
country_code: z.string().optional(),
province: z.string().optional(),
cart_id: z.string().optional(),
variants: z variants: z
.object({ .object({
options: z options: z
.object({ value: z.string(), option_id: z.string() }) .object({ value: z.string(), option_id: z.string() })
.optional(), .optional(),
$and: z.lazy(() => StoreGetProductsParams.array()).optional(),
$or: z.lazy(() => StoreGetProductsParams.array()).optional(),
}) })
.merge(applyAndAndOrOperators(StoreGetProductVariantsParamsFields))
.optional(), .optional(),
$and: z.lazy(() => StoreGetProductsParams.array()).optional(),
$or: z.lazy(() => StoreGetProductsParams.array()).optional(),
}) })
.merge(GetProductsParams) .merge(applyAndAndOrOperators(StoreGetProductParamsDirectFields))
.strict() .strict()
) )
.transform(transformProductParams) .transform(recursivelyNormalizeSchema(transformProductParams))

View File

@@ -1,20 +1,21 @@
import { z } from "zod" import { z } from "zod"
import { createFindParams, createSelectParams } from "../../utils/validators" import { createFindParams, createSelectParams } from "../../utils/validators"
import { applyAndAndOrOperators } from "../../utils/common-validators"
export type StoreGetRegionParamsType = z.infer<typeof StoreGetRegionParams> export type StoreGetRegionParamsType = z.infer<typeof StoreGetRegionParams>
export const StoreGetRegionParams = createSelectParams() export const StoreGetRegionParams = createSelectParams()
export const StoreGetRegionsParamsFields = z.object({
q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
currency_code: z.union([z.string(), z.array(z.string())]).optional(),
name: z.union([z.string(), z.array(z.string())]).optional(),
})
export type StoreGetRegionsParamsType = z.infer<typeof StoreGetRegionsParams> export type StoreGetRegionsParamsType = z.infer<typeof StoreGetRegionsParams>
export const StoreGetRegionsParams = createFindParams({ export const StoreGetRegionsParams = createFindParams({
limit: 50, limit: 50,
offset: 0, offset: 0,
}).merge( })
z.object({ .merge(StoreGetRegionsParamsFields)
q: z.string().optional(), .merge(applyAndAndOrOperators(StoreGetRegionsParamsFields))
id: z.union([z.string(), z.array(z.string())]).optional(),
currency_code: z.union([z.string(), z.array(z.string())]).optional(),
name: z.union([z.string(), z.array(z.string())]).optional(),
$and: z.lazy(() => StoreGetRegionsParams.array()).optional(),
$or: z.lazy(() => StoreGetRegionsParams.array()).optional(),
})
)

View File

@@ -1,18 +1,19 @@
import { z } from "zod" import { z } from "zod"
import { createFindParams, createSelectParams } from "../../utils/validators" import { createFindParams, createSelectParams } from "../../utils/validators"
import { applyAndAndOrOperators } from "../../utils/common-validators"
export type ReturnParamsType = z.infer<typeof ReturnParams> export type ReturnParamsType = z.infer<typeof ReturnParams>
export const ReturnParams = createSelectParams() export const ReturnParams = createSelectParams()
export const ReturnsParamsFields = z.object({
id: z.union([z.string(), z.array(z.string())]).optional(),
order_id: z.union([z.string(), z.array(z.string())]).optional(),
})
export type ReturnsParamsType = z.infer<typeof ReturnsParams> export type ReturnsParamsType = z.infer<typeof ReturnsParams>
export const ReturnsParams = createFindParams().merge( export const ReturnsParams = createFindParams()
z.object({ .merge(ReturnsParamsFields)
id: z.union([z.string(), z.array(z.string())]).optional(), .merge(applyAndAndOrOperators(ReturnsParamsFields))
order_id: z.union([z.string(), z.array(z.string())]).optional(),
$and: z.lazy(() => ReturnsParams.array()).optional(),
$or: z.lazy(() => ReturnsParams.array()).optional(),
})
)
const ReturnShippingSchema = z.object({ const ReturnShippingSchema = z.object({
option_id: z.string(), option_id: z.string(),

View File

@@ -1,5 +1,11 @@
import { z } from "zod" import { z } from "zod"
import { createFindParams } from "../../utils/validators" import { createFindParams } from "../../utils/validators"
import { applyAndAndOrOperators } from "../../utils/common-validators"
export const StoreGetShippingOptionsFields = z.object({
cart_id: z.string(),
is_return: z.boolean().optional(),
})
export type StoreGetShippingOptionsType = z.infer< export type StoreGetShippingOptionsType = z.infer<
typeof StoreGetShippingOptions typeof StoreGetShippingOptions
@@ -7,11 +13,6 @@ export type StoreGetShippingOptionsType = z.infer<
export const StoreGetShippingOptions = createFindParams({ export const StoreGetShippingOptions = createFindParams({
limit: 20, limit: 20,
offset: 0, offset: 0,
}).merge( })
z.object({ .merge(StoreGetShippingOptionsFields)
cart_id: z.string(), .merge(applyAndAndOrOperators(StoreGetShippingOptionsFields))
is_return: z.boolean().optional(),
$and: z.lazy(() => StoreGetShippingOptions.array()).optional(),
$or: z.lazy(() => StoreGetShippingOptions.array()).optional(),
})
)

View File

@@ -25,6 +25,21 @@ export const BigNumberInput = z.union([
}), }),
]) ])
/**
* Return a zod object to apply the $and and $or operators on a schema.
*
* @param {ZodObject<any>} schema
* @return {ZodObject<any>}
*/
export const applyAndAndOrOperators = (schema: z.ZodObject<any>) => {
return schema.merge(
z.object({
$and: z.lazy(() => schema.array()).optional(),
$or: z.lazy(() => schema.array()).optional(),
})
)
}
/** /**
* Validates that a value is a boolean when it is passed as a string. * Validates that a value is a boolean when it is passed as a string.
*/ */
@@ -37,3 +52,26 @@ export const booleanString = () =>
.transform((value) => { .transform((value) => {
return value.toString().toLowerCase() === "true" return value.toString().toLowerCase() === "true"
}) })
/**
* Apply a transformer on a schema when the data are validated and recursively normalize the data $and and $or.
*
* @param {(data: Data) => NormalizedData} transform
* @return {(data: Data) => NormalizedData}
*/
export function recursivelyNormalizeSchema<
Data extends object,
NormalizedData extends object
>(transform: (data: Data) => NormalizedData): (data: Data) => NormalizedData {
return (data: any) => {
const normalizedData = transform(data)
Object.keys(normalizedData)
.filter((key) => ["$and", "$or"].includes(key))
.forEach((key) => {
normalizedData[key] = normalizedData[key].map(transform)
})
return normalizedData
}
}

View File

@@ -6,14 +6,13 @@ import { booleanString } from "../common"
export const ProductStatusEnum = z.nativeEnum(ProductStatus) export const ProductStatusEnum = z.nativeEnum(ProductStatus)
export const GetProductsParams = z.object({ export const StoreGetProductParamsDirectFields = z.object({
q: z.string().optional(), q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(), id: z.union([z.string(), z.array(z.string())]).optional(),
title: z.string().optional(), title: z.string().optional(),
handle: z.string().optional(), handle: z.string().optional(),
is_giftcard: booleanString().optional(), is_giftcard: booleanString().optional(),
category_id: z.union([z.string(), z.array(z.string())]).optional(), category_id: z.union([z.string(), z.array(z.string())]).optional(),
sales_channel_id: z.union([z.string(), z.array(z.string())]).optional(),
collection_id: z.union([z.string(), z.array(z.string())]).optional(), collection_id: z.union([z.string(), z.array(z.string())]).optional(),
tag_id: z.union([z.string(), z.array(z.string())]).optional(), tag_id: z.union([z.string(), z.array(z.string())]).optional(),
type_id: z.union([z.string(), z.array(z.string())]).optional(), type_id: z.union([z.string(), z.array(z.string())]).optional(),
@@ -22,6 +21,12 @@ export const GetProductsParams = z.object({
deleted_at: createOperatorMap().optional(), deleted_at: createOperatorMap().optional(),
}) })
export const GetProductsParams = z
.object({
sales_channel_id: z.union([z.string(), z.array(z.string())]).optional(),
})
.merge(StoreGetProductParamsDirectFields)
type HttpProductFilters = FilterableProductProps & { type HttpProductFilters = FilterableProductProps & {
tag_id?: string | string[] tag_id?: string | string[]
category_id?: string | string[] category_id?: string | string[]
@@ -32,8 +37,8 @@ export const transformProductParams = (
): FilterableProductProps => { ): FilterableProductProps => {
const res = { const res = {
...data, ...data,
tags: normalizeArray(data, "tag_id"), tags: { id: data.tag_id },
categories: normalizeArray(data, "category_id"), categories: { id: data.category_id },
} }
delete res.tag_id delete res.tag_id
@@ -41,19 +46,3 @@ export const transformProductParams = (
return res as FilterableProductProps return res as FilterableProductProps
} }
const normalizeArray = (filters: HttpProductFilters, key: string) => {
if (filters[key]) {
if (Array.isArray(filters[key])) {
return {
id: { $in: filters[key] },
}
} else {
return {
id: filters[key] as string,
}
}
}
return undefined
}