/** * JSON Schema validation utilities using @cfworker/json-schema */ import { Validator } from '@cfworker/json-schema'; import type { TJsonSchema, IOpenApiRouteMeta, IOpenApiParamMeta } from '../decorators/decorators.types.js'; import type { IRequestContext } from '../core/smartserve.interfaces.js'; import { coerceQueryParams, coercePathParams } from './openapi.coerce.js'; /** * Validation error detail */ export interface IValidationError { /** JSON pointer path to the error location */ path: string; /** Human-readable error message */ message: string; /** JSON Schema keyword that failed */ keyword?: string; } /** * Validation result */ export interface IValidationResult { /** Whether validation passed */ valid: boolean; /** Array of validation errors */ errors: IValidationError[]; } /** * Validate data against a JSON Schema */ export function validateSchema(data: unknown, schema: TJsonSchema): IValidationResult { const validator = new Validator(schema as any, '2020-12', false); const result = validator.validate(data); return { valid: result.valid, errors: result.errors.map(err => ({ path: err.instanceLocation || '/', message: err.error, keyword: err.keyword, })), }; } /** * Validate a single parameter value */ export function validateParam( name: string, value: unknown, meta: IOpenApiParamMeta, location: 'path' | 'query' | 'header' ): IValidationResult { // Check required if (meta.required && (value === undefined || value === '')) { return { valid: false, errors: [{ path: `/${name}`, message: `${location} parameter "${name}" is required`, keyword: 'required', }], }; } // Skip validation if no value and not required if (value === undefined || value === '') { return { valid: true, errors: [] }; } // Validate against schema if (meta.schema) { const result = validateSchema(value, meta.schema); // Prefix errors with parameter name return { valid: result.valid, errors: result.errors.map(err => ({ ...err, path: `/${name}${err.path === '/' ? '' : err.path}`, })), }; } return { valid: true, errors: [] }; } /** * Create a 400 Bad Request response for validation errors */ export function createValidationErrorResponse( errors: IValidationError[], source: 'body' | 'params' | 'query' | 'headers' ): Response { const body = { error: 'Validation failed', source, details: errors.map(e => ({ path: e.path, message: e.message, })), }; return new Response(JSON.stringify(body), { status: 400, headers: { 'Content-Type': 'application/json', }, }); } /** * Validate the full request based on OpenAPI metadata * Returns a Response if validation fails, undefined if valid */ export function validateRequest( ctx: IRequestContext, openapi: IOpenApiRouteMeta ): { valid: boolean; response?: Response; coercedParams?: Record; coercedQuery?: Record; } { const allErrors: Array<{ errors: IValidationError[]; source: string }> = []; // Coerce and validate path parameters let coercedParams: Record = ctx.params; if (openapi.params && openapi.params.size > 0) { coercedParams = coercePathParams(ctx.params, openapi.params); for (const [name, meta] of openapi.params) { const result = validateParam(name, coercedParams[name], meta, 'path'); if (!result.valid) { allErrors.push({ errors: result.errors, source: 'params' }); } } } // Coerce and validate query parameters let coercedQuery: Record = ctx.query; if (openapi.query && openapi.query.size > 0) { coercedQuery = coerceQueryParams(ctx.query, openapi.query); for (const [name, meta] of openapi.query) { const result = validateParam(name, coercedQuery[name], meta, 'query'); if (!result.valid) { allErrors.push({ errors: result.errors, source: 'query' }); } } } // Validate header parameters if (openapi.headers && openapi.headers.size > 0) { for (const [name, meta] of openapi.headers) { const value = ctx.headers.get(name); const result = validateParam(name, value, meta, 'header'); if (!result.valid) { allErrors.push({ errors: result.errors, source: 'headers' }); } } } // Validate request body if (openapi.requestBody) { const required = openapi.requestBody.required !== false; const body = ctx.body; if (required && (body === undefined || body === null)) { allErrors.push({ errors: [{ path: '/', message: 'Request body is required', keyword: 'required', }], source: 'body', }); } else if (body !== undefined && body !== null) { const result = validateSchema(body, openapi.requestBody.schema); if (!result.valid) { allErrors.push({ errors: result.errors, source: 'body' }); } } } // Return first error source as response if (allErrors.length > 0) { const firstError = allErrors[0]; return { valid: false, response: createValidationErrorResponse( firstError.errors, firstError.source as 'body' | 'params' | 'query' | 'headers' ), }; } return { valid: true, coercedParams, coercedQuery, }; } /** * Create a validation request interceptor for a route */ export function createValidationInterceptor(openapi: IOpenApiRouteMeta) { return async (ctx: IRequestContext): Promise => { const result = validateRequest(ctx, openapi); if (!result.valid && result.response) { return result.response; } // Return modified context with coerced values if (result.coercedParams || result.coercedQuery) { // Create a new context with coerced values // We use Object.defineProperty to update readonly properties const newCtx = Object.create(ctx); if (result.coercedParams) { Object.defineProperty(newCtx, 'params', { value: result.coercedParams, writable: false, enumerable: true, }); } if (result.coercedQuery) { Object.defineProperty(newCtx, 'query', { value: result.coercedQuery, writable: false, enumerable: true, }); } return newCtx; } return; }; }