refactor, tests
This commit is contained in:
parent
fc46fe20ee
commit
b827038df3
11 changed files with 304 additions and 103 deletions
|
|
@ -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, '.*'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Icefox\DTO;
|
||||
|
||||
use Icefox\DTO\Factories\DataObjectFactory;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait DataObject
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace Icefox\DTO;
|
||||
namespace Icefox\DTO\Factories;
|
||||
|
||||
use Icefox\DTO\Attributes\FromInput;
|
||||
use Icefox\DTO\Attributes\FromRouteParameter;
|
||||
use Icefox\DTO\RuleFactory;
|
||||
use Icefox\DTO\ValueFactory;
|
||||
use Icefox\DTO\Factories\RuleFactory;
|
||||
use Icefox\DTO\Factories\ValueFactory;
|
||||
use Icefox\DTO\ReflectionHelper;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Route;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
|
@ -2,14 +2,13 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Icefox\DTO;
|
||||
namespace Icefox\DTO\Factories;
|
||||
|
||||
use Icefox\DTO\Attributes\Flat;
|
||||
use Icefox\DTO\Attributes\Overwrite;
|
||||
use Icefox\DTO\Config;
|
||||
use Icefox\DTO\ParameterMeta;
|
||||
use Icefox\DTO\ReflectionHelper;
|
||||
use Icefox\DTO\RuleFactory;
|
||||
use Icefox\DTO\Factories\RuleFactory;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -107,7 +106,7 @@ final class RuleFactory
|
|||
}
|
||||
|
||||
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) {
|
||||
$realPrefix = $prefix . $scopedPrefix;
|
||||
$rules[$realPrefix] = array_merge($rules[$realPrefix] ?? [], $values);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
206
tests/Http/RequestTests.php
Normal file
206
tests/Http/RequestTests.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue