Skip to content

Commit 530e873

Browse files
committed
Add DtoValidationHelper for streamlined DTO validation
- Introduce `DtoValidationHelper` class with methods for handling CSRF protection, default values, nullable checks, and type matching. - Simplify `DtoValidationEngine` by integrating `DtoValidationHelper` for default value resolution and CSRF field validation. - Create comprehensive unit tests for `DtoValidationHelper` to ensure robust validation logic.
1 parent b8ff50f commit 530e873

3 files changed

Lines changed: 391 additions & 114 deletions

File tree

src/base/dto/DtoValidationEngine.php

Lines changed: 103 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,8 @@
44

55
use PSFS\base\types\helpers\InjectorHelper;
66
use PSFS\base\types\helpers\MetadataReader;
7-
use PSFS\base\types\helpers\attributes\CsrfField;
87
use PSFS\base\types\helpers\attributes\CsrfProtected;
9-
use PSFS\base\types\helpers\attributes\DefaultValue;
108
use PSFS\base\types\helpers\attributes\DtoConstraintAttributeContract;
11-
use PSFS\base\types\helpers\attributes\Nullable;
129
use PSFS\base\types\helpers\attributes\Values;
1310
use ReflectionClass;
1411
use ReflectionProperty;
@@ -54,51 +51,51 @@ private function run(): ValidationResult
5451
private function applyDefaultValues(array $publicProperties): void
5552
{
5653
foreach ($publicProperties as $property) {
57-
if ($property->getValue($this->dto) !== null) {
54+
$defaultValue = DtoValidationHelper::defaultValueFor($property);
55+
if ($defaultValue === null || $property->getValue($this->dto) !== null) {
5856
continue;
5957
}
6058

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';
59+
$varType = MetadataReader::extractVarType(
60+
$property,
61+
DtoValidationHelper::propertyDoc($property)
62+
) ?: 'string';
7163
$property->setValue($this->dto, ($this->castValue)($defaultValue, $varType));
7264
}
7365
}
7466

7567
/**
7668
* @param array<int, ReflectionProperty> $publicProperties
7769
*/
78-
private function validateUnknownFields(array $publicProperties, ReflectionClass $reflector, ValidationResult $result): void
79-
{
70+
private function validateUnknownFields(
71+
array $publicProperties,
72+
ReflectionClass $reflector,
73+
ValidationResult $result
74+
): void {
8075
if (!$this->context->strictUnknownFields) {
8176
return;
8277
}
8378

84-
$allowed = [];
85-
foreach ($publicProperties as $property) {
86-
$allowed[$property->getName()] = true;
79+
$allowed = DtoValidationHelper::allowedPayloadFields(
80+
$publicProperties,
81+
$reflector,
82+
$this->context->enforceCsrf
83+
);
84+
foreach ($this->context->payload as $field => $value) {
85+
$this->validatePayloadField($field, $allowed, $result);
8786
}
87+
}
8888

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;
89+
/**
90+
* @param array<string, bool> $allowed
91+
*/
92+
private function validatePayloadField(mixed $field, array $allowed, ValidationResult $result): void
93+
{
94+
if (!is_string($field) || array_key_exists($field, $allowed)) {
95+
return;
9496
}
9597

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-
}
98+
$result->addError($field, 'unknown_field', $this->messageNotAllowed($field));
10299
}
103100

104101
/**
@@ -114,37 +111,91 @@ private function validateProperties(array $publicProperties, ValidationResult $r
114111
private function validateProperty(ReflectionProperty $property, ValidationResult $result): void
115112
{
116113
$name = $property->getName();
117-
$doc = (string)($property->getDocComment() ?: '');
114+
$doc = DtoValidationHelper::propertyDoc($property);
118115
$value = $property->getValue($this->dto);
119116
$existsInPayload = array_key_exists($name, $this->context->payload);
120117
$required = (bool)MetadataReader::getTagValue('required', $doc, false, $property);
121118

122-
if ($required && !$existsInPayload && $value === null) {
123-
$result->addError($name, 'required', $this->messageRequired($name));
119+
if (!$this->validateRequiredProperty($name, $value, $existsInPayload, $required, $result)) {
124120
return;
125121
}
126122

127-
if ($value === null) {
128-
if ($existsInPayload && !$this->allowsNull($property) && $required) {
129-
$result->addError($name, 'null_not_allowed', $this->messageRequired($name));
130-
}
123+
if (!$this->validateNullableProperty($property, $name, $value, $existsInPayload, $required, $result)) {
131124
return;
132125
}
133126

134127
$varType = MetadataReader::extractVarType($property, $doc);
135-
if (is_string($varType) && !$this->matchesDeclaredType($value, $varType)) {
136-
$result->addError($name, 'invalid_type', $this->messageInvalidFormat($name));
128+
if (!$this->validatePropertyType($name, $value, $varType, $result)) {
137129
return;
138130
}
139131

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-
}
132+
$this->validateLegacyEnum($property, $name, $value, $doc, $result);
133+
$this->validateConstraintAttributes($property, $name, $value, $result);
134+
}
135+
136+
private function validateRequiredProperty(
137+
string $field,
138+
mixed $value,
139+
bool $existsInPayload,
140+
bool $required,
141+
ValidationResult $result
142+
): bool {
143+
if (!$required || $existsInPayload || $value !== null) {
144+
return true;
145145
}
146146

147-
$this->validateConstraintAttributes($property, $name, $value, $result);
147+
$result->addError($field, 'required', $this->messageRequired($field));
148+
return false;
149+
}
150+
151+
private function validateNullableProperty(
152+
ReflectionProperty $property,
153+
string $field,
154+
mixed $value,
155+
bool $existsInPayload,
156+
bool $required,
157+
ValidationResult $result
158+
): bool {
159+
if ($value !== null) {
160+
return true;
161+
}
162+
163+
if ($existsInPayload && $required && !DtoValidationHelper::allowsNull($property)) {
164+
$result->addError($field, 'null_not_allowed', $this->messageRequired($field));
165+
}
166+
167+
return false;
168+
}
169+
170+
private function validatePropertyType(
171+
string $field,
172+
mixed $value,
173+
?string $varType,
174+
ValidationResult $result
175+
): bool {
176+
if (!is_string($varType) || DtoValidationHelper::matchesDeclaredType($value, $varType)) {
177+
return true;
178+
}
179+
180+
$result->addError($field, 'invalid_type', $this->messageInvalidFormat($field));
181+
return false;
182+
}
183+
184+
private function validateLegacyEnum(
185+
ReflectionProperty $property,
186+
string $field,
187+
mixed $value,
188+
string $doc,
189+
ValidationResult $result
190+
): void {
191+
if (DtoValidationHelper::hasAttribute($property, Values::class)) {
192+
return;
193+
}
194+
195+
$values = InjectorHelper::getValues($doc, $property);
196+
if (is_array($values) && !in_array($value, $values, true)) {
197+
$result->addError($field, 'invalid_enum', $this->messageInvalidFormat($field));
198+
}
148199
}
149200

150201
private function validateConstraintAttributes(
@@ -167,83 +218,22 @@ private function validateConstraintAttributes(
167218

168219
private function validateCsrfIfRequired(ReflectionClass $reflector, ValidationResult $result): void
169220
{
170-
$csrfProtected = $this->classAttribute($reflector, CsrfProtected::class);
171-
if (!$csrfProtected instanceof CsrfProtected || $this->context->enforceCsrf === false) {
221+
if (!DtoValidationHelper::requiresCsrfValidation($reflector, $this->context->enforceCsrf)) {
172222
return;
173223
}
174224

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-
}
225+
$csrfProtected = DtoValidationHelper::csrfProtection($reflector);
226+
assert($csrfProtected instanceof CsrfProtected);
227+
$csrfField = DtoValidationHelper::csrfField($reflector);
228+
[$tokenField] = DtoValidationHelper::csrfFieldNames($csrfField);
229+
$formKey = DtoValidationHelper::csrfFormKey($csrfProtected, $reflector);
230+
[$token, $tokenKey] = DtoValidationHelper::csrfTokensFromRequest($this->context, $csrfProtected, $csrfField);
188231

189232
if (!CsrfValidator::validateSubmission($token, $tokenKey, $formKey)) {
190233
$result->addError($tokenField, 'invalid_csrf', t('Invalid form'));
191234
}
192235
}
193236

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-
247237
private function messageRequired(string $field): string
248238
{
249239
return str_replace('%s', "<strong>{$field}</strong>", t('Field %s is required'));
@@ -259,4 +249,3 @@ private function messageNotAllowed(string $field): string
259249
return str_replace('%s', "<strong>{$field}</strong>", t('Field %s is not allowed'));
260250
}
261251
}
262-

0 commit comments

Comments
 (0)