Skip to content

Commit 58f1617

Browse files
committed
feat: Add support to validate a callable
1 parent b8a08c7 commit 58f1617

File tree

6 files changed

+261
-17
lines changed

6 files changed

+261
-17
lines changed

composer.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Property.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@
44

55
namespace Attributes\Validation;
66

7+
use ReflectionParameter;
78
use ReflectionProperty;
89

910
class Property
1011
{
11-
private ReflectionProperty $property;
12+
private ReflectionProperty|ReflectionParameter $property;
1213

1314
private mixed $value;
1415

15-
public function __construct(ReflectionProperty $property, mixed $value)
16+
public function __construct(ReflectionProperty|ReflectionParameter $property, mixed $value)
1617
{
1718
$this->property = $property;
1819
$this->value = $value;
@@ -33,7 +34,7 @@ public function setValue(mixed $value): void
3334
$this->value = $value;
3435
}
3536

36-
public function getReflection(): ReflectionProperty
37+
public function getReflection(): ReflectionProperty|ReflectionParameter
3738
{
3839
return $this->property;
3940
}

src/Validatable.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,14 @@ interface Validatable
1919
* @throws ValidationException - If the validation fails
2020
*/
2121
public function validate(array $data, object $model): object;
22+
23+
/**
24+
* @param array $data - Data to be validated
25+
* @param callable $call - Callable to be validated
26+
*
27+
* @returns array - Arguments in a sequence order for the given function
28+
*
29+
* @throws ValidationException - If the validation fails
30+
*/
31+
public function validateCallable(array $data, callable $call): array;
2232
}

src/Validator.php

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
use Attributes\Validation\Validators\TypeHintValidator;
1717
use ReflectionClass;
1818
use ReflectionException;
19+
use ReflectionFunction;
20+
use ReflectionParameter;
1921
use ReflectionProperty;
2022
use Respect\Validation\Exceptions\ValidationException as RespectValidationException;
2123
use Respect\Validation\Factory;
@@ -86,7 +88,11 @@ public function validate(array $data, string|object $model): object
8688

8789
if (! array_key_exists($aliasName, $data)) {
8890
if (! $reflectionProperty->isInitialized($validModel)) {
89-
$errorInfo->addError("Missing required property '$aliasName'");
91+
try {
92+
$errorInfo->addError("Missing required property '$aliasName'");
93+
} catch (StopValidationException $e) {
94+
break;
95+
}
9096
}
9197

9298
$this->context->pop('internal.currentProperty');
@@ -118,6 +124,72 @@ public function validate(array $data, string|object $model): object
118124
return $validModel;
119125
}
120126

127+
/**
128+
* Validates a given data according to a given model
129+
*
130+
* @param array $data - Data to validate
131+
* @param callable $call - Callable to validate data against
132+
* @return array - Returns an array with the necessary arguments for the callable
133+
*
134+
* @throws ValidationException - If validation fails
135+
* @throws ContextPropertyException - If unable to retrieve a given context property
136+
* @throws ReflectionException
137+
* @throws InvalidOptionException
138+
*/
139+
public function validateCallable(array $data, callable $call): array
140+
{
141+
$arguments = [];
142+
$reflectionFunction = new ReflectionFunction($call);
143+
$errorInfo = $this->context->getOptional(ErrorHolder::class) ?: new ErrorHolder($this->context);
144+
$this->context->set(ErrorHolder::class, $errorInfo, override: true);
145+
$defaultAliasGenerator = $this->getDefaultAliasGenerator($reflectionFunction);
146+
foreach ($reflectionFunction->getParameters() as $parameter) {
147+
if (! $this->isToValidate($parameter)) {
148+
continue;
149+
}
150+
151+
$propertyName = $parameter->getName();
152+
$aliasName = $this->getAliasName($parameter, $defaultAliasGenerator);
153+
$this->context->push('internal.currentProperty', $propertyName);
154+
155+
if (! array_key_exists($aliasName, $data)) {
156+
if (! $parameter->isDefaultValueAvailable()) {
157+
try {
158+
$errorInfo->addError("Missing required argument '$aliasName'");
159+
} catch (StopValidationException $error) {
160+
break;
161+
}
162+
}
163+
164+
$this->context->pop('internal.currentProperty');
165+
166+
continue;
167+
}
168+
169+
$propertyValue = $data[$aliasName];
170+
$property = new Property($parameter, $propertyValue);
171+
$this->context->set(Property::class, $property, override: true);
172+
173+
try {
174+
$this->validator->validate($property, $this->context);
175+
$arguments[$parameter->getName()] = $property->getValue();
176+
} catch (ValidationException|RespectValidationException $error) {
177+
$errorInfo->addError($error);
178+
} catch (ContinueValidationException $error) {
179+
} catch (StopValidationException $error) {
180+
break;
181+
} finally {
182+
$this->context->pop('internal.currentProperty');
183+
}
184+
}
185+
186+
if ($errorInfo->hasErrors()) {
187+
throw new ValidationException('Invalid data', $errorInfo);
188+
}
189+
190+
return $arguments;
191+
}
192+
121193
protected function getDefaultPropertyValidator(): PropertyValidator
122194
{
123195
$chainRulesExtractor = new ChainValidator;
@@ -133,9 +205,9 @@ protected function getDefaultPropertyValidator(): PropertyValidator
133205
* @throws ContextPropertyException
134206
* @throws InvalidOptionException
135207
*/
136-
protected function getDefaultAliasGenerator(ReflectionClass $reflectionClass): callable
208+
protected function getDefaultAliasGenerator(ReflectionClass|ReflectionFunction $reflection): callable
137209
{
138-
$allAttributes = $reflectionClass->getAttributes(Options\AliasGenerator::class);
210+
$allAttributes = $reflection->getAttributes(Options\AliasGenerator::class);
139211
foreach ($allAttributes as $attribute) {
140212
$instance = $attribute->newInstance();
141213

@@ -155,10 +227,10 @@ protected function getDefaultAliasGenerator(ReflectionClass $reflectionClass): c
155227
/**
156228
* Retrieves the alias for a given property
157229
*/
158-
protected function getAliasName(ReflectionProperty $reflectionProperty, callable $defaultAliasGenerator): string
230+
protected function getAliasName(ReflectionProperty|ReflectionParameter $reflection, callable $defaultAliasGenerator): string
159231
{
160-
$propertyName = $reflectionProperty->getName();
161-
$allAttributes = $reflectionProperty->getAttributes(Options\Alias::class);
232+
$propertyName = $reflection->getName();
233+
$allAttributes = $reflection->getAttributes(Options\Alias::class);
162234
foreach ($allAttributes as $attribute) {
163235
$instance = $attribute->newInstance();
164236

@@ -171,9 +243,9 @@ protected function getAliasName(ReflectionProperty $reflectionProperty, callable
171243
/**
172244
* Checks if a given property is to be ignored
173245
*/
174-
protected function isToValidate(ReflectionProperty $reflectionProperty): bool
246+
protected function isToValidate(ReflectionProperty|ReflectionParameter $reflection): bool
175247
{
176-
$allAttributes = $reflectionProperty->getAttributes(Options\Ignore::class);
248+
$allAttributes = $reflection->getAttributes(Options\Ignore::class);
177249
foreach ($allAttributes as $attribute) {
178250
$instance = $attribute->newInstance();
179251

tests/Integration/ErrorHandlingTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,24 @@
237237
})
238238
->with([true, false])
239239
->group('validator', 'error-handling', 'nested');
240+
241+
test('Error handling - stop first error basic - not initialized', function (bool $isStrict) {
242+
$validator = new Validator(stopFirstError: true, strict: $isStrict);
243+
$rawData = ['int' => 'invalid'];
244+
try {
245+
$validator->validate($rawData, new class
246+
{
247+
public bool $bool;
248+
249+
public int $int;
250+
});
251+
} catch (ValidationException $e) {
252+
expect($e->getMessage())
253+
->toBe('Invalid data')
254+
->and($e->getErrors())
255+
->toBeArray()
256+
->toBe([['field' => 'bool', 'reason' => 'Missing required property \'bool\'']]);
257+
}
258+
})
259+
->with([true, false])
260+
->group('validator', 'error-handling', 'basic');
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
<?php
2+
3+
/**
4+
* Holds integration tests for error handling
5+
*
6+
* @since 1.0.0
7+
*
8+
* @license MIT
9+
*/
10+
11+
declare(strict_types=1);
12+
13+
namespace Attributes\Validation\Tests\Integration;
14+
15+
use Attributes\Options;
16+
use Attributes\Validation\Exceptions\ValidationException;
17+
use Attributes\Validation\Tests\Models as Models;
18+
use Attributes\Validation\Validator;
19+
20+
// Alias and alias generator
21+
22+
test('Alias/AliasGenerator options - validate callable', function (bool $isStrict) {
23+
$validator = new Validator(strict: $isStrict);
24+
$rawData = [
25+
'FullName' => 'Full Name',
26+
'userProfile' => [
27+
'my_post' => [
28+
'postId' => 1,
29+
'myTitle' => 'My Post Title',
30+
],
31+
],
32+
];
33+
$call = #[Options\AliasGenerator('camel')] function (#[Options\Alias('FullName')] string $full_name, Models\Complex\Profile $user_profile, int $default = 1) {
34+
return 'success';
35+
};
36+
$args = $validator->validateCallable($rawData, $call);
37+
expect($args)
38+
->toBeArray()
39+
->toHaveCount(2)
40+
->and($args['full_name'])
41+
->toBe('Full Name')
42+
->and($args['user_profile'])
43+
->toBeInstanceOf(Models\Complex\Profile::class)
44+
->and($args['user_profile']->my_post->my_post_id)
45+
->toBe(1)
46+
->and($args['user_profile']->my_post->my_title)
47+
->toBe('My Post Title')
48+
->and(call_user_func_array($call, $args))
49+
->toBe('success');
50+
})
51+
->with([true, false])
52+
->group('validator', 'validate-callable', 'options', 'alias', 'alias-generator');
53+
54+
// Ignore
55+
56+
test('Ignore - validate callable', function (bool $isStrict) {
57+
$validator = new Validator(strict: $isStrict);
58+
$rawData = [
59+
'fullName' => 'My full name',
60+
'profile' => [
61+
'my_post' => [
62+
'postId' => 1,
63+
'myTitle' => 'My Post Title',
64+
],
65+
],
66+
'default' => 5,
67+
];
68+
$call = function (string $fullName, #[Options\Ignore] Models\Complex\Profile $profile, int $default = 1) {
69+
return 'success';
70+
};
71+
$args = $validator->validateCallable($rawData, $call);
72+
expect($args)
73+
->toBeArray()
74+
->toHaveCount(2)
75+
->toMatchArray([
76+
'fullName' => 'My full name',
77+
'default' => 5,
78+
]);
79+
})
80+
->with([true, false])
81+
->group('validator', 'validate-callable', 'options', 'ignore');
82+
83+
// Error handling
84+
85+
test('Error handling - validate callable', function (array $rawData, array $expectedErrorMessages) {
86+
$validator = new Validator;
87+
$call = function (string $fullName, Models\Complex\Profile $profile, int $default = 1) {
88+
return 'success';
89+
};
90+
try {
91+
$validator->validateCallable($rawData, $call);
92+
} catch (ValidationException $e) {
93+
expect($e->getMessage())
94+
->toBe('Invalid data')
95+
->and($e->getErrors())
96+
->toBeArray()
97+
->toBe($expectedErrorMessages);
98+
}
99+
})
100+
->with([
101+
[['fullName' => 'My full name'], [['field' => 'profile', 'reason' => 'Missing required argument \'profile\'']]],
102+
[['default' => 'invalid'], [
103+
['field' => 'fullName', 'reason' => 'Missing required argument \'fullName\''],
104+
['field' => 'profile', 'reason' => 'Missing required argument \'profile\''],
105+
['field' => 'default', 'reason' => '"invalid" must be a finite number'],
106+
['field' => 'default', 'reason' => '"invalid" must be numeric'],
107+
]],
108+
[['profile' => ['my_post' => ['myTitle' => '']]], [
109+
['field' => 'fullName', 'reason' => 'Missing required argument \'fullName\''],
110+
['field' => 'profile.my_post.my_post_id', 'reason' => 'Missing required property \'postId\''],
111+
['field' => 'profile.my_post.my_title', 'reason' => 'The value must not be empty'],
112+
]],
113+
])
114+
->group('validator', 'validate-callable', 'error-handling');
115+
116+
test('Error handling - stop first error - validate callable', function (array $rawData, array $expectedErrorMessages) {
117+
$validator = new Validator(strict: true, stopFirstError: true);
118+
$call = function (string $fullName, Models\Complex\Profile $profile, int $default = 1) {
119+
return 'success';
120+
};
121+
try {
122+
$validator->validateCallable($rawData, $call);
123+
} catch (ValidationException $e) {
124+
expect($e->getMessage())
125+
->toBe('Invalid data')
126+
->and($e->getErrors())
127+
->toBeArray()
128+
->toBe($expectedErrorMessages);
129+
}
130+
})
131+
->with([
132+
[['fullName' => 'My full name'], [['field' => 'profile', 'reason' => 'Missing required argument \'profile\'']]],
133+
[['default' => 'invalid'], [
134+
['field' => 'fullName', 'reason' => 'Missing required argument \'fullName\''],
135+
]],
136+
[['fullName' => 'My full name', 'profile' => ['my_post' => ['myTitle' => '']]], [
137+
['field' => 'profile.my_post.my_post_id', 'reason' => 'Missing required property \'postId\''],
138+
]],
139+
])
140+
->group('validator', 'validate-callable', 'error-handling');

0 commit comments

Comments
 (0)