diff --git a/src/DataObject.php b/src/DataObject.php index d703f47..ebfb754 100644 --- a/src/DataObject.php +++ b/src/DataObject.php @@ -9,7 +9,7 @@ use Illuminate\Http\Request; trait DataObject { - public static function fromRequest(Request $request): mixed + public static function fromRequest(Request $request): ?static { return DataObjectFactory::fromRequest(static::class, $request); } diff --git a/src/Factories/DataObjectFactory.php b/src/Factories/DataObjectFactory.php index 9ba6f88..89ae40c 100644 --- a/src/Factories/DataObjectFactory.php +++ b/src/Factories/DataObjectFactory.php @@ -118,14 +118,18 @@ class DataObjectFactory } if ($reflectionType instanceof ReflectionNamedType) { - $input[$parameterName] = $reflectionType->isBuiltin() - ? $rawInput[$parameterName] - : self::mapInput($reflectionType->__toString(), $rawInput[$parameterName], $routeParameters, $logger); - + if ($reflectionType->isBuiltin()) { + if (array_key_exists($parameterName, $rawInput)) { + $input[$parameterName] = $rawInput[$parameterName]; + } + } else { + $input[$parameterName] = self::mapInput($reflectionType->__toString(), $rawInput[$parameterName], $routeParameters, $logger); + } continue; } - - $input[$parameterName] = $rawInput[$parameterName]; + if (array_key_exists($parameterName, $rawInput)) { + $input[$parameterName] = $rawInput[$parameterName]; + } } $logger->debug('input', $input); return $input; diff --git a/src/Factories/ValueFactory.php b/src/Factories/ValueFactory.php index 3f262cd..5509c51 100644 --- a/src/Factories/ValueFactory.php +++ b/src/Factories/ValueFactory.php @@ -137,6 +137,6 @@ class ValueFactory $arguments[$name] = $parameterArgs; } - return App::makeWith($class, $arguments); + return new $class(...$arguments); } } diff --git a/src/IDataObject.php b/src/IDataObject.php new file mode 100644 index 0000000..5797c95 --- /dev/null +++ b/src/IDataObject.php @@ -0,0 +1,6 @@ +app->beforeResolving(function ($abstract, $parameters, $app) { + if ($app->has($abstract)) { + return; + } + if (is_subclass_of($abstract, IDataObject::class)) { + $app->bind($abstract, fn($container) => DataObjectFactory::fromRequest($abstract, $container['request'])); + } + }); + } +} + diff --git a/tests/DataObject/DataObjectTest.php b/tests/DataObject/DataObjectTest.php index 5994877..30b3055 100644 --- a/tests/DataObject/DataObjectTest.php +++ b/tests/DataObject/DataObjectTest.php @@ -3,6 +3,7 @@ namespace Tests\DataObject; use Icefox\DTO\Attributes\FromInput; +use Icefox\DTO\Attributes\FromRouteParameter; use Icefox\DTO\Factories\DataObjectFactory; use Illuminate\Support\Collection; use Psr\Log\NullLogger; @@ -125,3 +126,70 @@ test('annotated array', function () { expect($mapped)->toBe(['items' => [['value' => 1], ['value' => 2]]]); }); + +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/Http/RequestTest.php b/tests/Http/RequestTest.php new file mode 100644 index 0000000..c92e06d --- /dev/null +++ b/tests/Http/RequestTest.php @@ -0,0 +1,107 @@ +reply = $message == 'ping' ? 'pong' : 'unknown'; + } +} + +test('injects data object', function () { + Route::post('/', fn(Basic $object) => ['reply' => $object->reply]); + + /** @var \Tests\TestCase $this */ + $this->postJson('/', ['message' => 'ping'])->assertJson(['reply' => 'pong']); +}); + +test('fails on validation error', function () { + Route::post('/', fn(Basic $object) => ['reply' => $object->reply]); + /** @var \Tests\TestCase $this */ + $resp = $this->postJson('/', []) + ->assertStatus(422) + ->assertJson([ + 'message' => 'The message field is required.', + 'errors' => ['message' => ['The message field is required.']], + ]); +}); + +readonly class WithCustomValidator implements IDataObject +{ + public string $reply; + public function __construct(string $message) + { + $this->reply = $message == 'ping' ? 'pong' : 'unknown'; + } + + /** + * @param array $data + * @param array $rules + */ + public static function withValidator(array $data, array $rules): Validator + { + return ValidatorFacade::make($data, $rules, $messages = [ + 'message.required' => 'the known message is pong', + ]); + } +} + +test('replies with custom validator', function () { + Route::post('/', fn(WithCustomValidator $object) => []); + /** @var \Tests\TestCase $this */ + $this->postJson('/', []) + ->assertStatus(422) + ->assertJson([ + 'message' => 'the known message is pong', + 'errors' => ['message' => ['the known message is pong']], + ]); +}); + +readonly class WithCustomFailure implements IDataObject +{ + public function __construct(public bool $flag) {} + + public static function fails(Validator $validator): void + { + throw new HttpResponseException( + response(['result' => 'invalid, but that is ok' ], 202), + ); + } +} + +test('uses custom response', function () { + Route::post('/', fn(WithCustomFailure $object) => response('', 204)); + + /** @var \Tests\TestCase $this */ + $this->postJson('/', []) + ->assertStatus(202) + ->assertJson(['result' => 'invalid, but that is ok']); +}); + +readonly class WithDefaultObjectOnFailure implements IDataObject +{ + public function __construct(public bool $flag) {} + + public static function fails(): self + { + return new self(false); + } +} + +test('uses default object on failure', function () { + Route::post('/', fn(WithDefaultObjectOnFailure $object) => response(['flag' => $object->flag], 200)); + + /** @var \Tests\TestCase $this */ + $this->postJson('/', []) + ->assertStatus(200) + ->assertJson(['flag' => false]); +}); diff --git a/tests/Http/RequestTests.php b/tests/Http/RequestTests.php deleted file mode 100644 index b253e7d..0000000 --- a/tests/Http/RequestTests.php +++ /dev/null @@ -1,206 +0,0 @@ - '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'); -});