diff --git a/packages/validators/actions/rule-action.ts b/packages/validators/actions/rule-action.ts new file mode 100644 index 0000000..7938f38 --- /dev/null +++ b/packages/validators/actions/rule-action.ts @@ -0,0 +1,91 @@ +import { Constructor, RuleOperator, RuleType, VALIDATOR_METADATA } from '@gland/common'; +import Reflector from '@gland/metadata'; +import { ValidationField } from '../interface/validator.interface'; +import { ValidationRules } from '../rules/validation.rules'; +import { DependencyValidators } from '../rules/dependency-validators.rules'; + +export class RuleAction { + private static coreValidator = new ValidationRules(); + private static depValidator = new DependencyValidators(); + static applyRule({ ruleName, param }: { ruleName: RuleType; param?: string }, value: unknown): boolean { + switch (ruleName) { + case 'min': + case 'max': + if (!param) throw new Error(`Parameter required for ${ruleName} rule`); + return RuleAction.coreValidator[ruleName](value, param); + default: + return RuleAction.coreValidator[ruleName](value); + } + } + static applyDependencyRule({ operator, value }: { operator: RuleOperator; value?: unknown }, dependentValue: unknown): boolean { + switch (operator) { + // Handle array-based operations + case 'in': + case 'notIn': + if (!Array.isArray(value)) return false; + return RuleAction.depValidator[operator](dependentValue, value); + // Handle existence checks + case 'exists': + case 'notExists': + return RuleAction.depValidator[operator](dependentValue); + + // Handle string operations + case 'startsWith': + case 'endsWith': + case 'contains': + case 'notContains': + return RuleAction.validateStringOperation(operator, dependentValue, value); + + // Handle regex operations + case 'matches': + case 'notMatches': + return RuleAction.validateRegexOperation(operator, dependentValue, value); + + // Handle comparison operations + case 'greaterThan': + case 'lessThan': + case 'greaterOrEqual': + case 'lessOrEqual': + return RuleAction.validateComparison(operator, dependentValue, value); + + // Default equality checks + default: + return RuleAction.depValidator[operator](dependentValue, value); + } + } + private static validateStringOperation(operator: RuleOperator, value: unknown, search: unknown): boolean { + return typeof value === 'string' && typeof search === 'string' ? (RuleAction.depValidator[operator] as (value: string, search: string) => boolean)(value, search) : false; + } + + private static validateRegexOperation(operator: RuleOperator, value: unknown, pattern: unknown): boolean { + return typeof value === 'string' && typeof pattern === 'string' ? (RuleAction.depValidator[operator] as (value: string, pattern: string) => boolean)(value, pattern) : false; + } + + private static validateComparison(operator: RuleOperator, a: unknown, b: unknown): boolean { + const validTypes = (x: unknown) => typeof x === 'number' || x instanceof Date; + return validTypes(a) && validTypes(b) ? (RuleAction.depValidator[operator] as (a: T, b: T) => boolean)(a as number | Date, b as number | Date) : false; + } + + static filter(schemaClass: Constructor, pick?: (keyof T)[], omit?: (keyof T)[]): Record { + const rules: Record = Reflector.getMetadata(VALIDATOR_METADATA.RULES_METADATA, schemaClass) ?? {}; + const filteredRules: Record = {}; + + if (pick) { + for (const key of pick) { + if (key in rules) { + filteredRules[key as string] = rules[key as string]; + } + } + } else if (omit) { + for (const key in rules) { + if (!omit.includes(key as keyof T)) { + filteredRules[key] = rules[key]; + } + } + } else { + return rules; // No filtering if neither `pick` nor `omit` is provided + } + + return filteredRules; + } +} diff --git a/packages/validators/decorators/guard.decorator.ts b/packages/validators/decorators/guard.decorator.ts new file mode 100644 index 0000000..52d4f7d --- /dev/null +++ b/packages/validators/decorators/guard.decorator.ts @@ -0,0 +1,8 @@ +import { Context, VALIDATOR_METADATA } from '@gland/common'; +import Reflector from '@gland/metadata'; + +export function Guard(...guards: ((ctx: Context) => void | Promise)[]): MethodDecorator { + return (target: any, propertyKey: string | symbol) => { + Reflector.defineMetadata(VALIDATOR_METADATA.GUARD_FUNCTION_METADATA, guards, target.constructor, propertyKey); + }; +} diff --git a/packages/validators/decorators/index.ts b/packages/validators/decorators/index.ts new file mode 100644 index 0000000..43f5bc2 --- /dev/null +++ b/packages/validators/decorators/index.ts @@ -0,0 +1,5 @@ +export * from './guard.decorator'; +export * from './rule.decorator'; +export * from './schema-reference.decorator'; +export * from './schema.decorator'; +export * from './validate.decorator'; diff --git a/packages/validators/decorators/rule.decorator.ts b/packages/validators/decorators/rule.decorator.ts new file mode 100644 index 0000000..e7c3547 --- /dev/null +++ b/packages/validators/decorators/rule.decorator.ts @@ -0,0 +1,17 @@ +import { RuleMessages, VALIDATOR_METADATA } from '@gland/common'; +import { ValidationField } from '../interface/validator.interface'; +import Reflector from '@gland/metadata'; + +export function Rule(options?: { messages?: RuleMessages; options?: ValidationField['options'] }): PropertyDecorator { + return function (target, propertyKey) { + const instance = new (target.constructor as any)(); + const rules = instance[propertyKey]; + const existingRules: Record = Reflector.getMetadata(VALIDATOR_METADATA.RULES_METADATA, target.constructor) ?? {}; + existingRules[propertyKey as string] = { + rules: Array.isArray(rules) ? rules : [rules], + messages: options?.messages ?? {}, + options: options?.options ?? {}, + }; + Reflector.defineMetadata(VALIDATOR_METADATA.RULES_METADATA, existingRules, target.constructor); + }; +} diff --git a/packages/validators/decorators/schema-reference.decorator.ts b/packages/validators/decorators/schema-reference.decorator.ts new file mode 100644 index 0000000..ccbf362 --- /dev/null +++ b/packages/validators/decorators/schema-reference.decorator.ts @@ -0,0 +1,23 @@ +import { Constructor, VALIDATOR_METADATA } from '@gland/common'; +import { ValidationOptions } from '../interface/validator.interface'; +import Reflector from '@gland/metadata'; + +/** + * `@SchemaRef` decorator is used to reference nested schema classes and apply optional validation options. + * It dynamically infers the schema class type and attaches it to the metadata. + */ +export function SchemaRef(options?: ValidationOptions): PropertyDecorator { + return function (target, propertyKey) { + const instance = new (target.constructor as any)(); + const schemaClass: Constructor = instance[propertyKey]; + // Ensure schema class is assigned correctly if not set + if (typeof schemaClass !== 'function' || !Reflector.getMetadata(VALIDATOR_METADATA.SCHEMA_METADATA_WATERMARK, schemaClass)) { + throw new Error(`The property '${String(propertyKey)}' must reference a valid schema class.`); + } + // Retrieve existing nested schemas or initialize a new object + const nestedSchemas = Reflector.getMetadata(VALIDATOR_METADATA.NESTED_SCHEMA_METADATA, target.constructor) ?? {}; + + nestedSchemas[propertyKey] = { schemaClass: schemaClass, options: options ?? {} }; + Reflector.defineMetadata(VALIDATOR_METADATA.NESTED_SCHEMA_METADATA, nestedSchemas, target.constructor); + }; +} diff --git a/packages/validators/decorators/schema.decorator.ts b/packages/validators/decorators/schema.decorator.ts new file mode 100644 index 0000000..67ca6ab --- /dev/null +++ b/packages/validators/decorators/schema.decorator.ts @@ -0,0 +1,19 @@ +import { VALIDATOR_METADATA } from '@gland/common'; +import { SchemaOptions } from '../interface/validator.interface'; +import Reflector from '@gland/metadata'; + +export function Schema(options: SchemaOptions): ClassDecorator { + const { section = 'body', defaultRules } = options; + + if (defaultRules?.includes('inherit')) { + throw new Error("The 'inherit' rule cannot be used as a default rule in the schema. Please remove it from the defaultRules array."); + } + return (target) => { + Reflector.defineMetadata(VALIDATOR_METADATA.SCHEMA_METADATA_WATERMARK, true, target); + Reflector.defineMetadata(VALIDATOR_METADATA.SCHEMA_SECTION_METADATA, section, target); + + if (defaultRules) { + Reflector.defineMetadata(VALIDATOR_METADATA.RULES_DEFAULTS_METADATA, defaultRules, target); + } + }; +} diff --git a/packages/validators/decorators/validate.decorator.ts b/packages/validators/decorators/validate.decorator.ts new file mode 100644 index 0000000..5fa8e32 --- /dev/null +++ b/packages/validators/decorators/validate.decorator.ts @@ -0,0 +1,18 @@ +import { Constructor } from '@gland/common/interfaces'; +import { ValidatorEngine } from '../validators/validator-engine'; +export function Validate(schemaClass: Constructor, options: { returnFirstError?: boolean } = {}): MethodDecorator { + return function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) { + const originalMethod = descriptor.value; + + descriptor.value = function (...args: any[]) { + const data = args[0]; + + const validationErrors = ValidatorEngine.validate(schemaClass, data, options.returnFirstError ?? false); + if (validationErrors && Object.keys(validationErrors).length > 0) { + return validationErrors; + } + + return originalMethod.apply(this, args); + }; + }; +} diff --git a/packages/validators/index.ts b/packages/validators/index.ts new file mode 100644 index 0000000..011b41e --- /dev/null +++ b/packages/validators/index.ts @@ -0,0 +1 @@ +export * from './validators'; diff --git a/packages/validators/interface/validator.interface.ts b/packages/validators/interface/validator.interface.ts new file mode 100644 index 0000000..d886296 --- /dev/null +++ b/packages/validators/interface/validator.interface.ts @@ -0,0 +1,66 @@ +import { RulesList, RuleMessages, RuleOperator, Constructor, RequestSchema } from '@gland/common'; +export interface ValidationField { + /** + * The validation rules as a string or a custom validation function. + */ + rules: RulesList; + + /** + * Custom error messages for specific rule types. + */ + messages?: RuleMessages; + /** + * Additional options for the validation field. + */ + options?: { + /** + * A custom validation function for advanced scenarios. + */ + custom?: (value: any) => boolean; + + /** + * Specifies dependencies between fields within the same schema. + * For example, a "passwordConfirm" field that depends on the "password" field. + */ + dependsOn?: { + field: string; // The field this depends on. + operator: RuleOperator; + value?: any; // Value to compare against, if applicable. + }; + }; +} +export interface ValidationOptions { + /** + * Specifies which fields of the schema to validate. + * Only the fields in this array will be validated. + */ + pick?: (keyof T)[]; + + /** + * Specifies which fields of the schema to exclude from validation. + * All other fields will be validated. + */ + omit?: (keyof T)[]; + + /** + * Defines conditional relationships between schemas. + * If the condition is valid, it validates the specified schema. + */ + dependsOn?: { + /** + * The schema class to validate if the condition is met. + */ + schema: Constructor; + }[]; +} +export interface SchemaOptions { + /** + * Specifies a name or section for the schema (e.g., "body", "query"). + */ + section?: RequestSchema; + + /** + * Applies a default set of rules to all fields in the schema. + */ + defaultRules?: RulesList; +} diff --git a/packages/validators/package.json b/packages/validators/package.json new file mode 100644 index 0000000..532e292 --- /dev/null +++ b/packages/validators/package.json @@ -0,0 +1,16 @@ +{ + "name": "@gland/validator", + "version": "1.0.0", + "author": "Mahdi", + "license": "MIT", + "scripts": { + "build": "tsc" + }, + "dependencies": { + "@gland/common": "workspace:*", + "@gland/metadata": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.5.4" + } +} diff --git a/packages/validators/rules/dependency-validators.rules.ts b/packages/validators/rules/dependency-validators.rules.ts new file mode 100644 index 0000000..0013e37 --- /dev/null +++ b/packages/validators/rules/dependency-validators.rules.ts @@ -0,0 +1,78 @@ +import { DependencyRuleValidation } from "../types/rules-validation.types"; + +// Dependency validation rules implementation +export class DependencyValidators implements DependencyRuleValidation { + equal(a: T, b: T): boolean { + return a === b; + } + + notEqual(a: T, b: T): boolean { + return a !== b; + } + + greaterThan(a: T, b: T): boolean { + return a > b; + } + + lessThan(a: T, b: T): boolean { + return a < b; + } + + greaterOrEqual(a: T, b: T): boolean { + return a >= b; + } + + lessOrEqual(a: T, b: T): boolean { + return a <= b; + } + + in(value: T, list: T[]): boolean { + return list.includes(value); + } + + notIn(value: T, list: T[]): boolean { + return !list.includes(value); + } + + exists(value: T | null | undefined): value is T { + return value !== null && value !== undefined; + } + + notExists(value: T | null | undefined): boolean { + return value === null || value === undefined; + } + + startsWith(value: string, search: string): boolean { + return value.startsWith(search); + } + + endsWith(value: string, search: string): boolean { + return value.endsWith(search); + } + + contains(value: string, search: string): boolean { + return value.includes(search); + } + + notContains(value: string, search: string): boolean { + return !value.includes(search); + } + + matches(value: string, pattern: string): boolean { + try { + const regex = new RegExp(pattern); + return regex.test(value); + } catch { + return false; + } + } + + notMatches(value: string, pattern: string): boolean { + try { + const regex = new RegExp(pattern); + return !regex.test(value); + } catch { + return false; + } + } +} diff --git a/packages/validators/rules/validation.rules.ts b/packages/validators/rules/validation.rules.ts new file mode 100644 index 0000000..b5e8b27 --- /dev/null +++ b/packages/validators/rules/validation.rules.ts @@ -0,0 +1,56 @@ +import { isNil, isNumber, isString } from '@gland/common'; +import { RuleValidation } from '../types/rules-validation.types'; + +export class ValidationRules implements Partial { + required(value: unknown): value is NonNullable { + return !isNil(value); + } + + string(value: unknown): value is string { + return isString(value); + } + + integer(value: unknown): value is number { + return isNumber(value) && Number.isSafeInteger(value); + } + + boolean(value: unknown): boolean { + return typeof value === 'boolean'; + } + + min(value: unknown, param: string): boolean { + const min = Number(param); + if (isNumber(value)) return value >= min; + if (isString(value)) return value.length >= min; + if (Array.isArray(value)) return value.length >= min; + return false; + } + + max(value: unknown, param: string): boolean { + const max = Number(param); + if (isNumber(value)) return value <= max; + if (isString(value)) return value.length <= max; + if (Array.isArray(value)) return value.length <= max; + return false; + } + + array(value: unknown): boolean { + return Array.isArray(value); + } + + alpha(value: unknown): boolean { + return isString(value) && /^[A-Za-z]+$/.test(value); + } + + alphanumeric(value: unknown): boolean { + return isString(value) && /^[A-Za-z0-9]+$/.test(value); + } + + float(value: unknown): boolean { + return isNumber(value) && !Number.isSafeInteger(value); + } + + optional(value: unknown): boolean { + return isNil(value); + } +} diff --git a/packages/validators/tsconfig.json b/packages/validators/tsconfig.json new file mode 100644 index 0000000..cf23091 --- /dev/null +++ b/packages/validators/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "." + }, + "files": [], + "include": ["**/**/**.ts"], + "exclude": ["node_modules"], + "references": [ + { + "path": "../common" + }, + { + "path": "../metadata" + } + ] +} diff --git a/packages/validators/types/rules-validation.types.ts b/packages/validators/types/rules-validation.types.ts new file mode 100644 index 0000000..24daaae --- /dev/null +++ b/packages/validators/types/rules-validation.types.ts @@ -0,0 +1,39 @@ +import { RuleOperator, RuleType } from "@gland/common"; + +/** + * defines the structure for all rule validation methods. + * Each method corresponds to a specific validation rule and returns a boolean + * indicating whether the provided value satisfies the rule. + */ +export type RuleValidation ={ + [K in RuleType]: + K extends 'required' ? (value: unknown) => value is NonNullable : + K extends 'string' ? (value: unknown) => value is string : + K extends 'integer' ? (value: unknown) => value is number : + K extends 'boolean' ? (value: unknown) => boolean : + K extends 'min' | 'max' ? (value: unknown, param: string) => boolean : + K extends 'array' ? (value: unknown) => boolean : + K extends 'alpha' ? (value: unknown) => boolean : + K extends 'alphanumeric' ? (value: unknown) => boolean : + K extends 'float' ? (value: unknown) => boolean : + K extends 'optional' ? (value: unknown) => boolean :never; +}; + +/** + * defines the structure for dependency-based validation methods. + * Each method corresponds to a specific operator that validates the dependent field's value against a dependency value. +*/ +export type DependencyRuleValidation = { + [K in RuleOperator]: + K extends 'equal' ? (a: T, b: T) => boolean : + K extends 'notEqual' ? (a: T, b: T) => boolean : + K extends 'greaterThan' | 'lessThan' | 'greaterOrEqual' | 'lessOrEqual' ? + (a: T, b: T) => boolean : + K extends 'in' | 'notIn' ? (value: T, list: T[]) => boolean : + K extends 'exists' ? (value: T | null | undefined) => value is T : + K extends 'notExists' ? (value: T | null | undefined) => boolean : + K extends 'startsWith' | 'endsWith' | 'contains' | 'notContains' ? + (value: string, search: string) => boolean : + K extends 'matches' | 'notMatches' ? (value: string, pattern: string) => boolean : + never; +}; diff --git a/packages/validators/types/validator.type.ts b/packages/validators/types/validator.type.ts new file mode 100644 index 0000000..d6123ca --- /dev/null +++ b/packages/validators/types/validator.type.ts @@ -0,0 +1,9 @@ +import { Constructor } from '@gland/common'; +import { ValidationOptions } from '../interface/validator.interface'; + +/** + * SchemaRegistry keeps track of schemas in the validation system. + * - `schemaClass` is the actual schema class being referenced. + * - `options` holds the validation options associated with the schema. + */ +export type SchemaRegistry = Record; options: ValidationOptions }>; diff --git a/packages/validators/utils/index.ts b/packages/validators/utils/index.ts new file mode 100644 index 0000000..e4f2397 --- /dev/null +++ b/packages/validators/utils/index.ts @@ -0,0 +1 @@ +export * from './messages'; diff --git a/packages/validators/utils/messages.ts b/packages/validators/utils/messages.ts new file mode 100644 index 0000000..013f554 --- /dev/null +++ b/packages/validators/utils/messages.ts @@ -0,0 +1,47 @@ +import { RuleMessages } from '@gland/common'; + +export function renderMessage(message: string, placeholders: Record>): string { + return message.replace(/{(\w+)}/g, (_, key) => String(placeholders[key] ?? `{${key}}`)); +} +/** Default error messages for validation rules */ +export const DefaultMessages: RuleMessages = { + message: '', + // Core validation messages + required: 'The {field} field is required and cannot be left blank.', + string: 'The {field} field must contain a valid string.', + boolean: 'The {field} field must be either true or false.', + array: 'The {field} field must be an array of values.', + optional: '', // No message for optional fields + integer: 'The {field} field must be a valid integer.', + min: 'The {field} field must have a value of at least {value}.', + max: 'The {field} field must not exceed a value of {value}.', + alpha: 'The {field} field must contain only alphabetic characters (A-Z, a-z).', + alphanumeric: 'The {field} field must contain only alphanumeric characters (A-Z, a-z, 0-9).', + float: 'The {field} field must be a valid floating-point number.', + + // Custom validation messages + custom: 'The {field} field has failed custom validation.', + + // Dependency validation messages + dependsOn: 'The {field} field requires the {dependentField} field to meet certain conditions.', + + // Dependency rule-specific messages + dependsOnRules: { + equal: 'The {field} field must have the same value as {dependentField}.', + notEqual: 'The {field} field must not have the same value as {dependentField}.', + greaterThan: 'The {field} field must be greater than the value of {dependentField}.', + lessThan: 'The {field} field must be less than the value of {dependentField}.', + greaterOrEqual: 'The {field} field must be greater than or equal to the value of {dependentField}.', + lessOrEqual: 'The {field} field must be less than or equal to the value of {dependentField}.', + in: 'The {field} field must contain one of the following values: {allowedValues}.', + notIn: 'The {field} field must not contain any of the following values: {disallowedValues}.', + exists: 'The {field} field requires the {dependentField} field to exist.', + notExists: 'The {field} field requires the {dependentField} field to not exist.', + startsWith: 'The {field} field must start with "{value}".', + endsWith: 'The {field} field must end with "{value}".', + contains: 'The {field} field must include the value "{value}".', + notContains: 'The {field} field must not include the value "{value}".', + matches: 'The {field} field must match the pattern "{pattern}".', + notMatches: 'The {field} field must not match the pattern "{pattern}".', + }, +}; diff --git a/packages/validators/validators/condition-evaluator.ts b/packages/validators/validators/condition-evaluator.ts new file mode 100644 index 0000000..337509c --- /dev/null +++ b/packages/validators/validators/condition-evaluator.ts @@ -0,0 +1,41 @@ +import { Constructor, isNil, SectionErrors } from '@gland/common'; +import { SchemaRegistry } from '../types/validator.type'; +import { ValidationContainer } from './container'; +import { ValidatorEngine } from './validator-engine'; + +export class ConditionEvaluator { + static process(registry: SchemaRegistry, data: Record, container: ValidationContainer, returnFirstError: boolean) { + const processed = new Set>(); + const conditionalSchemas = Object.values(registry).filter((s) => s.options?.dependsOn); + if (conditionalSchemas.length === 0) { + return processed; + } + for (const { schemaClass, options } of conditionalSchemas) { + for (const condition of options.dependsOn ?? []) { + const errors = this.evaluateCondition(condition, registry, data, container, returnFirstError); + if (errors) { + processed.add(schemaClass); + processed.add(condition.schema); + } + } + } + + return processed; + } + private static evaluateCondition( + condition: { schema: Constructor }, + registry: SchemaRegistry, + data: Record, + container: ValidationContainer, + returnFirstError: boolean, + ): null | SectionErrors { + const schema = Object.values(registry).find((s) => s.schemaClass === condition.schema); + if (isNil(schema)) throw new Error(`Missing schema in registry: ${condition.schema.name}`); + + ValidatorEngine.validateSchemaFields(schema.schemaClass, data, container, returnFirstError); + if (container.hasErrors()) { + return container.errors; + } + return null; + } +} diff --git a/packages/validators/validators/container.ts b/packages/validators/validators/container.ts new file mode 100644 index 0000000..39c9e64 --- /dev/null +++ b/packages/validators/validators/container.ts @@ -0,0 +1,16 @@ +import { SectionErrors } from '@gland/common/types'; +/** + * Validation result container with error accumulation + */ +export class ValidationContainer { + readonly errors: SectionErrors = {}; + addError(section: string, field: string, messages: string[]): void { + this.errors[section] ??= {}; + this.errors[section][field] ??= []; + this.errors[section][field].push(...messages); + } + /** Check if there are any errors */ + hasErrors(): boolean { + return Object.keys(this.errors).length > 0; + } +} diff --git a/packages/validators/validators/field-validator.ts b/packages/validators/validators/field-validator.ts new file mode 100644 index 0000000..6101f86 --- /dev/null +++ b/packages/validators/validators/field-validator.ts @@ -0,0 +1,74 @@ +import { isNil, RulesList, RuleType } from '@gland/common'; +import { RuleAction } from '../actions/rule-action'; +import { DefaultMessages, renderMessage } from '../utils'; +import { ValidationField } from '../interface/validator.interface'; + +export class FieldValidator { + static validateField(value: any, fieldRules: ValidationField, fieldKey: string, returnFirstError: boolean, allData: Record, defaultRules: RulesList = []): string[] { + const errors: string[] = []; + + // Combine default rules and current field rules, handling duplicates. + const rules = Array.isArray(fieldRules.rules) + ? [ + ...new Set(fieldRules.rules.includes('inherit') ?? defaultRules ? [...defaultRules, ...fieldRules.rules.filter((rule) => rule !== 'inherit')] : fieldRules.rules), // Avoid duplicates + ] + : fieldRules.rules; + + /// Handle `dependsOn` validation + if (fieldRules.options?.dependsOn) { + const { field, operator, value } = fieldRules.options.dependsOn; + const dependentFieldValue = allData[field]; + if (!RuleAction.applyDependencyRule({ operator, value }, dependentFieldValue)) { + const template = fieldRules.messages?.dependsOn ?? DefaultMessages.dependsOn; + if (isNil(template)) { + throw new Error('No message template defined for dependsOn rule.'); + } + const message = renderMessage(template, { field: fieldKey, dependentField: field }); + errors.push(message); + + if (returnFirstError) { + return errors; // Stop and return immediately if `returnFirstError` is true. + } + } + } + // Handle `custom` validation + if (fieldRules.options?.custom) { + const isValid = fieldRules.options.custom(value); + if (isValid) return errors; + const template = fieldRules.messages?.custom ?? DefaultMessages.custom; + if (isNil(template)) { + throw new Error('No message template defined for custom rule.'); + } + const message = renderMessage(template, { field: fieldKey }); + errors.push(message); + + if (returnFirstError) { + return errors; // Stop and return immediately if `returnFirstError` is true. + } + } + for (const rule of rules) { + const [ruleName, param] = rule.split(':') as [RuleType, string]; + if (!RuleAction.applyRule({ param, ruleName }, value)) { + const messageTemplate = fieldRules.messages?.message; + if (messageTemplate) { + // If the custom message is set, use it + const message = renderMessage(messageTemplate, { field: fieldKey }); + errors.push(message); + break; + } else { + const template = fieldRules.messages?.[ruleName] ?? DefaultMessages[ruleName]; + if (isNil(template)) { + throw new Error(`No message template defined for rule '${ruleName}'.`); + } + const message = renderMessage(template, { field: fieldKey, value: param }); + errors.push(message); + } + if (returnFirstError) { + break; // If `returnFirstError` is true, exit on the first validation failure. + } + } + } + + return errors; + } +} diff --git a/packages/validators/validators/index.ts b/packages/validators/validators/index.ts new file mode 100644 index 0000000..1b20abf --- /dev/null +++ b/packages/validators/validators/index.ts @@ -0,0 +1 @@ +export * from './validator-engine'; diff --git a/packages/validators/validators/validator-engine.ts b/packages/validators/validators/validator-engine.ts new file mode 100644 index 0000000..7b88682 --- /dev/null +++ b/packages/validators/validators/validator-engine.ts @@ -0,0 +1,90 @@ +import { SectionErrors, Constructor, VALIDATOR_METADATA, RulesList, isNil } from '@gland/common'; +import { FieldValidator } from './field-validator'; +import { RuleAction } from '../actions/rule-action'; +import { ValidationContainer } from './container'; +import Reflector from '@gland/metadata'; +import { SchemaRegistry } from '../types/validator.type'; +import { ValidationField, ValidationOptions } from '../interface/validator.interface'; +import { ConditionEvaluator } from './condition-evaluator'; + +export class ValidatorEngine { + /** Apply validation to the provided schema and data */ + static validate(schemaClass: Constructor, data: Record, returnFirstError: boolean = false): SectionErrors { + const container = new ValidationContainer(); + const schemaRegistry = ValidatorEngine.getSchemaRegistry(schemaClass); + const conditionallyValidated = ConditionEvaluator.process(schemaRegistry, data, container, returnFirstError); + + // Validate remaining non-conditional schemas + ValidatorEngine.validateRemainingSchemas(schemaRegistry, conditionallyValidated, data, container, returnFirstError); + + return container.errors; + } + + /** Validate fields for schemas that have not been validated by conditions */ + private static validateRemainingSchemas(registry: SchemaRegistry, validatedSchemas: Set>, data: Record, container: ValidationContainer, returnFirstError: boolean) { + for (const { schemaClass, options } of Object.values(registry)) { + if (validatedSchemas.has(schemaClass)) continue; + + // ValidatorProcess.applyOptions(nestedSchemaClass, schemaOptions); + ValidatorEngine.SchemaOptionsApplier(schemaClass, options); + ValidatorEngine.validateSchemaFields(schemaClass, data, container, returnFirstError); + } + } + /** Apply schema options like pick, omit, and conditions */ + private static SchemaOptionsApplier(schemaClass: Constructor, options: ValidationOptions) { + if (isNil(options)) return; + if (options.pick && options.omit) { + throw new Error("Conflict: 'pick' and 'omit' cannot be used together"); + } + if (options.pick || options.omit) { + const filteredRules = RuleAction.filter(schemaClass, options.pick, options.omit); + Reflector.defineMetadata(VALIDATOR_METADATA.RULES_METADATA, filteredRules, schemaClass); + } + } + + /** Validate fields and collect errors */ + static validateSchemaFields(schemaClass: Constructor, data: Record, contianer: ValidationContainer, returnFirstError: boolean) { + const rules = ValidatorEngine.getValidationRules(schemaClass); + const defaultRules = ValidatorEngine.getDefaultRules(schemaClass); + const section = ValidatorEngine.getSchemaSection(schemaClass); + const sectionData = data[section] ?? {}; + + for (const [field, fieldRules] of Object.entries(rules)) { + const mergedRules = ValidatorEngine.mergeRules(fieldRules); + if (ValidatorEngine.InvalidInheritRule(mergedRules, defaultRules)) { + throw new Error(`Field '${field}' uses "inherit" without defined default rules.`); + } + const value = sectionData[field]; + const fieldErrors = FieldValidator.validateField(value, mergedRules, field, returnFirstError, sectionData, defaultRules); + + if (fieldErrors.length) { + contianer.addError(section, field, fieldErrors); + if (returnFirstError) return; + } + } + } + private static getSchemaRegistry(schemaClass: Constructor): SchemaRegistry { + return Reflector.getMetadata(VALIDATOR_METADATA.NESTED_SCHEMA_METADATA, schemaClass) ?? {}; + } + + private static getValidationRules(schemaClass: Constructor): Record { + return Reflector.getMetadata(VALIDATOR_METADATA.RULES_METADATA, schemaClass); + } + + private static getDefaultRules(schemaClass: Constructor): RulesList { + return Reflector.getMetadata(VALIDATOR_METADATA.RULES_DEFAULTS_METADATA, schemaClass); + } + + private static getSchemaSection(schemaClass: Constructor): string { + return Reflector.getMetadata(VALIDATOR_METADATA.SCHEMA_SECTION_METADATA, schemaClass); + } + private static mergeRules(fieldRules: ValidationField): ValidationField { + return { + ...fieldRules, + rules: [...(Array.isArray(fieldRules.rules) ? fieldRules.rules : [fieldRules.rules])].filter((rule) => rule !== undefined), + }; + } + private static InvalidInheritRule(fieldRules: ValidationField, defaultRules: RulesList): boolean { + return !defaultRules && fieldRules.rules.includes('inherit'); + } +}