refactor, tests

This commit is contained in:
icefox 2026-02-25 17:47:17 -03:00
parent fc46fe20ee
commit b827038df3
No known key found for this signature in database
11 changed files with 304 additions and 103 deletions

View file

@ -1,63 +0,0 @@
<?php
declare(strict_types=1);
namespace Icefox\DTO;
use Icefox\DTO\RuleFactory;
use Illuminate\Support\Collection;
use phpDocumentor\Reflection\PseudoTypes\Generic;
class Config
{
/**
* @param class-string $class
**/
public static function getCaster(string $class): ?callable
{
return config('dto.cast.' . $class, null);
}
/**
* @param class-string $class
**/
public static function getRules(string $class): ?callable
{
if ($userDefined = config('dto.rules.' . $class, null)) {
return $userDefined;
}
return match ($class) {
Collection::class => static::rulesIlluminateCollection(...),
default => null,
};
}
/**
* @return array<string,string[]>
*/
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, '.*'),
);
}
}

View file

@ -2,7 +2,7 @@
namespace Icefox\DTO; namespace Icefox\DTO;
use Icefox\DTO\RuleFactory; use Icefox\DTO\Factories\RuleFactory;
use phpDocumentor\Reflection\PseudoTypes\Generic; use phpDocumentor\Reflection\PseudoTypes\Generic;
class CustomHandlers class CustomHandlers
@ -35,4 +35,3 @@ class CustomHandlers
); );
} }
} }

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Icefox\DTO; namespace Icefox\DTO;
use Icefox\DTO\Factories\DataObjectFactory;
use Illuminate\Http\Request; use Illuminate\Http\Request;
trait DataObject trait DataObject

View file

@ -1,11 +1,12 @@
<?php <?php
namespace Icefox\DTO; namespace Icefox\DTO\Factories;
use Icefox\DTO\Attributes\FromInput; use Icefox\DTO\Attributes\FromInput;
use Icefox\DTO\Attributes\FromRouteParameter; use Icefox\DTO\Attributes\FromRouteParameter;
use Icefox\DTO\RuleFactory; use Icefox\DTO\Factories\RuleFactory;
use Icefox\DTO\ValueFactory; use Icefox\DTO\Factories\ValueFactory;
use Icefox\DTO\ReflectionHelper;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Route; use Illuminate\Routing\Route;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;

View file

@ -2,14 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace Icefox\DTO; namespace Icefox\DTO\Factories;
use Icefox\DTO\Attributes\Flat; use Icefox\DTO\Attributes\Flat;
use Icefox\DTO\Attributes\Overwrite; use Icefox\DTO\Attributes\Overwrite;
use Icefox\DTO\Config;
use Icefox\DTO\ParameterMeta; use Icefox\DTO\ParameterMeta;
use Icefox\DTO\ReflectionHelper; use Icefox\DTO\ReflectionHelper;
use Icefox\DTO\RuleFactory; use Icefox\DTO\Factories\RuleFactory;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -107,7 +106,7 @@ final class RuleFactory
} }
if ($type instanceof ReflectionNamedType && $name = $type->getName()) { if ($type instanceof ReflectionNamedType && $name = $type->getName()) {
if ($globalRules = Config::getRules($name)) { if ($globalRules = config('dto.rules.' . $name, null)) {
foreach ($globalRules($parameter, $this) as $scopedPrefix => $values) { foreach ($globalRules($parameter, $this) as $scopedPrefix => $values) {
$realPrefix = $prefix . $scopedPrefix; $realPrefix = $prefix . $scopedPrefix;
$rules[$realPrefix] = array_merge($rules[$realPrefix] ?? [], $values); $rules[$realPrefix] = array_merge($rules[$realPrefix] ?? [], $values);

View file

@ -2,15 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace Icefox\DTO; namespace Icefox\DTO\Factories;
use Icefox\DTO\Attributes\CastWith; use Icefox\DTO\Attributes\CastWith;
use Icefox\DTO\Attributes\Flat; use Icefox\DTO\Attributes\Flat;
use Icefox\DTO\Config;
use Icefox\DTO\ReflectionHelper; use Icefox\DTO\ReflectionHelper;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use ReflectionNamedType; use ReflectionNamedType;
use phpDocumentor\Reflection\DocBlock\Tags\Param;
use phpDocumentor\Reflection\PseudoTypes\Generic; use phpDocumentor\Reflection\PseudoTypes\Generic;
use phpDocumentor\Reflection\Type; use phpDocumentor\Reflection\Type;
use phpDocumentor\Reflection\Types\AbstractList; use phpDocumentor\Reflection\Types\AbstractList;
@ -22,21 +20,6 @@ use phpDocumentor\Reflection\Types\Object_;
class ValueFactory 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 public static function resolveAnnotatedValue(Type $type, mixed $rawValue): mixed
{ {
if ($type instanceof Nullable) { if ($type instanceof Nullable) {
@ -89,14 +72,18 @@ class ValueFactory
public static function resolveDeclaredTypeValue(ReflectionNamedType $parameter, mixed $rawValue): mixed 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, 'string' => $rawValue,
'bool' => boolval($rawValue), 'bool' => boolval($rawValue),
'int' => intval($rawValue), 'int' => intval($rawValue),
'float' => floatval($rawValue), 'float' => floatval($rawValue),
'array' => $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; $parameterArgs = empty($parameter->reflection->getAttributes(Flat::class)) ? ($input[$name] ?? null) : $input;
if (is_null($parameterArgs)) { if (is_null($parameterArgs)) {
if ($parameter->reflection->allowsNull()) { $arguments[$name] = $parameter->reflection->isDefaultValueAvailable()
$arguments[$name] = null; ? $parameter->reflection->getDefaultValue()
} : null;
continue; continue;
} }
@ -129,20 +116,22 @@ class ValueFactory
$type = $parameter->tag?->getType(); $type = $parameter->tag?->getType();
if (empty($parameterClass) && $type instanceof Object_) { if (empty($parameterClass) && $type instanceof Object_) {
$parameterClass = $type->getFqsen(); $parameterClass = $type->getFqsen()?->__toString();
} }
if (!empty($parameterClass) && $caster = config('dto.cast.' . $parameterClass, null)) { if (!empty($parameterClass) && $caster = config('dto.cast.' . $parameterClass, null)) {
$arguments[$name] = App::call($caster, ['data' => $parameterArgs]); $arguments[$name] = App::call($caster, ['data' => $parameterArgs]);
continue; continue;
} }
if ($parameter->tag instanceof Param) { if (!is_null($type)) {
$arguments[$name] = self::resolveAnnotatedValue($type, $parameterArgs); $arguments[$name] = self::resolveAnnotatedValue($type, $parameterArgs);
continue; continue;
} }
if ($parameter->reflection->getType() instanceof ReflectionNamedType) { $reflectionType = $parameter->reflection->getType();
$arguments[$name] = self::resolveDeclaredTypeValue($parameter->reflection->getType(), $parameterArgs); if ($reflectionType instanceof ReflectionNamedType) {
$arguments[$name] = self::resolveDeclaredTypeValue($reflectionType, $parameterArgs);
continue; continue;
} }

View file

@ -2,6 +2,7 @@
namespace Icefox\DTO; namespace Icefox\DTO;
use ReflectionNamedType;
use ReflectionParameter; use ReflectionParameter;
use phpDocumentor\Reflection\DocBlock\Tag; use phpDocumentor\Reflection\DocBlock\Tag;
use phpDocumentor\Reflection\DocBlock\Tags\Param; use phpDocumentor\Reflection\DocBlock\Tags\Param;
@ -63,4 +64,17 @@ class ReflectionHelper
return count($subtypes) > 1 ? $subtypes[1]->__toString() : $subtypes[0]->__toString(); 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;
}
} }

View file

@ -3,7 +3,7 @@
namespace Tests\DataObject; namespace Tests\DataObject;
use Icefox\DTO\Attributes\FromInput; use Icefox\DTO\Attributes\FromInput;
use Icefox\DTO\DataObjectFactory; use Icefox\DTO\Factories\DataObjectFactory;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;

206
tests/Http/RequestTests.php Normal file
View file

@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace Tests\Http;
use Icefox\DTO\Attributes\FromInput;
use Icefox\DTO\Attributes\FromRouteParameter;
use Icefox\DTO\Factories\DataObjectFactory;
use Icefox\DTO\Factories\ValueFactory;
use Illuminate\Routing\Route;
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Validator;
readonly class SimplePostDTO
{
public function __construct(
public string $title,
public string $content,
) {}
}
test('successful validation returns 200 equivalent', function () {
$dto = DataObjectFactory::fromArray(SimplePostDTO::class, [
'title' => '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');
});

View file

@ -6,7 +6,7 @@ namespace Tests\Rules;
use Icefox\DTO\Attributes\Flat; use Icefox\DTO\Attributes\Flat;
use Icefox\DTO\Attributes\Overwrite; use Icefox\DTO\Attributes\Overwrite;
use Icefox\DTO\RuleFactory; use Icefox\DTO\Factories\RuleFactory;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
readonly class BasicPrimitives readonly class BasicPrimitives

View file

@ -6,7 +6,7 @@ namespace Tests\Values;
use Carbon\CarbonPeriod; use Carbon\CarbonPeriod;
use Icefox\DTO\Attributes\CastWith; use Icefox\DTO\Attributes\CastWith;
use Icefox\DTO\ValueFactory; use Icefox\DTO\Factories\ValueFactory;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Collection; 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->start->format('Y-m-d'))->toBe('1980-10-01');
expect($object->period->end->format('Y-m-d'))->toBe('1990-06-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);
});