Skip to content

Commit 7d05fdf

Browse files
committed
Extract DTO validation logic into dedicated engine
1 parent 6580e9e commit 7d05fdf

2 files changed

Lines changed: 268 additions & 248 deletions

File tree

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
<?php
2+
3+
namespace PSFS\base\dto;
4+
5+
use PSFS\base\types\helpers\InjectorHelper;
6+
use PSFS\base\types\helpers\MetadataReader;
7+
use PSFS\base\types\helpers\attributes\CsrfField;
8+
use PSFS\base\types\helpers\attributes\CsrfProtected;
9+
use PSFS\base\types\helpers\attributes\DefaultValue;
10+
use PSFS\base\types\helpers\attributes\DtoConstraintAttributeContract;
11+
use PSFS\base\types\helpers\attributes\Nullable;
12+
use PSFS\base\types\helpers\attributes\Values;
13+
use ReflectionClass;
14+
use ReflectionProperty;
15+
16+
final class DtoValidationEngine
17+
{
18+
/**
19+
* @param callable(mixed,string):mixed $castValue
20+
*/
21+
public static function validate(object $dto, ValidationContext $context, callable $castValue): ValidationResult
22+
{
23+
$engine = new self($dto, $context, $castValue);
24+
return $engine->run();
25+
}
26+
27+
/**
28+
* @param callable(mixed,string):mixed $castValue
29+
*/
30+
private function __construct(
31+
private object $dto,
32+
private ValidationContext $context,
33+
private $castValue
34+
) {
35+
}
36+
37+
private function run(): ValidationResult
38+
{
39+
$result = new ValidationResult();
40+
$reflector = new ReflectionClass($this->dto);
41+
$publicProperties = $reflector->getProperties(ReflectionProperty::IS_PUBLIC);
42+
43+
$this->applyDefaultValues($publicProperties);
44+
$this->validateUnknownFields($publicProperties, $reflector, $result);
45+
$this->validateProperties($publicProperties, $result);
46+
$this->validateCsrfIfRequired($reflector, $result);
47+
48+
return $result;
49+
}
50+
51+
/**
52+
* @param array<int, ReflectionProperty> $publicProperties
53+
*/
54+
private function applyDefaultValues(array $publicProperties): void
55+
{
56+
foreach ($publicProperties as $property) {
57+
if ($property->getValue($this->dto) !== null) {
58+
continue;
59+
}
60+
61+
$defaultAttr = $this->propertyAttribute($property, DefaultValue::class);
62+
$doc = (string)($property->getDocComment() ?: '');
63+
$defaultValue = $defaultAttr instanceof DefaultValue
64+
? $defaultAttr->value
65+
: MetadataReader::getTagValue('default', $doc, null, $property);
66+
if ($defaultValue === null) {
67+
continue;
68+
}
69+
70+
$varType = MetadataReader::extractVarType($property, $doc) ?: 'string';
71+
$property->setValue($this->dto, ($this->castValue)($defaultValue, $varType));
72+
}
73+
}
74+
75+
/**
76+
* @param array<int, ReflectionProperty> $publicProperties
77+
*/
78+
private function validateUnknownFields(array $publicProperties, ReflectionClass $reflector, ValidationResult $result): void
79+
{
80+
if (!$this->context->strictUnknownFields) {
81+
return;
82+
}
83+
84+
$allowed = [];
85+
foreach ($publicProperties as $property) {
86+
$allowed[$property->getName()] = true;
87+
}
88+
89+
$csrfProtected = $this->classAttribute($reflector, CsrfProtected::class);
90+
if ($csrfProtected instanceof CsrfProtected && $this->context->enforceCsrf !== false) {
91+
$csrfField = $this->classAttribute($reflector, CsrfField::class);
92+
$allowed[$csrfField?->tokenField ?? '_csrf'] = true;
93+
$allowed[$csrfField?->tokenKeyField ?? '_csrf_key'] = true;
94+
}
95+
96+
foreach ($this->context->payload as $field => $value) {
97+
if (!is_string($field) || array_key_exists($field, $allowed)) {
98+
continue;
99+
}
100+
$result->addError($field, 'unknown_field', $this->messageNotAllowed($field));
101+
}
102+
}
103+
104+
/**
105+
* @param array<int, ReflectionProperty> $publicProperties
106+
*/
107+
private function validateProperties(array $publicProperties, ValidationResult $result): void
108+
{
109+
foreach ($publicProperties as $property) {
110+
$this->validateProperty($property, $result);
111+
}
112+
}
113+
114+
private function validateProperty(ReflectionProperty $property, ValidationResult $result): void
115+
{
116+
$name = $property->getName();
117+
$doc = (string)($property->getDocComment() ?: '');
118+
$value = $property->getValue($this->dto);
119+
$existsInPayload = array_key_exists($name, $this->context->payload);
120+
$required = (bool)MetadataReader::getTagValue('required', $doc, false, $property);
121+
122+
if ($required && !$existsInPayload && $value === null) {
123+
$result->addError($name, 'required', $this->messageRequired($name));
124+
return;
125+
}
126+
127+
if ($value === null) {
128+
if ($existsInPayload && !$this->allowsNull($property) && $required) {
129+
$result->addError($name, 'null_not_allowed', $this->messageRequired($name));
130+
}
131+
return;
132+
}
133+
134+
$varType = MetadataReader::extractVarType($property, $doc);
135+
if (is_string($varType) && !$this->matchesDeclaredType($value, $varType)) {
136+
$result->addError($name, 'invalid_type', $this->messageInvalidFormat($name));
137+
return;
138+
}
139+
140+
if (!$this->hasAttribute($property, Values::class)) {
141+
$values = InjectorHelper::getValues($doc, $property);
142+
if (is_array($values) && !in_array($value, $values, true)) {
143+
$result->addError($name, 'invalid_enum', $this->messageInvalidFormat($name));
144+
}
145+
}
146+
147+
$this->validateConstraintAttributes($property, $name, $value, $result);
148+
}
149+
150+
private function validateConstraintAttributes(
151+
ReflectionProperty $property,
152+
string $field,
153+
mixed $value,
154+
ValidationResult $result
155+
): void {
156+
foreach ($property->getAttributes() as $reflectionAttribute) {
157+
$attribute = $reflectionAttribute->newInstance();
158+
if (!$attribute instanceof DtoConstraintAttributeContract) {
159+
continue;
160+
}
161+
if ($attribute->validateValue($value)) {
162+
continue;
163+
}
164+
$result->addError($field, $attribute->errorCode(), $this->messageInvalidFormat($field));
165+
}
166+
}
167+
168+
private function validateCsrfIfRequired(ReflectionClass $reflector, ValidationResult $result): void
169+
{
170+
$csrfProtected = $this->classAttribute($reflector, CsrfProtected::class);
171+
if (!$csrfProtected instanceof CsrfProtected || $this->context->enforceCsrf === false) {
172+
return;
173+
}
174+
175+
$csrfField = $this->classAttribute($reflector, CsrfField::class);
176+
$tokenField = $csrfField?->tokenField ?? '_csrf';
177+
$tokenKeyField = $csrfField?->tokenKeyField ?? '_csrf_key';
178+
$formKey = $csrfProtected->formKey !== '' ? $csrfProtected->formKey : $reflector->getShortName();
179+
180+
$token = $this->payloadScalar($tokenField);
181+
$tokenKey = $this->payloadScalar($tokenKeyField);
182+
if ($token === '') {
183+
$token = (string)($this->context->header($csrfProtected->headerName) ?? '');
184+
}
185+
if ($tokenKey === '' && $csrfProtected->headerKeyName !== '') {
186+
$tokenKey = (string)($this->context->header($csrfProtected->headerKeyName) ?? '');
187+
}
188+
189+
if (!CsrfValidator::validateSubmission($token, $tokenKey, $formKey)) {
190+
$result->addError($tokenField, 'invalid_csrf', t('Invalid form'));
191+
}
192+
}
193+
194+
private function payloadScalar(string $field): string
195+
{
196+
if (!array_key_exists($field, $this->context->payload) || !is_scalar($this->context->payload[$field])) {
197+
return '';
198+
}
199+
return (string)$this->context->payload[$field];
200+
}
201+
202+
private function allowsNull(ReflectionProperty $property): bool
203+
{
204+
$nullable = $this->propertyAttribute($property, Nullable::class);
205+
return $nullable instanceof Nullable && $nullable->allowsNull();
206+
}
207+
208+
private function hasAttribute(ReflectionProperty $property, string $attributeClass): bool
209+
{
210+
return !empty($property->getAttributes($attributeClass));
211+
}
212+
213+
private function matchesDeclaredType(mixed $value, string $type): bool
214+
{
215+
$normalized = strtolower(trim($type));
216+
if (str_contains($normalized, '|')) {
217+
foreach (array_map('trim', explode('|', $normalized)) as $candidate) {
218+
if ($this->matchesDeclaredType($value, $candidate)) {
219+
return true;
220+
}
221+
}
222+
return false;
223+
}
224+
225+
return match ($normalized) {
226+
'string' => is_string($value),
227+
'int', 'integer' => is_int($value),
228+
'bool', 'boolean' => is_bool($value),
229+
'float', 'double', 'number' => is_float($value) || is_int($value),
230+
'array' => is_array($value),
231+
default => true,
232+
};
233+
}
234+
235+
private function propertyAttribute(ReflectionProperty $property, string $attributeClass): mixed
236+
{
237+
$attrs = $property->getAttributes($attributeClass);
238+
return empty($attrs) ? null : $attrs[0]->newInstance();
239+
}
240+
241+
private function classAttribute(ReflectionClass $reflector, string $attributeClass): mixed
242+
{
243+
$attrs = $reflector->getAttributes($attributeClass);
244+
return empty($attrs) ? null : $attrs[0]->newInstance();
245+
}
246+
247+
private function messageRequired(string $field): string
248+
{
249+
return str_replace('%s', "<strong>{$field}</strong>", t('Field %s is required'));
250+
}
251+
252+
private function messageInvalidFormat(string $field): string
253+
{
254+
return str_replace('%s', "<strong>{$field}</strong>", t('Field %s has an invalid format'));
255+
}
256+
257+
private function messageNotAllowed(string $field): string
258+
{
259+
return str_replace('%s', "<strong>{$field}</strong>", t('Field %s is not allowed'));
260+
}
261+
}
262+

0 commit comments

Comments
 (0)