From b827038df3388d76ff4f9ecc9d3e96e7227bc97a Mon Sep 17 00:00:00 2001 From: icefox Date: Wed, 25 Feb 2026 17:47:17 -0300 Subject: [PATCH] refactor, tests --- src/Config.php | 63 ------- src/CustomHandlers.php | 3 +- src/DataObject.php | 1 + src/{ => Factories}/DataObjectFactory.php | 7 +- src/{ => Factories}/RuleFactory.php | 7 +- src/{ => Factories}/ValueFactory.php | 45 ++--- src/ReflectionHelper.php | 14 ++ tests/DataObject/DataObjectTest.php | 2 +- tests/Http/RequestTests.php | 206 ++++++++++++++++++++++ tests/Rules/RulesTest.php | 2 +- tests/Values/ValuesTest.php | 57 +++++- 11 files changed, 304 insertions(+), 103 deletions(-) delete mode 100644 src/Config.php rename src/{ => Factories}/DataObjectFactory.php (97%) rename src/{ => Factories}/RuleFactory.php (97%) rename src/{ => Factories}/ValueFactory.php (79%) create mode 100644 tests/Http/RequestTests.php diff --git a/src/Config.php b/src/Config.php deleted file mode 100644 index e804397..0000000 --- a/src/Config.php +++ /dev/null @@ -1,63 +0,0 @@ - static::rulesIlluminateCollection(...), - default => null, - }; - } - - - /** - * @return array - */ - private static function rulesIlluminateCollection(ParameterMeta $parameter, RuleFactory $factory): array - { - if (is_null($parameter->tag)) { - return []; - } - - $type = $parameter->tag->getType(); - if (!$type instanceof Generic) { - return []; - } - - $subtypes = $type->getTypes(); - - if (count($subtypes) == 0) { - return ['' => ['array']]; - } - - $subtype = count($subtypes) == 1 ? $subtypes[0] : $subtypes[1]; - - return $factory->mergeRules( - ['' => ['array']], - $factory->getRulesFromDocBlock($subtype, '.*'), - ); - } -} diff --git a/src/CustomHandlers.php b/src/CustomHandlers.php index 075e1ba..f76b988 100644 --- a/src/CustomHandlers.php +++ b/src/CustomHandlers.php @@ -2,7 +2,7 @@ namespace Icefox\DTO; -use Icefox\DTO\RuleFactory; +use Icefox\DTO\Factories\RuleFactory; use phpDocumentor\Reflection\PseudoTypes\Generic; class CustomHandlers @@ -35,4 +35,3 @@ class CustomHandlers ); } } - diff --git a/src/DataObject.php b/src/DataObject.php index 95a7235..d703f47 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Icefox\DTO; +use Icefox\DTO\Factories\DataObjectFactory; use Illuminate\Http\Request; trait DataObject diff --git a/src/DataObjectFactory.php b/src/Factories/DataObjectFactory.php similarity index 97% rename from src/DataObjectFactory.php rename to src/Factories/DataObjectFactory.php index 2d14088..9ba6f88 100644 --- a/src/DataObjectFactory.php +++ b/src/Factories/DataObjectFactory.php @@ -1,11 +1,12 @@ getName()) { - if ($globalRules = Config::getRules($name)) { + if ($globalRules = config('dto.rules.' . $name, null)) { foreach ($globalRules($parameter, $this) as $scopedPrefix => $values) { $realPrefix = $prefix . $scopedPrefix; $rules[$realPrefix] = array_merge($rules[$realPrefix] ?? [], $values); diff --git a/src/ValueFactory.php b/src/Factories/ValueFactory.php similarity index 79% rename from src/ValueFactory.php rename to src/Factories/ValueFactory.php index 53de322..3f262cd 100644 --- a/src/ValueFactory.php +++ b/src/Factories/ValueFactory.php @@ -2,15 +2,13 @@ declare(strict_types=1); -namespace Icefox\DTO; +namespace Icefox\DTO\Factories; use Icefox\DTO\Attributes\CastWith; use Icefox\DTO\Attributes\Flat; -use Icefox\DTO\Config; use Icefox\DTO\ReflectionHelper; use Illuminate\Support\Facades\App; use ReflectionNamedType; -use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\PseudoTypes\Generic; use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Types\AbstractList; @@ -22,21 +20,6 @@ use phpDocumentor\Reflection\Types\Object_; class ValueFactory { - public static function constructObject(string $className, mixed $rawValue): object - { - if ($mapper = Config::getCaster($className)) { - return $mapper($rawValue); - } - - // Plain values or numeric arrays are passed as a single parameter to the constructor - if (!is_array($rawValue) || array_key_exists(0, $rawValue)) { - return new $className($className); - } - - // Associative arrays leverage Laravel service container - return App::makeWith($className, $rawValue); - } - public static function resolveAnnotatedValue(Type $type, mixed $rawValue): mixed { if ($type instanceof Nullable) { @@ -89,14 +72,18 @@ class ValueFactory public static function resolveDeclaredTypeValue(ReflectionNamedType $parameter, mixed $rawValue): mixed { - return match ($parameter->getName()) { + $type = $parameter->getName(); + if (is_a($type, \BackedEnum::class, true)) { + return $type::from($rawValue); + } + + return match ($type) { 'string' => $rawValue, 'bool' => boolval($rawValue), 'int' => intval($rawValue), 'float' => floatval($rawValue), 'array' => $rawValue, - default => self::make($parameter->getName(), $rawValue), - + default => self::make($type, $rawValue), }; } @@ -113,9 +100,9 @@ class ValueFactory $parameterArgs = empty($parameter->reflection->getAttributes(Flat::class)) ? ($input[$name] ?? null) : $input; if (is_null($parameterArgs)) { - if ($parameter->reflection->allowsNull()) { - $arguments[$name] = null; - } + $arguments[$name] = $parameter->reflection->isDefaultValueAvailable() + ? $parameter->reflection->getDefaultValue() + : null; continue; } @@ -129,20 +116,22 @@ class ValueFactory $type = $parameter->tag?->getType(); if (empty($parameterClass) && $type instanceof Object_) { - $parameterClass = $type->getFqsen(); + $parameterClass = $type->getFqsen()?->__toString(); } + if (!empty($parameterClass) && $caster = config('dto.cast.' . $parameterClass, null)) { $arguments[$name] = App::call($caster, ['data' => $parameterArgs]); continue; } - if ($parameter->tag instanceof Param) { + if (!is_null($type)) { $arguments[$name] = self::resolveAnnotatedValue($type, $parameterArgs); continue; } - if ($parameter->reflection->getType() instanceof ReflectionNamedType) { - $arguments[$name] = self::resolveDeclaredTypeValue($parameter->reflection->getType(), $parameterArgs); + $reflectionType = $parameter->reflection->getType(); + if ($reflectionType instanceof ReflectionNamedType) { + $arguments[$name] = self::resolveDeclaredTypeValue($reflectionType, $parameterArgs); continue; } diff --git a/src/ReflectionHelper.php b/src/ReflectionHelper.php index b6f77a5..47ebd39 100644 --- a/src/ReflectionHelper.php +++ b/src/ReflectionHelper.php @@ -2,6 +2,7 @@ namespace Icefox\DTO; +use ReflectionNamedType; use ReflectionParameter; use phpDocumentor\Reflection\DocBlock\Tag; use phpDocumentor\Reflection\DocBlock\Tags\Param; @@ -63,4 +64,17 @@ class ReflectionHelper return count($subtypes) > 1 ? $subtypes[1]->__toString() : $subtypes[0]->__toString(); } + public static function isListType(ParameterMeta $parameter): bool + { + $reflectionType = $parameter->reflection->getType(); + $namedType = $reflectionType instanceof ReflectionNamedType ? $reflectionType->getName() : null; + $annotatedType = $parameter->tag?->getType(); + return + $parameter->reflection->isArray() + || in_array($namedType, config('dto.listTypes', [])) + || in_array($annotatedType?->__toString(), config('dto.listTypes', [])) + || $annotatedType instanceof AbstractList; + + } + } diff --git a/tests/DataObject/DataObjectTest.php b/tests/DataObject/DataObjectTest.php index 05b1787..5994877 100644 --- a/tests/DataObject/DataObjectTest.php +++ b/tests/DataObject/DataObjectTest.php @@ -3,7 +3,7 @@ namespace Tests\DataObject; use Icefox\DTO\Attributes\FromInput; -use Icefox\DTO\DataObjectFactory; +use Icefox\DTO\Factories\DataObjectFactory; use Illuminate\Support\Collection; use Psr\Log\NullLogger; diff --git a/tests/Http/RequestTests.php b/tests/Http/RequestTests.php new file mode 100644 index 0000000..b253e7d --- /dev/null +++ b/tests/Http/RequestTests.php @@ -0,0 +1,206 @@ + 'Test Title', + 'content' => 'Test Content', + ], []); + + expect($dto)->toBeInstanceOf(SimplePostDTO::class) + ->and($dto->title)->toBe('Test Title') + ->and($dto->content)->toBe('Test Content'); +}); + +test('failed validation throws validation exception', function () { + try { + DataObjectFactory::fromArray(SimplePostDTO::class, [ + 'title' => 'Test', + 'content' => [], + ], []); + } catch (\Throwable $e) { + expect($e)->toBeInstanceOf(ValidationException::class); + return; + } + throw new \Exception('Should have thrown an exception'); +}); + +readonly class PostDTOWithNumeric +{ + public function __construct( + public string $title, + public int $views, + ) {} +} + +test('failed validation returns proper error format', function () { + try { + DataObjectFactory::fromArray(PostDTOWithNumeric::class, [ + 'title' => 'Test', + 'views' => 'not-a-number', + ], []); + } catch (ValidationException $e) { + $errors = $e->validator->errors(); + + expect($errors->has('views'))->toBeTrue() + ->and($errors->first('views'))->toContain('number'); + } +}); + +test('validation error message format matches laravel', function () { + try { + DataObjectFactory::fromArray(PostDTOWithNumeric::class, [ + 'title' => 'Test', + 'views' => 'invalid', + ], []); + } catch (ValidationException $e) { + $errors = $e->validator->errors()->toArray(); + + expect($errors)->toHaveKey('views') + ->and($errors['views'][0])->toContain('views'); + } +}); + +readonly class RestrictedDTO +{ + public function __construct(public string $content) {} + + public static function fails($validator): object + { + return (object) [ + 'error' => 'Access denied', + 'details' => $validator->errors()->toArray(), + 'status' => 403, + ]; + } +} + +test('custom fails returns custom response structure', function () { + $result = DataObjectFactory::fromArray(RestrictedDTO::class, [ + 'content' => [], // invalid - must be string + ], []); + + expect($result->error)->toBe('Access denied') + ->and($result->details)->toHaveKey('content') + ->and($result->status)->toBe(403); +}); + +readonly class CustomBagDTO +{ + public string $name; + public float $price; + + public function __construct(string $name, float $price) + { + $this->name = $name; + $this->price = $price; + } + + public static function rules(): array + { + return [ + 'name' => ['required', 'min:3'], + 'price' => ['required', 'numeric', 'min:0'], + ]; + } +} + +test('named error bag test', function () { + try { + DataObjectFactory::fromArray(CustomBagDTO::class, [ + 'name' => 'ab', + 'price' => -10, + ], []); + } catch (ValidationException $e) { + $errors = $e->validator->errors()->toArray(); + + expect($errors)->toHaveKey('name') + ->and($errors)->toHaveKey('price'); + } +}); + +test('route parameter priority over from input', function () { + $dto = new class (123) { + public function __construct( + #[FromRouteParameter('user_id')] + #[FromInput('user_id')] + public int $id, + ) {} + }; + + $result = DataObjectFactory::mapInput( + $dto::class, + ['user_id' => 456], + ['user_id' => 123], + new \Psr\Log\NullLogger(), + ); + + expect($result['id'])->toBe(123); +}); + +test('multiple route parameters', function () { + $dto = new class (45, 89, 'A') { + public function __construct( + #[FromRouteParameter('course_id')] + public int $courseId, + #[FromRouteParameter('student_id')] + public int $studentId, + #[FromInput('grade')] + public string $grade, + ) {} + }; + + $result = DataObjectFactory::mapInput( + $dto::class, + ['grade' => 'A'], + ['course_id' => 45, 'student_id' => 89], + new \Psr\Log\NullLogger(), + ); + + expect($result['courseId'])->toBe(45) + ->and($result['studentId'])->toBe(89) + ->and($result['grade'])->toBe('A'); +}); + +test('route parameter with nested object', function () { + $addressDto = new class ('Main St') { + public function __construct(public string $street) {} + }; + + $dto = new class (123, $addressDto) { + public function __construct( + #[FromRouteParameter('user_id')] + public int $userId, + public $address, + ) {} + }; + + $result = DataObjectFactory::mapInput( + $dto::class, + ['user_id' => 456, 'address' => ['street' => 'Main St']], + ['user_id' => 123], + new \Psr\Log\NullLogger(), + ); + + expect($result['userId'])->toBe(123) + ->and($result['address']['street'])->toBe('Main St'); +}); diff --git a/tests/Rules/RulesTest.php b/tests/Rules/RulesTest.php index 1d722b5..16ec1a7 100644 --- a/tests/Rules/RulesTest.php +++ b/tests/Rules/RulesTest.php @@ -6,7 +6,7 @@ namespace Tests\Rules; use Icefox\DTO\Attributes\Flat; use Icefox\DTO\Attributes\Overwrite; -use Icefox\DTO\RuleFactory; +use Icefox\DTO\Factories\RuleFactory; use Illuminate\Support\Collection; readonly class BasicPrimitives diff --git a/tests/Values/ValuesTest.php b/tests/Values/ValuesTest.php index 93eb02f..17cd822 100644 --- a/tests/Values/ValuesTest.php +++ b/tests/Values/ValuesTest.php @@ -6,7 +6,7 @@ namespace Tests\Values; use Carbon\CarbonPeriod; use Icefox\DTO\Attributes\CastWith; -use Icefox\DTO\ValueFactory; +use Icefox\DTO\Factories\ValueFactory; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -216,3 +216,58 @@ test('with object cast', function () { expect($object->period->start->format('Y-m-d'))->toBe('1980-10-01'); expect($object->period->end->format('Y-m-d'))->toBe('1990-06-01'); }); + +enum Status: string +{ + case ACTIVE = 'active'; + case INACTIVE = 'inactive'; + case PENDING = 'pending'; +} + +readonly class TaskDTOWithEnum +{ + public function __construct( + public string $title, + public Status $status, + ) {} +} + +readonly class TaskDTOWithNullableEnum +{ + public function __construct( + public string $title, + public ?Status $status, + ) {} +} + +test('backed enum properly cast from validated data', function () { + $object = ValueFactory::make(TaskDTOWithEnum::class, [ + 'title' => 'Task 1', + 'status' => 'active', + ]); + + expect($object->title)->toBe('Task 1') + ->and($object->status)->toBeInstanceOf(Status::class) + ->and($object->status)->toBe(Status::ACTIVE); +}); + +test('nullable backed enum with null', function () { + $object = ValueFactory::make(TaskDTOWithNullableEnum::class, [ + 'title' => 'Task 1', + 'status' => null, + ]); + + expect($object->title)->toBe('Task 1') + ->and($object->status)->toBeNull(); +}); + +test('nullable backed enum with valid value', function () { + $object = ValueFactory::make(TaskDTOWithNullableEnum::class, [ + 'title' => 'Task 1', + 'status' => 'pending', + ]); + + expect($object->title)->toBe('Task 1') + ->and($object->status)->toBeInstanceOf(Status::class) + ->and($object->status)->toBe(Status::PENDING); +});