253 lines
6.5 KiB
TypeScript
253 lines
6.5 KiB
TypeScript
/**
|
|
* 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 async function validateRequest(
|
|
ctx: IRequestContext,
|
|
openapi: IOpenApiRouteMeta
|
|
): Promise<{
|
|
valid: boolean;
|
|
response?: Response;
|
|
coercedParams?: Record<string, unknown>;
|
|
coercedQuery?: Record<string, unknown>;
|
|
}> {
|
|
const allErrors: Array<{ errors: IValidationError[]; source: string }> = [];
|
|
|
|
// Coerce and validate path parameters
|
|
let coercedParams: Record<string, unknown> = 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<string, unknown> = 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 (lazy parsing via ctx.json())
|
|
if (openapi.requestBody) {
|
|
const required = openapi.requestBody.required !== false;
|
|
let body: unknown;
|
|
|
|
try {
|
|
body = await ctx.json();
|
|
} catch {
|
|
body = undefined;
|
|
}
|
|
|
|
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<IRequestContext | Response | void> => {
|
|
const result = await 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;
|
|
};
|
|
}
|