Files
flyer-crawler.projectium.com/docs/adr/0003-standardized-input-validation-using-middleware.md

9.3 KiB

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)

// 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:

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

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:

{
  "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:

// 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
  • ADR-001 - Error handling for validation errors
  • ADR-032 - Rate limiting applied alongside validation