Skip to content

Commit f418c39

Browse files
authored
feat: add attribute-based DTO validation (#304)
2 parents 9186df2 + 519e97e commit f418c39

15 files changed

Lines changed: 585 additions & 28 deletions

File tree

‎src/Phaseolies/Http/Requests/Attributes/BindPayload.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ final class BindPayload
77
{
88
public function __construct(
99
public bool $strict = false,
10+
public bool $validate = false,
1011
) {}
1112
}

‎src/Phaseolies/Http/Support/InteractsWithDTO.php‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,19 @@ trait InteractsWithDTO
1313
*/
1414
public function bindTo(object $object, bool $strict = true): object
1515
{
16-
$data = $this->all();
16+
return $this->bindData($object, $this->all(), $strict);
17+
}
1718

19+
/**
20+
* Binds the given data to an object.
21+
*
22+
* @param object $object
23+
* @param array<string, mixed> $data
24+
* @param bool $strict
25+
* @return object
26+
*/
27+
public function bindData(object $object, array $data, bool $strict = true): object
28+
{
1829
foreach ($data as $key => $value) {
1930
if ($strict && !property_exists($object, $key)) {
2031
continue;

‎src/Phaseolies/Http/Support/RequestHelper.php‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use InvalidArgumentException;
66
use Phaseolies\Database\Entity\Model;
7+
use Phaseolies\Validation\DtoValidator;
8+
use Phaseolies\Validation\MessageResolver;
79

810
trait RequestHelper
911
{
@@ -73,6 +75,27 @@ public function passed(array $excludeKeys = ['csrf_token']): array
7375
return array_diff_key($this->passedData, $exclude);
7476
}
7577

78+
/**
79+
* Validates and hydrates a DTO using property constraints.
80+
*
81+
* @param object $dto
82+
* @param bool $strict
83+
* @return object
84+
*/
85+
public function validateDto(object $dto, bool $strict = true): object
86+
{
87+
$validator = new DtoValidator(
88+
new MessageResolver(app('translator')),
89+
);
90+
$errors = $validator->errors($dto, $this->all());
91+
92+
if ($errors !== []) {
93+
$this->failValidation($errors);
94+
}
95+
96+
return $this->bindData($dto, $validator->normalize($dto, $this->all()), $strict);
97+
}
98+
7699
/**
77100
* Retrieves the validation errors, excluding the `csrf_token` field.
78101
*

‎src/Phaseolies/Http/Validation/Rule.php‎

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,41 @@ trait Rule
1313
{
1414
use ValidationRules;
1515

16+
/**
17+
* Converts validation errors into the response expected by the request type.
18+
*
19+
* @param array<string, mixed> $errors
20+
*/
21+
protected function failValidation(array $errors): never
22+
{
23+
if (request()->isAjax() || request()->isApiRequest()) {
24+
$exception = new HttpResponseException(
25+
$errors,
26+
Response::HTTP_UNPROCESSABLE_ENTITY
27+
);
28+
29+
$exception->setResponse(
30+
(new JsonErrorRenderer())->render(
31+
$exception,
32+
Response::HTTP_UNPROCESSABLE_ENTITY,
33+
$errors
34+
)
35+
);
36+
37+
throw $exception;
38+
}
39+
40+
$this->setErrors($errors);
41+
foreach ($errors as $key => $error) {
42+
session()->putPeek($key, implode(' ', (array) $error));
43+
}
44+
45+
$response = redirect()->back()->withInput()->withErrors($errors);
46+
47+
throw (new HttpResponseException($errors, $response->getStatusCode()))
48+
->setResponse($response);
49+
}
50+
1651
/**
1752
* Validate the input data against the given rules.
1853
*
@@ -50,32 +85,7 @@ public function sanitize(array $rules): array|Response
5085
}
5186

5287
if (!empty($errors)) {
53-
if (request()->isAjax() || request()->isApiRequest()) {
54-
$exception = new HttpResponseException(
55-
$errors,
56-
Response::HTTP_UNPROCESSABLE_ENTITY
57-
);
58-
59-
$exception->setResponse(
60-
(new JsonErrorRenderer())->render(
61-
$exception,
62-
Response::HTTP_UNPROCESSABLE_ENTITY,
63-
$errors
64-
)
65-
);
66-
67-
throw $exception;
68-
}
69-
70-
$this->setErrors($errors);
71-
foreach ($errors as $key => $error) {
72-
session()->putPeek($key, implode(' ', (array)$error));
73-
}
74-
75-
$response = redirect()->back()->withInput()->withErrors($errors);
76-
77-
throw (new HttpResponseException($errors, $response->getStatusCode()))
78-
->setResponse($response);
88+
$this->failValidation($errors);
7989
}
8090

8191
$this->setPassedData($input);

‎src/Phaseolies/Support/Router.php‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1590,9 +1590,13 @@ private function handleBindPayloadAttribute(\ReflectionParameter $parameter, App
15901590
}
15911591

15921592
$dto = $app->make($dtoClass);
1593+
/** @var Request $request */
15931594
$request = $app->make('request');
15941595
$attributeInstance = $mapAttributes[0]->newInstance();
1595-
$instance = $request->bindTo($dto, (bool)($attributeInstance->strict ?? true));
1596+
$strict = (bool) ($attributeInstance->strict ?? true);
1597+
$instance = $attributeInstance->validate
1598+
? $request->validateDto($dto, $strict)
1599+
: $request->bindTo($dto, $strict);
15961600

15971601
return ['handled' => true, 'instance' => $instance];
15981602
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
namespace Phaseolies\Validation\Attributes;
4+
5+
use Attribute;
6+
use Phaseolies\Validation\Constraint;
7+
use Phaseolies\Validation\ConstraintViolation;
8+
9+
#[Attribute(Attribute::TARGET_PROPERTY)]
10+
final class Between implements Constraint
11+
{
12+
/**
13+
* Creates a between constraint.
14+
*
15+
* @param int|float $min
16+
* @param int|float $max
17+
*/
18+
public function __construct(
19+
private readonly int|float $min,
20+
private readonly int|float $max,
21+
) {
22+
}
23+
24+
/**
25+
* Validates that a value is within the configured range.
26+
*
27+
* @param mixed $value
28+
* @param string $property
29+
* @param array<string, mixed> $payload
30+
* @return ConstraintViolation|null
31+
*/
32+
public function validate(mixed $value, string $property, array $payload): ?ConstraintViolation
33+
{
34+
if ($value === null || !is_numeric($value)) {
35+
return null;
36+
}
37+
38+
return $value < $this->min || $value > $this->max
39+
? new ConstraintViolation($property, 'between', [
40+
':min' => $this->min,
41+
'min' => $this->min,
42+
':max' => $this->max,
43+
'max' => $this->max,
44+
])
45+
: null;
46+
}
47+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php
2+
3+
namespace Phaseolies\Validation\Attributes;
4+
5+
use Attribute;
6+
use Phaseolies\Validation\Constraint;
7+
use Phaseolies\Validation\ConstraintViolation;
8+
9+
#[Attribute(Attribute::TARGET_PROPERTY)]
10+
final class Integer implements Constraint
11+
{
12+
/**
13+
* Creates an integer constraint.
14+
*
15+
* @param string $messageKey
16+
*/
17+
public function __construct(
18+
private readonly string $messageKey = 'int',
19+
) {
20+
}
21+
22+
/**
23+
* Validates that a value is an integer.
24+
*
25+
* @param mixed $value
26+
* @param string $property
27+
* @param array<string, mixed> $payload
28+
* @return ConstraintViolation|null
29+
*/
30+
public function validate(mixed $value, string $property, array $payload): ?ConstraintViolation
31+
{
32+
$valid = is_int($value) || (is_string($value) && filter_var($value, FILTER_VALIDATE_INT) !== false);
33+
34+
return $value !== null && !$valid
35+
? new ConstraintViolation($property, $this->messageKey)
36+
: null;
37+
}
38+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace Phaseolies\Validation\Attributes;
4+
5+
use Attribute;
6+
use Phaseolies\Validation\Constraint;
7+
use Phaseolies\Validation\ConstraintViolation;
8+
9+
#[Attribute(Attribute::TARGET_PROPERTY)]
10+
final class Length implements Constraint
11+
{
12+
/**
13+
* Creates a length constraint.
14+
*
15+
* @param int|null $min
16+
* @param int|null $max
17+
*/
18+
public function __construct(
19+
private readonly ?int $min = null,
20+
private readonly ?int $max = null,
21+
) {
22+
}
23+
24+
/**
25+
* Validates the length of a string value.
26+
*
27+
* @param mixed $value
28+
* @param string $property
29+
* @param array<string, mixed> $payload
30+
* @return ConstraintViolation|null
31+
*/
32+
public function validate(mixed $value, string $property, array $payload): ?ConstraintViolation
33+
{
34+
if ($value === null || !is_string($value)) {
35+
return null;
36+
}
37+
38+
$length = mb_strlen($value);
39+
40+
if ($this->min !== null && $length < $this->min) {
41+
return new ConstraintViolation($property, 'min.string', [
42+
':min' => $this->min,
43+
'min' => $this->min,
44+
]);
45+
}
46+
47+
if ($this->max !== null && $length > $this->max) {
48+
return new ConstraintViolation($property, 'max.string', [
49+
':max' => $this->max,
50+
'max' => $this->max,
51+
]);
52+
}
53+
54+
return null;
55+
}
56+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace Phaseolies\Validation\Attributes;
4+
5+
use Attribute;
6+
use Phaseolies\Validation\Constraint;
7+
use Phaseolies\Validation\ConstraintViolation;
8+
9+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
10+
final class NotBlank implements Constraint
11+
{
12+
/**
13+
* Creates a not-blank constraint.
14+
*
15+
* @param string $messageKey
16+
*/
17+
public function __construct(
18+
private readonly string $messageKey = 'required',
19+
) {
20+
}
21+
22+
/**
23+
* Validates that a value is not blank.
24+
*
25+
* @param mixed $value
26+
* @param string $property
27+
* @param array<string, mixed> $payload
28+
* @return ConstraintViolation|null
29+
*/
30+
public function validate(mixed $value, string $property, array $payload): ?ConstraintViolation
31+
{
32+
return $value === null || (is_string($value) && trim($value) === '')
33+
? new ConstraintViolation($property, $this->messageKey)
34+
: null;
35+
}
36+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace Phaseolies\Validation\Attributes;
4+
5+
use Attribute;
6+
use Phaseolies\Validation\Constraint;
7+
use Phaseolies\Validation\ConstraintViolation;
8+
9+
#[Attribute(Attribute::TARGET_PROPERTY)]
10+
final class StringType implements Constraint
11+
{
12+
/**
13+
* Creates a string constraint.
14+
*
15+
* @param string $messageKey
16+
*/
17+
public function __construct(
18+
private readonly string $messageKey = 'string',
19+
) {
20+
}
21+
22+
/**
23+
* Validates that a value is a string.
24+
*
25+
* @param mixed $value
26+
* @param string $property
27+
* @param array<string, mixed> $payload
28+
* @return ConstraintViolation|null
29+
*/
30+
public function validate(mixed $value, string $property, array $payload): ?ConstraintViolation
31+
{
32+
return $value !== null && !is_string($value)
33+
? new ConstraintViolation($property, $this->messageKey)
34+
: null;
35+
}
36+
}

0 commit comments

Comments
 (0)