Skip to content

Commit 23f1f46

Browse files
committed
feat: add flattened errors for API consumption
- Add getFlattenedErrors() instance method on ValidationException - Add ValidationException::flattenErrors() static method for tryValidate() results - Implement '_root' namespace for root-level errors, dot notation for nested fields - Add comprehensive test coverage (15 test cases) - Update documentation with usage examples and API reference - Update CHANGELOG, ROADMAP, TASKS, and README
1 parent 57bfc5a commit 23f1f46

10 files changed

Lines changed: 605 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Added
8+
- `getFlattenedErrors()` method on `ValidationException` returning a flattened list of errors suitable for API consumption. Each error entry contains `path` (field path with `'_root'` for root-level errors) and `message` (error message). Supports nested structures, array items, and maintains consistent path notation.
9+
- `ValidationException::flattenErrors()` static method for flattening errors from `tryValidate()` results without needing to catch exceptions. Returns empty array when passed `null`.
10+
711
## [0.10.0] - 2025-12-10
812

913
### Added

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Rather than reimplementing every possible transformation or validation rule, Lem
3030
- **Form Safety First**: Empty strings coerce to `null` (not dangerous `0`/`false`) to prevent real-world issues like accidental zero bank balances
3131
- **Fluent API with Execution Order Guarantee**: Validation rules read like natural language and execute in the exact order written -- `Validator::isString()->pipe('trim')->nullifyEmpty()->required()`
3232
- **Comprehensive Error Collection**: All validation errors are collected, not just the first failure
33+
- **API-Friendly Error Format**: Flattened errors with field paths (`'_root'` for root-level, dot notation for nested) perfect for frontend consumption
3334
- **Type-Aware Transformations**: Intelligent transformation system that maintains type context and handles coercion automatically
3435
- **Extensible Architecture**: Generic transformation methods work with any PHP callable or external library
3536
- **Strict Typing**: All files use `declare(strict_types=1);`, so scalar mismatches raise clear errors; opt into `coerce()` when you need form-friendly conversions.
@@ -41,6 +42,7 @@ Rather than reimplementing every possible transformation or validation rule, Lem
4142
- **Type-safe validation** for strings, integers, floats, arrays, and objects
4243
- **Fluent, chainable API** with guaranteed execution order -- methods execute exactly as written in the chain
4344
- **Comprehensive error collection** with detailed, structured feedback
45+
- **API-friendly flattened errors** with field paths for easy frontend integration (`getFlattenedErrors()`, `ValidationException::flattenErrors()`)
4446
- **Intuitive custom validation** with `satisfies()` method and optional error messages
4547
- **Logical combinators** (`Validator::allOf()`, `Validator::anyOf()`, `Validator::not()`) for complex validation logic
4648
- **Form-safe coercion** - empty strings become `null` (not dangerous `0`/`false`) for real-world safety
@@ -71,9 +73,18 @@ $userSchema = Validator::isAssociative([
7173

7274
// Tuple-based validation (no exceptions)
7375
[$valid, $user, $errors] = $userSchema->tryValidate($input);
76+
if (!$valid) {
77+
$flattened = \Lemmon\Validator\ValidationException::flattenErrors($errors);
78+
// Returns: [['path' => 'name', 'message' => 'Value is required'], ...]
79+
}
7480

7581
// Exception-based validation
76-
$user = $userSchema->validate($input); // Throws ValidationException on failure
82+
try {
83+
$user = $userSchema->validate($input);
84+
} catch (\Lemmon\Validator\ValidationException $e) {
85+
$flattened = $e->getFlattenedErrors();
86+
// Returns: [['path' => 'name', 'message' => 'Value is required'], ...]
87+
}
7788
```
7889

7990
## Documentation

ROADMAP.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ This roadmap outlines the strategic development plan for future releases, priori
1010

1111
## Recently Completed
1212

13-
### String Validators Bundle & Array Enhancements (Current)
13+
### Flattened Errors for API Consumption (Current)
14+
- [x] **Flattened Error Response** - Added `getFlattenedErrors()` method on `ValidationException` returning API-friendly error format
15+
- [x] **Static Flatten Method** - Added `ValidationException::flattenErrors()` static method for use with `tryValidate()` results
16+
- [x] **Path Convention** - Implemented `'_root'` namespace for root-level errors, dot notation for nested fields, index notation for arrays
17+
- [x] **Comprehensive Documentation** - Added complete guide section with examples for both exception and tuple-based patterns
18+
- [x] **Full Test Coverage** - Added 15 test cases covering all validation scenarios and edge cases
19+
20+
### String Validators Bundle & Array Enhancements
1421
- [x] **String Format Validators** - Added `hostname()`, `time()`, `base64()`, `hex()`, `domain()`, and `regex()` alias
1522
- [x] **Enum-Based Variant Flags** - Implemented `IpVersion`, `Base64Variant`, and `UuidVariant` enums for type-safe variant selection
1623
- [x] **UUID v7 Support** - Added support for UUID version 7 (Unix timestamp-based, sortable)
@@ -291,8 +298,8 @@ $stringValidator = Validator::isString()
291298
- ~~`when(condition, transform)`~~ -- Complex conditional logic, use external control flow
292299

293300
### Enhanced Error Handling
301+
- [x] **Error path enhancement** - Full field paths for nested validation errors (flattened errors with `'_root'` namespace)
294302
- [ ] **Structured error codes** - Programmatic error identification
295-
- [ ] **Error path enhancement** - Full field paths for nested validation errors
296303

297304
### Real-World Validation Gaps (For Consideration)
298305
- [ ] **`notEmpty()` method** - Explicit validation that value is not empty string/array (clearer than custom validation)

TASKS.md

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,23 @@
22

33
Public repo; keep this list short (about 7) and up to date. Rules: no numbering (keeps churn low), prune completed items, and replace them with the next priority. Contributors: pick one task, keep PRs focused, and update the list as things land.
44

5-
- Universal enum/const
6-
- Add `enum()` and `const()` validators available on all types; cover mixed scalar inputs and document usage.
5+
- Universal enum/const validators
6+
- Add `enum()` and `const()` methods available on all validator types; handle mixed scalar inputs and document usage patterns.
77

8-
- Error metadata
9-
- Introduce structured error codes and enhanced error paths for nested schemas; keep backward compatibility for existing error shapes.
8+
- Structured error codes
9+
- Add programmatic error codes to validation errors (e.g., 'STRING_TOO_SHORT', 'INVALID_EMAIL') for better error handling and i18n support; keep backward compatibility.
10+
11+
- `in()` alias for `oneOf()`
12+
- Add `in()` method as an alias for `oneOf()` for more intuitive API (`->in(['active', 'inactive'])` reads better than `->oneOf()`).
13+
14+
- `between()` convenience methods
15+
- Add `between(min, max)` shorthand for strings (length) and numerics (range) to reduce boilerplate (`->between(3, 50)` vs `->minLength(3)->maxLength(50)`).
16+
17+
- `notEmpty()` validation method
18+
- Add explicit `notEmpty()` method for validating non-empty strings/arrays; clearer than custom validation for common use case.
1019

1120
- Mutation testing pilot
12-
- Wire Infection (or similar) to the suite, add baseline config, and document how to run it locally.
21+
- Wire Infection (or similar) to the test suite, add baseline config, and document how to run it locally for enhanced test quality verification.
1322

1423
- Property-based tests
15-
- Add a small property-based test set for key validators (string patterns, numeric constraints) to harden edge cases.
24+
- Add a small property-based test set for key validators (string patterns, numeric constraints) to harden edge cases and catch regressions.

docs/guides/error-handling.md

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,92 @@ try {
6262
}
6363
```
6464

65+
## Flattened Errors for API Consumption
66+
67+
For API responses, you often need a flat list of errors with field paths. The `ValidationException` class provides methods to convert nested error structures into a flattened format suitable for frontend consumption.
68+
69+
### Using getFlattenedErrors() with Exceptions
70+
71+
When catching a `ValidationException`, use `getFlattenedErrors()` to get a flattened list:
72+
73+
```php
74+
use Lemmon\Validator\ValidationException;
75+
76+
try {
77+
$schema->validate($input);
78+
} catch (ValidationException $e) {
79+
$flattened = $e->getFlattenedErrors();
80+
// Returns: [
81+
// ['path' => 'name', 'message' => 'Value is required'],
82+
// ['path' => 'email', 'message' => 'Value must be a valid email address'],
83+
// ['path' => 'user.profile.phone', 'message' => 'Invalid phone format']
84+
// ]
85+
}
86+
```
87+
88+
### Using flattenErrors() with tryValidate()
89+
90+
When using `tryValidate()` (which doesn't throw exceptions), use the static `flattenErrors()` method:
91+
92+
```php
93+
use Lemmon\Validator\ValidationException;
94+
95+
[$valid, $data, $errors] = $validator->tryValidate($input);
96+
97+
if (!$valid) {
98+
$flattened = ValidationException::flattenErrors($errors);
99+
// Returns empty array if $errors is null
100+
// Otherwise returns same format as getFlattenedErrors()
101+
}
102+
```
103+
104+
### Error Path Convention
105+
106+
- **Root-level errors**: Use `'_root'` path for scalar validator errors and container type errors
107+
- **Field paths**: Use dot notation for nested fields (e.g., `'user.profile.email'`)
108+
- **Array items**: Use index notation (e.g., `'items.0'`, `'items.1'`)
109+
110+
### Example: API Response Format
111+
112+
```php
113+
try {
114+
$schema->validate($input);
115+
return ['success' => true, 'data' => $validated];
116+
} catch (ValidationException $e) {
117+
return [
118+
'success' => false,
119+
'errors' => $e->getFlattenedErrors()
120+
];
121+
}
122+
```
123+
124+
JSON output example:
125+
```json
126+
{
127+
"success": false,
128+
"errors": [
129+
{"path": "name", "message": "Value is required"},
130+
{"path": "email", "message": "Value must be a valid email address"},
131+
{"path": "user.profile.phone", "message": "Invalid phone format"}
132+
]
133+
}
134+
```
135+
136+
### Example: Root-Level Errors
137+
138+
For scalar validators or container type errors:
139+
140+
```php
141+
try {
142+
Validator::isString()->email()->validate('invalid');
143+
} catch (ValidationException $e) {
144+
$flattened = $e->getFlattenedErrors();
145+
// Returns: [
146+
// ['path' => '_root', 'message' => 'Value must be a valid email address']
147+
// ]
148+
}
149+
```
150+
65151
## Comprehensive Error Collection
66152

67153
Unlike many validators that stop at the first error, Lemmon Validator collects **all** validation errors:

docs/guides/object-validation.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,34 @@ if (!$valid) {
431431
}
432432
```
433433

434+
### Flattened Errors for API Responses
435+
436+
For API consumption, you can flatten the nested error structure:
437+
438+
```php
439+
use Lemmon\Validator\ValidationException;
440+
441+
// With exceptions
442+
try {
443+
$schema->validate($input);
444+
} catch (ValidationException $e) {
445+
$flattened = $e->getFlattenedErrors();
446+
// Returns: [
447+
// ['path' => 'name', 'message' => 'Value is required'],
448+
// ['path' => 'user.profile.email', 'message' => 'Value must be a valid email address']
449+
// ]
450+
}
451+
452+
// With tryValidate
453+
[$valid, $data, $errors] = $schema->tryValidate($input);
454+
if (!$valid) {
455+
$flattened = ValidationException::flattenErrors($errors);
456+
// Same format as above
457+
}
458+
```
459+
460+
See the [Error Handling Guide](../guides/error-handling.md#flattened-errors-for-api-consumption) for complete documentation on flattened errors.
461+
434462
## Advanced Examples
435463

436464
### Multi-Step Form Validation

src/Lemmon/Validator/StringValidator.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,8 +217,7 @@ public function base64(
217217
Base64Variant::Standard => preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $value) === 1
218218
&& ($decoded = base64_decode($value, true)) !== false
219219
&& base64_encode($decoded) === $value,
220-
Base64Variant::UrlSafe => // Try standard Base64 first // URL-safe parsers accept both standard and URL-safe variants
221-
preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $value) === 1
220+
Base64Variant::UrlSafe => preg_match('/^[A-Za-z0-9+\/]*={0,2}$/', $value) === 1 // Try standard Base64 first // URL-safe parsers accept both standard and URL-safe variants
222221
&& ($decoded = base64_decode($value, true)) !== false
223222
&& base64_encode($decoded) === $value
224223
// Or try URL-safe Base64

src/Lemmon/Validator/ValidationException.php

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,141 @@ public function getErrors(): array
2323
{
2424
return $this->errors;
2525
}
26+
27+
/**
28+
* Returns a flattened list of errors suitable for API consumption.
29+
*
30+
* Each error entry contains:
31+
* - 'path': Field path ('_root' for root-level scalar/container errors, '_global' reserved for future cross-field validation)
32+
* - 'message': Error message
33+
*
34+
* @return array<int, array{path: string, message: string}>
35+
*/
36+
public function getFlattenedErrors(): array
37+
{
38+
return self::flattenErrors($this->errors);
39+
}
40+
41+
/**
42+
* Flattens an error array structure into a list suitable for API consumption.
43+
*
44+
* Useful for flattening errors from `tryValidate()` results:
45+
* ```php
46+
* [$valid, $data, $errors] = $validator->tryValidate($value);
47+
* if (!$valid) {
48+
* $flattened = ValidationException::flattenErrors($errors);
49+
* }
50+
* ```
51+
*
52+
* Each error entry contains:
53+
* - 'path': Field path ('_root' for root-level scalar/container errors, '_global' reserved for future cross-field validation)
54+
* - 'message': Error message
55+
*
56+
* @param array<array-key, mixed>|null $errors The error structure to flatten (null returns empty array)
57+
* @return array<int, array{path: string, message: string}>
58+
*/
59+
public static function flattenErrors(null|array $errors): array
60+
{
61+
if ($errors === null) {
62+
return [];
63+
}
64+
65+
return self::flattenErrorsRecursive($errors, '');
66+
}
67+
68+
/**
69+
* Recursively flattens nested error structure into a list of path/message pairs.
70+
*
71+
* @param array<array-key, mixed> $errors The error structure to flatten
72+
* @param string $basePath The base path prefix for building full paths
73+
* @return array<int, array{path: string, message: string}>
74+
*/
75+
private static function flattenErrorsRecursive(array $errors, string $basePath): array
76+
{
77+
$result = [];
78+
79+
// Check if this is a flat array of strings (scalar/root-level errors)
80+
if (self::isFlatErrorArray($errors)) {
81+
// This is a root-level error (scalar validator or container type error)
82+
$path = $basePath === '' ? '_root' : $basePath;
83+
foreach ($errors as $message) {
84+
if (!is_string($message)) {
85+
continue;
86+
}
87+
$result[] = ['path' => $path, 'message' => $message];
88+
}
89+
return $result;
90+
}
91+
92+
// This is a nested structure - recurse through it
93+
foreach ($errors as $key => $value) {
94+
$currentPath = self::buildPath($basePath, (string) $key);
95+
96+
if (!is_array($value)) {
97+
if (is_string($value)) {
98+
// Single string error at this level
99+
$result[] = ['path' => $currentPath, 'message' => $value];
100+
}
101+
continue;
102+
}
103+
104+
if (self::isFlatErrorArray($value)) {
105+
// This is a field-level error array
106+
foreach ($value as $message) {
107+
if (!is_string($message)) {
108+
continue;
109+
}
110+
$result[] = ['path' => $currentPath, 'message' => $message];
111+
}
112+
continue;
113+
}
114+
115+
// This is a nested structure - recurse
116+
$result = array_merge($result, self::flattenErrorsRecursive($value, $currentPath));
117+
}
118+
119+
return $result;
120+
}
121+
122+
/**
123+
* Checks if an array is a flat array of strings (error messages).
124+
*
125+
* @param array<array-key, mixed> $array
126+
* @return bool
127+
*/
128+
private static function isFlatErrorArray(array $array): bool
129+
{
130+
// Empty array is considered flat
131+
if ($array === []) {
132+
return false;
133+
}
134+
135+
// Check if all values are strings and keys are numeric (0-indexed)
136+
foreach ($array as $key => $value) {
137+
if (is_int($key) && is_string($value)) {
138+
continue;
139+
}
140+
return false;
141+
}
142+
143+
// Check if keys are sequential starting from 0
144+
$keys = array_keys($array);
145+
return $keys === range(0, count($array) - 1);
146+
}
147+
148+
/**
149+
* Builds a path string by concatenating base path with a key.
150+
*
151+
* @param string $basePath The base path
152+
* @param string $key The key to append
153+
* @return string The combined path
154+
*/
155+
private static function buildPath(string $basePath, string $key): string
156+
{
157+
if ($basePath === '') {
158+
return $key;
159+
}
160+
161+
return $basePath . '.' . $key;
162+
}
26163
}

0 commit comments

Comments
 (0)