Skip to content

Commit 6580e9e

Browse files
committed
Refactor DTO validation by delegating constraints to attributes
1 parent 2336d70 commit 6580e9e

8 files changed

Lines changed: 156 additions & 49 deletions

File tree

src/base/dto/ValidatableDtoTrait.php

Lines changed: 69 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,8 @@
77
use PSFS\base\types\helpers\attributes\CsrfField;
88
use PSFS\base\types\helpers\attributes\CsrfProtected;
99
use PSFS\base\types\helpers\attributes\DefaultValue;
10-
use PSFS\base\types\helpers\attributes\Length;
11-
use PSFS\base\types\helpers\attributes\Max;
12-
use PSFS\base\types\helpers\attributes\Min;
10+
use PSFS\base\types\helpers\attributes\DtoConstraintAttributeContract;
1311
use PSFS\base\types\helpers\attributes\Nullable;
14-
use PSFS\base\types\helpers\attributes\Pattern;
1512
use PSFS\base\types\helpers\attributes\Values;
1613
use ReflectionClass;
1714
use ReflectionProperty;
@@ -30,9 +27,7 @@ public function validate(?ValidationContext $ctx = null): ValidationResult
3027

3128
$this->applyDefaultValues($publicProperties);
3229
$this->validateUnknownFields($publicProperties, $context, $result);
33-
foreach ($publicProperties as $property) {
34-
$this->validateProperty($property, $context, $result);
35-
}
30+
$this->validateProperties($publicProperties, $context, $result);
3631
$this->validateCsrfIfRequired($reflector, $context, $result);
3732

3833
$this->__validationResult = $result;
@@ -145,53 +140,31 @@ private function validateProperty(ReflectionProperty $property, ValidationContex
145140
$existsInPayload = array_key_exists($name, $context->payload);
146141

147142
$required = (bool)MetadataReader::getTagValue('required', $doc, false, $property);
148-
$nullable = $this->propertyAttribute($property, Nullable::class)?->value ?? false;
149-
if ($required && !$existsInPayload && $value === null) {
150-
$result->addError($name, 'required', str_replace('%s', "<strong>{$name}</strong>", t('Field %s is required')));
143+
if ($this->isRequiredMissing($required, $existsInPayload, $value)) {
144+
$result->addError($name, 'required', $this->requiredFieldMessage($name));
151145
return;
152146
}
153147
if ($value === null) {
154-
if ($existsInPayload && !$nullable && $required) {
155-
$result->addError($name, 'null_not_allowed', str_replace('%s', "<strong>{$name}</strong>", t('Field %s is required')));
148+
if ($existsInPayload && !$this->allowsNull($property) && $required) {
149+
$result->addError($name, 'null_not_allowed', $this->requiredFieldMessage($name));
156150
}
157151
return;
158152
}
159153

160154
$varType = MetadataReader::extractVarType($property, $doc);
161155
if (is_string($varType) && !$this->matchesDeclaredType($value, $varType)) {
162-
$result->addError($name, 'invalid_type', str_replace('%s', "<strong>{$name}</strong>", t('Field %s has an invalid format')));
156+
$result->addError($name, 'invalid_type', $this->invalidFormatMessage($name));
163157
return;
164158
}
165159

166-
$valuesAttr = $this->propertyAttribute($property, Values::class);
167-
$values = $valuesAttr instanceof Values
168-
? (is_array($valuesAttr->value) ? $valuesAttr->value : [$valuesAttr->value])
169-
: InjectorHelper::getValues($doc, $property);
170-
if (is_array($values) && !in_array($value, $values, true)) {
171-
$result->addError($name, 'invalid_enum', str_replace('%s', "<strong>{$name}</strong>", t('Field %s has an invalid format')));
172-
}
173-
174-
$pattern = $this->propertyAttribute($property, Pattern::class);
175-
if ($pattern instanceof Pattern && is_string($value) && preg_match($pattern->value, $value) !== 1) {
176-
$result->addError($name, 'pattern_mismatch', str_replace('%s', "<strong>{$name}</strong>", t('Field %s has an invalid format')));
177-
}
178-
179-
$length = $this->propertyAttribute($property, Length::class);
180-
if ($length instanceof Length && is_string($value)) {
181-
$strlen = mb_strlen($value);
182-
if ($strlen < $length->min || ($length->max !== null && $strlen > $length->max)) {
183-
$result->addError($name, 'invalid_length', str_replace('%s', "<strong>{$name}</strong>", t('Field %s has an invalid format')));
160+
if (!$this->hasConstraintAttribute($property, Values::class)) {
161+
$values = InjectorHelper::getValues($doc, $property);
162+
if (is_array($values) && !in_array($value, $values, true)) {
163+
$result->addError($name, 'invalid_enum', $this->invalidFormatMessage($name));
184164
}
185165
}
186166

187-
$min = $this->propertyAttribute($property, Min::class);
188-
if ($min instanceof Min && is_numeric($value) && (float)$value < $min->value) {
189-
$result->addError($name, 'min_value', str_replace('%s', "<strong>{$name}</strong>", t('Field %s has an invalid format')));
190-
}
191-
$max = $this->propertyAttribute($property, Max::class);
192-
if ($max instanceof Max && is_numeric($value) && (float)$value > $max->value) {
193-
$result->addError($name, 'max_value', str_replace('%s', "<strong>{$name}</strong>", t('Field %s has an invalid format')));
194-
}
167+
$this->validateConstraintAttributes($property, $name, $value, $result);
195168
}
196169

197170
private function validateCsrfIfRequired(
@@ -272,4 +245,61 @@ private function classAttribute(ReflectionClass $reflector, string $attributeCla
272245
}
273246
return $attrs[0]->newInstance();
274247
}
248+
249+
/**
250+
* @param array<int, ReflectionProperty> $publicProperties
251+
*/
252+
private function validateProperties(array $publicProperties, ValidationContext $context, ValidationResult $result): void
253+
{
254+
foreach ($publicProperties as $property) {
255+
$this->validateProperty($property, $context, $result);
256+
}
257+
}
258+
259+
private function isRequiredMissing(bool $required, bool $existsInPayload, mixed $value): bool
260+
{
261+
return $required && !$existsInPayload && $value === null;
262+
}
263+
264+
private function allowsNull(ReflectionProperty $property): bool
265+
{
266+
$nullable = $this->propertyAttribute($property, Nullable::class);
267+
if (!$nullable instanceof Nullable) {
268+
return false;
269+
}
270+
return $nullable->allowsNull();
271+
}
272+
273+
private function requiredFieldMessage(string $field): string
274+
{
275+
return str_replace('%s', "<strong>{$field}</strong>", t('Field %s is required'));
276+
}
277+
278+
private function invalidFormatMessage(string $field): string
279+
{
280+
return str_replace('%s', "<strong>{$field}</strong>", t('Field %s has an invalid format'));
281+
}
282+
283+
private function hasConstraintAttribute(ReflectionProperty $property, string $attributeClass): bool
284+
{
285+
return !empty($property->getAttributes($attributeClass));
286+
}
287+
288+
private function validateConstraintAttributes(
289+
ReflectionProperty $property,
290+
string $field,
291+
mixed $value,
292+
ValidationResult $result
293+
): void {
294+
foreach ($property->getAttributes() as $reflectionAttribute) {
295+
$attribute = $reflectionAttribute->newInstance();
296+
if (!$attribute instanceof DtoConstraintAttributeContract) {
297+
continue;
298+
}
299+
if ($attribute->validateValue($value)) {
300+
continue;
301+
}
302+
$result->addError($field, $attribute->errorCode(), $this->invalidFormatMessage($field));
303+
}
304+
}
275305
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace PSFS\base\types\helpers\attributes;
4+
5+
interface DtoConstraintAttributeContract
6+
{
7+
public function validateValue(mixed $value): bool;
8+
9+
public function errorCode(): string;
10+
}
11+

src/base/types/helpers/attributes/Length.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,23 @@
33
namespace PSFS\base\types\helpers\attributes;
44

55
#[\Attribute(\Attribute::TARGET_PROPERTY)]
6-
class Length
6+
class Length implements DtoConstraintAttributeContract
77
{
88
public function __construct(public int $min = 0, public ?int $max = null)
99
{
1010
}
11-
}
1211

12+
public function validateValue(mixed $value): bool
13+
{
14+
if (!is_string($value)) {
15+
return true;
16+
}
17+
$strlen = mb_strlen($value);
18+
return $strlen >= $this->min && ($this->max === null || $strlen <= $this->max);
19+
}
20+
21+
public function errorCode(): string
22+
{
23+
return 'invalid_length';
24+
}
25+
}

src/base/types/helpers/attributes/Max.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,22 @@
33
namespace PSFS\base\types\helpers\attributes;
44

55
#[\Attribute(\Attribute::TARGET_PROPERTY)]
6-
class Max
6+
class Max implements DtoConstraintAttributeContract
77
{
88
public function __construct(public float|int $value)
99
{
1010
}
11-
}
1211

12+
public function validateValue(mixed $value): bool
13+
{
14+
if (!is_numeric($value)) {
15+
return true;
16+
}
17+
return (float)$value <= $this->value;
18+
}
19+
20+
public function errorCode(): string
21+
{
22+
return 'max_value';
23+
}
24+
}

src/base/types/helpers/attributes/Min.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,22 @@
33
namespace PSFS\base\types\helpers\attributes;
44

55
#[\Attribute(\Attribute::TARGET_PROPERTY)]
6-
class Min
6+
class Min implements DtoConstraintAttributeContract
77
{
88
public function __construct(public float|int $value)
99
{
1010
}
11-
}
1211

12+
public function validateValue(mixed $value): bool
13+
{
14+
if (!is_numeric($value)) {
15+
return true;
16+
}
17+
return (float)$value >= $this->value;
18+
}
19+
20+
public function errorCode(): string
21+
{
22+
return 'min_value';
23+
}
24+
}

src/base/types/helpers/attributes/Nullable.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,9 @@ class Nullable
88
public function __construct(public bool $value = true)
99
{
1010
}
11-
}
1211

12+
public function allowsNull(): bool
13+
{
14+
return $this->value;
15+
}
16+
}

src/base/types/helpers/attributes/Pattern.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,22 @@
33
namespace PSFS\base\types\helpers\attributes;
44

55
#[\Attribute(\Attribute::TARGET_PROPERTY)]
6-
class Pattern
6+
class Pattern implements DtoConstraintAttributeContract
77
{
88
public function __construct(public string $value)
99
{
1010
}
11-
}
1211

12+
public function validateValue(mixed $value): bool
13+
{
14+
if (!is_string($value)) {
15+
return true;
16+
}
17+
return preg_match($this->value, $value) === 1;
18+
}
19+
20+
public function errorCode(): string
21+
{
22+
return 'pattern_mismatch';
23+
}
24+
}

src/base/types/helpers/attributes/Values.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
namespace PSFS\base\types\helpers\attributes;
44

55
#[\Attribute(\Attribute::TARGET_PROPERTY)]
6-
class Values implements MetadataAttributeContract
6+
class Values implements MetadataAttributeContract, DtoConstraintAttributeContract
77
{
88
use MetadataAttributeValueResolverTrait;
99
use MetadataStringNormalizerTrait;
@@ -24,4 +24,17 @@ public static function tag(): string
2424
{
2525
return 'values';
2626
}
27+
28+
public function validateValue(mixed $value): bool
29+
{
30+
if (!is_array($this->value)) {
31+
return true;
32+
}
33+
return in_array($value, $this->value, true);
34+
}
35+
36+
public function errorCode(): string
37+
{
38+
return 'invalid_enum';
39+
}
2740
}

0 commit comments

Comments
 (0)