All files / src/lib/security request-validator.ts

32.35% Statements 55/170
100% Branches 0/0
0% Functions 0/5
32.35% Lines 55/170

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 1711x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x                                                                                                                                               1x 1x 1x 1x                                     1x 1x 1x 1x                           1x 1x 1x 1x 1x               1x 1x 1x 1x              
/**
 * Request Validation Middleware
 *
 * Provides HOC for validating API route requests.
 */
 
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
 
interface ValidationOptions {
  body?: z.ZodType;
  query?: z.ZodType;
  params?: z.ZodType;
}
 
type ValidatedData<T extends ValidationOptions> = {
  body: T['body'] extends z.ZodType ? z.infer<T['body']> : undefined;
  query: T['query'] extends z.ZodType ? z.infer<T['query']> : undefined;
  params: T['params'] extends z.ZodType ? z.infer<T['params']> : undefined;
};
 
/**
 * HOC for validating request data
 *
 * @example
 * ```ts
 * export const POST = withValidation(
 *   {
 *     body: z.object({ name: z.string() }),
 *     query: z.object({ page: z.number() }),
 *   },
 *   async (request, { body, query }) => {
 *     // body and query are validated and typed
 *     return NextResponse.json({ success: true });
 *   }
 * );
 * ```
 */
export function withValidation<T extends ValidationOptions>(
  options: T,
  handler: (
    request: NextRequest,
    validated: ValidatedData<T>,
    context?: { params: Promise<Record<string, string>> }
  ) => Promise<NextResponse>
) {
  return async (
    request: NextRequest,
    context?: { params: Promise<Record<string, string>> }
  ): Promise<NextResponse> => {
    try {
      const validated: Record<string, unknown> = {
        body: undefined,
        query: undefined,
        params: undefined,
      };

      // Validate body for non-GET requests
      if (options.body && request.method !== 'GET' && request.method !== 'HEAD') {
        try {
          const body = await request.json();
          validated.body = options.body.parse(body);
        } catch (error) {
          if (error instanceof z.ZodError) {
            return createValidationErrorResponse(error);
          }
          return NextResponse.json(
            { error: 'Invalid JSON body' },
            { status: 400 }
          );
        }
      }

      // Validate query parameters
      if (options.query) {
        const searchParams = request.nextUrl.searchParams;
        const query = parseSearchParams(searchParams);
        try {
          validated.query = options.query.parse(query);
        } catch (error) {
          if (error instanceof z.ZodError) {
            return createValidationErrorResponse(error);
          }
          throw error;
        }
      }

      // Validate route parameters
      if (options.params && context?.params) {
        try {
          const params = await context.params;
          validated.params = options.params.parse(params);
        } catch (error) {
          if (error instanceof z.ZodError) {
            return createValidationErrorResponse(error);
          }
          throw error;
        }
      }

      return handler(request, validated as ValidatedData<T>, context);
    } catch (error) {
      if (error instanceof z.ZodError) {
        return createValidationErrorResponse(error);
      }
      throw error;
    }
  };
}
 
/**
 * Parse URLSearchParams into an object
 */
function parseSearchParams(searchParams: URLSearchParams): Record<string, string | string[]> {
  const result: Record<string, string | string[]> = {};

  searchParams.forEach((value, key) => {
    const existing = result[key];
    if (existing !== undefined) {
      if (Array.isArray(existing)) {
        existing.push(value);
      } else {
        result[key] = [existing, value];
      }
    } else {
      result[key] = value;
    }
  });

  return result;
}
 
/**
 * Create a standardized validation error response
 */
function createValidationErrorResponse(error: z.ZodError): NextResponse {
  return NextResponse.json(
    {
      error: 'Validation failed',
      details: error.issues.map((e: z.ZodIssue) => ({
        field: e.path.join('.'),
        message: e.message,
        code: e.code,
      })),
    },
    { status: 400 }
  );
}
 
/**
 * Validate and extract a single field from request
 */
export async function validateField<T extends z.ZodType>(
  request: NextRequest,
  field: string,
  schema: T
): Promise<z.infer<T>> {
  const body = await request.json();
  return schema.parse(body[field]);
}
 
/**
 * Create a typed params validator for route handlers
 */
export function createParamsValidator<T extends z.ZodType>(schema: T) {
  return async (params: Promise<Record<string, string>>): Promise<z.infer<T>> => {
    const resolvedParams = await params;
    return schema.parse(resolvedParams);
  };
}