Files
smartserve/ts/openapi/openapi.validator.ts

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;
};
}