diff --git a/integration-tests/modules/__tests__/shipping-options/admin/shipping-options.spec.ts b/integration-tests/modules/__tests__/shipping-options/admin/shipping-options.spec.ts index 0885377be1..3e188eb37b 100644 --- a/integration-tests/modules/__tests__/shipping-options/admin/shipping-options.spec.ts +++ b/integration-tests/modules/__tests__/shipping-options/admin/shipping-options.spec.ts @@ -78,55 +78,10 @@ medusaIntegrationTestRunner({ ) .catch((e) => e.response) - const errorsFields = [ - { - code: "invalid_type", - expected: "string", - received: "undefined", - path: ["service_zone_id"], - message: "Required", - }, - { - code: "invalid_type", - expected: "string", - received: "undefined", - path: ["shipping_profile_id"], - message: "Required", - }, - { - expected: "'calculated' | 'flat'", - received: "undefined", - code: "invalid_type", - path: ["price_type"], - message: "Required", - }, - { - code: "invalid_type", - expected: "string", - received: "undefined", - path: ["provider_id"], - message: "Required", - }, - { - code: "invalid_type", - expected: "object", - received: "undefined", - path: ["type"], - message: "Required", - }, - { - code: "invalid_type", - expected: "array", - received: "undefined", - path: ["prices"], - message: "Required", - }, - ] - expect(err.status).toEqual(400) expect(err.data).toEqual({ type: "invalid_data", - message: `Invalid request body: ${JSON.stringify(errorsFields)}`, + message: `Invalid request: Field 'service_zone_id' is required; Field 'shipping_profile_id' is required; Field 'price_type' is required`, }) }) diff --git a/packages/medusa/src/api/utils/__tests__/validate-body.ts b/packages/medusa/src/api/utils/__tests__/validate-body.ts deleted file mode 100644 index 281191b451..0000000000 --- a/packages/medusa/src/api/utils/__tests__/validate-body.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { z } from "zod" -import { zodValidator } from "../validate-body" - -describe("zodValidator", () => { - it("should validate and return validated", async () => { - const schema = z.object({ - id: z.string(), - name: z.string(), - }) - - const toValidate = { - id: "1", - name: "Tony Stark", - } - - const validated = await zodValidator(schema, toValidate) - - expect(JSON.stringify(validated)).toBe( - JSON.stringify({ - id: "1", - name: "Tony Stark", - }) - ) - }) - - it("should show human readable error message", async () => { - const schema = z - .object({ - id: z.string(), - test: z.object({ - name: z.string(), - test2: z.object({ - name: z.string(), - }), - }), - }) - .strict() - - const toValidate = { - id: "1", - name: "Tony Stark", - company: "Stark Industries", - } - - const errorMessage = await zodValidator(schema, toValidate).catch( - (e) => e.message - ) - - expect(errorMessage).toContain( - "Invalid request body: " - ) - }) - - it("should allow for non-strict parsing", async () => { - const schema = z.object({ - id: z.string(), - }) - - const toValidate = { - id: "1", - name: "Tony Stark", - company: "Stark Industries", - } - - const validated = await zodValidator(schema, toValidate, { strict: false }) - - expect(JSON.stringify(validated)).toBe( - JSON.stringify({ - id: "1", - }) - ) - }) -}) diff --git a/packages/medusa/src/api/utils/__tests__/zod-helper.spec.ts b/packages/medusa/src/api/utils/__tests__/zod-helper.spec.ts new file mode 100644 index 0000000000..a6c9b59403 --- /dev/null +++ b/packages/medusa/src/api/utils/__tests__/zod-helper.spec.ts @@ -0,0 +1,134 @@ +import { z } from "zod" +import { zodValidator } from "../zod-helper" + +describe("zodValidator", () => { + it("should validate and return validated", async () => { + const schema = z.object({ + id: z.string(), + name: z.string(), + }) + + const toValidate = { + id: "1", + name: "Tony Stark", + } + + const validated = await zodValidator(schema, toValidate) + + expect(JSON.stringify(validated)).toBe( + JSON.stringify({ + id: "1", + name: "Tony Stark", + }) + ) + }) + + it("should show human readable error message for invalid data and unrecognized fields", async () => { + const errorMessage = await zodValidator( + z + .object({ + id: z.string(), + test: z.object({ + name: z.string(), + test2: z.object({ + name: z.string(), + }), + }), + }) + .strict(), + { + id: "1", + name: "Tony Stark", + company: "Stark Industries", + } + ).catch((e) => e.message) + + expect(errorMessage).toContain( + "Invalid request: Field 'test' is required; Unrecognized fields: 'name, company'" + ) + }) + + it("should show human readable error message for invalid type", async () => { + const errorMessage = await zodValidator( + z + .object({ + id: z.string(), + }) + .strict(), + { + id: 1, + } + ).catch((e) => e.message) + + expect(errorMessage).toContain( + "Invalid request: Expected type: 'string' for field 'id', got: 'number'" + ) + }) + + it("should show human readable error message for invalid enum", async () => { + const errorMessage = await zodValidator( + z + .object({ + id: z.enum(["1", "2"]), + }) + .strict(), + { + id: "3", + } + ).catch((e) => e.message) + + expect(errorMessage).toContain( + "Invalid request: Expected: '1, 2' for field 'id', but got: '3'" + ) + }) + + it("should show human readable error message for invalid union", async () => { + const errorMessage = await zodValidator( + z + .object({ + id: z.union([z.string(), z.number()]), + }) + .strict(), + { + id: true, + } + ).catch((e) => e.message) + + expect(errorMessage).toContain( + "Invalid request: Expected type: 'string, number' for field 'id', got: 'boolean'" + ) + }) + + it("should show human readable error message for missing required field", async () => { + const errorMessage = await zodValidator( + z + .object({ + id: z.union([z.string(), z.number()]), + }) + .strict(), + {} + ).catch((e) => e.message) + + expect(errorMessage).toContain("Invalid request: Field 'id' is required") + }) + + it("should allow for non-strict parsing", async () => { + const schema = z.object({ + id: z.string(), + }) + + const toValidate = { + id: "1", + name: "Tony Stark", + company: "Stark Industries", + } + + const validated = await zodValidator(schema, toValidate) + + expect(JSON.stringify(validated)).toBe( + JSON.stringify({ + id: "1", + }) + ) + }) +}) diff --git a/packages/medusa/src/api/utils/validate-body.ts b/packages/medusa/src/api/utils/validate-body.ts index a9cff172e6..30c9dd615c 100644 --- a/packages/medusa/src/api/utils/validate-body.ts +++ b/packages/medusa/src/api/utils/validate-body.ts @@ -1,25 +1,7 @@ -import { MedusaError } from "@medusajs/utils" import { NextFunction } from "express" -import { z, ZodError } from "zod" +import { z } from "zod" import { MedusaRequest, MedusaResponse } from "../../types/routing" - -export async function zodValidator( - zodSchema: z.ZodObject | z.ZodEffects, - body: T -): Promise { - try { - return await zodSchema.parseAsync(body) - } catch (err) { - if (err instanceof ZodError) { - throw new MedusaError( - MedusaError.Types.INVALID_DATA, - `Invalid request body: ${JSON.stringify(err.errors)}` - ) - } - - throw err - } -} +import { zodValidator } from "./zod-helper" export function validateAndTransformBody( zodSchema: z.ZodObject | z.ZodEffects diff --git a/packages/medusa/src/api/utils/validate-query.ts b/packages/medusa/src/api/utils/validate-query.ts index 7e7b3314f9..dcfa93c4f4 100644 --- a/packages/medusa/src/api/utils/validate-query.ts +++ b/packages/medusa/src/api/utils/validate-query.ts @@ -8,7 +8,7 @@ import { prepareListQuery, prepareRetrieveQuery, } from "../../utils/get-query-config" -import { zodValidator } from "./validate-body" +import { zodValidator } from "./zod-helper" /** * Normalize an input query, especially from array like query params to an array type diff --git a/packages/medusa/src/api/utils/zod-helper.ts b/packages/medusa/src/api/utils/zod-helper.ts new file mode 100644 index 0000000000..2621c7e392 --- /dev/null +++ b/packages/medusa/src/api/utils/zod-helper.ts @@ -0,0 +1,128 @@ +import { MedusaError } from "@medusajs/utils" +import { + z, + ZodError, + ZodInvalidTypeIssue, + ZodInvalidUnionIssue, + ZodIssue, +} from "zod" + +const formatPath = (issue: ZodIssue) => { + return issue.path.join(", ") +} + +const formatInvalidType = (issues: ZodIssue[]) => { + const expected = issues + .map((i) => { + // Unforutnately the zod library doesn't distinguish between a wrong type and a required field, which we want to handle differently + if (i.code === "invalid_type" && i.message !== "Required") { + return i.expected + } + return + }) + .filter(Boolean) + + if (!expected.length) { + return + } + + const received = (issues?.[0] as ZodInvalidTypeIssue)?.received + + return `Expected type: '${expected.join(", ")}' for field '${formatPath( + issues[0] + )}', got: '${received}'` +} + +const formatRequiredField = (issues: ZodIssue[]) => { + const expected = issues + .map((i) => { + if (i.code === "invalid_type" && i.message === "Required") { + return i.expected + } + return + }) + .filter(Boolean) + + if (!expected.length) { + return + } + + return `Field '${formatPath(issues[0])}' is required` +} + +const formatUnionError = (issue: ZodInvalidUnionIssue) => { + const issues = issue.unionErrors.flatMap((e) => e.issues) + return ( + formatInvalidType(issues) || formatRequiredField(issues) || issue.message + ) +} + +const formatError = (err: ZodError) => { + const issueMessages = err.issues.slice(0, 3).map((issue) => { + switch (issue.code) { + case "invalid_type": + return ( + formatInvalidType([issue]) || + formatRequiredField([issue]) || + issue.message + ) + case "invalid_literal": + return `Expected literal: '${issue.expected}' for field '${formatPath( + issue + )}', but got: '${issue.received}'` + case "invalid_union": + return formatUnionError(issue) + case "invalid_enum_value": + return `Expected: '${issue.options.join(", ")}' for field '${formatPath( + issue + )}', but got: '${issue.received}'` + case "unrecognized_keys": + return `Unrecognized fields: '${issue.keys.join(", ")}'` + case "invalid_arguments": + return `Invalid arguments for '${issue.path.join(", ")}'` + case "too_small": + return `Value for field '${formatPath( + issue + )}' too small, expected at least: '${issue.minimum}'` + case "too_big": + return `Value for field '${formatPath( + issue + )}' too big, expected at most: '${issue.maximum}'` + case "not_multiple_of": + return `Value for field '${formatPath(issue)}' not multiple of: '${ + issue.multipleOf + }'` + case "not_finite": + return `Value for field '${formatPath(issue)}' not finite: '${ + issue.message + }'` + case "invalid_union_discriminator": + case "invalid_return_type": + case "invalid_date": + case "invalid_string": + case "invalid_intersection_types": + default: + return issue.message + } + }) + + return issueMessages.join("; ") +} + +export async function zodValidator( + zodSchema: z.ZodObject | z.ZodEffects, + body: T +): Promise { + try { + return await zodSchema.parseAsync(body) + } catch (err) { + if (err instanceof ZodError) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Invalid request: ${formatError(err)}` + ) + } + + throw err + } +}