UNPKG

tero

Version:

tero is a JSON document Manager, with ACID complaint and backup recovery mechanism.

426 lines (425 loc) 15.7 kB
export class SchemaValidator { schemas = new Map(); setSchema(collectionName, schema) { try { // Validate the schema itself this.validateSchemaDefinition(schema); this.schemas.set(collectionName, schema); } catch (error) { throw new Error(`Invalid schema definition: ${error instanceof Error ? error.message : 'Unknown error'}`); } } getSchema(collectionName) { return this.schemas.get(collectionName); } removeSchema(collectionName) { return this.schemas.delete(collectionName); } hasSchema(collectionName) { return this.schemas.has(collectionName); } listSchemas() { return Array.from(this.schemas.keys()); } validateSchemaDefinition(schema) { for (const [fieldName, fieldSchema] of Object.entries(schema)) { if (!fieldName || typeof fieldName !== 'string') { throw new Error('Field names must be non-empty strings'); } if (!fieldSchema || typeof fieldSchema !== 'object') { throw new Error(`Field '${fieldName}' must have a schema definition`); } if (!fieldSchema.type) { throw new Error(`Field '${fieldName}' must have a type`); } const validTypes = ['string', 'number', 'boolean', 'object', 'array', 'date', 'any']; if (!validTypes.includes(fieldSchema.type)) { throw new Error(`Field '${fieldName}' has invalid type '${fieldSchema.type}'`); } // Validate format if specified if (fieldSchema.format) { const validFormats = ['email', 'url', 'uuid', 'date', 'time', 'datetime', 'phone', 'ip']; if (!validFormats.includes(fieldSchema.format)) { throw new Error(`Field '${fieldName}' has invalid format '${fieldSchema.format}'`); } } // Validate min/max for numbers if (fieldSchema.type === 'number' && fieldSchema.min !== undefined && fieldSchema.max !== undefined) { if (fieldSchema.min > fieldSchema.max) { throw new Error(`Field '${fieldName}' min value cannot be greater than max value`); } } // Validate nested object schema if (fieldSchema.type === 'object' && fieldSchema.properties) { this.validateSchemaDefinition(fieldSchema.properties); } // Validate array items schema if (fieldSchema.type === 'array' && fieldSchema.items) { this.validateSchemaDefinition({ items: fieldSchema.items }); } } } validate(collectionName, data) { const schema = this.schemas.get(collectionName); // If no schema is defined, validation passes if (!schema) { return { valid: true, errors: [], data }; } try { const errors = []; const sanitizedData = this.validateAndSanitizeData(data, schema, '', errors); return { valid: errors.length === 0, errors, data: errors.length === 0 ? sanitizedData : data }; } catch (error) { return { valid: false, errors: [{ field: 'root', message: error instanceof Error ? error.message : 'Validation failed', value: data }], data }; } } validateAndSanitizeData(data, schema, path, errors) { if (data === null || data === undefined) { data = {}; } if (typeof data !== 'object' || Array.isArray(data)) { errors.push({ field: path || 'root', message: 'Data must be an object', value: data, expected: 'object' }); return data; } const result = {}; // Validate each field in the schema for (const [fieldName, fieldSchema] of Object.entries(schema)) { const fieldPath = path ? `${path}.${fieldName}` : fieldName; const fieldValue = data[fieldName]; // Check if required field is missing if (fieldSchema.required && (fieldValue === undefined || fieldValue === null)) { // Use default value if available if (fieldSchema.default !== undefined) { result[fieldName] = fieldSchema.default; continue; } errors.push({ field: fieldPath, message: `Required field '${fieldName}' is missing`, expected: fieldSchema.type }); continue; } // Skip validation if field is not provided and not required if (fieldValue === undefined || fieldValue === null) { if (fieldSchema.default !== undefined) { result[fieldName] = fieldSchema.default; } continue; } // Validate the field const validatedValue = this.validateField(fieldValue, fieldSchema, fieldPath, errors); if (validatedValue !== undefined) { result[fieldName] = validatedValue; } } // Copy over any additional fields not in schema (flexible schema) for (const [key, value] of Object.entries(data)) { if (!(key in schema)) { result[key] = value; } } return result; } validateField(value, fieldSchema, path, errors) { // Type validation if (!this.validateType(value, fieldSchema.type)) { errors.push({ field: path, message: `Expected ${fieldSchema.type} but got ${typeof value}`, value, expected: fieldSchema.type }); return value; } // Convert and validate based on type let processedValue = value; switch (fieldSchema.type) { case 'string': processedValue = this.validateString(value, fieldSchema, path, errors); break; case 'number': processedValue = this.validateNumber(value, fieldSchema, path, errors); break; case 'boolean': processedValue = this.validateBoolean(value, fieldSchema, path, errors); break; case 'date': processedValue = this.validateDate(value, fieldSchema, path, errors); break; case 'array': processedValue = this.validateArray(value, fieldSchema, path, errors); break; case 'object': processedValue = this.validateObject(value, fieldSchema, path, errors); break; case 'any': // No specific validation for 'any' type break; } // Enum validation if (fieldSchema.enum && !fieldSchema.enum.includes(processedValue)) { errors.push({ field: path, message: `Value must be one of: ${fieldSchema.enum.join(', ')}`, value: processedValue, expected: `enum: [${fieldSchema.enum.join(', ')}]` }); } // Custom validation if (fieldSchema.custom) { const customResult = fieldSchema.custom(processedValue); if (customResult !== true) { errors.push({ field: path, message: typeof customResult === 'string' ? customResult : 'Custom validation failed', value: processedValue }); } } return processedValue; } validateType(value, expectedType) { switch (expectedType) { case 'string': return typeof value === 'string'; case 'number': return typeof value === 'number' && !isNaN(value); case 'boolean': return typeof value === 'boolean'; case 'object': return typeof value === 'object' && value !== null && !Array.isArray(value); case 'array': return Array.isArray(value); case 'date': return value instanceof Date || typeof value === 'string' || typeof value === 'number'; case 'any': return true; default: return false; } } validateString(value, schema, path, errors) { // Length validation if (schema.min !== undefined && value.length < schema.min) { errors.push({ field: path, message: `String must be at least ${schema.min} characters long`, value, expected: `min length: ${schema.min}` }); } if (schema.max !== undefined && value.length > schema.max) { errors.push({ field: path, message: `String must be at most ${schema.max} characters long`, value, expected: `max length: ${schema.max}` }); } // Format validation if (schema.format) { if (!this.validateFormat(value, schema.format)) { errors.push({ field: path, message: `Invalid ${schema.format} format`, value, expected: `valid ${schema.format}` }); } } // Pattern validation if (schema.pattern) { const regex = new RegExp(schema.pattern); if (!regex.test(value)) { errors.push({ field: path, message: `String does not match required pattern: ${schema.pattern}`, value, expected: `pattern: ${schema.pattern}` }); } } return value; } validateNumber(value, schema, path, errors) { // Range validation if (schema.min !== undefined && value < schema.min) { errors.push({ field: path, message: `Number must be at least ${schema.min}`, value, expected: `min: ${schema.min}` }); } if (schema.max !== undefined && value > schema.max) { errors.push({ field: path, message: `Number must be at most ${schema.max}`, value, expected: `max: ${schema.max}` }); } return value; } validateBoolean(value, schema, path, errors) { return value; } validateDate(value, schema, path, errors) { let date; if (value instanceof Date) { date = value; } else if (typeof value === 'string' || typeof value === 'number') { date = new Date(value); } else { errors.push({ field: path, message: 'Invalid date value', value, expected: 'Date, string, or number' }); return value; } if (isNaN(date.getTime())) { errors.push({ field: path, message: 'Invalid date value', value, expected: 'valid date' }); return value; } return date; } validateArray(value, schema, path, errors) { if (!Array.isArray(value)) { errors.push({ field: path, message: 'Expected array', value, expected: 'array' }); return value; } // Length validation if (schema.min !== undefined && value.length < schema.min) { errors.push({ field: path, message: `Array must have at least ${schema.min} items`, value, expected: `min length: ${schema.min}` }); } if (schema.max !== undefined && value.length > schema.max) { errors.push({ field: path, message: `Array must have at most ${schema.max} items`, value, expected: `max length: ${schema.max}` }); } // Validate array items if (schema.items) { const validatedArray = value.map((item, index) => { const itemPath = `${path}[${index}]`; return this.validateField(item, schema.items, itemPath, errors); }); return validatedArray; } return value; } validateObject(value, schema, path, errors) { if (typeof value !== 'object' || value === null || Array.isArray(value)) { errors.push({ field: path, message: 'Expected object', value, expected: 'object' }); return value; } // Validate nested object properties if (schema.properties) { return this.validateAndSanitizeData(value, schema.properties, path, errors); } return value; } validateFormat(value, format) { switch (format) { case 'email': return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); case 'url': try { new URL(value); return true; } catch { return false; } case 'uuid': return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); case 'date': return /^\d{4}-\d{2}-\d{2}$/.test(value) && !isNaN(Date.parse(value)); case 'time': return /^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/.test(value); case 'datetime': return !isNaN(Date.parse(value)); case 'phone': return /^[\+]?[1-9][\d]{0,15}$/.test(value.replace(/[\s\-\(\)]/g, '')); case 'ip': return /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(value) || /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/.test(value); default: return true; } } // Utility methods getSchemaStats() { const schemaNames = Array.from(this.schemas.keys()); let totalFields = 0; for (const schema of this.schemas.values()) { totalFields += Object.keys(schema).length; } return { totalSchemas: this.schemas.size, schemaNames, totalFields }; } exportSchemas() { const exported = {}; for (const [name, schema] of this.schemas.entries()) { exported[name] = { ...schema }; } return exported; } importSchemas(schemas) { for (const [name, schema] of Object.entries(schemas)) { this.setSchema(name, schema); } } clearAllSchemas() { this.schemas.clear(); } }