ValidationService.tsβ’16.9 kB
/**
* ValidationService - Centralized validation utilities for universal handlers
*
* Extracted from shared-handlers.ts as part of Issue #489 Phase 2.
* Provides parameter validation, UUID validation, field validation, and email validation.
*/
import { performance } from 'perf_hooks';
import { isValidEmail } from '../utils/validation/email-validation.js';
import { isValidId } from '../utils/validation.js';
import {
isValidUUID,
createInvalidUUIDError,
} from '../utils/validation/uuid-validation.js';
import { enhancedPerformanceTracker } from '../middleware/performance-enhanced.js';
import {
UniversalValidationError,
ErrorType,
} from '../handlers/tool-configs/universal/schemas.js';
import { UniversalResourceType } from '../handlers/tool-configs/universal/types.js';
import {
validateFields,
FIELD_MAPPINGS,
} from '../handlers/tool-configs/universal/field-mapper.js';
/**
* ValidationService provides centralized validation functionality for universal handlers
*/
export class ValidationService {
/**
* Create a standardized validation error object
*
* @param message - Error message
* @param resourceType - Type of resource being validated (default: 'resource')
* @returns Validation error object with timestamp
*/
static createValidationError(
message: string,
resourceType: string = 'resource'
): {
error: boolean;
message: string;
details: string;
timestamp: string;
} {
return {
error: true,
message,
details: `${resourceType} validation failed`,
timestamp: new Date().toISOString(),
};
}
/**
* Validate limit parameter for pagination
*
* @param limit - Limit value to validate
* @param perfId - Performance tracking ID for error reporting
* @param maxLimit - Maximum allowed limit (default: 100)
* @throws Error if validation fails
*/
static validateLimitParameter(
limit: number | undefined,
perfId?: string,
maxLimit: number = 100
): void {
if (limit !== undefined) {
if (!Number.isInteger(limit) || limit <= 0) {
if (perfId) {
enhancedPerformanceTracker.endOperation(
perfId,
false,
'Invalid limit parameter',
400
);
}
throw new Error('limit must be a positive integer greater than 0');
}
if (limit > maxLimit) {
if (perfId) {
enhancedPerformanceTracker.endOperation(
perfId,
false,
'Limit exceeds maximum',
400
);
}
throw new Error(`limit must not exceed ${maxLimit}`);
}
}
}
/**
* Validate offset parameter for pagination
*
* @param offset - Offset value to validate
* @param perfId - Performance tracking ID for error reporting
* @param maxOffset - Maximum allowed offset (default: 10000)
* @throws Error if validation fails
*/
static validateOffsetParameter(
offset: number | undefined,
perfId?: string,
maxOffset: number = 10000
): void {
if (offset !== undefined) {
if (!Number.isInteger(offset) || offset < 0) {
if (perfId) {
enhancedPerformanceTracker.endOperation(
perfId,
false,
'Invalid offset parameter',
400
);
}
throw new Error('offset must be a non-negative integer');
}
if (offset > maxOffset) {
if (perfId) {
enhancedPerformanceTracker.endOperation(
perfId,
false,
'Offset exceeds maximum',
400
);
}
throw new Error(`offset must not exceed ${maxOffset}`);
}
}
}
/**
* Validate pagination parameters (limit and offset together)
*
* @param params - Object containing limit and offset
* @param perfId - Performance tracking ID for error reporting
* @param maxLimit - Maximum allowed limit (default: 100)
* @param maxOffset - Maximum allowed offset (default: 10000)
*/
static validatePaginationParameters(
params: { limit?: number; offset?: number },
perfId?: string,
maxLimit: number = 100,
maxOffset: number = 10000
): void {
this.validateLimitParameter(params.limit, perfId, maxLimit);
this.validateOffsetParameter(params.offset, perfId, maxOffset);
}
/**
* Validate UUID format for record IDs
*
* @param recordId - Record ID to validate
* @param resourceType - Type of resource
* @param operation - Operation being performed (for error context)
* @param perfId - Performance tracking ID for error reporting
* @throws Error if UUID validation fails
*/
static validateUUID(
recordId: string,
resourceType: string,
operation: string = 'operation',
perfId?: string
): void {
const validationStart = performance.now();
// Skip UUID validation for tasks as they may use different ID formats
if (
resourceType !== UniversalResourceType.TASKS &&
!isValidUUID(recordId)
) {
if (perfId) {
enhancedPerformanceTracker.markTiming(
perfId,
'validation',
performance.now() - validationStart
);
enhancedPerformanceTracker.endOperation(
perfId,
false,
'Invalid UUID format',
400
);
}
throw createInvalidUUIDError(recordId, resourceType, operation);
}
if (perfId) {
enhancedPerformanceTracker.markTiming(
perfId,
'validation',
performance.now() - validationStart
);
}
}
/**
* Truncate suggestions to prevent buffer overflow in MCP protocol
*
* @param suggestions - Array of suggestion strings
* @param maxCount - Maximum number of suggestions to return (default: 3)
* @returns Truncated suggestions array
*/
static truncateSuggestions(
suggestions: string[],
maxCount: number = 3
): string[] {
const limited = suggestions.slice(0, maxCount);
if (suggestions.length > maxCount) {
limited.push(`... and ${suggestions.length - maxCount} more suggestions`);
}
return limited;
}
/**
* Validate fields for a resource and build comprehensive error messages
*
* @param resourceType - Type of resource being validated
* @param recordData - Record data to validate
* @param throwOnInvalid - Whether to throw error on validation failure (default: true)
* @returns Validation result with formatted error message if invalid
*/
static validateFieldsWithErrorHandling(
resourceType: UniversalResourceType | string,
recordData: Record<string, unknown>,
throwOnInvalid: boolean = true
): { valid: boolean; errorMessage?: string } {
const fieldValidation = validateFields(
resourceType as UniversalResourceType,
recordData
);
if (fieldValidation.warnings.length > 0) {
// Avoid importing logger here to prevent cross-dependencies in validation layer.
// Use lightweight stderr output through logger facade pattern if available in future.
// For now, retain messages via structured string construction in error flows.
}
if (fieldValidation.suggestions.length > 0) {
// Same note as above regarding minimal logging in validation layer.
}
if (!fieldValidation.valid) {
// Build a clear, helpful error message
let errorMessage = `Field validation failed for ${resourceType}:\n`;
// Add each error on its own line for clarity
if (fieldValidation.errors.length > 0) {
errorMessage += fieldValidation.errors
.map((err) => ` β ${err}`)
.join('\n');
}
// Add suggestions if available (truncated to prevent buffer overflow)
if (fieldValidation.suggestions.length > 0) {
const truncated = this.truncateSuggestions(fieldValidation.suggestions);
errorMessage += '\n\nπ‘ Suggestions:\n';
errorMessage += truncated.map((sug) => ` β’ ${sug}`).join('\n');
}
// List available fields for this resource type
const mapping = FIELD_MAPPINGS[resourceType as UniversalResourceType];
if (mapping && mapping.validFields.length > 0) {
errorMessage += `\n\nπ Available fields for ${resourceType}:\n ${mapping.validFields.join(
', '
)}`;
}
if (throwOnInvalid) {
throw new UniversalValidationError(errorMessage, ErrorType.USER_ERROR, {
suggestion: this.truncateSuggestions(
fieldValidation.suggestions
).join('. '),
field: 'record_data',
});
}
return { valid: false, errorMessage };
}
return { valid: true };
}
/**
* Validate email addresses in record data for consistent validation across create/update
*
* @param recordData - Record data containing potential email fields
* @throws UniversalValidationError if email validation fails
*/
static validateEmailAddresses(recordData: Record<string, unknown>): void {
if (!recordData || typeof recordData !== 'object') return;
// Handle various email field formats
const emailFields = ['email_addresses', 'email', 'emails', 'emailAddress'];
for (const field of emailFields) {
if (field in recordData && recordData[field]) {
const emails = Array.isArray(recordData[field])
? recordData[field]
: [recordData[field]];
for (const emailItem of emails) {
let emailAddress: string;
// Handle different email formats
if (typeof emailItem === 'string') {
emailAddress = emailItem;
} else if (
typeof emailItem === 'object' &&
emailItem &&
'email_address' in emailItem
) {
const emailValue = (emailItem as Record<string, unknown>)
.email_address;
if (typeof emailValue === 'string') {
emailAddress = emailValue;
} else {
throw new UniversalValidationError(
`Invalid email format: email_address must be a string, got ${typeof emailValue}. Please provide a valid email address (e.g., user@example.com)`,
ErrorType.USER_ERROR,
{
field: field,
suggestion:
'Ensure email_address field contains a string value',
}
);
}
} else if (
typeof emailItem === 'object' &&
emailItem &&
'email' in emailItem
) {
const emailValue = (emailItem as Record<string, unknown>).email;
if (typeof emailValue === 'string') {
emailAddress = emailValue;
} else {
throw new UniversalValidationError(
`Invalid email format: email must be a string, got ${typeof emailValue}. Please provide a valid email address (e.g., user@example.com)`,
ErrorType.USER_ERROR,
{
field: field,
suggestion: 'Ensure email field contains a string value',
}
);
}
} else if (
typeof emailItem === 'object' &&
emailItem &&
'value' in emailItem
) {
const emailValue = (emailItem as Record<string, unknown>).value;
if (typeof emailValue === 'string') {
emailAddress = emailValue;
} else {
throw new UniversalValidationError(
`Invalid email format: value must be a string, got ${typeof emailValue}. Please provide a valid email address (e.g., user@example.com)`,
ErrorType.USER_ERROR,
{
field: field,
suggestion: 'Ensure value field contains a string value',
}
);
}
} else {
continue; // Skip invalid email formats
}
// Validate email format using the same function as PersonValidator
if (!isValidEmail(emailAddress!)) {
throw new UniversalValidationError(
`Invalid email format: "${emailAddress!}". Please provide a valid email address (e.g., user@example.com)`,
ErrorType.USER_ERROR,
{
field: field,
suggestion:
'Ensure email addresses are in the format: user@domain.com',
}
);
}
}
}
}
}
/**
* Validate record ID using the appropriate validation method
*
* @param recordId - Record ID to validate
* @param allowGeneric - Whether to allow generic ID validation (default: true)
* @returns True if valid, false otherwise
*/
static isValidRecordId(
recordId: string,
allowGeneric: boolean = true
): boolean {
// Try UUID validation first
if (isValidUUID(recordId)) {
return true;
}
// Fall back to generic ID validation if allowed
if (allowGeneric && isValidId(recordId)) {
return true;
}
return false;
}
/**
* Validate UUID for search operations with lenient validation
* Returns false for invalid UUIDs instead of throwing (used for filters)
*
* @param uuid - UUID string to validate
* @returns True if valid UUID, false otherwise
*/
static validateUUIDForSearch(uuid: string): boolean {
try {
return isValidUUID(uuid);
} catch {
return false;
}
}
/**
* Validate filter schema for search operations
*
* @param filters - Filter object to validate
* @throws UniversalValidationError for malformed filter schemas
*/
static validateFiltersSchema(filters: unknown): void {
if (filters == null) return;
if (typeof filters !== 'object') {
throw new UniversalValidationError(
'filters must be an object',
ErrorType.USER_ERROR,
{ field: 'filters' }
);
}
const filtersObj = filters as Record<string, unknown>;
if ('advanced' in filtersObj) {
if (!Array.isArray(filtersObj.advanced)) {
throw new UniversalValidationError(
'filters.advanced must be an array',
ErrorType.USER_ERROR,
{ field: 'filters.advanced' }
);
}
filtersObj.advanced.forEach((filter: unknown, index: number) => {
if (!filter || typeof filter !== 'object') {
throw new UniversalValidationError(
`advanced[${index}] must be an object`,
ErrorType.USER_ERROR,
{ field: `filters.advanced[${index}]` }
);
}
const filterObj = filter as Record<string, unknown>;
if (!filterObj.op || !filterObj.field) {
throw new UniversalValidationError(
`advanced[${index}] missing "op" or "field"`,
ErrorType.USER_ERROR,
{
field: `filters.advanced[${index}]`,
suggestion: 'Include both "op" and "field" properties',
}
);
}
});
}
}
/**
* Comprehensive validation for universal operations
*
* @param params - Validation parameters
* @returns Validation result
*/
static validateUniversalOperation(params: {
resourceType: string;
recordId?: string;
recordData?: Record<string, unknown>;
limit?: number;
offset?: number;
operation?: string;
perfId?: string;
}): { valid: boolean; errors: string[] } {
const errors: string[] = [];
try {
// Validate pagination parameters if provided
if (params.limit !== undefined || params.offset !== undefined) {
this.validatePaginationParameters(
{ limit: params.limit, offset: params.offset },
params.perfId
);
}
// Validate UUID if record ID is provided
if (params.recordId) {
this.validateUUID(
params.recordId,
params.resourceType,
params.operation || 'operation',
params.perfId
);
}
// Validate fields if record data is provided
if (params.recordData) {
const fieldValidation = this.validateFieldsWithErrorHandling(
params.resourceType,
params.recordData,
false // Don't throw, collect errors
);
if (!fieldValidation.valid && fieldValidation.errorMessage) {
errors.push(fieldValidation.errorMessage);
}
// Validate email addresses if present
this.validateEmailAddresses(params.recordData);
}
} catch (error) {
if (error instanceof Error) {
errors.push(error.message);
} else {
errors.push(String(error));
}
}
return {
valid: errors.length === 0,
errors,
};
}
}