Skip to content

Commit

Permalink
Merge pull request #18 from medishen/dev/v2
Browse files Browse the repository at this point in the history
Added schema validation error handling nested schema support and validation field options
  • Loading branch information
0xii00 authored Jan 20, 2025
2 parents 528e461 + 47f788f commit ee46642
Show file tree
Hide file tree
Showing 17 changed files with 537 additions and 8 deletions.
6 changes: 4 additions & 2 deletions example/advanced-app/src/controllers/product.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export class ProductController {
@Post('/')
createProduct(ctx: ServerRequest): void {
const productData = ctx.body;
const newProduct = this.productService.createProduct(productData);
ctx.send(newProduct);
return this.productService.createProduct(productData);
/**
curl -X POST http://localhost:4000/products -H "Content-Type: application/json" -d '{"name": "Product C","price": 200 }'
*/
}
}
1 change: 1 addition & 0 deletions lib/common/enums/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './method.enum';
export * from './status.enum';
export * from './router.enum';
export * from './decorator.enum';
export * from './validator.enum';
8 changes: 8 additions & 0 deletions lib/common/enums/validator.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export enum ValidationMetadataKey {
SCHEMA = 'validation:schema',
DEFAULT_RULES = 'validation:schema:default_rules',
VALIDATION_STATUS = 'validation:schema:status',
RULES = 'validation:rules',
VALIDATOR = 'validation:validator',
NESTED = 'validation:nested',
}
1 change: 1 addition & 0 deletions lib/common/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './router.interface';
export * from './reflect.interface';
export * from './context.interface';
export * from './app.interface';
export * from './validator.interface';
73 changes: 73 additions & 0 deletions lib/common/interfaces/validator.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { ValidationSchema } from '../types';
import { RuleString, RuleType, ValidationOperator } from '../types/validator.type';
import { Constructor } from './module.interface';
export interface ValidationField {
/**
* The validation rules as a string or a custom validation function.
*/
rules: RuleString;

/**
* Custom error messages for specific rule types.
*/
messages?: Partial<Record<RuleType, string>>;
/**
* Additional options for the validation field.
*/
options?: {
/**
* A custom validation function for advanced scenarios.
*/
custom?: (value: any) => boolean | Promise<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: ValidationOperator;
value?: any; // Value to compare against, if applicable.
};
};
}
export interface ValidationOptions<T> {
/**
* 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.
*/
conditions?: {
/**
* The schema class to validate if the condition is met.
*/
schema: Constructor<any>;

/**
* Custom error message to show if the condition is not met.
*/
message: string;
}[];
}
export interface SchemaOptions {
/**
* Specifies a name or section for the schema (e.g., "body", "query").
*/
section?: ValidationSchema;

/**
* Applies a default set of rules to all fields in the schema.
*/
defaultRules?: RuleString;
}
1 change: 1 addition & 0 deletions lib/common/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './event.type';
export * from './middleware.type';
export * from './module.type';
export * from './app.types';
export * from './validator.type';
4 changes: 2 additions & 2 deletions lib/common/types/middleware.type.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { ServerRequest } from '../interfaces';

export type MiddlewareFn = (ctx: ServerRequest, next: Function) => void | Promise<void>;
export type NextFunction = () => void | Promise<void>;
export type MiddlewareFn = (ctx: ServerRequest, next: NextFunction) => void | Promise<void>;
24 changes: 24 additions & 0 deletions lib/common/types/validator.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Constructor, ValidationOptions } from '../interfaces';

export type RuleType = 'required' | 'string' | 'boolean' | 'array' | 'email' | 'url' | 'optional' | 'integer' | 'date' | 'min' | 'max' | 'regex' | 'inherit';
export type RuleParameter = RuleType | `min:${number}` | `max:${number}`;
export type RuleString = RuleParameter | Array<RuleParameter>;
export type ValidationOperator =
| 'equal' // Value must equal a specific value
| 'notEqual' // Value must not equal a specific value
| 'greaterThan' // Value must be greater than a specific value
| 'lessThan' // Value must be less than a specific value
| 'greaterOrEqual' // Value must be greater than or equal to a specific value
| 'lessOrEqual' // Value must be less than or equal to a specific value
| 'in' // Value must exist in a list of allowed values
| 'notIn' // Value must not exist in a list of disallowed values
| 'exists' // Dependent field must exist
| 'notExists' // Dependent field must not exist
| 'startsWith' // Value must start with a specific string
| 'endsWith' // Value must end with a specific string
| 'contains' // Value must contain a specific string
| 'notContains' // Value must not contain a specific string
| 'matches' // Value must match a specific regex
| 'notMatches'; // Value must not match a specific regex
export type NestedSchemas<T> = Record<string, { schemaClass: Constructor<T>; options: ValidationOptions<T> }>;
export type ValidationSchema = 'body' | 'query' | 'headers' | 'params';
6 changes: 2 additions & 4 deletions lib/context/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,8 @@ export class Context {

let len: number | undefined = calculateContentLength(chunk, encoding);
if (len !== undefined) {
if (!this.ctx.bodySize) {
this.ctx.bodySize = len;
}
res.setHeader('Content-Length', this.ctx.bodySize ?? len);
this.ctx.bodySize = len;
res.setHeader('Content-Length', len);
}
handleETag(this.ctx, chunk, len, res);

Expand Down
35 changes: 35 additions & 0 deletions lib/decorator/Validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Reflector from '../metadata';
import { ValidationMetadataKey } from '../common/enums';
import { SchemaOptions, ValidationField, ValidationOptions } from '../common/interfaces';
import { ValidationMessages } from '../validator/config';
export function Rule(options?: { messages?: ValidationMessages; options?: ValidationField['options'] }): PropertyDecorator {
return function (target, propertyKey) {
const instance = new (target.constructor as any)();
const rules = instance[propertyKey];
const existingRules: Record<string, ValidationField> = Reflector.get(ValidationMetadataKey.RULES, target.constructor) ?? {};
existingRules[propertyKey as string] = {
rules: Array.isArray(rules) ? rules : [rules],
messages: options?.messages ?? {},
options: options?.options ?? {},
};
Reflector.define(ValidationMetadataKey.RULES, existingRules, target.constructor);
};
}

export function Schema({ section = 'body', defaultRules }: SchemaOptions): ClassDecorator {
return (target) => {
Reflector.define(ValidationMetadataKey.SCHEMA, { section, defaultRules: defaultRules }, target);
};
}
export function NestedSchema<T = any>(options?: ValidationOptions<T>): PropertyDecorator {
return function (target, propertyKey) {
const instance = new (target.constructor as any)();
let schemaClass: any = instance[propertyKey].constructor;
if (schemaClass.toString().includes('[native code]')) {
schemaClass = instance[propertyKey];
}
const nestedSchemas = Reflector.get(ValidationMetadataKey.NESTED, target.constructor) ?? {};
nestedSchemas[propertyKey] = { schemaClass: schemaClass, options: options ?? {} };
Reflector.define(ValidationMetadataKey.NESTED, nestedSchemas, target.constructor);
};
}
1 change: 1 addition & 0 deletions lib/decorator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from './Transform';
export * from './http';
export * from './module';
export * from './Middleware';
export * from './Validator';
56 changes: 56 additions & 0 deletions lib/validator/FieldValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ValidationField } from '../common/interfaces';
import { DefaultMessages, renderMessage } from './config';
import { RuleString } from '../common/types';
import { RuleAction } from './RuleAction';

export class FieldValidator {
static async validateField(value: any, fieldRules: ValidationField, fieldKey: string, returnFirstError: boolean, allData: Record<string, any>, defaultRules: RuleString = []): Promise<string[]> {
const errors: string[] = [];
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: dependencyValue } = fieldRules.options.dependsOn;
const dependentFieldValue = allData[field];
if (!RuleAction.applyDependencyRule({ operator, dependencyValue }, dependentFieldValue)) {
const template = (fieldRules.messages as any)?.dependsOn || DefaultMessages.dependsOn;
const message = renderMessage(template, { field: fieldKey, dependentField: field });
errors.push(message);

if (returnFirstError) {
return errors;
}
}
}
// Handle `custom` validation
if (fieldRules.options?.custom) {
const isValid = await fieldRules.options.custom(value);
if (!isValid) {
const template = (fieldRules.messages as any)?.custom || DefaultMessages.custom;
const message = renderMessage(template, { field: fieldKey });
errors.push(message);

if (returnFirstError) {
return errors;
}
}
}
for (const rule of rules) {
const [ruleName, param] = rule.split(':');
if (!RuleAction.applyRule({ param, ruleName }, value)) {
const template = (fieldRules.messages as any)?.[ruleName] || (DefaultMessages as any)[ruleName];
const message = renderMessage(template, { field: fieldKey, value: param });
errors.push(message);
if (returnFirstError) {
break;
}
}
}

return errors;
}
}
91 changes: 91 additions & 0 deletions lib/validator/RuleAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { ValidationMetadataKey } from '../common/enums';
import { Constructor, ValidationField } from '../common/interfaces';
import Reflector from '../metadata';

export class RuleAction {
static applyRule({ param, ruleName }: { ruleName: string; param: string }, value: any): boolean {
switch (ruleName) {
case 'required':
return value !== undefined && value !== null && value !== '';
case 'string':
return typeof value === 'string';
case 'integer':
return Number.isInteger(value);
case 'boolean':
return typeof value === 'boolean';
case 'email':
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
case 'min':
return typeof value === 'string' && value.length >= parseInt(param);
case 'max':
return typeof value === 'string' && value.length <= parseInt(param);
case 'array':
return Array.isArray(value);
case 'regex':
return new RegExp(param).test(value);
case 'url':
return /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([\/\w .-]*)*\/?$/.test(value);
default:
return true;
}
}
static applyDependencyRule({ operator, dependencyValue }: { operator: string; dependencyValue?: any }, dependentFieldValue: any): boolean {
switch (operator) {
case 'equal':
return dependentFieldValue === dependencyValue;
case 'notEqual':
return dependentFieldValue !== dependencyValue;
case 'greaterThan':
return dependentFieldValue > dependencyValue;
case 'lessThan':
return dependentFieldValue < dependencyValue;
case 'greaterOrEqual':
return dependentFieldValue >= dependencyValue;
case 'lessOrEqual':
return dependentFieldValue <= dependencyValue;
case 'in':
return Array.isArray(dependencyValue) && dependencyValue.includes(dependentFieldValue);
case 'notIn':
return Array.isArray(dependencyValue) && !dependencyValue.includes(dependentFieldValue);
case 'exists':
return dependentFieldValue !== undefined && dependentFieldValue !== null;
case 'notExists':
return dependentFieldValue === undefined || dependentFieldValue === null;
case 'startsWith':
return typeof dependentFieldValue === 'string' && dependentFieldValue.startsWith(dependencyValue);
case 'endsWith':
return typeof dependentFieldValue === 'string' && dependentFieldValue.endsWith(dependencyValue);
case 'contains':
return typeof dependentFieldValue === 'string' && dependentFieldValue.includes(dependencyValue);
case 'notContains':
return typeof dependentFieldValue === 'string' && !dependentFieldValue.includes(dependencyValue);
case 'matches':
return typeof dependentFieldValue === 'string' && new RegExp(dependencyValue).test(dependentFieldValue);
case 'notMatches':
return typeof dependentFieldValue === 'string' && !new RegExp(dependencyValue).test(dependentFieldValue);
default:
return true; // Default to valid for unknown rules
}
}
static filter<T>(schemaClass: Constructor<T>, pick?: (keyof T)[], omit?: (keyof T)[]): Record<string, ValidationField> {
const rules: Record<string, ValidationField> = Reflector.get(ValidationMetadataKey.RULES, schemaClass) ?? {};
const filteredRules: Record<string, ValidationField> = {};
if (pick) {
for (const key of pick) {
if (rules[key as string]) {
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;
}
}
9 changes: 9 additions & 0 deletions lib/validator/ValidationError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export class ValidationError {
errors: Record<string, Record<string, string[]>> = {};

addError(section: string, field: string, message: string[]): void {
if (!this.errors[section]) this.errors[section] = {};
if (!this.errors[section][field]) this.errors[section][field] = [];
this.errors[section][field].push(...message);
}
}
Loading

0 comments on commit ee46642

Please sign in to comment.