feat(openapi): Add OpenAPI module: decorators, spec generator, runtime validation and Swagger UI
This commit is contained in:
246
ts/openapi/openapi.validator.ts
Normal file
246
ts/openapi/openapi.validator.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 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<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
|
||||
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<IRequestContext | Response | void> => {
|
||||
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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user