feat: Extending API routes validators (#8254)

This commit is contained in:
Harminder Virk
2024-07-24 14:36:41 +05:30
committed by GitHub
parent 2188a4e1ba
commit ca88f204d4
8 changed files with 198 additions and 17 deletions

View File

@@ -26,7 +26,7 @@ export const AdminGetCampaignsParams = createFindParams({
)
.strict()
export const CreateCampaignBudget = z
const CreateCampaignBudget = z
.object({
type: z.nativeEnum(CampaignBudgetType),
limit: z.number().nullish(),

View File

@@ -1,4 +1,4 @@
import { z } from "zod"
import { z, ZodObject } from "zod"
import { AddressPayload, BigNumberInput } from "../../utils/common-validators"
import { createFindParams, createSelectParams } from "../../utils/validators"
@@ -49,8 +49,8 @@ const Item = z
return true
})
export type AdminCreateDraftOrderType = z.infer<typeof AdminCreateDraftOrder>
export const AdminCreateDraftOrder = z
export type AdminCreateDraftOrderType = z.infer<typeof _AdminCreateDraftOrder>
const _AdminCreateDraftOrder = z
.object({
status: z.nativeEnum(Status).optional(),
sales_channel_id: z.string().nullish(),
@@ -67,7 +67,13 @@ export const AdminCreateDraftOrder = z
metadata: z.record(z.unknown()).nullish(),
})
.strict()
.refine(
export const AdminCreateDraftOrder = (customSchema?: ZodObject<any, any>) => {
const schema = customSchema
? _AdminCreateDraftOrder.merge(customSchema)
: _AdminCreateDraftOrder
return schema.refine(
(data) => {
if (!data.email && !data.customer_id) {
return false
@@ -77,3 +83,4 @@ export const AdminCreateDraftOrder = z
},
{ message: "Either email or customer_id must be provided" }
)
}

View File

@@ -5,7 +5,7 @@ import {
PromotionRuleOperator,
PromotionType,
} from "@medusajs/utils"
import { z } from "zod"
import { z, ZodObject } from "zod"
import {
createFindParams,
createOperatorMap,
@@ -155,8 +155,8 @@ const promoRefinement = (promo) => {
return true
}
export type AdminCreatePromotionType = z.infer<typeof AdminCreatePromotion>
export const AdminCreatePromotion = z
export type AdminCreatePromotionType = z.infer<typeof _AdminCreatePromotion>
const _AdminCreatePromotion = z
.object({
code: z.string(),
is_automatic: z.boolean().optional(),
@@ -167,14 +167,21 @@ export const AdminCreatePromotion = z
rules: z.array(AdminCreatePromotionRule).optional(),
})
.strict()
export const AdminCreatePromotion = (customSchema?: ZodObject<any, any>) => {
const schema = customSchema
? _AdminCreatePromotion.merge(customSchema)
: _AdminCreatePromotion
// In the case of a buyget promotion, we require at least one buy rule and quantities
.refine(promoRefinement, {
return schema.refine(promoRefinement, {
message:
"Buyget promotions require at least one buy rule and quantities to be defined",
})
}
export type AdminUpdatePromotionType = z.infer<typeof AdminUpdatePromotion>
export const AdminUpdatePromotion = z
export type AdminUpdatePromotionType = z.infer<typeof _AdminUpdatePromotion>
const _AdminUpdatePromotion = z
.object({
code: z.string().optional(),
is_automatic: z.boolean().optional(),
@@ -185,8 +192,15 @@ export const AdminUpdatePromotion = z
rules: z.array(AdminCreatePromotionRule).optional(),
})
.strict()
export const AdminUpdatePromotion = (customSchema?: ZodObject<any, any>) => {
const schema = customSchema
? _AdminUpdatePromotion.merge(customSchema)
: _AdminUpdatePromotion
// In the case of a buyget promotion, we require at least one buy rule and quantities
.refine(promoRefinement, {
return schema.refine(promoRefinement, {
message:
"Buyget promotions require at least one buy rule and quantities to be defined",
})
}

View File

@@ -0,0 +1,65 @@
import zod from "zod"
import { MedusaError } from "@medusajs/utils"
import { createLinkBody } from "../validators"
import { validateAndTransformBody } from "../validate-body"
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
describe("validateAndTransformBody", () => {
afterEach(() => {
jest.clearAllMocks()
})
it("should merge a custom validators schema", async () => {
let mockRequest = {
query: {},
body: {},
} as MedusaRequest
const mockResponse = {} as MedusaResponse
const nextFunction = jest.fn()
mockRequest.extendedValidators = {
body: zod.object({
brand_id: zod.number(),
}),
}
let middleware = validateAndTransformBody(createLinkBody())
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenCalledWith(
new MedusaError(
"invalid_data",
`Invalid request: Field 'brand_id' is required`
)
)
})
it("should pass schema to merge to the original validator factory", async () => {
let mockRequest = {
query: {},
body: {},
} as MedusaRequest
const mockResponse = {} as MedusaResponse
const nextFunction = jest.fn()
mockRequest.extendedValidators = {
body: zod.object({
brand_id: zod.number(),
}),
}
const validatorFactory = (schema?: Zod.ZodObject<any, any>) => {
return schema ? createLinkBody().merge(schema) : createLinkBody()
}
let middleware = validateAndTransformBody(validatorFactory)
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenCalledWith(
new MedusaError(
"invalid_data",
`Invalid request: Field 'brand_id' is required`
)
)
})
})

View File

@@ -1,7 +1,10 @@
import zod from "zod"
import { MedusaError } from "@medusajs/utils"
import { NextFunction, Request, Response } from "express"
import { createFindParams } from "../validators"
import { validateAndTransformQuery } from "../validate-query"
import { MedusaError } from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
describe("validateAndTransformQuery", () => {
afterEach(() => {
@@ -692,4 +695,56 @@ describe("validateAndTransformQuery", () => {
)
)
})
it("should merge a custom validators schema", async () => {
let mockRequest = {
query: {},
} as MedusaRequest
const mockResponse = {} as MedusaResponse
const nextFunction = jest.fn()
mockRequest.extendedValidators = {
queryParams: zod.object({
page: zod.number(),
}),
}
let middleware = validateAndTransformQuery(createFindParams(), {})
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenCalledWith(
new MedusaError(
"invalid_data",
`Invalid request: Field 'page' is required`
)
)
})
it("should pass schema to merge to the original validator factory", async () => {
let mockRequest = {
query: {},
} as MedusaRequest
const mockResponse = {} as MedusaResponse
const nextFunction = jest.fn()
mockRequest.extendedValidators = {
queryParams: zod.object({
page: zod.number(),
}),
}
const validatorFactory = (schema?: Zod.ZodObject<any, any>) => {
return schema ? createFindParams().merge(schema) : createFindParams()
}
let middleware = validateAndTransformQuery(validatorFactory, {})
await middleware(mockRequest, mockResponse, nextFunction)
expect(nextFunction).toHaveBeenCalledWith(
new MedusaError(
"invalid_data",
`Invalid request: Field 'page' is required`
)
)
})
})

View File

@@ -4,7 +4,11 @@ import { MedusaRequest, MedusaResponse } from "../../types/routing"
import { zodValidator } from "./zod-helper"
export function validateAndTransformBody(
zodSchema: z.ZodObject<any, any> | z.ZodEffects<any, any>
zodSchema:
| z.ZodObject<any, any>
| ((
customSchema?: z.ZodObject<any, any>
) => z.ZodObject<any, any> | z.ZodEffects<any, any>)
): (
req: MedusaRequest,
res: MedusaResponse,
@@ -12,7 +16,18 @@ export function validateAndTransformBody(
) => Promise<void> {
return async (req: MedusaRequest, _: MedusaResponse, next: NextFunction) => {
try {
req.validatedBody = await zodValidator(zodSchema, req.body)
let schema: z.ZodObject<any, any> | z.ZodEffects<any, any>
const { body: bodyValidatorToMerge } = req.extendedValidators ?? {}
if (typeof zodSchema === "function") {
schema = zodSchema(bodyValidatorToMerge)
} else if (bodyValidatorToMerge) {
schema = zodSchema.merge(bodyValidatorToMerge)
} else {
schema = zodSchema
}
req.validatedBody = await zodValidator(schema, req.body)
next()
} catch (e) {
next(e)

View File

@@ -58,7 +58,11 @@ const getFilterableFields = <T extends RequestQueryFields>(obj: T): T => {
}
export function validateAndTransformQuery<TEntity extends BaseEntity>(
zodSchema: z.ZodObject<any, any> | z.ZodEffects<any, any>,
zodSchema:
| z.ZodObject<any, any>
| ((
customSchema?: z.ZodObject<any, any>
) => z.ZodObject<any, any> | z.ZodEffects<any, any>),
queryConfig: QueryConfig<TEntity>
): (
req: MedusaRequest,
@@ -70,7 +74,19 @@ export function validateAndTransformQuery<TEntity extends BaseEntity>(
const allowed = (req.allowed ?? queryConfig.allowed ?? []) as string[]
delete req.allowed
const query = normalizeQuery(req)
const validated = await zodValidator(zodSchema, query)
let schema: z.ZodObject<any, any> | z.ZodEffects<any, any>
const { queryParams: queryParamsToMerge } = req.extendedValidators ?? {}
if (typeof zodSchema === "function") {
schema = zodSchema(queryParamsToMerge)
} else if (queryParamsToMerge) {
schema = zodSchema.merge(queryParamsToMerge)
} else {
schema = zodSchema
}
const validated = await zodValidator(schema, query)
const cnf = queryConfig.isList
? prepareListQuery(validated, { ...queryConfig, allowed })
: prepareRetrieveQuery(validated, { ...queryConfig, allowed })

View File

@@ -1,3 +1,4 @@
import { ZodObject } from "zod"
import type { NextFunction, Request, Response } from "express"
import {
@@ -61,6 +62,14 @@ export interface MedusaRequest<Body = unknown>
* A generic context object that can be used across the request lifecycle
*/
context?: Record<string, any>
/**
* Custom validators for the request body and query params that will be
* merged with the original validator of the route.
*/
extendedValidators?: {
body?: ZodObject<any, any>
queryParams?: ZodObject<any, any>
}
}
export interface AuthContext {