From a21308eb6b5d3048d6bcd3275bf97cf08219926c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96zg=C3=BCr=20Adem=20I=C5=9EIKLI?= Date: Mon, 13 Jan 2025 17:27:33 +0100 Subject: [PATCH] Fixed #54 - Array of Object validation --- CHANGELOG.md | 4 + docs/getting-started.md | 87 ++++++++++++++++++++ docs/index.md | 4 +- package-lock.json | 4 +- package.json | 2 +- src/Interface.ts | 11 +++ src/helpers/validate.ts | 175 +++++++++++++++++++++++++++------------- tests/index.test.ts | 2 +- tests/nested.test.ts | 137 +++++++++++++++++++++++++++++++ 9 files changed, 363 insertions(+), 63 deletions(-) create mode 100644 tests/nested.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a4ab137..1083ef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Release Notes +## [2.2.0 (2025-01-13)](https://github.com/axe-api/axe-api/compare/2.2.0...2.2.1) + +- Array and object validation [#54](https://github.com/axe-api/validator/issues/54) + ## [2.1.1 (2024-10-27)](https://github.com/axe-api/axe-api/compare/2.1.1...2.1.0) - TypeError: list.map is not a function [#62](https://github.com/axe-api/validator/issues/62) diff --git a/docs/getting-started.md b/docs/getting-started.md index 58b018e..39b1565 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -89,3 +89,90 @@ By the example, you would get the following response: } } ``` + +## Nested data validation + +This feature allows dynamic traversal of nested data structures, supporting complex validation rules for paths like `users.*.addresses.*.city`. + +It is inspired by Laravel's validation system and works seamlessly with arrays and objects, including deeply nested data. + +```ts +import { validate, setLocales, en } from "robust-validator"; + +setLocales(en); + +const data = { + secret: "some secret", + users: [ + { + addresses: [ + { + city: "New York", + }, + { + city: "Istanbul", + }, + ], + }, + { + addresses: [ + { + city: "New York", + }, + { + street: "Wall Street", + }, + ], + }, + ], + permissons: { + read: true, + write: true, + }, +}; + +const definition = { + secret: "required|min:100", + "users.*.addresses.*.city": "required", + "permissons.read": "required|boolean", + "permissons.delete": "required|boolean", +}; + +const result = await validate(data, definition); +console.log(result); +``` + +And this is the content of the `result` variable: + +```json +{ + "isValid": false, + "isInvalid": true, + "fields": { + "secret": false, + "users.*.addresses.*.city": false, + "permissons.read": true, + "permissons.delete": false + }, + "errors": { + "secret": [ + { + "rule": "min", + "message": "The field must be at least 100." + } + ], + "users.1.addresses.1.city": [ + { + "rule": "required", + "message": "The field is required." + } + ], + "permissons.delete": [ + { + "rule": "required", + "message": "The field is required." + } + ] + } +} +``` diff --git a/docs/index.md b/docs/index.md index 76d2f6e..d65d0a3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,9 +15,9 @@ features: - title: Declarative ✍🏽 details: Declarative rule definition allows you to save your rules in different places such as configuration files, databases, etc. - title: Simple 🐀 - details: Starting to validate data is very fast instead of creating complicated validation rules. You just need seconds. + details: Starting to validate data is very fast. Instead of creating complicated validation rules, you just need seconds. - title: Proof of work πŸ’ͺ details: Laravel-ish data validation rules are well-tested as a concept. This library is just another implementation for JavaScript. - title: i18n πŸ‡ΊπŸ‡³ - details: Multi-language error messages are supported internally, unlike other libraries. It provides consistency. + details: Multi-language error messages are supported internally, unlike other libraries. --- diff --git a/package-lock.json b/package-lock.json index 423711c..6d27ca4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "robust-validator", - "version": "2.1.1", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "robust-validator", - "version": "2.1.1", + "version": "2.2.0", "license": "MIT", "dependencies": { "date-fns": "^4.1.0" diff --git a/package.json b/package.json index b1f019d..35206d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "robust-validator", - "version": "2.1.1", + "version": "2.2.0", "description": "Rule-based data validation library", "type": "module", "main": "dist/index.cjs", diff --git a/src/Interface.ts b/src/Interface.ts index 744b1bb..2b3e73b 100644 --- a/src/Interface.ts +++ b/src/Interface.ts @@ -44,3 +44,14 @@ export interface ILocale { key: LanguageType; values: Translation; } + +export interface ITraversePair { + path: string; + value: any; +} + +export interface ITraverseItem { + path: string; + rules: string | string[]; + resolved: Array; +} diff --git a/src/helpers/validate.ts b/src/helpers/validate.ts index e292758..cb3cb60 100644 --- a/src/helpers/validate.ts +++ b/src/helpers/validate.ts @@ -1,8 +1,12 @@ -import { IContext, IValidationOptions, IValidationResult } from "../Interface"; +import { + IContext, + ITraverseItem, + IValidationOptions, + IValidationResult, +} from "../Interface"; import { getMessage } from "../Locale"; import { Definition, ValidationResult } from "../Types"; import { toRuleDefinition } from "../Factory"; -import { getValueViaPath } from "./getValueViaPath"; import { getOptions } from "../Options"; export const validate = async ( @@ -29,6 +33,65 @@ export const validate = async ( }; }; +const toTraverseArray = (data: any, definition: Definition) => { + function resolvePath(data: any, path: string) { + const parts = path.split("."); + const result: Array<{ path: string; value: any }> = []; + + function traverse( + current: any, + index = 0, + resolvedPath: Array = [] + ) { + if (index >= parts.length) { + result.push({ path: resolvedPath.join("."), value: current }); + return; + } + + const part = parts[index]; + + if (part === "*") { + if (Array.isArray(current)) { + current.forEach((item, i) => { + traverse(item, index + 1, [...resolvedPath, i]); + }); + } else if (current && typeof current === "object") { + Object.keys(current).forEach((key) => { + traverse(current[key], index + 1, [...resolvedPath, key]); + }); + } else { + result.push({ + path: [...resolvedPath, "*"].join("."), + value: current, + }); + } + } else { + if (current && typeof current === "object" && part in current) { + traverse(current[part], index + 1, [...resolvedPath, part]); + } else { + result.push({ + path: [...resolvedPath, part].join("."), + value: undefined, + }); + } + } + } + + traverse(data); + return result; + } + + const checks: ITraverseItem[] = []; + + // Example usage + Object.entries(definition).forEach(([path, rules]) => { + const resolved = resolvePath(data, path); + checks.push({ path, rules, resolved }); + }); + + return checks; +}; + const getResults = async ( data: any, definition: Definition, @@ -38,69 +101,63 @@ const getResults = async ( const fields: Record = {}; const results: ValidationResult = {}; - // Checking all validations - for (const field in definition) { - fields[field] = true; - // Parsing the rules - const params = definition[field]; - let ruleGroup: string = ""; - if (Array.isArray(params)) { - ruleGroup = params.join("|"); - } else { - ruleGroup = params; - } + const traverse = toTraverseArray(data, definition); - const rules = toRuleNameArray(ruleGroup).map(toRuleDefinition); + for (const item of traverse) { + const { path, rules, resolved } = item; + fields[path] = true; - // Getting the value by the path - const value = getValueViaPath(data, field); + const rulesAsString = Array.isArray(rules) ? rules.join("|") : rules; + + const ruleDefinitions = + toRuleNameArray(rulesAsString).map(toRuleDefinition); const context: IContext = { data, - field, - definition: ruleGroup, + field: path, + definition: rulesAsString, }; - // Checking all rules one by one - for (const rule of rules) { - // If the value is empty but the rule is not required, we don't execute - // the rules - if (rule.name !== "required" && (value === null || value === undefined)) { - continue; - } - - // Calling the rule function with the validation parameters - const isRuleValid = await rule.callback( - value, - ...[...rule.params, context] - ); - - // Is the value valid? - if (isRuleValid === false) { - if (!results[field]) { - results[field] = []; + for (const check of resolved) { + // Checking all rules one by one + for (const rule of ruleDefinitions) { + // If the value is empty but the rule is not required, we don't execute + // the rules + if ( + rule.name !== "required" && + (check.value === null || check.value === undefined) + ) { + continue; } - - isValid = false; - fields[field] = false; - - // Setting the rule and the error message - results[field].push({ - rule: rule.name, - message: getMessage( - rule.name, - rule.params, - options.language, - options.translations || {} - ), - }); - - if (options.stopOnFail) { - return { - isValid: false, - fields, - results, - }; + // Calling the rule function with the validation parameters + const isRuleValid = await rule.callback( + check.value, + ...[...rule.params, context] + ); + // Is the value valid? + if (isRuleValid === false) { + if (!results[check.path]) { + results[check.path] = []; + } + isValid = false; + fields[path] = false; + // Setting the rule and the error message + results[check.path].push({ + rule: rule.name, + message: getMessage( + rule.name, + rule.params, + options.language, + options.translations || {} + ), + }); + if (options.stopOnFail) { + return { + isValid: false, + fields, + results, + }; + } } } } @@ -114,5 +171,9 @@ const getResults = async ( }; const toRuleNameArray = (rules: string): string[] => { + if (Array.isArray(rules)) { + return rules; + } + return rules.split("|"); }; diff --git a/tests/index.test.ts b/tests/index.test.ts index 967ff04..1dfd472 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -19,7 +19,7 @@ const EXISTS_RULE_TRANSLATIONS = { describe("validate() function ", () => { beforeAll(async () => { - await setLocales(en as ILocale); + setLocales(en as ILocale); }); test("should be able to validate the general structure", async () => { diff --git a/tests/nested.test.ts b/tests/nested.test.ts new file mode 100644 index 0000000..a81979c --- /dev/null +++ b/tests/nested.test.ts @@ -0,0 +1,137 @@ +import { beforeAll, describe, test, expect } from "vitest"; +import { validate, setLocales, en, ILocale } from "../index"; + +describe("validate()", () => { + beforeAll(async () => { + setLocales(en as ILocale); + }); + + test("should validate nested objects", async () => { + const result = await validate( + { + id: 1, + token: "123", + user: { + id: 1, + email: "email", + }, + }, + { + id: "required|numeric", + token: "required|min:20", + "user.email": "required|email", + } + ); + expect(result.isValid).toBe(false); + expect(result.fields.id).toBe(true); + expect(result.fields.token).toBe(false); + expect(result.fields["user.email"]).toBe(false); + + expect(result.errors["user.email"][0].message).toBe( + "The field must be an email." + ); + }); + + test("should validate arrays", async () => { + const result = await validate( + { + users: [ + { + email: "correct@mail.com", + }, + { email: "email" }, + ], + }, + { + "users.*.email": "required|email", + } + ); + expect(result.isValid).toBe(false); + expect(result.fields["users.*.email"]).toBe(false); + expect(result.errors["users.1.email"][0].message).toBe( + "The field must be an email." + ); + }); + + test("should validate nested arrays", async () => { + const result = await validate( + { + users: [ + { + addresses: [ + { + city: "New York", + }, + { + street: "Wall Street", + }, + ], + }, + { + addresses: [ + { + city: "New York", + }, + { + city: "Los Angeles", + }, + ], + }, + ], + }, + { + "users.*.addresses.*.city": "required", + } + ); + expect(result.isValid).toBe(false); + expect(result.fields["users.*.addresses.*.city"]).toBe(false); + expect(result.errors["users.0.addresses.1.city"][0].message).toBe( + "The field is required." + ); + }); + + test("should validate everything", async () => { + const result = await validate( + { + secret: "some secret", + users: [ + { + addresses: [ + { + city: "New York", + }, + { + city: "Istanbul", + }, + ], + }, + { + addresses: [ + { + city: "New York", + }, + { + street: "Wall Street", + }, + ], + }, + ], + permissons: { + read: true, + write: true, + }, + }, + { + secret: "required|min:100", + "users.*.addresses.*.city": "required", + "permissons.read": "required|boolean", + "permissons.delete": "required|boolean", + } + ); + expect(result.isValid).toBe(false); + expect(result.fields.secret).toBe(false); + expect(result.fields["users.*.addresses.*.city"]).toBe(false); + expect(result.fields["permissons.read"]).toBe(true); + expect(result.fields["permissons.delete"]).toBe(false); + }); +});