Skip to content

Commit 3617c8c

Browse files
sdangolsvozza
andauthored
feat(event-handler): add validation support for REST router (#4736)
Co-authored-by: svozza <svozza@amazon.com>
1 parent 564b637 commit 3617c8c

34 files changed

+2569
-170
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,5 @@ tsconfig.tsbuildinfo
5252
.claude
5353
.amazonq
5454
.kiro
55-
.github/instructions
55+
.github/instructions
56+
aidlc-docs

docs/features/event-handler/http.md

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Event handler for Amazon API Gateway REST and HTTP APIs, Application Loader Bala
99
## Key Features
1010

1111
* Lightweight routing to reduce boilerplate for API Gateway REST/HTTP API, ALB and Lambda Function URLs.
12-
* Built-in middleware engine for request/response transformation (validation coming soon).
12+
* Built-in middleware engine for request/response transformation and validation.
1313
* Works with micro function (one or a few routes) and monolithic functions (see [Considerations](#considerations)).
1414

1515
## Getting started
@@ -92,6 +92,26 @@ For your convenience, when you return a JavaScript object from your route handle
9292
!!! tip "Automatic response format transformation"
9393
The event handler automatically ensures the correct response format is returned based on the event type received. For example, if your handler returns an API Gateway v1 proxy response but processes an ALB event, we'll automatically transform it into an ALB-compatible response. This allows you to swap integrations with little to no code changes.
9494

95+
If you need to control the status code or headers alongside auto-serialization, return an object with `statusCode` and `headers` fields. The `body` field accepts any JSON-serializable value — object or array — and is automatically serialized. Omitting `body` produces an empty response body, which is useful for responses like `204 No Content`.
96+
97+
=== "index.ts"
98+
99+
```ts hl_lines="7-9 14 18"
100+
--8<-- "examples/snippets/event-handler/http/gettingStarted_extended_proxy_result.ts"
101+
```
102+
103+
1. Set a custom status code alongside JSON auto-serialization
104+
2. Set custom response headers
105+
3. Object body is automatically serialized to JSON
106+
4. Array body is automatically serialized to JSON
107+
5. Omitting `body` produces an empty response body
108+
109+
=== "Response"
110+
111+
```json hl_lines="2"
112+
--8<-- "examples/snippets/event-handler/http/samples/gettingStarted_extended_proxy_result.json"
113+
```
114+
95115
### Dynamic routes
96116

97117
You can use `/todos/:todoId` to configure dynamic URL paths, where `:todoId` will be resolved at runtime.
@@ -182,11 +202,111 @@ If you prefer to use the decorator syntax, you can use the same methods on a cla
182202

183203
### Data validation
184204

185-
!!! note "Coming soon"
205+
Event Handler supports built-in request and response validation using [Standard Schema](https://standardschema.dev){target="_blank"}, a common specification supported by popular TypeScript validation libraries including [Zod](https://zod.dev){target="_blank"}, [Valibot](https://valibot.dev){target="_blank"}, and [ArkType](https://arktype.io){target="_blank"}.
206+
207+
!!! note "Body validation is limited to JSON-serializable values"
208+
Standard Schema validators operate on JavaScript values, not raw bytes. For body validation, Event Handler deserializes the body before passing it to your schema: `application/json` content is parsed as an object, all other content types are passed as a plain string. Binary data, streams, and other non-serializable response types cannot be validated.
209+
210+
#### Validating requests
211+
212+
Pass a `validation` option to your route handler with a `req` configuration to validate the incoming request. Validation runs before your handler, and if it fails, Event Handler automatically returns a **422 Unprocessable Entity** response with structured error details.
213+
214+
Validated data is available via `reqCtx.valid.req` and is fully typed based on your schema. Only the fields you provide schemas for are accessible: accessing an unvalidated field (e.g., `reqCtx.valid.req.headers` when no headers schema was configured) is a compile-time error.
215+
216+
=== "index.ts"
217+
218+
```ts hl_lines="3 7-10 15 19"
219+
--8<-- "examples/snippets/event-handler/http/gettingStarted_validation_request.ts"
220+
```
221+
222+
1. Access the validated and typed request body via `reqCtx.valid.req.body`
223+
224+
=== "Request"
225+
226+
```json
227+
--8<-- "examples/snippets/event-handler/http/samples/gettingStarted_validation_req.json"
228+
```
229+
230+
=== "Response"
231+
232+
```json
233+
--8<-- "examples/snippets/event-handler/http/samples/gettingStarted_validation_res.json"
234+
```
235+
236+
=== "Validation error (422)"
237+
238+
```json
239+
--8<-- "examples/snippets/event-handler/http/samples/gettingStarted_validation_422.json"
240+
```
241+
242+
You can validate any combination of `body`, `headers`, `path` parameters, and `query` parameters — at least one must be specified:
243+
244+
=== "index.ts"
245+
246+
```ts hl_lines="8-11 17-19"
247+
--8<-- "examples/snippets/event-handler/http/gettingStarted_validation_request_parts.ts"
248+
```
249+
250+
1. Query params are always strings — `z.coerce.number()` coerces and validates the value, typing it as `number`
251+
2. Validated path parameters available via `reqCtx.valid.req.path`
252+
3. `page` and `limit` are typed as `number` — no manual casting needed
253+
4. Validated headers available via `reqCtx.valid.req.headers`
254+
5. Headers are always strings — `z.coerce.number()` coerces and validates the value, typing it as `number`
255+
256+
#### Validating responses
257+
258+
Pass a `validation` option to your route handler with a `res` configuration to validate the outgoing response body and headers after your handler executes. If validation fails, Event Handler returns a **500 Internal Server Error**.
259+
260+
Response validation is useful for ensuring your handler returns the expected shape and catching contract violations early.
261+
Validated data is available via `reqCtx.valid.res` and is fully typed based on your schema. Only the fields you provide schemas
262+
for are accessible: accessing an unvalidated field (e.g., `reqCtx.valid.res.headers` when no headers schema was configured) is a compile-time error.
263+
264+
=== "index.ts"
265+
266+
```ts hl_lines="3 7-11 19"
267+
--8<-- "examples/snippets/event-handler/http/gettingStarted_validation_response.ts"
268+
```
269+
270+
1. If the response doesn't match the schema, a 500 is returned
271+
272+
=== "Validation error (500)"
273+
274+
```json
275+
--8<-- "examples/snippets/event-handler/http/samples/gettingStarted_validation_500.json"
276+
```
277+
278+
You can also validate response headers by providing a `headers` schema in the `res` configuration. This is useful for enforcing that required headers — such as correlation IDs or cache directives — are always present:
279+
280+
=== "index.ts"
281+
282+
```ts hl_lines="7-10 26"
283+
--8<-- "examples/snippets/event-handler/http/gettingStarted_validation_response_headers.ts"
284+
```
285+
286+
1. Enforce that the correlation ID is a valid UUID
287+
2. If either required header is absent or invalid, a 500 is returned
288+
3. Headers are always strings — `z.coerce.number()` coerces and validates the value, typing it as `number`
289+
290+
You can combine both request and response validation in a single route by providing both `req` and `res`:
186291

187-
We plan to add built-in support for request and response validation using [Standard Schema](https://standardschema.dev){target="_blank"} in a future release. For the time being, you can use any validation library of your choice in your route handlers or middleware.
292+
=== "index.ts"
293+
294+
```ts hl_lines="7-13 18 23-24"
295+
--8<-- "examples/snippets/event-handler/http/gettingStarted_validation_combined.ts"
296+
```
297+
298+
1. Access the validated and typed request body via `reqCtx.valid.req.body`
299+
2. If the response doesn't match the schema, a 500 is returned
300+
301+
!!! tip
302+
If you need request validation but want to skip runtime response validation, annotate your handler's return type directly and TypeScript will enforce the return shape at compile time:
303+
304+
```ts hl_lines="9 13 19"
305+
--8<-- "examples/snippets/event-handler/http/gettingStarted_validation_response_types.ts"
306+
```
188307

189-
Please [check this issue](https://github.com/aws-powertools/powertools-lambda-typescript/issues/4516) for more details and examples, and add 👍 if you would like us to prioritize it.
308+
1. TypeScript enforces `Todo` as the return type at compile time
309+
2. Only request validation runs at runtime — no response validation
190310

191311
### Accessing request details
192312

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Router } from '@aws-lambda-powertools/event-handler/http';
2+
import type { Context } from 'aws-lambda';
3+
4+
const app = new Router();
5+
6+
app.post('/todos', () => ({
7+
statusCode: 201, // (1)!
8+
headers: { 'x-todo-id': '123' }, // (2)!
9+
body: { id: '123', title: 'Buy milk' }, // (3)!
10+
}));
11+
12+
app.get('/todos', () => ({
13+
statusCode: 200,
14+
body: [
15+
{ id: '1', title: 'Buy milk' },
16+
{ id: '2', title: 'Take out trash' },
17+
], // (4)!
18+
}));
19+
20+
app.delete('/todos/:id', () => ({
21+
statusCode: 204, // (5)!
22+
}));
23+
24+
export const handler = async (event: unknown, context: Context) =>
25+
app.resolve(event, context);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Router } from '@aws-lambda-powertools/event-handler/http';
2+
import type { Context } from 'aws-lambda';
3+
import { z } from 'zod';
4+
5+
const app = new Router();
6+
7+
const createTodoSchema = z.object({ title: z.string() });
8+
9+
const todoResponseSchema = z.object({
10+
id: z.string(),
11+
title: z.string(),
12+
completed: z.boolean(),
13+
});
14+
15+
app.post(
16+
'/todos',
17+
(reqCtx) => {
18+
const { title } = reqCtx.valid.req.body; // (1)!
19+
return { id: '123', title, completed: false };
20+
},
21+
{
22+
validation: {
23+
req: { body: createTodoSchema },
24+
res: { body: todoResponseSchema }, // (2)!
25+
},
26+
}
27+
);
28+
29+
export const handler = async (event: unknown, context: Context) =>
30+
app.resolve(event, context);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Router } from '@aws-lambda-powertools/event-handler/http';
2+
import type { Context } from 'aws-lambda';
3+
import { z } from 'zod';
4+
5+
const app = new Router();
6+
7+
const createTodoSchema = z.object({
8+
title: z.string(),
9+
completed: z.boolean().optional().default(false),
10+
});
11+
12+
app.post(
13+
'/todos',
14+
(reqCtx) => {
15+
const { title, completed } = reqCtx.valid.req.body; // (1)!
16+
return { id: '123', title, completed };
17+
},
18+
{
19+
validation: { req: { body: createTodoSchema } },
20+
}
21+
);
22+
23+
export const handler = async (event: unknown, context: Context) =>
24+
app.resolve(event, context);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Router } from '@aws-lambda-powertools/event-handler/http';
2+
import type { Context } from 'aws-lambda';
3+
import { z } from 'zod';
4+
5+
const app = new Router();
6+
7+
const pathSchema = z.object({ todoId: z.string().uuid() });
8+
const querySchema = z.object({
9+
page: z.coerce.number().int().positive(), // (1)!
10+
limit: z.coerce.number().int().positive().max(100),
11+
});
12+
const headerSchema = z.object({
13+
'x-api-key': z.string(),
14+
'x-max-age': z.coerce.number().int().nonnegative(), // (5)!
15+
});
16+
17+
app.get(
18+
'/todos/:todoId',
19+
(reqCtx) => {
20+
const { todoId } = reqCtx.valid.req.path; // (2)!
21+
const { page, limit } = reqCtx.valid.req.query; // (3)!
22+
const { 'x-api-key': apiKey } = reqCtx.valid.req.headers; // (4)!
23+
return { id: todoId, page, limit, apiKey };
24+
},
25+
{
26+
validation: {
27+
req: {
28+
path: pathSchema,
29+
query: querySchema,
30+
headers: headerSchema,
31+
},
32+
},
33+
}
34+
);
35+
36+
export const handler = async (event: unknown, context: Context) =>
37+
app.resolve(event, context);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Router } from '@aws-lambda-powertools/event-handler/http';
2+
import type { Context } from 'aws-lambda';
3+
import { z } from 'zod';
4+
5+
const app = new Router();
6+
7+
const todoSchema = z.object({
8+
id: z.string(),
9+
title: z.string(),
10+
completed: z.boolean(),
11+
});
12+
13+
app.get(
14+
'/todos/:id',
15+
({ params }) => {
16+
return { id: params.id, title: 'Buy milk', completed: false };
17+
},
18+
{
19+
validation: { res: { body: todoSchema } }, // (1)!
20+
}
21+
);
22+
23+
export const handler = async (event: unknown, context: Context) =>
24+
app.resolve(event, context);
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Router } from '@aws-lambda-powertools/event-handler/http';
2+
import type { Context } from 'aws-lambda';
3+
import { z } from 'zod';
4+
5+
const app = new Router();
6+
7+
const responseHeaderSchema = z.object({
8+
'x-correlation-id': z.string().uuid(), // (1)!
9+
'x-max-age': z.coerce.number().int().nonnegative(), // (3)!
10+
});
11+
12+
app.get(
13+
'/todos/:id',
14+
({ params }) => {
15+
return {
16+
statusCode: 200,
17+
headers: {
18+
'content-type': 'application/json',
19+
'x-correlation-id': crypto.randomUUID(),
20+
'x-max-age': 300,
21+
},
22+
body: { id: params.id, title: 'Buy milk' },
23+
};
24+
},
25+
{
26+
validation: { res: { headers: responseHeaderSchema } }, // (2)!
27+
}
28+
);
29+
30+
export const handler = async (event: unknown, context: Context) =>
31+
app.resolve(event, context);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Router } from '@aws-lambda-powertools/event-handler/http';
2+
import type { Context } from 'aws-lambda';
3+
import { z } from 'zod';
4+
5+
const app = new Router();
6+
7+
const createTodoSchema = z.object({ title: z.string() });
8+
9+
type Todo = { id: string; title: string; completed: boolean };
10+
11+
app.post(
12+
'/todos',
13+
(reqCtx): Todo => {
14+
// (1)!
15+
const { title } = reqCtx.valid.req.body;
16+
return { id: '123', title, completed: false };
17+
},
18+
{
19+
validation: { req: { body: createTodoSchema } }, // (2)!
20+
}
21+
);
22+
23+
export const handler = async (event: unknown, context: Context) =>
24+
app.resolve(event, context);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"statusCode": 201,
3+
"headers": {
4+
"Content-Type": "application/json",
5+
"x-todo-id": "123"
6+
},
7+
"body": "{\"id\":\"123\",\"title\":\"Buy milk\"}",
8+
"isBase64Encoded": false
9+
}

0 commit comments

Comments
 (0)