Skip to content

Commit

Permalink
Fixed #54 - Array of Object validation
Browse files Browse the repository at this point in the history
  • Loading branch information
ozziest committed Jan 13, 2025
1 parent 1716993 commit a21308e
Show file tree
Hide file tree
Showing 9 changed files with 363 additions and 63 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
87 changes: 87 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
}
```
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
---
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src/Interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ITraversePair>;
}
175 changes: 118 additions & 57 deletions src/helpers/validate.ts
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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<string | number> = []
) {
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,
Expand All @@ -38,69 +101,63 @@ const getResults = async (
const fields: Record<string, boolean> = {};
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,
};
}
}
}
}
Expand All @@ -114,5 +171,9 @@ const getResults = async (
};

const toRuleNameArray = (rules: string): string[] => {
if (Array.isArray(rules)) {
return rules;
}

return rules.split("|");
};
2 changes: 1 addition & 1 deletion tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading

0 comments on commit a21308e

Please sign in to comment.