diff --git a/lambdas/client-transform-filter-lambda/package.json b/lambdas/client-transform-filter-lambda/package.json index 82cd8dc5..a41a7c02 100644 --- a/lambdas/client-transform-filter-lambda/package.json +++ b/lambdas/client-transform-filter-lambda/package.json @@ -7,7 +7,8 @@ "cloudevents": "^8.0.2", "esbuild": "^0.25.0", "p-map": "^4.0.0", - "zod": "^4.1.13" + "zod": "^4.1.13", + "zod-validation-error": "^5.0.0" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts index fc3317b6..b43df486 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/index.test.ts @@ -232,7 +232,7 @@ describe("Lambda handler", () => { }; await expect(handler([sqsMessage])).rejects.toThrow( - "Validation failed: type: Invalid option", + /Validation error:.*at "type"/, ); }); diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts index b7c1555c..11f25920 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/services/error-handler.test.ts @@ -4,7 +4,6 @@ import { LambdaError, TransformationError, ValidationError, - formatValidationIssuePath, getEventError, wrapUnknownError, } from "services/error-handler"; @@ -129,32 +128,6 @@ describe("TransformationError", () => { }); }); -describe("formatValidationIssuePath", () => { - it("returns empty string for empty path", () => { - expect(formatValidationIssuePath([])).toBe(""); - }); - - it("returns string segment directly at root", () => { - expect(formatValidationIssuePath(["traceparent"])).toBe("traceparent"); - }); - - it("uses dot notation for nested string segments", () => { - expect(formatValidationIssuePath(["data", "clientId"])).toBe( - "data.clientId", - ); - }); - - it("uses bracket notation for numeric segments", () => { - expect(formatValidationIssuePath([0])).toBe("[0]"); - }); - - it("combines bracket and dot notation for mixed paths", () => { - expect(formatValidationIssuePath(["channels", 0, "type"])).toBe( - "channels[0].type", - ); - }); -}); - describe("ConfigValidationError", () => { it("should create error with issues array", () => { const issues = [ diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts index ad4f680f..5dabe987 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/validators/config-validator.test.ts @@ -58,6 +58,23 @@ describe("validateClientConfig", () => { expect(() => validateClientConfig({})).toThrow(ConfigValidationError); }); + it("throws with descriptive error message when config is not an array", () => { + let thrownError; + try { + validateClientConfig({}); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeInstanceOf(ConfigValidationError); + const configError = thrownError as ConfigValidationError; + expect(configError.issues).toHaveLength(1); + expect(configError.issues[0].path).toBe("config"); + expect(configError.issues[0].message).toBe( + "Validation error: Invalid input: expected array, received object", + ); + }); + it("throws when invocation endpoint is not https", () => { const config = createValidConfig(); config[0].Targets[0].InvocationEndpoint = "http://example.com"; diff --git a/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts b/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts index 8792bc45..c848ab16 100644 --- a/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts +++ b/lambdas/client-transform-filter-lambda/src/__tests__/validators/event-validator.test.ts @@ -57,7 +57,7 @@ describe("event-validator", () => { delete invalidEvent.traceparent; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: traceparent: Invalid input: expected string, received undefined", + 'Validation error: Invalid input: expected string, received undefined at "traceparent"', ); }); }); @@ -70,7 +70,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: type: Invalid option", + 'Validation error: Invalid option: expected one of "uk.nhs.notify.message.status.PUBLISHED.v1"|"uk.nhs.notify.channel.status.PUBLISHED.v1" at "type"', ); }); }); @@ -83,7 +83,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - 'Validation failed: datacontenttype: Invalid input: expected "application/json"', + 'Validation error: Invalid input: expected "application/json" at "datacontenttype"', ); }); }); @@ -99,7 +99,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: clientId: Invalid input: expected string, received undefined", + 'Validation error: Invalid input: expected string, received undefined at "clientId"', ); }); @@ -113,7 +113,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: messageId: Invalid input: expected string, received undefined", + 'Validation error: Invalid input: expected string, received undefined at "messageId"', ); }); @@ -127,7 +127,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: timestamp: Invalid input: expected string, received undefined", + 'Validation error: Invalid input: expected string, received undefined at "timestamp"', ); }); @@ -141,7 +141,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "data.timestamp must be a valid RFC 3339 timestamp", + 'Validation error: Data.timestamp must be a valid RFC 3339 timestamp at "timestamp"', ); }); }); @@ -157,7 +157,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: messageStatus: Invalid input: expected string, received undefined", + 'Validation error: Invalid input: expected string, received undefined at "messageStatus"', ); }); @@ -171,7 +171,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: channels: Invalid input: expected array, received undefined", + 'Validation error: Invalid input: expected array, received undefined at "channels"', ); }); @@ -185,7 +185,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "data.channels must have at least one channel", + 'Validation error: Data.channels must have at least one channel at "channels"', ); }); @@ -199,7 +199,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: channels[0].type: Invalid input: expected string, received undefined", + 'Validation error: Invalid input: expected string, received undefined at "channels[0].type"', ); }); @@ -213,7 +213,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: channels[0].channelStatus: Invalid input: expected string, received undefined", + 'Validation error: Invalid input: expected string, received undefined at "channels[0].channelStatus"', ); }); }); @@ -252,7 +252,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: channel: Invalid input: expected string, received undefined", + 'Validation error: Invalid input: expected string, received undefined at "channel"', ); }); @@ -266,7 +266,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: channelStatus: Invalid input: expected string, received undefined", + 'Validation error: Invalid input: expected string, received undefined at "channelStatus"', ); }); @@ -280,7 +280,7 @@ describe("event-validator", () => { }; expect(() => validateStatusPublishEvent(invalidEvent)).toThrow( - "Validation failed: supplierStatus: Invalid input: expected string, received undefined", + 'Validation error: Invalid input: expected string, received undefined at "supplierStatus"', ); }); }); diff --git a/lambdas/client-transform-filter-lambda/src/services/error-handler.ts b/lambdas/client-transform-filter-lambda/src/services/error-handler.ts index 8eef206f..caeb1afe 100644 --- a/lambdas/client-transform-filter-lambda/src/services/error-handler.ts +++ b/lambdas/client-transform-filter-lambda/src/services/error-handler.ts @@ -49,22 +49,6 @@ export type ValidationIssue = { message: string; }; -export function formatValidationIssuePath(path: (string | number)[]): string { - let formatted = ""; - - for (const segment of path) { - if (typeof segment === "number") { - formatted = `${formatted}[${segment}]`; - } else if (formatted) { - formatted = `${formatted}.${segment}`; - } else { - formatted = segment; - } - } - - return formatted; -} - export class ConfigValidationError extends LambdaError { constructor(public readonly issues: ValidationIssue[]) { super( diff --git a/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts index cf476d5b..ecaee9c1 100644 --- a/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts +++ b/lambdas/client-transform-filter-lambda/src/services/validators/config-validator.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { fromZodError } from "zod-validation-error"; import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models"; import { CHANNEL_STATUSES, @@ -6,11 +7,7 @@ import { MESSAGE_STATUSES, SUPPLIER_STATUSES, } from "@nhs-notify-client-callbacks/models"; -import { - ConfigValidationError, - type ValidationIssue, - formatValidationIssuePath, -} from "services/error-handler"; +import { ConfigValidationError } from "services/error-handler"; export { ConfigValidationError } from "services/error-handler"; @@ -85,18 +82,10 @@ export const validateClientConfig = ( const result = configSchema.safeParse(rawConfig); if (!result.success) { - const issues: ValidationIssue[] = result.error.issues.map((issue) => { - const pathSegments = issue.path.filter( - (segment): segment is string | number => - typeof segment === "string" || typeof segment === "number", - ); - - return { - path: formatValidationIssuePath(pathSegments), - message: issue.message, - }; - }); - throw new ConfigValidationError(issues); + const errorMessage = fromZodError(result.error).message; + throw new ConfigValidationError([ + { path: "config", message: errorMessage }, + ]); } return result.data; diff --git a/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts b/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts index 03e37807..5d969fdf 100644 --- a/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts +++ b/lambdas/client-transform-filter-lambda/src/services/validators/event-validator.ts @@ -3,14 +3,12 @@ import { ValidationError as CloudEventsValidationError, } from "cloudevents"; import { z } from "zod"; +import { fromZodError } from "zod-validation-error"; import { EventTypes, type StatusPublishEvent, } from "@nhs-notify-client-callbacks/models"; -import { - ValidationError, - formatValidationIssuePath, -} from "services/error-handler"; +import { ValidationError } from "services/error-handler"; import { extractCorrelationId } from "services/logger"; const NHSNotifyExtensionsSchema = z.object({ @@ -68,18 +66,7 @@ function formatValidationError(error: unknown, event: unknown): never { if (error instanceof CloudEventsValidationError) { message = `CloudEvents validation failed: ${error.message}`; } else if (error instanceof z.ZodError) { - const issues = error.issues - .map((issue) => { - const path = formatValidationIssuePath( - issue.path.filter( - (segment): segment is string | number => - typeof segment === "string" || typeof segment === "number", - ), - ); - return path ? `${path}: ${issue.message}` : issue.message; - }) - .join(", "); - message = `Validation failed: ${issues}`; + message = fromZodError(error).message; } else if (error instanceof Error) { message = error.message; } else { diff --git a/package-lock.json b/package-lock.json index e56e383f..f7424787 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,8 @@ "cloudevents": "^8.0.2", "esbuild": "^0.25.0", "p-map": "^4.0.0", - "zod": "^4.1.13" + "zod": "^4.1.13", + "zod-validation-error": "^5.0.0" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", @@ -10930,6 +10931,18 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-validation-error": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-5.0.0.tgz", + "integrity": "sha512-hmk+pkyKq7Q71PiWVSDUc3VfpzpvcRHZ3QPw9yEMVvmtCekaMeOHnbr3WbxfrgEnQTv6haGP4cmv0Ojmihzsxw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "src/logger": { "name": "@nhs-notify-client-callbacks/logger", "version": "0.0.1",