Skip to content

Commit 55f2f73

Browse files
committed
create single req, res, path, query schema so errors have correct path
1 parent 45e1ab2 commit 55f2f73

File tree

3 files changed

+223
-58
lines changed

3 files changed

+223
-58
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/event-handler/src/http/middleware/validation.ts

Lines changed: 94 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -48,67 +48,133 @@ async function validateRequestData<TReq extends ReqSchema>(
4848
typedReqCtx: TypedRequestContext<TReq, HandlerResponse>,
4949
reqSchemas: NonNullable<ValidationConfig<TReq, HandlerResponse>['req']>
5050
): Promise<void> {
51+
const schemaEntries: [string, StandardSchemaV1][] = [];
52+
const dataEntries: [string, unknown][] = [];
53+
5154
if (reqSchemas.body) {
5255
const bodyData = await extractBody(typedReqCtx.req);
53-
typedReqCtx.valid.req.body = await validateRequest<TReq['body']>(
54-
reqSchemas.body,
55-
bodyData,
56-
'body'
57-
);
56+
schemaEntries.push(['body', reqSchemas.body]);
57+
dataEntries.push(['body', bodyData]);
5858
}
5959

6060
if (reqSchemas.headers) {
6161
const headers = Object.fromEntries(typedReqCtx.req.headers.entries());
62-
typedReqCtx.valid.req.headers = await validateRequest<TReq['headers']>(
63-
reqSchemas.headers,
64-
headers,
65-
'headers'
66-
);
62+
schemaEntries.push(['headers', reqSchemas.headers]);
63+
dataEntries.push(['headers', headers]);
6764
}
6865

6966
if (reqSchemas.path) {
70-
typedReqCtx.valid.req.path = await validateRequest<TReq['path']>(
71-
reqSchemas.path,
72-
typedReqCtx.params,
73-
'path'
74-
);
67+
schemaEntries.push(['path', reqSchemas.path]);
68+
dataEntries.push(['path', typedReqCtx.params]);
7569
}
7670

7771
if (reqSchemas.query) {
7872
const query = Object.fromEntries(
7973
new URL(typedReqCtx.req.url).searchParams.entries()
8074
);
81-
typedReqCtx.valid.req.query = await validateRequest<TReq['query']>(
82-
reqSchemas.query,
83-
query,
84-
'query'
75+
schemaEntries.push(['query', reqSchemas.query]);
76+
dataEntries.push(['query', query]);
77+
}
78+
79+
const stitchedSchema = createObjectSchema(schemaEntries);
80+
const stitchedData = Object.fromEntries(dataEntries);
81+
82+
const result = await stitchedSchema['~standard'].validate(stitchedData);
83+
84+
if ('issues' in result) {
85+
throw new RequestValidationError(
86+
'Validation failed for request',
87+
result.issues
8588
);
8689
}
90+
91+
const validated = result.value as Record<string, unknown>;
92+
if (reqSchemas.body)
93+
typedReqCtx.valid.req.body = validated.body as TReq['body'];
94+
if (reqSchemas.headers)
95+
typedReqCtx.valid.req.headers = validated.headers as TReq['headers'];
96+
if (reqSchemas.path)
97+
typedReqCtx.valid.req.path = validated.path as TReq['path'];
98+
if (reqSchemas.query)
99+
typedReqCtx.valid.req.query = validated.query as TReq['query'];
87100
}
88101

89102
async function validateResponseData<TResBody extends HandlerResponse>(
90103
typedReqCtx: TypedRequestContext<ReqSchema, TResBody>,
91104
resSchemas: NonNullable<ValidationConfig<ReqSchema, TResBody>['res']>
92105
): Promise<void> {
93106
const response = typedReqCtx.res;
107+
const schemaEntries: [string, StandardSchemaV1][] = [];
108+
const dataEntries: [string, unknown][] = [];
94109

95110
if (resSchemas.body && response.body) {
96111
const bodyData = await extractBody(response);
97-
typedReqCtx.valid.res.body = await validateResponse<TResBody>(
98-
resSchemas.body,
99-
bodyData,
100-
'body'
101-
);
112+
schemaEntries.push(['body', resSchemas.body]);
113+
dataEntries.push(['body', bodyData]);
102114
}
103115

104116
if (resSchemas.headers) {
105117
const headers = Object.fromEntries(response.headers.entries());
106-
typedReqCtx.valid.res.headers = await validateResponse(
107-
resSchemas.headers,
108-
headers,
109-
'headers'
118+
schemaEntries.push(['headers', resSchemas.headers]);
119+
dataEntries.push(['headers', headers]);
120+
}
121+
122+
const stitchedSchema = createObjectSchema(schemaEntries);
123+
const stitchedData = Object.fromEntries(dataEntries);
124+
125+
const result = await stitchedSchema['~standard'].validate(stitchedData);
126+
127+
if ('issues' in result) {
128+
throw new ResponseValidationError(
129+
'Validation failed for response',
130+
result.issues
110131
);
111132
}
133+
134+
const validated = result.value as Record<string, unknown>;
135+
if (resSchemas.body) {
136+
typedReqCtx.valid.res.body = validated.body as TResBody;
137+
}
138+
if (resSchemas.headers) {
139+
typedReqCtx.valid.res.headers = validated.headers as Record<string, string>;
140+
}
141+
}
142+
143+
function createObjectSchema(
144+
entries: [string, StandardSchemaV1][]
145+
): StandardSchemaV1 {
146+
return {
147+
'~standard': {
148+
version: 1,
149+
vendor: 'powertools',
150+
validate: async (data): Promise<StandardSchemaV1.Result<unknown>> => {
151+
const dataObj = data as Record<string, unknown>;
152+
const validated: Record<string, unknown> = {};
153+
const allIssues: StandardSchemaV1.Issue[] = [];
154+
155+
for (const [key, schema] of entries) {
156+
const result = await schema['~standard'].validate(dataObj[key]);
157+
158+
for (const issue of result.issues ?? []) {
159+
allIssues.push({
160+
message: issue.message,
161+
path: [key, ...(issue.path || [])],
162+
});
163+
}
164+
165+
if ('value' in result) {
166+
validated[key] = result.value;
167+
}
168+
}
169+
170+
if (allIssues.length > 0) {
171+
return { issues: allIssues };
172+
}
173+
174+
return { value: validated };
175+
},
176+
},
177+
};
112178
}
113179

114180
async function extractBody(source: Request | Response): Promise<unknown> {
@@ -140,33 +206,3 @@ async function extractBody(source: Request | Response): Promise<unknown> {
140206

141207
return await cloned.text();
142208
}
143-
144-
async function validateRequest<T>(
145-
schema: StandardSchemaV1,
146-
data: unknown,
147-
component: 'body' | 'headers' | 'path' | 'query'
148-
): Promise<T> {
149-
const result = await schema['~standard'].validate(data);
150-
151-
if ('issues' in result) {
152-
const message = `Validation failed for request ${component}`;
153-
throw new RequestValidationError(message, result.issues);
154-
}
155-
156-
return result.value as T;
157-
}
158-
159-
async function validateResponse<T>(
160-
schema: StandardSchemaV1,
161-
data: unknown,
162-
component: 'body' | 'headers'
163-
): Promise<T> {
164-
const result = await schema['~standard'].validate(data);
165-
166-
if ('issues' in result) {
167-
const message = `Validation failed for response ${component}`;
168-
throw new ResponseValidationError(message, result.issues);
169-
}
170-
171-
return result.value as T;
172-
}

packages/event-handler/tests/unit/http/middleware/validation.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { setTimeout } from 'node:timers/promises';
12
import context from '@aws-lambda-powertools/testing-utils/context';
23
import { beforeEach, describe, expect, it } from 'vitest';
34
import { z } from 'zod';
@@ -469,4 +470,131 @@ describe('Router Validation Integration', () => {
469470
expect(validatedResult.statusCode).toBe(422);
470471
expect(unvalidatedResult.statusCode).toBe(200);
471472
});
473+
474+
it('validates with async schema refinement', async () => {
475+
// Prepare
476+
const requestBodySchema = z.object({ email: z.string().email() }).refine(
477+
async (data) => {
478+
// Simulate async validation (e.g., checking if email exists)
479+
await setTimeout(10);
480+
return !data.email.includes('blocked');
481+
},
482+
{ message: 'Email is blocked' }
483+
);
484+
485+
app.post(
486+
'/register',
487+
(reqCtx) => {
488+
const { email } = reqCtx.valid.req.body;
489+
return { statusCode: 201, body: `Registered ${email}` };
490+
},
491+
{
492+
validation: { req: { body: requestBodySchema } },
493+
}
494+
);
495+
496+
const validEvent = createTestEvent('/register', 'POST', {
497+
'content-type': 'application/json',
498+
});
499+
validEvent.body = JSON.stringify({ email: 'user@example.com' });
500+
501+
const blockedEvent = createTestEvent('/register', 'POST', {
502+
'content-type': 'application/json',
503+
});
504+
blockedEvent.body = JSON.stringify({ email: 'blocked@example.com' });
505+
506+
// Act
507+
const validResult = await app.resolve(validEvent, context);
508+
const blockedResult = await app.resolve(blockedEvent, context);
509+
510+
// Assess
511+
expect(validResult.statusCode).toBe(201);
512+
expect(validResult.body).toBe('Registered user@example.com');
513+
expect(blockedResult.statusCode).toBe(422);
514+
});
515+
516+
it('includes correct path in validation errors', async () => {
517+
// Prepare
518+
const bodySchema = z.object({ name: z.string() });
519+
const querySchema = z.object({ page: z.string() });
520+
const pathSchema = z.object({ id: z.string().uuid() });
521+
522+
app.post('/users/:id', () => ({ statusCode: 200 }), {
523+
validation: {
524+
req: { body: bodySchema, query: querySchema, path: pathSchema },
525+
},
526+
});
527+
528+
const event = createTestEvent('/users/invalid-id', 'POST', {
529+
'content-type': 'application/json',
530+
});
531+
event.body = JSON.stringify({ invalid: 'field' });
532+
event.queryStringParameters = { wrong: 'param' };
533+
event.pathParameters = { id: 'invalid-id' };
534+
535+
// Act
536+
const result = await app.resolve(event, context);
537+
538+
// Assess
539+
expect(result.statusCode).toBe(422);
540+
const body = JSON.parse(result.body);
541+
expect(body.details?.issues).toBeDefined();
542+
543+
const paths = body.details.issues.map(
544+
(issue: { path: unknown[] }) => issue.path
545+
);
546+
expect(paths.some((p: unknown[]) => p[0] === 'body')).toBe(true);
547+
expect(paths.some((p: unknown[]) => p[0] === 'query')).toBe(true);
548+
expect(paths.some((p: unknown[]) => p[0] === 'path')).toBe(true);
549+
});
550+
551+
it('returns 422 when request body is null with object schema', async () => {
552+
// Prepare
553+
const requestBodySchema = z.object({ name: z.string() });
554+
555+
app.post('/users', () => ({ statusCode: 201 }), {
556+
validation: { req: { body: requestBodySchema } },
557+
});
558+
559+
const event = createTestEvent('/users', 'POST', {
560+
'content-type': 'application/json',
561+
});
562+
event.body = 'null';
563+
564+
// Act
565+
const result = await app.resolve(event, context);
566+
567+
// Assess
568+
expect(result.statusCode).toBe(422);
569+
const body = JSON.parse(result.body);
570+
expect(body.error).toBe('RequestValidationError');
571+
});
572+
573+
it('handles validation error with missing path in issue', async () => {
574+
// Prepare
575+
const customSchema = {
576+
'~standard': {
577+
version: 1,
578+
vendor: 'custom',
579+
validate: async () => ({
580+
issues: [{ message: 'Error without path' }],
581+
}),
582+
},
583+
} as const;
584+
585+
app.post('/test', () => ({ statusCode: 200 }), {
586+
validation: { req: { body: customSchema } },
587+
});
588+
589+
const event = createTestEvent('/test', 'POST', {
590+
'content-type': 'application/json',
591+
});
592+
event.body = '{}';
593+
594+
// Act
595+
const result = await app.resolve(event, context);
596+
597+
// Assess
598+
expect(result.statusCode).toBe(422);
599+
});
472600
});

0 commit comments

Comments
 (0)