Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lambdas/client-transform-filter-lambda/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ describe("Lambda handler", () => {
};

await expect(handler([sqsMessage])).rejects.toThrow(
"Validation failed: type: Invalid option",
/Validation error:.*at "type"/,
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
LambdaError,
TransformationError,
ValidationError,
formatValidationIssuePath,
getEventError,
wrapUnknownError,
} from "services/error-handler";
Expand Down Expand Up @@ -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 = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"',
);
});
});
Expand All @@ -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"',
);
});
});
Expand All @@ -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"',
);
});
});
Expand All @@ -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"',
);
});

Expand All @@ -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"',
);
});

Expand All @@ -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"',
);
});

Expand All @@ -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"',
);
});
});
Expand All @@ -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"',
);
});

Expand All @@ -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"',
);
});

Expand All @@ -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"',
);
});

Expand All @@ -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"',
);
});

Expand All @@ -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"',
);
});
});
Expand Down Expand Up @@ -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"',
);
});

Expand All @@ -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"',
);
});

Expand All @@ -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"',
);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
import type { ClientSubscriptionConfiguration } from "@nhs-notify-client-callbacks/models";
import {
CHANNEL_STATUSES,
CHANNEL_TYPES,
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";

Expand Down Expand Up @@ -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 },
]);
Comment on lines +85 to +88
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refactoring collapses all validation issues into a single ValidationIssue with a hardcoded path: "config", losing per-issue path granularity. Previously, each zod issue was mapped to its own ValidationIssue with a specific path (e.g., [0].Name, [0].Targets[0].InvocationEndpoint), which was logged in config-loader.ts at line 21 (validationErrors: error.issues).

Now the logged output for a config with multiple errors will be a single issue [{ path: "config", message: "Validation error: ... ; ..." }] instead of an array of separate, individually-addressed issues. This degrades the observability of config validation failures.

Consider either keeping the per-issue structure (mapping fromZodError's details back to individual issues) or simplifying ConfigValidationError to just hold a message string instead of an issues array, which would better reflect the new single-issue semantics.

Copilot uses AI. Check for mistakes.
}

return result.data;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 {
Expand Down
15 changes: 14 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading