219 lines
9.3 KiB
Markdown
219 lines
9.3 KiB
Markdown
# ADR-003: Standardized Input Validation using Middleware
|
|
|
|
**Date**: 2025-12-12
|
|
|
|
**Status**: Accepted
|
|
|
|
**Implemented**: 2026-01-07
|
|
|
|
## Context
|
|
|
|
Our Express route handlers currently perform manual validation of request parameters, queries, and bodies. This involves repetitive boilerplate code using `parseInt`, `isNaN`, and type checks like `Array.isArray`. This approach has several disadvantages:
|
|
|
|
1. **Code Duplication**: The same validation logic (e.g., checking for a valid integer ID) is repeated across many different routes.
|
|
2. **Cluttered Business Logic**: Route handlers are cluttered with validation code, obscuring their primary business logic.
|
|
3. **Inconsistent Error Messages**: Manual validation can lead to inconsistent error messages for similar validation failures across the API.
|
|
4. **Error-Prone**: It is easy to forget a validation check, leading to unexpected data types being passed to service and repository layers, which could cause runtime errors.
|
|
|
|
## Decision
|
|
|
|
We will adopt a schema-based approach for input validation using the `zod` library and a custom Express middleware.
|
|
|
|
1. **Adopt `zod` for Schema Definition**: We will use `zod` to define clear, type-safe schemas for the `params`, `query`, and `body` of each API request. `zod` provides powerful and declarative validation rules and automatically infers TypeScript types.
|
|
|
|
2. **Create a Reusable Validation Middleware**: A generic `validateRequest(schema)` middleware will be created. This middleware will take a `zod` schema, parse the incoming request against it, and handle success and error cases.
|
|
- On successful validation, the parsed and typed data will be attached to the `req` object (e.g., `req.body` will be replaced with the parsed body), and `next()` will be called.
|
|
- On validation failure, the middleware will call `next()` with a custom `ValidationError` containing a structured list of issues, which `ADR-001`'s `errorHandler` can then format into a user-friendly `400 Bad Request` response.
|
|
|
|
3. **Refactor Routes**: All route handlers will be refactored to use this new middleware, removing all manual validation logic.
|
|
|
|
4. **(New) Resilient Type Inference**: To achieve full type safety, we will use **inline type assertions** with `z.infer<typeof schema>`. This ensures the code inside the handler is fully typed and benefits from IntelliSense without creating complex utility types that conflict with Express's `RequestHandler` signature.
|
|
|
|
### Example Usage (Refined Pattern)
|
|
|
|
```typescript
|
|
// In flyer.routes.ts
|
|
|
|
import { z } from 'zod';
|
|
import { validateRequest } from '../middleware/validation';
|
|
|
|
// 1. Define the schema
|
|
const getFlyerSchema = z.object({
|
|
params: z.object({
|
|
id: z.string().pipe(z.coerce.number().int().positive()),
|
|
}),
|
|
});
|
|
|
|
// 2. Infer the type from the schema for local use
|
|
type GetFlyerRequest = z.infer<typeof getFlyerSchema>;
|
|
|
|
// 3. Apply the middleware and use an inline cast for the request
|
|
router.get('/:id', validateRequest(getFlyerSchema), async (req, res, next) => {
|
|
// Cast 'req' to the inferred type.
|
|
// This provides full type safety for params, query, and body.
|
|
const { params } = req as unknown as GetFlyerRequest;
|
|
|
|
try {
|
|
const flyer = await db.flyerRepo.getFlyerById(params.id); // params.id is 'number'
|
|
res.json(flyer);
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
```
|
|
|
|
## Consequences
|
|
|
|
### Positive
|
|
|
|
**Reduced Complexity**: Avoids maintaining a complex ValidatedRequestHandler utility type that could conflict with Express or TypeScript upgrades.
|
|
**Explicit Contracts**: By defining the Request type right next to the route, the contract for that endpoint is immediately visible.
|
|
**Improved Readability**: Route handlers become much cleaner and focus exclusively on their core business logic.
|
|
**Type Safety**: `zod` schemas provide strong compile-time and runtime type safety, reducing bugs.
|
|
**Consistent and Detailed Errors**: The `errorHandler` can be configured to provide consistent, detailed validation error messages for all routes (e.g., "Query parameter 'limit' must be a positive integer").
|
|
**Robustness**: Prevents invalid data from ever reaching the service or database layers.
|
|
|
|
### Negative
|
|
|
|
**Minor Verbosity**: Requires one extra line (type ... = z.infer<...>) and a controlled cast (as unknown as ...) within the handler function.
|
|
**New Dependency**: Introduces `zod` as a new project dependency.
|
|
**Learning Curve**: Developers need to learn the `zod` schema definition syntax.
|
|
**Refactoring Effort**: Requires a one-time effort to create schemas and refactor all existing routes to use the `validateRequest` middleware.
|
|
|
|
## Implementation Details
|
|
|
|
### The `validateRequest` Middleware
|
|
|
|
Located in `src/middleware/validation.middleware.ts`:
|
|
|
|
```typescript
|
|
export const validateRequest =
|
|
(schema: ZodObject<z.ZodRawShape>) => async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const { params, query, body } = await schema.parseAsync({
|
|
params: req.params,
|
|
query: req.query,
|
|
body: req.body,
|
|
});
|
|
|
|
// Merge parsed data back into request
|
|
Object.keys(req.params).forEach((key) => delete req.params[key]);
|
|
Object.assign(req.params, params);
|
|
Object.keys(req.query).forEach((key) => delete req.query[key]);
|
|
Object.assign(req.query, query);
|
|
req.body = body;
|
|
|
|
return next();
|
|
} catch (error) {
|
|
if (error instanceof ZodError) {
|
|
const validationIssues = error.issues.map((issue) => ({
|
|
...issue,
|
|
path: issue.path.map((p) => String(p)),
|
|
}));
|
|
return next(new ValidationError(validationIssues));
|
|
}
|
|
return next(error);
|
|
}
|
|
};
|
|
```
|
|
|
|
### Common Zod Patterns
|
|
|
|
```typescript
|
|
import { z } from 'zod';
|
|
import { requiredString } from '../utils/zodUtils';
|
|
|
|
// String that coerces to positive integer (for ID params)
|
|
const idParam = z.string().pipe(z.coerce.number().int().positive());
|
|
|
|
// Pagination query params with defaults
|
|
const paginationQuery = z.object({
|
|
limit: z.coerce.number().int().positive().max(100).default(20),
|
|
offset: z.coerce.number().int().nonnegative().default(0),
|
|
});
|
|
|
|
// Email with sanitization
|
|
const emailSchema = z.string().trim().toLowerCase().email('A valid email is required.');
|
|
|
|
// Password with strength validation
|
|
const passwordSchema = z
|
|
.string()
|
|
.trim()
|
|
.min(8, 'Password must be at least 8 characters long.')
|
|
.superRefine((password, ctx) => {
|
|
const strength = validatePasswordStrength(password);
|
|
if (!strength.isValid) ctx.addIssue({ code: 'custom', message: strength.feedback });
|
|
});
|
|
|
|
// Optional string that converts empty string to undefined
|
|
const optionalString = z.preprocess(
|
|
(val) => (val === '' ? undefined : val),
|
|
z.string().trim().optional(),
|
|
);
|
|
```
|
|
|
|
### Routes Using `validateRequest`
|
|
|
|
All API routes use the validation middleware:
|
|
|
|
| Router | Schemas Defined | Validated Endpoints |
|
|
| ------------------------ | --------------- | -------------------------------------------------------------------------------- |
|
|
| `auth.routes.ts` | 5 | `/register`, `/login`, `/forgot-password`, `/reset-password`, `/change-password` |
|
|
| `user.routes.ts` | 4 | `/profile`, `/address`, `/preferences`, `/notifications` |
|
|
| `flyer.routes.ts` | 6 | `GET /:id`, `GET /`, `GET /:id/items`, `DELETE /:id` |
|
|
| `budget.routes.ts` | 5 | `/`, `/:id`, `/batch`, `/categories` |
|
|
| `recipe.routes.ts` | 4 | `GET /`, `GET /:id`, `POST /`, `PATCH /:id` |
|
|
| `admin.routes.ts` | 8 | Various admin endpoints |
|
|
| `ai.routes.ts` | 3 | `/upload-and-process`, `/analyze`, `/jobs/:jobId/status` |
|
|
| `gamification.routes.ts` | 3 | `/achievements`, `/leaderboard`, `/points` |
|
|
|
|
### Validation Error Response Format
|
|
|
|
When validation fails, the `errorHandler` returns:
|
|
|
|
```json
|
|
{
|
|
"message": "The request data is invalid.",
|
|
"errors": [
|
|
{
|
|
"path": ["body", "email"],
|
|
"message": "A valid email is required."
|
|
},
|
|
{
|
|
"path": ["body", "password"],
|
|
"message": "Password must be at least 8 characters long."
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
HTTP Status: `400 Bad Request`
|
|
|
|
### Zod Utility Functions
|
|
|
|
Located in `src/utils/zodUtils.ts`:
|
|
|
|
```typescript
|
|
// String that rejects empty strings
|
|
export const requiredString = (message?: string) =>
|
|
z.string().min(1, message || 'This field is required.');
|
|
|
|
// Number from string with validation
|
|
export const numericString = z.string().pipe(z.coerce.number());
|
|
|
|
// Boolean from string ('true'/'false')
|
|
export const booleanString = z.enum(['true', 'false']).transform((v) => v === 'true');
|
|
```
|
|
|
|
## Key Files
|
|
|
|
- `src/middleware/validation.middleware.ts` - The `validateRequest` middleware
|
|
- `src/services/db/errors.db.ts` - `ValidationError` class definition
|
|
- `src/middleware/errorHandler.ts` - Error formatting for validation errors
|
|
- `src/utils/zodUtils.ts` - Reusable Zod schema utilities
|
|
|
|
## Related ADRs
|
|
|
|
- [ADR-001](./0001-standardized-error-handling.md) - Error handling for validation errors
|
|
- [ADR-032](./0032-rate-limiting-strategy.md) - Rate limiting applied alongside validation
|