├── .env.dist ├── .docker └── php │ ├── php.ini │ └── Dockerfile ├── .gitignore ├── tests ├── Stubs │ ├── ChildStub.php │ ├── StaticStub.php │ ├── DenyUnknownStub.php │ ├── IgnoreStub.php │ ├── ArrayInstanceStub.php │ ├── NestedStub.php │ ├── RequiredStub.php │ ├── NumericStub.php │ ├── ValidationStub.php │ ├── StrictCastStub.php │ ├── UnionTypeStub.php │ ├── NameAliasMixStub.php │ ├── ValidationStrategyStub.php │ ├── MatchesStub.php │ ├── SelfValidationStub.php │ ├── ArrayGenericTypeStub.php │ ├── RejectStub.php │ ├── BooleanStub.php │ ├── FilterStubProvider.php │ ├── QueryFieldStub.php │ ├── OptionalStub.php │ ├── PaginationStub.php │ ├── FilterStub.php │ ├── PersonStub.php │ ├── LimitStub.php │ └── CastStub.php ├── NestedStubTest.php ├── UnionTypeTest.php ├── IgnoreTest.php ├── StaticTest.php ├── Annotation │ └── NumberBetween.php ├── RequiredTest.php ├── FilterTest.php ├── SelfValidationTest.php ├── QueryFieldTest.php ├── DenyUnknownFieldsTest.php ├── PaginationTest.php ├── NameAliasMixTest.php ├── ArrayInstanceTest.php ├── ValidationStrategyTest.php ├── RejectTest.php ├── ValidationTest.php ├── OptionalTest.php ├── LimitTest.php ├── PathTest.php ├── ArrayTypeTest.php ├── BooleanTest.php ├── MatchesTest.php ├── CastTest.php └── NumericTest.php ├── src ├── ValidationException.php ├── Annotation │ ├── Ignore.php │ ├── Validation.php │ ├── Finalize.php │ ├── Transformation.php │ ├── Name.php │ ├── SelfValidation.php │ ├── Alias.php │ ├── Reject.php │ ├── Required.php │ ├── Trim.php │ ├── Exceptional.php │ ├── Matches.php │ ├── Boolean.php │ ├── Optional.php │ ├── Numeric.php │ ├── In.php │ ├── NotIn.php │ ├── Instance.php │ ├── Max.php │ ├── Min.php │ ├── Type.php │ ├── ValidationStrategy.php │ ├── Date.php │ ├── DenyUnknownFields.php │ ├── Path.php │ └── Cast.php ├── Failure │ ├── FailureHandler.php │ └── FailureCollection.php ├── DataTransfer.php ├── DataTransferValue.php ├── DataTransferObject.php └── DataTransferProperty.php ├── .phpcstd.ini ├── docker-compose.yml ├── phpunit.xml ├── LICENSE ├── phpstan.neon ├── CHANGELOG.md ├── Makefile ├── ecs.php ├── .github └── workflows │ └── php.yml ├── composer.json ├── .editorconfig └── README.md /.env.dist: -------------------------------------------------------------------------------- 1 | USER_ID=1000 2 | -------------------------------------------------------------------------------- /.docker/php/php.ini: -------------------------------------------------------------------------------- 1 | memory_limit = 128M 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /.idea 3 | .env 4 | .phpunit.result.cache 5 | *.lock 6 | -------------------------------------------------------------------------------- /tests/Stubs/ChildStub.php: -------------------------------------------------------------------------------- 1 | $input 11 | */ 12 | public function finalize(array $input): void; 13 | } 14 | -------------------------------------------------------------------------------- /tests/Stubs/StaticStub.php: -------------------------------------------------------------------------------- 1 | name; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Stubs/NumericStub.php: -------------------------------------------------------------------------------- 1 | method; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Annotation/Alias.php: -------------------------------------------------------------------------------- 1 | name; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tests/Stubs/ValidationStub.php: -------------------------------------------------------------------------------- 1 | age; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Stubs/StrictCastStub.php: -------------------------------------------------------------------------------- 1 | age = $age; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Failure/FailureHandler.php: -------------------------------------------------------------------------------- 1 | hasFailures()) { 14 | return; 15 | } 16 | 17 | throw new ValidationException(implode(PHP_EOL, $collection->getFailures())); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/Stubs/UnionTypeStub.php: -------------------------------------------------------------------------------- 1 | id; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/DataTransfer.php: -------------------------------------------------------------------------------- 1 | from($input); 20 | 21 | return $dto->getInstance(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Stubs/NameAliasMixStub.php: -------------------------------------------------------------------------------- 1 | id > 0); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Stubs/ArrayGenericTypeStub.php: -------------------------------------------------------------------------------- 1 | > /var/www/.ssh/known_hosts 12 | 13 | WORKDIR /var/www/html 14 | 15 | COPY --chown=www-data:www-data --from=composer:2 /usr/bin/composer /usr/local/bin/composer 16 | 17 | RUN usermod -u $USER_ID www-data && chown -R www-data:www-data /var/www/ . 18 | USER www-data 19 | 20 | CMD ["php-fpm"] 21 | -------------------------------------------------------------------------------- /tests/Stubs/FilterStubProvider.php: -------------------------------------------------------------------------------- 1 | $value) { 17 | $output[$key] = base64_encode((string) $value); 18 | } 19 | 20 | return $output; 21 | } 22 | 23 | public function toInt(int|string $id): int 24 | { 25 | return (int) $id; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/Stubs/QueryFieldStub.php: -------------------------------------------------------------------------------- 1 | query; 23 | } 24 | 25 | public function getFields(): array 26 | { 27 | return $this->fields; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Stubs/OptionalStub.php: -------------------------------------------------------------------------------- 1 | ['offset' => 23, 'size' => 42], 'match' => ['query' => 'ab|c']]; 15 | $stub = NestedStub::from($input); 16 | $this->assertInstanceOf(NestedStub::class, $stub); 17 | $this->assertEquals(23, $stub->limit->getFrom()); 18 | $this->assertEquals(42, $stub->limit->getSize()); 19 | $this->assertEquals('ab|c', $stub->query->getQuery()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /tests/Stubs/PaginationStub.php: -------------------------------------------------------------------------------- 1 | offset = $from; 22 | $this->size = $size; 23 | } 24 | 25 | public function getOffset(): ?int 26 | { 27 | return $this->offset; 28 | } 29 | 30 | public function getSize(): ?int 31 | { 32 | return $this->size; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Annotation/Reject.php: -------------------------------------------------------------------------------- 1 | $exception 19 | */ 20 | public function __construct(string $reason, string $exception = InvalidArgumentException::class) 21 | { 22 | $this->exceptional = new Exceptional(message: $reason, exception: $exception); 23 | } 24 | 25 | public function execute(): void 26 | { 27 | $this->exceptional->execute(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Stubs/FilterStub.php: -------------------------------------------------------------------------------- 1 | id; 27 | } 28 | 29 | public function getFilter(): array 30 | { 31 | return $this->filter; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Annotation/Required.php: -------------------------------------------------------------------------------- 1 | $exception 19 | */ 20 | public function __construct(string $reason, string $exception = InvalidArgumentException::class) 21 | { 22 | $this->exceptional = new Exceptional(message: $reason, exception: $exception); 23 | } 24 | 25 | public function execute(): void 26 | { 27 | $this->exceptional->execute(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/UnionTypeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expected, $ut->getId()); 22 | } 23 | 24 | public function provideInput(): iterable 25 | { 26 | yield [['id' => 42], 42]; 27 | yield [['id' => 'abc'], 'abc']; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Annotation/Trim.php: -------------------------------------------------------------------------------- 1 | $item) { 21 | $value[$key] = $this->transform($item, $property); 22 | } 23 | 24 | return $value; 25 | } 26 | 27 | return is_string($value) ? trim($value, $this->characters) : $value; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Stubs/PersonStub.php: -------------------------------------------------------------------------------- 1 | from; 28 | } 29 | 30 | public function getSize(): ?int 31 | { 32 | return $this->size; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Stubs/CastStub.php: -------------------------------------------------------------------------------- 1 | id = $id; 26 | $this->age = $age; 27 | $this->uid = $uid; 28 | } 29 | 30 | public static function toInt(string|int|float|bool $value): int 31 | { 32 | return (int) $value; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Annotation/Exceptional.php: -------------------------------------------------------------------------------- 1 | $exception 15 | */ 16 | public function __construct(private string $message, private string $exception = InvalidArgumentException::class) 17 | { 18 | } 19 | 20 | public function execute(): void 21 | { 22 | throw $this->getException(); 23 | } 24 | 25 | private function getException(): Throwable 26 | { 27 | $exception = new ($this->exception)($this->message); 28 | /** @phpstan-ignore-next-line */ 29 | assert($exception instanceof Throwable); 30 | 31 | return $exception; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/IgnoreTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('abc', $stub->uuid); 27 | $this->assertEquals($expectedId, $stub->id); 28 | } 29 | 30 | public function provideIgnoreData(): iterable 31 | { 32 | yield [['uuid' => 'xyz', 'id' => 42], 42]; 33 | yield [['id' => 23], 23]; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Annotation/Matches.php: -------------------------------------------------------------------------------- 1 | pattern, (string) $value) !== 1) { 19 | $validationStrategy->setFailure( 20 | strtr( 21 | $this->message ?? '{value} of {path} does not match pattern {pattern}', 22 | [ 23 | '{value}' => var_export($value, true), 24 | '{values}' => $this->pattern 25 | ] 26 | ) 27 | ); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/StaticTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expectedId, $stub::$id); 27 | $this->assertEquals($expectedId, StaticStub::$id); 28 | } 29 | 30 | public function provideStaticData(): iterable 31 | { 32 | yield [[], 0]; 33 | yield [['id' => 42], 42]; 34 | yield [['id' => 23], 23]; 35 | yield [[], 23]; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Annotation/Boolean.php: -------------------------------------------------------------------------------- 1 | setFailure( 22 | strtr( 23 | $this->message ?? 'Value {value} of {path} is not a bool', 24 | ['{value}' => var_export($value, true)] 25 | ) 26 | ); 27 | } 28 | } 29 | 30 | public function transform(mixed $value, ReflectionProperty $property): mixed 31 | { 32 | return bool($value) ?? $value; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Annotation/Optional.php: -------------------------------------------------------------------------------- 1 | getType(); 24 | if (!($reflectionNamedType instanceof ReflectionNamedType)) { 25 | throw new InvalidArgumentException('Cannot cast to unknown type'); 26 | } 27 | 28 | $propertyType = PhpType::fromReflectionType($reflectionNamedType); 29 | 30 | return $this->value ?? ($propertyType instanceof Defaultable ? $propertyType->getDefaultValue() : $this->value); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Annotation/Numeric.php: -------------------------------------------------------------------------------- 1 | setFailure( 22 | strtr( 23 | $this->message ?? 'Value {value} of {path} is not numeric', 24 | ['{value}' => var_export($value, true)] 25 | ) 26 | ); 27 | } 28 | } 29 | 30 | public function transform(mixed $value, ReflectionProperty $property): mixed 31 | { 32 | return number($value) ?? $value; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Annotation/In.php: -------------------------------------------------------------------------------- 1 | $values 14 | */ 15 | public function __construct(private array $values, private ?string $message = null) 16 | { 17 | } 18 | 19 | public function validate(mixed $value, ValidationStrategy $validationStrategy): void 20 | { 21 | if (!in_array(needle: $value, haystack: $this->values, strict: true)) { 22 | $validationStrategy->setFailure( 23 | strtr( 24 | $this->message ?? '{value} of {path} must be in {values}', 25 | [ 26 | '{value}' => var_export($value, true), 27 | '{values}' => var_export($this->values, true) 28 | ] 29 | ) 30 | ); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Annotation/NotIn.php: -------------------------------------------------------------------------------- 1 | $values 14 | */ 15 | public function __construct(private array $values, private ?string $message = null) 16 | { 17 | } 18 | 19 | public function validate(mixed $value, ValidationStrategy $validationStrategy): void 20 | { 21 | if (in_array(needle: $value, haystack: $this->values, strict: true)) { 22 | $validationStrategy->setFailure( 23 | strtr( 24 | $this->message ?? '{value} of {path} must not be in {values}', 25 | [ 26 | '{value}' => var_export($value, true), 27 | '{values}' => var_export($this->values, true) 28 | ] 29 | ) 30 | ); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Failure/FailureCollection.php: -------------------------------------------------------------------------------- 1 | path[] = $path; 23 | } 24 | 25 | public function popPath(): ?string 26 | { 27 | return array_pop($this->path); 28 | } 29 | 30 | public function setFailure(string $failure): void 31 | { 32 | $this->failures[] = strtr($failure, [self::PATH => implode('.', $this->path)]); 33 | } 34 | 35 | public function hasFailures(): bool 36 | { 37 | return $this->failures !== []; 38 | } 39 | 40 | /** 41 | * @return string[] 42 | */ 43 | public function getFailures(): array 44 | { 45 | return $this->failures; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Annotation/NumberBetween.php: -------------------------------------------------------------------------------- 1 | setFailure(var_export($value, true) . ' must be a numeric value'); 22 | 23 | return; 24 | } 25 | 26 | if ($value < $this->min) { 27 | $validationStrategy->setFailure(var_export($value, true) . ' must be >= ' . $this->min); 28 | } 29 | 30 | if ($value > $this->max) { 31 | $validationStrategy->setFailure(var_export($value, true) . ' must be <= ' . $this->max); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Randy Schütt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Annotation/Instance.php: -------------------------------------------------------------------------------- 1 | validate($item, $validationStrategy); 21 | } 22 | 23 | return; 24 | } 25 | 26 | if (!is_object($value)) { 27 | $validationStrategy->setFailure($this->message ?? var_export($value, true) . ' must be an object-instance of ' . $this->class . ' in {path}'); 28 | 29 | return; 30 | } 31 | 32 | if (!($value instanceof $this->class)) { 33 | $validationStrategy->setFailure($this->message ?? var_export($value, true) . ' is not an instance of ' . $this->class . ' in {path}'); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/RequiredTest.php: -------------------------------------------------------------------------------- 1 | expectException($exception); 28 | $this->expectExceptionMessage('We need an "id" to identify the class'); 29 | } 30 | 31 | $stub = RequiredStub::from($input); 32 | $this->assertEquals($expectedId, $stub->id); 33 | } 34 | 35 | public function provideAbsentData(): iterable 36 | { 37 | yield [['id' => 23], 23]; 38 | yield [[], 23]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/FilterTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expectedId, $filter->getId()); 23 | $this->assertEquals($expectedFilter, $filter->getFilter()); 24 | } 25 | 26 | public function provideData(): iterable 27 | { 28 | yield [ 29 | ['filter' => [1, 2, 3], 'id' => '_'], 30 | [0 => base64_encode('1'), 1 => base64_encode('2'), 2 => base64_encode('3')], 31 | 0 32 | ]; 33 | 34 | yield [ 35 | ['filter' => ['a' => 'b', 'x' => 'y'], 'id' => '42a'], 36 | ['a' => base64_encode('b'), 'x' => base64_encode('y')], 37 | 42 38 | ]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/thecodingmachine/phpstan-safe-rule/phpstan-safe-rule.neon 3 | - vendor/thecodingmachine/phpstan-strict-rules/phpstan-strict-rules.neon 4 | - vendor/phpstan/phpstan-deprecation-rules/rules.neon 5 | - vendor/phpstan/phpstan-strict-rules/rules.neon 6 | - vendor/phpstan/phpstan/conf/bleedingEdge.neon 7 | - vendor/spaze/phpstan-disallowed-calls/extension.neon 8 | - vendor/spaze/phpstan-disallowed-calls/disallowed-dangerous-calls.neon 9 | - vendor/spaze/phpstan-disallowed-calls/disallowed-execution-calls.neon 10 | 11 | parameters: 12 | treatPhpDocTypesAsCertain: false 13 | checkInternalClassCaseSensitivity: true 14 | checkTooWideReturnTypesInProtectedAndPublicMethods: true 15 | checkUninitializedProperties: true 16 | checkMissingCallableSignature: true 17 | checkExplicitMixed: true 18 | level: max 19 | paths: 20 | - src 21 | 22 | rules: 23 | - Ergebnis\PHPStan\Rules\Expressions\NoCompactRule 24 | - Ergebnis\PHPStan\Rules\Expressions\NoEmptyRule 25 | - Ergebnis\PHPStan\Rules\Expressions\NoErrorSuppressionRule 26 | - Ergebnis\PHPStan\Rules\Expressions\NoEvalRule 27 | - Ergebnis\PHPStan\Rules\Expressions\NoIssetRule 28 | - Ergebnis\PHPStan\Rules\Files\DeclareStrictTypesRule 29 | - Ergebnis\PHPStan\Rules\Methods\PrivateInFinalClassRule 30 | -------------------------------------------------------------------------------- /tests/SelfValidationTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expected, $stub); 29 | } catch (AssertionError $error) { 30 | $this->assertEquals('assert($this->id > 0)', $error->getMessage()); 31 | } 32 | } 33 | 34 | public function provideSelfValidationData(): iterable 35 | { 36 | yield [['id' => 1], new SelfValidationStub(id: 1)]; 37 | yield [['id' => 99], new SelfValidationStub(id: 99)]; 38 | yield [['id' => 0], new SelfValidationStub(id: 0)]; 39 | yield [['id' => -1], new SelfValidationStub(id: -1)]; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/QueryFieldTest.php: -------------------------------------------------------------------------------- 1 | $input 14 | * @param string $expectedQuery 15 | * @param string[] $expectedFields 16 | * 17 | * @dataProvider provideQueryFieldInput 18 | */ 19 | public function testQueryFieldStub(array $input, string $expectedQuery, array $expectedFields): void 20 | { 21 | $stub = QueryFieldStub::from($input); 22 | $this->assertEquals($expectedQuery, $stub->getQuery()); 23 | $this->assertEquals($expectedFields, $stub->getFields()); 24 | } 25 | 26 | public function provideQueryFieldInput(): iterable 27 | { 28 | yield 'Just Query' => [ 29 | ['query' => 'abc | def'], 30 | 'abc | def', 31 | [] 32 | ]; 33 | 34 | yield 'Query char' => [ 35 | ['query' => '*'], 36 | '*', 37 | [] 38 | ]; 39 | 40 | yield 'With Fields' => [ 41 | ['query' => 'a+b', 'fields' => ['x', 'y', 'z']], 42 | 'a+b', 43 | ['x', 'y', 'z'] 44 | ]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Annotation/Max.php: -------------------------------------------------------------------------------- 1 | $this->maxValue) { 20 | $validationStrategy->setFailure($this->message ?? 'Value ' . var_export($value, true) . ' of {path} must be <= ' . $this->maxValue); 21 | } 22 | 23 | return; 24 | } 25 | 26 | if (is_string($value)) { 27 | if (strlen($value) > $this->maxValue) { 28 | $validationStrategy->setFailure($this->message ?? 'Value ' . var_export($value, true) . ' of {path} must have at most a length of ' . $this->maxValue); 29 | } 30 | 31 | return; 32 | } 33 | 34 | if (is_array($value) && count($value) > $this->maxValue) { 35 | $validationStrategy->setFailure($this->message ?? 'Value ' . var_export($value, true) . ' of {path} must have at most a length of ' . $this->maxValue); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Annotation/Min.php: -------------------------------------------------------------------------------- 1 | minValue) { 20 | $validationStrategy->setFailure($this->message ?? 'Value ' . var_export($value, true) . ' of {path} must be >= ' . $this->minValue); 21 | } 22 | 23 | return; 24 | } 25 | 26 | if (is_string($value)) { 27 | if (strlen($value) < $this->minValue) { 28 | $validationStrategy->setFailure($this->message ?? 'Value ' . var_export($value, true) . ' of {path} must have at least a length of ' . $this->minValue); 29 | } 30 | 31 | return; 32 | } 33 | 34 | if (is_array($value) && count($value) < $this->minValue) { 35 | $validationStrategy->setFailure($this->message ?? 'Value ' . var_export($value, true) . ' of {path} must have at least a length of ' . $this->minValue); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.4.0 - 12.09.2021 4 | 5 | ### Added 6 | 7 | - [Boolean](/README.md/#Boolean) 8 | - [Date](/README.md/#Date) 9 | - [In](/README.md/#In) 10 | - [Matches](/README.md/#Matches) 11 | - [NotIn](/README.md/#NotIn) 12 | - [Numeric](/README.md/#Numeric) 13 | - [Trim](/README.md/#Trim) 14 | 15 | ### Changed 16 | 17 | - `finalize`of `DataTransferObject` is now private an will be called at the end of `from`. 18 | 19 | ## v0.3.0 - 15.08.2021 20 | 21 | ### Added 22 | 23 | - [Transformations](/README.md/#Transformations) (e.g. [Cast](/README.md/#Cast)) 24 | - [Optional](/README.md/#Optional) 25 | - [Path](/README.md/#Path) 26 | - [SelfValidation](/README.md/#SelfValidation) 27 | - [ValidationStrategy](/README.md/#ValidationStrategy) 28 | 29 | ### Changed 30 | 31 | - **Renamed** trait `From` to `DataTransfer` 32 | - **Improved** [Type](/README.md/#Type) so that it can even detect generic arrays 33 | - Validations can now be configured with a [ValidationStrategy](/README.md/#ValidationStrategy) to fail fast (default) or to collect **all** failures. 34 | The non-fast failure collection and handling can be configured by either implementing your own [ValidationStrategy](/README.md/#ValidationStrategy) or by overriding `FailureCollection` and `FailureHandler`. 35 | 36 | ### Removed 37 | 38 | - `Call`. [Cast](/README.md/#Cast) can be used for the most common tasks instead of `Call`. 39 | -------------------------------------------------------------------------------- /tests/DenyUnknownFieldsTest.php: -------------------------------------------------------------------------------- 1 | expectException($exception); 28 | } 29 | 30 | if ($message !== null) { 31 | $this->expectExceptionMessage($message); 32 | } 33 | 34 | $stub = DenyUnknownStub::from($input); 35 | $this->assertInstanceOf(DenyUnknownStub::class, $stub); 36 | } 37 | 38 | public function provideData(): iterable 39 | { 40 | yield [['id' => 42], null, null]; 41 | yield [['Id' => 42], null, 'The field "Id" is not expected']; 42 | yield [['a' => null, 'b' => null], null, 'The fields "a, b" are not expected']; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tests/PaginationTest.php: -------------------------------------------------------------------------------- 1 | $input 14 | * @param int|null $expectedOffset 15 | * @param int|null $expectedSize 16 | * 17 | * @dataProvider provideLimitInput 18 | */ 19 | public function testPaginationStub(array $input, ?int $expectedOffset, ?int $expectedSize): void 20 | { 21 | $stub = PaginationStub::from($input); 22 | $this->assertEquals($expectedOffset, $stub->getOffset()); 23 | $this->assertEquals($expectedSize, $stub->getSize()); 24 | } 25 | 26 | public function provideLimitInput(): iterable 27 | { 28 | yield 'Empty' => [ 29 | [], 30 | null, 31 | null 32 | ]; 33 | 34 | yield 'Offset 0' => [ 35 | ['offset' => 0], 36 | 0, 37 | null 38 | ]; 39 | 40 | yield 'Size 42' => [ 41 | ['size' => 42], 42 | null, 43 | 42 44 | ]; 45 | 46 | yield 'Offset 23, Size 42' => [ 47 | ['offset' => 23, 'size' => 42], 48 | 23, 49 | 42 50 | ]; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/NameAliasMixTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 27 | $this->expectExceptionMessage('Expected one of "a, z"'); 28 | } 29 | 30 | $stub = NameAliasMixStub::from($input); 31 | $this->assertEquals($expectedId, $stub->id); 32 | $this->assertEquals($expectedUuid, $stub->uuid); 33 | } 34 | 35 | public function provideNameAliasMixedData(): iterable 36 | { 37 | yield [['a' => 42, 'x' => 1], 42, 1]; 38 | yield [['z' => 23, 'y' => 2], 23, 2]; 39 | yield [['a' => 1337, 'uuid' => 3], 1337, 3]; 40 | yield [['z' => 1337, 'uuid' => 4], 1337, 4]; 41 | yield [['id' => 1337, 'uuid' => 42], 1337, 42]; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Annotation/Type.php: -------------------------------------------------------------------------------- 1 | type = PhpType::fromName($name, $allowsNull); 19 | } 20 | 21 | public static function from(ReflectionNamedType $type): self 22 | { 23 | return new self($type->getName(), allowsNull: $type->allowsNull()); 24 | } 25 | 26 | public function validate(mixed $value, ValidationStrategy $validationStrategy): void 27 | { 28 | if ($value === null) { 29 | if (!$this->type->allowsNull()) { 30 | $validationStrategy->setFailure($this->message ?? 'Cannot assign null to non-nullable ' . $this->type->getName() . ' of {path}'); 31 | } 32 | 33 | return; 34 | } 35 | 36 | $valueType = PhpType::fromValue($value); 37 | if (!$this->type->isAssignable($valueType)) { 38 | $validationStrategy->setFailure($this->message ?? 'Cannot assign ' . $valueType->getName() . ' ' . var_export($value, return: true) . ' to ' . $this->type->getName() . ' of {path}'); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Annotation/ValidationStrategy.php: -------------------------------------------------------------------------------- 1 | collection = $collection ?? new FailureCollection(); 20 | $this->handler = $handler ?? new FailureHandler(); 21 | } 22 | 23 | public function pushPath(string $path): void 24 | { 25 | $this->collection->pushPath($path); 26 | } 27 | 28 | public function popPath(): ?string 29 | { 30 | return $this->collection->popPath(); 31 | } 32 | 33 | public function setFailure(string $failure): void 34 | { 35 | $this->collection->setFailure($failure); 36 | if ($this->failFast) { 37 | $this->handle(); 38 | } 39 | } 40 | 41 | public function hasFailures(): bool 42 | { 43 | return $this->collection->hasFailures(); 44 | } 45 | 46 | public function handle(): void 47 | { 48 | $this->handler->handle($this->collection); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /tests/ArrayInstanceTest.php: -------------------------------------------------------------------------------- 1 | expectException(InvalidArgumentException::class); 28 | $this->expectExceptionMessage($message); 29 | } 30 | 31 | $stub = ArrayInstanceStub::from($input); 32 | $this->assertContainsOnlyInstancesOf(LimitStub::class, $stub->limits); 33 | } 34 | 35 | public function provideArrayData(): iterable 36 | { 37 | yield [['limits' => [new LimitStub(from: 42, size: 23)]]]; 38 | yield [['limits' => [new LimitStub(from: 1, size: 2), new LimitStub(from: 3, size: 4)]]]; 39 | yield [['limits' => []]]; 40 | yield [['limits' => [null]], 'NULL must be an object-instance of ' . LimitStub::class]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DOCKER_SEARCH_SERVICE = dto 2 | 3 | env: 4 | cp .env.dist .env 5 | 6 | new: kill 7 | docker-compose up -d --build --remove-orphans 8 | make install 9 | up: 10 | docker-compose up -d 11 | make autoload 12 | stop: 13 | docker-compose stop 14 | kill: 15 | docker-compose kill 16 | 17 | test: 18 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer test 19 | 20 | lint: 21 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer lint 22 | lint-static: 23 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer lint:static 24 | lint-fix: 25 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer lint:fix 26 | lint-style: 27 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer lint:style 28 | lint-fix-style: 29 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer lint:fix-style 30 | 31 | autoload: 32 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer dump-autoload 33 | install: 34 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer install --no-interaction --prefer-dist 35 | normalize: 36 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer normalize 37 | update-lock: 38 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer update --lock 39 | update: 40 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer update 41 | upgrade: 42 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer upgrade 43 | validate: 44 | docker-compose exec $(DOCKER_SEARCH_SERVICE) composer validate 45 | 46 | bash: 47 | docker-compose exec $(DOCKER_SEARCH_SERVICE) bash 48 | -------------------------------------------------------------------------------- /src/Annotation/Date.php: -------------------------------------------------------------------------------- 1 | format !== null) { 22 | /** @phpstan-ignore-next-line => short ternary */ 23 | $dt = DateTime::createFromFormat($this->format, $value) ?: null; 24 | } else { 25 | /** @phpstan-ignore-next-line => short ternary */ 26 | $info = date_parse($value) ?: []; 27 | if (($info['error_count'] ?? 0) === 0 && ($info['warning_count'] ?? 0) === 0) { 28 | /** @phpstan-ignore-next-line */ 29 | $dt = new DateTime($value, $this->timezone === null ? null : new DateTimeZone($this->timezone)); 30 | } 31 | } 32 | 33 | if ($dt === null) { 34 | $validationStrategy->setFailure( 35 | strtr( 36 | $this->message ?? '{value} of {path} is not a Date', 37 | ['{value}' => var_export($value, true)] 38 | ) 39 | ); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | parameters(); 17 | $parameters->set(Option::PATHS, [ 18 | __DIR__ . '/src', 19 | __DIR__ . '/tests' 20 | ]); 21 | $containerConfigurator->import(SetList::PSR_12); 22 | $parameters->set(Option::SKIP, [ 23 | BinaryOperatorSpacesFixer::class, // So that union types like `int|bool` won't be replaced with `int | bool` 24 | FunctionDeclarationFixer::class // So that `fn(int $a)` won't be replaced with `fn (int $a)` 25 | ]); 26 | $containerConfigurator->import(SetList::CLEAN_CODE); 27 | 28 | $services = $containerConfigurator->services(); 29 | $services->set(StrictParamFixer::class); 30 | $services->set(DeclareStrictTypesFixer::class); 31 | $services->set(NoUselessElseFixer::class); 32 | $services->set(NoUnusedImportsFixer::class); 33 | }; 34 | -------------------------------------------------------------------------------- /tests/ValidationStrategyTest.php: -------------------------------------------------------------------------------- 1 | expectException(ValidationException::class); 26 | $this->expectExceptionMessage($message); 27 | 28 | ValidationStrategyStub::from($input); 29 | } 30 | 31 | public function provideValidationStrategyData(): iterable 32 | { 33 | yield [[], 'Expected a value for ValidationStrategyStub.name']; 34 | yield [['id' => -1], 'Expected a value for ValidationStrategyStub.name' . PHP_EOL . 'Value -1 of ValidationStrategyStub.id must be >= 0']; 35 | yield [['name' => 'FooBar', 'id' => -42], 'Value -42 of ValidationStrategyStub.id must be >= 0']; 36 | yield [['name' => 'a', 'id' => 42], 'Value \'a\' of ValidationStrategyStub.name must have at least a length of 3']; 37 | yield [['id' => 23], 'Expected a value for ValidationStrategyStub.name']; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/RejectTest.php: -------------------------------------------------------------------------------- 1 | expectException($exception); 28 | $this->expectExceptionMessage('The attribute "uuid" is not supposed to be set'); 29 | } elseif (array_key_exists('new', $input)) { 30 | $this->expectException($exception); 31 | $this->expectExceptionMessage('The attribute "new" is not supposed to be set'); 32 | } 33 | 34 | $stub = RejectStub::from($input); 35 | $this->assertEquals('abc', $stub->uuid); 36 | $this->assertEquals($input['new'] ?? true, $stub->new); 37 | $this->assertEquals($expectedId, $stub->id); 38 | } 39 | 40 | public function provideIgnoreData(): iterable 41 | { 42 | yield [['uuid' => 'xyz', 'new' => false, 'id' => 42], 42]; 43 | yield [['uuid' => 'xyz', 'id' => 42], 42]; 44 | yield [['id' => 23], 23]; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Annotation/DenyUnknownFields.php: -------------------------------------------------------------------------------- 1 | |null $exception 16 | * @param string|null $message 17 | */ 18 | public function __construct(private ?string $exception = null, private ?string $message = null) 19 | { 20 | } 21 | 22 | /** 23 | * @inheritDoc 24 | * @throws Throwable 25 | */ 26 | public function finalize(array $input): void 27 | { 28 | if ($input === []) { 29 | return; 30 | } 31 | 32 | $message = $this->getMessage(array_keys($input)); 33 | if ($this->exception !== null) { 34 | $exception = new ($this->exception)($message); 35 | /** @phpstan-ignore-next-line */ 36 | assert($exception instanceof Throwable); 37 | 38 | throw $exception; 39 | } 40 | 41 | throw new InvalidArgumentException($message); 42 | } 43 | 44 | /** 45 | * @param string[] $fields 46 | * 47 | * @return string 48 | */ 49 | private function getMessage(array $fields): string 50 | { 51 | return $this->message ?? match (count($fields)) { 52 | 0 => 'Found unexpected fields', 53 | 1 => 'The field "' . implode(', ', $fields) . '" is not expected', 54 | default => 'The fields "' . implode(', ', $fields) . '" are not expected', 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/ValidationTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expectedAge, $vs->getAge()); 23 | } 24 | 25 | /** 26 | * @param array $input 27 | * @param string $message 28 | * 29 | * @dataProvider provideInvalidAge 30 | */ 31 | public function testInvalidAge(array $input, string $message): void 32 | { 33 | $this->expectException(InvalidArgumentException::class); 34 | $this->expectExceptionMessage($message); 35 | ValidationStub::from($input); 36 | } 37 | 38 | public function provideValidAge(): iterable 39 | { 40 | yield [['age' => 18], 18]; 41 | yield [['age' => 25], 25]; 42 | yield [['age' => 42], 42]; 43 | yield [['age' => 99], 99]; 44 | yield [['age' => 125], 125]; 45 | } 46 | 47 | public function provideInvalidAge(): iterable 48 | { 49 | yield [['age' => 'abc'], '\'abc\' must be a numeric value']; 50 | yield [['age' => 0], '0 must be >= 18']; 51 | yield [['age' => 16], '16 must be >= 18']; 52 | yield [['age' => 126], '126 must be <= 125']; 53 | yield [['age' => 255], '255 must be <= 125']; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tests/OptionalTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expected, $stub); 27 | } 28 | 29 | public function provideOptionalData(): iterable 30 | { 31 | yield [[], new OptionalStub(id: 0, answer: 42, question: null, message: 'foobar', age: 18)]; 32 | yield [['age' => 21], new OptionalStub(id: 0, answer: 42, question: null, message: 'foobar', age: 21)]; 33 | yield [['id' => 23], new OptionalStub(id: 23, answer: 42, question: null, message: 'foobar', age: 18)]; 34 | yield [['answer' => 23], new OptionalStub(id: 0, answer: 23, question: null, message: 'foobar', age: 18)]; 35 | yield [['id' => 1337, 'answer' => 23], new OptionalStub(id: 1337, answer: 23, question: null, message: 'foobar', age: 18)]; 36 | yield [['id' => 1337, 'answer' => 23, 'message' => 'quatz'], new OptionalStub(id: 1337, answer: 23, question: null, message: 'quatz', age: 18)]; 37 | yield [['id' => 1337, 'answer' => 23, 'question' => 'quatz'], new OptionalStub(id: 1337, answer: 23, question: 'quatz', message: 'foobar', age: 18)]; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/LimitTest.php: -------------------------------------------------------------------------------- 1 | $input 15 | * @param int|null $expectedOffset 16 | * @param string|int|null $expectedSize 17 | * 18 | * @throws \ReflectionException 19 | * @throws \Throwable 20 | * @dataProvider provideLimitInput 21 | */ 22 | public function testLimitStub(array $input, ?int $expectedOffset, string|int|null $expectedSize): void 23 | { 24 | if (is_string($expectedSize)) { 25 | $this->expectException(ValidationException::class); 26 | $this->expectExceptionMessage('Cannot assign string \'' . $expectedSize . '\' to int'); 27 | } 28 | 29 | $stub = LimitStub::from($input); 30 | $this->assertEquals($expectedOffset, $stub->getFrom()); 31 | $this->assertEquals($expectedSize, $stub->getSize()); 32 | } 33 | 34 | public function provideLimitInput(): iterable 35 | { 36 | yield 'Empty' => [ 37 | [], 38 | null, 39 | null 40 | ]; 41 | 42 | yield 'Offset 0' => [ 43 | ['offset' => 0], 44 | 0, 45 | null 46 | ]; 47 | 48 | yield 'Size 42' => [ 49 | ['size' => 42], 50 | null, 51 | 42 52 | ]; 53 | 54 | yield 'Size is string' => [ 55 | ['size' => 'a'], 56 | null, 57 | 'a' 58 | ]; 59 | 60 | yield 'Offset 23, Size 42' => [ 61 | ['offset' => 23, 'size' => 42], 62 | 23, 63 | 42 64 | ]; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: Validation Workflow 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | - reopened 9 | - ready_for_review 10 | paths: 11 | - '**/*.php' 12 | 13 | jobs: 14 | lint-and-test: 15 | strategy: 16 | matrix: 17 | operating-system: ['ubuntu-latest'] 18 | php-versions: ['8.0'] 19 | runs-on: ${{ matrix.operating-system }} 20 | if: github.event.pull_request.draft == false 21 | steps: 22 | - name: Cancel Previous Runs 23 | uses: styfle/cancel-workflow-action@0.9.0 24 | with: 25 | ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | 27 | - name: Checkout 28 | uses: actions/checkout@v2 29 | 30 | - name: Setup PHP 31 | uses: shivammathur/setup-php@v2 32 | with: 33 | php-version: ${{ matrix.php-versions }} 34 | tools: composer:v2 35 | coverage: none 36 | env: 37 | COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | 39 | - name: Display PHP information 40 | run: | 41 | php -v 42 | php -m 43 | composer --version 44 | - name: Get composer cache directory 45 | id: composer-cache 46 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 47 | 48 | - name: Cache dependencies 49 | uses: actions/cache@v2 50 | with: 51 | path: ${{ steps.composer-cache.outputs.dir }} 52 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 53 | restore-keys: ${{ runner.os }}-composer- 54 | 55 | - name: Run composer validate 56 | run: composer validate 57 | 58 | - name: Install dependencies 59 | run: composer install --no-interaction --no-suggest --no-scripts --prefer-dist --ansi 60 | 61 | - name: Run phpcstd 62 | run: vendor/bin/phpcstd --ci --ansi 63 | 64 | - name: Setup problem matchers for PHPUnit 65 | run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 66 | 67 | - name: Run Unit tests 68 | run: composer test --ansi 69 | -------------------------------------------------------------------------------- /tests/PathTest.php: -------------------------------------------------------------------------------- 1 | $input 16 | * @param PersonStub $expected 17 | * 18 | * @throws ReflectionException 19 | * @throws Throwable 20 | * 21 | * @dataProvider providePersonData 22 | */ 23 | public function testPath(array $input, PersonStub $expected): void 24 | { 25 | $stub = PersonStub::from($input); 26 | $this->assertEquals($expected, $stub); 27 | } 28 | 29 | /** 30 | * @return iterable 31 | */ 32 | public function providePersonData(): iterable 33 | { 34 | yield [ 35 | ['id' => 42, 'person' => ['name' => 'Foo'], 'married' => ['$value' => true], 'first' => ['name' => ['#text' => 'Bar']]], 36 | new PersonStub(id: 42, name: 'Foo', married: true, firstname: 'Bar') 37 | ]; 38 | 39 | yield [ 40 | ['id' => 42, 'first' => ['name' => ['#text' => 'Bar']]], 41 | new PersonStub(id: 42, firstname: 'Bar') 42 | ]; 43 | 44 | yield [ 45 | ['id' => 42, 'person.name' => 'Foo', 'married' => ['$value' => true], 'first' => ['name' => ['#text' => 'Bar']]], 46 | new PersonStub(id: 42, married: true, firstname: 'Bar') 47 | ]; 48 | 49 | yield [ 50 | 'married' => ['$value' => false], 51 | new PersonStub(married: false) 52 | ]; 53 | 54 | $stub = new PersonStub(); 55 | $stub->firstChild = ['born' => 'Junior', 'age' => 42]; 56 | yield [ 57 | ['child' => ['born' => 'Junior', 'age' => 42]], 58 | $stub 59 | ]; 60 | 61 | $stub = new PersonStub(); 62 | $stub->parent = new PersonStub(id: 23, name: 'Odin'); 63 | yield [ 64 | ['ancestor' => ['name' => 'Odin', 'married' => true, 'id' => 23]], 65 | $stub 66 | ]; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dgame/php-dto", 3 | "description": "A data transfer object inspired by Rust's serde", 4 | "license": "MIT", 5 | "type": "package", 6 | "keywords": [ 7 | "DTO", 8 | "data transfer object" 9 | ], 10 | "authors": [ 11 | { 12 | "name": "Randy Schütt", 13 | "email": "rswhite4@gmail.com", 14 | "role": "lead" 15 | } 16 | ], 17 | "require": { 18 | "php": "^8.0", 19 | "dgame/php-cast": "^0.1.0", 20 | "dgame/php-type": "^1.0", 21 | "thecodingmachine/safe": "^1.3" 22 | }, 23 | "require-dev": { 24 | "ergebnis/composer-normalize": "^2.4", 25 | "ergebnis/phpstan-rules": "^0.15", 26 | "php-parallel-lint/php-parallel-lint": "^1.2", 27 | "phpstan/phpstan": "^0.12", 28 | "phpstan/phpstan-deprecation-rules": "^0.12", 29 | "phpstan/phpstan-strict-rules": "^0.12", 30 | "phpunit/phpunit": "^9.4", 31 | "roave/security-advisories": "dev-latest", 32 | "slevomat/coding-standard": "dev-master", 33 | "spaceemotion/php-coding-standard": "dev-master", 34 | "spaze/phpstan-disallowed-calls": "^1.5", 35 | "symplify/easy-coding-standard": "^9.3", 36 | "thecodingmachine/phpstan-safe-rule": "^1.0", 37 | "thecodingmachine/phpstan-strict-rules": "^0.12" 38 | }, 39 | "minimum-stability": "dev", 40 | "prefer-stable": true, 41 | "autoload": { 42 | "psr-4": { 43 | "Dgame\\DataTransferObject\\": "src/" 44 | } 45 | }, 46 | "autoload-dev": { 47 | "psr-4": { 48 | "Dgame\\DataTransferObject\\Tests\\": "tests/" 49 | } 50 | }, 51 | "config": { 52 | "allow-plugins": { 53 | "ergebnis/composer-normalize": true, 54 | "dealerdirect/phpcodesniffer-composer-installer": true 55 | }, 56 | "optimize-autoloader": true, 57 | "platform": { 58 | "php": "8.0" 59 | }, 60 | "preferred-install": "dist", 61 | "process-timeout": 0, 62 | "sort-packages": true 63 | }, 64 | "scripts": { 65 | "coverage": "phpunit --coverage-clover=coverage", 66 | "lint": "phpcstd --continue", 67 | "lint:fix": "phpcstd --fix --continue", 68 | "lint:fix-style": "ecs --fix", 69 | "lint:static": "phpstan", 70 | "lint:style": "ecs", 71 | "test": "phpunit" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/ArrayTypeTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expected, $stub); 28 | } 29 | 30 | /** 31 | * @param array $input 32 | * @param string|null $expectedMessage 33 | * 34 | * @throws ReflectionException 35 | * @throws Throwable 36 | * @dataProvider provideArrayWrongTypeData 37 | */ 38 | public function testArrayWrongType(array $input, string $expectedMessage = null): void 39 | { 40 | $this->expectException(ValidationException::class); 41 | $this->expectExceptionMessageMatches($expectedMessage); 42 | 43 | ArrayGenericTypeStub::from($input); 44 | } 45 | 46 | public function provideArrayTypeData(): iterable 47 | { 48 | yield [['names' => [], 'ids' => [[1]]], new ArrayGenericTypeStub(names: [], ids: [[1]])]; 49 | yield [['names' => [''], 'ids' => [[1, 2]]], new ArrayGenericTypeStub(names: [''], ids: [[1, 2]])]; 50 | yield [['names' => ['foo'], 'ids' => [[1, 2]]], new ArrayGenericTypeStub(names: ['foo'], ids: [[1, 2]])]; 51 | yield [['names' => ['foo', 'bar'], 'ids' => [[1, 2]]], new ArrayGenericTypeStub(names: ['foo', 'bar'], ids: [[1, 2]])]; 52 | yield [['names' => ['a', 'b', 'c'], 'ids' => [[1, 2]]], new ArrayGenericTypeStub(names: ['a', 'b', 'c'], ids: [[1, 2]])]; 53 | } 54 | 55 | public function provideArrayWrongTypeData(): iterable 56 | { 57 | yield [['names' => null], '/Cannot assign null to non-nullable array/']; 58 | yield [['names' => [1]], '/Cannot assign array array \(.+?\) to array/s']; 59 | yield [['names' => [1, 2]], '/Cannot assign array array \(.+?\) to array/s']; 60 | yield [['names' => [1, 2, 3]], '/Cannot assign array array \(.+?\) to array/s']; 61 | yield [['names' => [], 'ids' => [1]], '/Cannot assign array array \(.+?\) to array>/s']; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/DataTransferValue.php: -------------------------------------------------------------------------------- 1 | applyTransformations(); 26 | $this->tryResolvingIntoObject(); 27 | $this->validate($validationStrategy); 28 | } 29 | 30 | public function getValue(): mixed 31 | { 32 | return $this->value; 33 | } 34 | 35 | private function applyTransformations(): void 36 | { 37 | foreach ($this->property->getAttributes(Transformation::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { 38 | /** @var Transformation $transformation */ 39 | $transformation = $attribute->newInstance(); 40 | $this->value = $transformation->transform($this->value, $this->property); 41 | } 42 | } 43 | 44 | /** 45 | * @throws ReflectionException 46 | * @throws Throwable 47 | */ 48 | private function tryResolvingIntoObject(): void 49 | { 50 | if (!is_array($this->value)) { 51 | return; 52 | } 53 | 54 | $type = $this->property->getType(); 55 | if ($type === null) { 56 | return; 57 | } 58 | 59 | if (!($type instanceof ReflectionNamedType)) { 60 | return; 61 | } 62 | 63 | if ($type->isBuiltin()) { 64 | return; 65 | } 66 | 67 | $typeName = $type->getName(); 68 | if (!class_exists(class: $typeName, autoload: true)) { 69 | return; 70 | } 71 | 72 | $dto = new DataTransferObject($typeName); 73 | $dto->from($this->value); 74 | $this->value = $dto->getInstance(); 75 | } 76 | 77 | private function validate(ValidationStrategy $validationStrategy): void 78 | { 79 | $typeChecked = false; 80 | foreach ($this->property->getAttributes(Validation::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { 81 | /** @var Validation $validation */ 82 | $validation = $attribute->newInstance(); 83 | $validation->validate($this->value, $validationStrategy); 84 | 85 | $typeChecked = $typeChecked || $validation instanceof Type; 86 | } 87 | 88 | if ($typeChecked || $validationStrategy->hasFailures()) { 89 | return; 90 | } 91 | 92 | $type = $this->property->getType(); 93 | if ($type instanceof ReflectionNamedType) { 94 | Type::from($type)->validate($this->value, $validationStrategy); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/BooleanTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expceted, $stub); 27 | } 28 | 29 | public function provideBooleans(): iterable 30 | { 31 | yield [['yes' => 'yes'], (static function(): BooleanStub { 32 | $stub = new BooleanStub(); 33 | $stub->yes = true; 34 | 35 | return $stub; 36 | })()]; 37 | 38 | yield [['no' => 'no'], (static function(): BooleanStub { 39 | $stub = new BooleanStub(); 40 | $stub->no = false; 41 | 42 | return $stub; 43 | })()]; 44 | 45 | yield [['on' => 'on'], (static function(): BooleanStub { 46 | $stub = new BooleanStub(); 47 | $stub->on = true; 48 | 49 | return $stub; 50 | })()]; 51 | 52 | yield [['off' => 'off'], (static function(): BooleanStub { 53 | $stub = new BooleanStub(); 54 | $stub->off = false; 55 | 56 | return $stub; 57 | })()]; 58 | 59 | yield [['one' => 1], (static function(): BooleanStub { 60 | $stub = new BooleanStub(); 61 | $stub->one = true; 62 | 63 | return $stub; 64 | })()]; 65 | 66 | yield [['zero' => 0], (static function(): BooleanStub { 67 | $stub = new BooleanStub(); 68 | $stub->zero = false; 69 | 70 | return $stub; 71 | })()]; 72 | 73 | yield [['yes' => true], (static function(): BooleanStub { 74 | $stub = new BooleanStub(); 75 | $stub->yes = true; 76 | 77 | return $stub; 78 | })()]; 79 | 80 | yield [['zero' => false], (static function(): BooleanStub { 81 | $stub = new BooleanStub(); 82 | $stub->zero = false; 83 | 84 | return $stub; 85 | })()]; 86 | 87 | yield [['yes' => 'true'], (static function(): BooleanStub { 88 | $stub = new BooleanStub(); 89 | $stub->yes = true; 90 | 91 | return $stub; 92 | })()]; 93 | 94 | yield [['zero' => 'false'], (static function(): BooleanStub { 95 | $stub = new BooleanStub(); 96 | $stub->zero = false; 97 | 98 | return $stub; 99 | })()]; 100 | 101 | yield [['one' => '1'], (static function(): BooleanStub { 102 | $stub = new BooleanStub(); 103 | $stub->one = true; 104 | 105 | return $stub; 106 | })()]; 107 | 108 | yield [['zero' => '0'], (static function(): BooleanStub { 109 | $stub = new BooleanStub(); 110 | $stub->zero = false; 111 | 112 | return $stub; 113 | })()]; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /tests/MatchesTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expected, $stub); 28 | } 29 | 30 | /** 31 | * @param array $input 32 | * 33 | * @throws ReflectionException 34 | * @throws Throwable 35 | * 36 | * @dataProvider provideNotMatches 37 | */ 38 | public function testNotMatch(array $input): void 39 | { 40 | $this->expectException(ValidationException::class); 41 | MatchesStub::from($input); 42 | } 43 | 44 | public function provideMatches(): iterable 45 | { 46 | yield [['name' => 'Dagobert Duck'], (static function (): MatchesStub { 47 | $stub = new MatchesStub(); 48 | $stub->name = 'Dagobert Duck'; 49 | 50 | return $stub; 51 | })()]; 52 | 53 | yield [['name' => ' Daisy Duck '], (static function (): MatchesStub { 54 | $stub = new MatchesStub(); 55 | $stub->name = 'Daisy Duck'; 56 | 57 | return $stub; 58 | })()]; 59 | 60 | yield [['name' => ' Foo'], (static function (): MatchesStub { 61 | $stub = new MatchesStub(); 62 | $stub->name = 'Foo'; 63 | 64 | return $stub; 65 | })()]; 66 | 67 | yield [['age' => 99], (static function (): MatchesStub { 68 | $stub = new MatchesStub(); 69 | $stub->age = 99; 70 | 71 | return $stub; 72 | })()]; 73 | 74 | yield [['age' => 19], (static function (): MatchesStub { 75 | $stub = new MatchesStub(); 76 | $stub->age = 19; 77 | 78 | return $stub; 79 | })()]; 80 | 81 | yield [['age' => 10], (static function (): MatchesStub { 82 | $stub = new MatchesStub(); 83 | $stub->age = 10; 84 | 85 | return $stub; 86 | })()]; 87 | } 88 | 89 | public function provideNotMatches(): iterable 90 | { 91 | yield [['name' => 'A '], (static function (): MatchesStub { 92 | $stub = new MatchesStub(); 93 | $stub->name = 'A'; 94 | 95 | return $stub; 96 | })()]; 97 | 98 | yield [['name' => ' '], (static function (): MatchesStub { 99 | $stub = new MatchesStub(); 100 | $stub->name = ''; 101 | 102 | return $stub; 103 | })()]; 104 | 105 | yield [['age' => 100], (static function (): MatchesStub { 106 | $stub = new MatchesStub(); 107 | $stub->age = 100; 108 | 109 | return $stub; 110 | })()]; 111 | 112 | yield [['age' => 9], (static function (): MatchesStub { 113 | $stub = new MatchesStub(); 114 | $stub->age = 9; 115 | 116 | return $stub; 117 | })()]; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /tests/CastTest.php: -------------------------------------------------------------------------------- 1 | expectExceptionObject($exception); 30 | } 31 | 32 | $stub = $expected::from($input); 33 | $this->assertEquals($expected, $stub); 34 | } 35 | 36 | public function provideCastData(): iterable 37 | { 38 | yield [['id' => 42], new CastStub(id: 42)]; 39 | yield [['id' => true], new CastStub(id: 1)]; 40 | yield [['id' => false], new CastStub(id: 0)]; 41 | yield [['id' => '43'], new CastStub(id: 43)]; 42 | yield [['id' => '43a'], new CastStub(id: 43)]; 43 | yield [['id' => ' 43a'], new CastStub(id: 43)]; 44 | yield [['id' => '-43a'], new CastStub(id: -43)]; 45 | yield [['id' => 3.14], new CastStub(id: 3)]; 46 | 47 | yield [['age' => 42], new CastStub(age: 42)]; 48 | yield [['age' => true], new CastStub(age: 1)]; 49 | yield [['age' => false], new CastStub(age: 0)]; 50 | yield [['age' => '43'], new CastStub(age: 43)]; 51 | yield [['age' => '43a'], new CastStub(age: 43)]; 52 | yield [['age' => ' 43a'], new CastStub(age: 43)]; 53 | yield [['age' => '-43a'], new CastStub(age: -43)]; 54 | yield [['age' => 3.14], new CastStub(age: 3)]; 55 | yield [['age' => null], new CastStub(age: null)]; 56 | 57 | yield [['uid' => 42], new CastStub(uid: 42)]; 58 | yield [['uid' => true], new CastStub(uid: 1)]; 59 | yield [['uid' => false], new CastStub(uid: 0)]; 60 | yield [['uid' => '43'], new CastStub(uid: 43)]; 61 | yield [['uid' => '43a'], new CastStub(uid: 43)]; 62 | yield [['uid' => ' 43a'], new CastStub(uid: 43)]; 63 | yield [['uid' => '-43a'], new CastStub(uid: -43)]; 64 | yield [['uid' => 3.14], new CastStub(uid: 3.14)]; 65 | yield [['uid' => '3.14'], new CastStub(uid: 3)]; 66 | yield [['uid' => null], new CastStub(uid: null)]; 67 | 68 | yield [['age' => 42], new StrictCastStub(age: 42)]; 69 | yield [['age' => true], new StrictCastStub(), new InvalidArgumentException("Only float is accepted, bool (true) given.")]; 70 | yield [['age' => false], new StrictCastStub(), new InvalidArgumentException("Only float is accepted, bool (false) given.")]; 71 | yield [['age' => '43'], new StrictCastStub(), new InvalidArgumentException("Only float is accepted, string ('43') given.")]; 72 | yield [['age' => '43a'], new StrictCastStub(), new InvalidArgumentException("Only float is accepted, string ('43a') given.")]; 73 | yield [['age' => ' 43a'], new StrictCastStub(), new InvalidArgumentException("Only float is accepted, string (' 43a') given.")]; 74 | yield [['age' => '-43a'], new StrictCastStub(), new InvalidArgumentException("Only float is accepted, string ('-43a') given.")]; 75 | yield [['age' => 3.14], new StrictCastStub(age: 3)]; 76 | yield [['age' => null], new StrictCastStub(age: null)]; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Annotation/Path.php: -------------------------------------------------------------------------------- 1 | key; 26 | } 27 | 28 | /** 29 | * @throws PcreException 30 | */ 31 | public function extract(mixed $value): mixed 32 | { 33 | if (!is_array($value)) { 34 | return $value; 35 | } 36 | 37 | return $this->get($this->path, $value); 38 | } 39 | 40 | /** 41 | * @param string $path 42 | * @param array $input 43 | * 44 | * @return mixed 45 | * @throws PcreException 46 | */ 47 | private function get(string $path, array $input): mixed 48 | { 49 | if ($input === []) { 50 | return null; 51 | } 52 | 53 | $pathSteps = $this->split($path); 54 | if ($this->key === null) { 55 | $this->key = key($pathSteps); 56 | } 57 | 58 | $output = $input; 59 | foreach ($pathSteps as $i => $step) { 60 | // Wildcard 61 | if ($step === self::WILDCARD) { 62 | $stepsLeft = array_slice($pathSteps, $i + 1); 63 | if (count($stepsLeft) === 0) { 64 | return $output; 65 | } 66 | 67 | $nestedPath = $this->assemble($stepsLeft); 68 | $results = []; 69 | foreach ($output as $value) { 70 | $results[] = $this->get($nestedPath, $value); 71 | } 72 | 73 | return $results; 74 | } 75 | 76 | // Multiselect 77 | if (\Safe\preg_match('/^{(.*?)}$/S', $step, $matches) === 1) { 78 | [, $subSteps] = $matches; 79 | 80 | $results = []; 81 | foreach (explode(',', $subSteps) as $subStep) { 82 | $subStep = trim($subStep); 83 | $results[$subStep] = $this->get($subStep, $output); 84 | } 85 | 86 | return $results; 87 | } 88 | 89 | if (!array_key_exists($step, $output)) { 90 | return null; 91 | } 92 | 93 | $output = $output[$step]; 94 | } 95 | 96 | return $output; 97 | } 98 | 99 | /** 100 | * @param string $path 101 | * 102 | * @return string[] 103 | */ 104 | private function split(string $path): array 105 | { 106 | if ($path === '') { 107 | throw new InvalidArgumentException('Path can\'t be empty.'); 108 | } 109 | 110 | if (str_contains($path, '{')) { 111 | if (!str_contains($path, '}')) { 112 | throw new InvalidArgumentException('Multimatch syntax not closed'); 113 | } 114 | 115 | if (strpos($path, '}') !== strlen($path) - 1) { 116 | throw new InvalidArgumentException('Multimatch must be used at the end of path'); 117 | } 118 | } 119 | 120 | if (str_starts_with($path, '{') && str_contains($path, '}')) { 121 | return [$path]; 122 | } 123 | 124 | return explode(self::SEPARATOR, $path); 125 | } 126 | 127 | /** 128 | * @param string[] $steps 129 | * 130 | * @return string 131 | */ 132 | private function assemble(array $steps): string 133 | { 134 | return implode(self::SEPARATOR, $steps); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /tests/NumericTest.php: -------------------------------------------------------------------------------- 1 | assertEquals($expected, $stub); 28 | } 29 | 30 | /** 31 | * @param array $input 32 | * 33 | * @throws ReflectionException 34 | * @throws Throwable 35 | * 36 | * @dataProvider provideNonNumeric 37 | */ 38 | public function testNonNumeric(array $input): void 39 | { 40 | $this->expectException(ValidationException::class); 41 | NumericStub::from($input); 42 | } 43 | 44 | public function provideNumeric(): iterable 45 | { 46 | yield [['int' => '123'], (static function (): NumericStub { 47 | $stub = new NumericStub(); 48 | $stub->int = 123; 49 | 50 | return $stub; 51 | })()]; 52 | 53 | yield [['int' => ' -4'], (static function (): NumericStub { 54 | $stub = new NumericStub(); 55 | $stub->int = -4; 56 | 57 | return $stub; 58 | })()]; 59 | 60 | yield [['int' => true], (static function (): NumericStub { 61 | $stub = new NumericStub(); 62 | $stub->int = 1; 63 | 64 | return $stub; 65 | })()]; 66 | 67 | yield [['float' => '42'], (static function (): NumericStub { 68 | $stub = new NumericStub(); 69 | $stub->float = 42.0; 70 | 71 | return $stub; 72 | })()]; 73 | 74 | yield [['float' => '4.2'], (static function (): NumericStub { 75 | $stub = new NumericStub(); 76 | $stub->float = 4.2; 77 | 78 | return $stub; 79 | })()]; 80 | 81 | yield [['float' => (string) M_PI], (static function (): NumericStub { 82 | $stub = new NumericStub(); 83 | $stub->float = M_PI; 84 | 85 | return $stub; 86 | })()]; 87 | } 88 | 89 | public function provideNonNumeric(): iterable 90 | { 91 | yield [['int' => 'a123'], (static function (): NumericStub { 92 | $stub = new NumericStub(); 93 | $stub->int = 123; 94 | 95 | return $stub; 96 | })()]; 97 | 98 | yield [['int' => '123a'], (static function (): NumericStub { 99 | $stub = new NumericStub(); 100 | $stub->int = 123; 101 | 102 | return $stub; 103 | })()]; 104 | 105 | yield [['int' => ' '], (static function (): NumericStub { 106 | $stub = new NumericStub(); 107 | $stub->int = 0; 108 | 109 | return $stub; 110 | })()]; 111 | 112 | yield [['int' => ' - 4'], (static function (): NumericStub { 113 | $stub = new NumericStub(); 114 | $stub->int = -4; 115 | 116 | return $stub; 117 | })()]; 118 | 119 | yield [['int' => '4 2'], (static function (): NumericStub { 120 | $stub = new NumericStub(); 121 | $stub->int = 42; 122 | 123 | return $stub; 124 | })()]; 125 | 126 | yield [['int' => M_PI], (static function (): NumericStub { 127 | $stub = new NumericStub(); 128 | $stub->int = 3; 129 | 130 | return $stub; 131 | })()]; 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/DataTransferObject.php: -------------------------------------------------------------------------------- 1 | 25 | */ 26 | private ReflectionClass $reflection; 27 | /** 28 | * @var T 29 | */ 30 | private object $object; 31 | private ?ReflectionMethod $constructor; 32 | private ValidationStrategy $validationStrategy; 33 | 34 | /** 35 | * @param class-string $class 36 | * 37 | * @throws ReflectionException 38 | */ 39 | public function __construct(string $class) 40 | { 41 | $this->reflection = new ReflectionClass($class); 42 | $this->constructor = $this->reflection->getConstructor(); 43 | $this->createInstance(); 44 | $this->createValidationStrategy(); 45 | } 46 | 47 | /** 48 | * @param array $input 49 | * 50 | * @throws Throwable 51 | */ 52 | public function from(array &$input): void 53 | { 54 | $this->validationStrategy->pushPath($this->reflection->getShortName()); 55 | 56 | foreach ($this->reflection->getProperties() as $property) { 57 | $this->validationStrategy->pushPath($property->getName()); 58 | 59 | $dtp = new DataTransferProperty($property, $this); 60 | if ($dtp->isIgnored()) { 61 | $dtp->ignoreIn($input); 62 | } else { 63 | $dtp->setValueFrom($input); 64 | } 65 | 66 | $this->validationStrategy->popPath(); 67 | } 68 | 69 | $this->validationStrategy->popPath(); 70 | 71 | $this->finalize($input); 72 | } 73 | 74 | /** 75 | * @param array $input 76 | * 77 | * @throws ReflectionException 78 | */ 79 | private function finalize(array $input): void 80 | { 81 | $this->validationStrategy->handle(); 82 | 83 | foreach ($this->reflection->getAttributes(Finalize::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { 84 | /** @var Finalize $finalize */ 85 | $finalize = $attribute->newInstance(); 86 | $finalize->finalize($input); 87 | } 88 | 89 | foreach ($this->reflection->getAttributes(SelfValidation::class) as $attribute) { 90 | /** @var SelfValidation $validation */ 91 | $validation = $attribute->newInstance(); 92 | $method = $this->reflection->getMethod($validation->getMethod()); 93 | $method->invoke($this->object); 94 | } 95 | } 96 | 97 | /** 98 | * @return T 99 | */ 100 | public function getInstance(): object 101 | { 102 | return $this->object; 103 | } 104 | 105 | public function getConstructor(): ?ReflectionMethod 106 | { 107 | return $this->constructor; 108 | } 109 | 110 | public function getValidationStrategy(): ValidationStrategy 111 | { 112 | return $this->validationStrategy; 113 | } 114 | 115 | /** 116 | * @throws ReflectionException 117 | */ 118 | private function createInstance(): void 119 | { 120 | if ($this->constructor === null || $this->constructor->getNumberOfRequiredParameters() === 0) { 121 | $this->object = $this->reflection->newInstance(); 122 | } else { 123 | $this->object = $this->reflection->newInstanceWithoutConstructor(); 124 | } 125 | } 126 | 127 | private function createValidationStrategy(): void 128 | { 129 | foreach ($this->reflection->getAttributes(ValidationStrategy::class, ReflectionAttribute::IS_INSTANCEOF) as $attribute) { 130 | /** @var ValidationStrategy $validationStrategy */ 131 | $validationStrategy = $attribute->newInstance(); 132 | $this->validationStrategy = $validationStrategy; 133 | break; 134 | } 135 | 136 | $this->validationStrategy ??= new ValidationStrategy( 137 | collection: new FailureCollection(), 138 | handler: new FailureHandler(), 139 | failFast: true 140 | ); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Annotation/Cast.php: -------------------------------------------------------------------------------- 1 | class !== null) { 36 | $this->method ??= '__invoke'; 37 | 38 | $refl = new ReflectionClass($this->class); 39 | if (!$refl->hasMethod($this->method)) { 40 | throw new InvalidArgumentException('Class ' . $this->class . ' needs to implement ' . $this->method); 41 | } 42 | } 43 | 44 | foreach ($types as $type) { 45 | $this->types[] = PhpType::fromName($type); 46 | } 47 | } 48 | 49 | public function transform(mixed $value, ReflectionProperty $property): mixed 50 | { 51 | $propertyType = $this->getType($property); 52 | if ($propertyType->accept($value)) { 53 | return $this->cast($value); 54 | } 55 | 56 | $this->validate($value); 57 | 58 | if (!($propertyType instanceof Castable) && (!($propertyType instanceof UnionType) || !$propertyType->isCastable())) { 59 | throw new InvalidArgumentException('Cannot cast to type ' . $propertyType->getName() . ' with value ' . var_export($value, true)); 60 | } 61 | 62 | return $propertyType->cast($this->cast($value)); 63 | } 64 | 65 | private function getType(ReflectionProperty $property): PhpType 66 | { 67 | $reflectionType = $property->getType(); 68 | if ($reflectionType instanceof ReflectionNamedType) { 69 | return PhpType::fromReflectionType($reflectionType); 70 | } 71 | 72 | if ($reflectionType instanceof ReflectionUnionType) { 73 | $typeName = implode( 74 | '|', 75 | array_map( 76 | static fn(ReflectionNamedType $type) => $type->getName(), 77 | $reflectionType->getTypes() 78 | ) 79 | ); 80 | 81 | return PhpType::fromName($typeName); 82 | } 83 | 84 | throw new InvalidArgumentException('Cannot cast to unknown type'); 85 | } 86 | 87 | private function validate(mixed $value): void 88 | { 89 | if ($this->types === []) { 90 | return; 91 | } 92 | 93 | $other = PhpType::fromValue($value); 94 | foreach ($this->types as $type) { 95 | if ($type instanceof $other) { 96 | return; 97 | } 98 | } 99 | 100 | throw new InvalidArgumentException( 101 | \Safe\sprintf( 102 | 'Only %s %s accepted, %s (%s) given.', 103 | implode(' and ', array_map(static fn(PhpType $type) => $type->getName(), $this->types)), 104 | count($this->types) === 1 ? 'is' : 'are', 105 | $other->getName(), 106 | var_export($value, true) 107 | ) 108 | ); 109 | } 110 | 111 | private function cast(mixed $value): mixed 112 | { 113 | if ($this->method === null) { 114 | return $value; 115 | } 116 | 117 | if ($this->class === null) { 118 | if (!is_callable($this->method)) { 119 | throw new InvalidArgumentException('Need an callable, not ' . $this->method); 120 | } 121 | 122 | return ($this->method)($value); 123 | } 124 | 125 | $refl = new ReflectionClass($this->class); 126 | if (!$refl->hasMethod($this->method)) { 127 | throw new InvalidArgumentException('Class ' . $this->class . ' needs to implement ' . $this->method); 128 | } 129 | 130 | $method = $refl->getMethod($this->method); 131 | if ($method->isStatic()) { 132 | return $method->invoke(null, $value); 133 | } 134 | 135 | return $method->invoke($refl->newInstance(), $value); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/DataTransferProperty.php: -------------------------------------------------------------------------------- 1 | $parent 41 | * 42 | * @throws ReflectionException 43 | */ 44 | public function __construct(private ReflectionProperty $property, DataTransferObject $parent) 45 | { 46 | $this->validationStrategy = $parent->getValidationStrategy(); 47 | 48 | if (version_compare(PHP_VERSION, '8.1') < 0) { 49 | $property->setAccessible(true); 50 | } 51 | 52 | $this->ignore = $this->property->getAttributes(Ignore::class) !== []; 53 | $this->setNames(); 54 | 55 | $this->instance = $parent->getInstance(); 56 | 57 | if ($property->hasDefaultValue()) { 58 | $this->hasDefaultValue = true; 59 | $this->defaultValue = $property->getDefaultValue(); 60 | } else { 61 | $parameter = $this->getPromotedConstructorParameter($parent->getConstructor(), $property->getName()); 62 | if ($parameter !== null && $parameter->isOptional()) { 63 | $this->hasDefaultValue = true; 64 | $this->defaultValue = $parameter->getDefaultValue(); 65 | } else { 66 | $this->hasDefaultValue = $property->getType()?->allowsNull() ?? false; 67 | $this->defaultValue = null; 68 | } 69 | } 70 | } 71 | 72 | public function isIgnored(): bool 73 | { 74 | return $this->ignore; 75 | } 76 | 77 | /** 78 | * @param array $input 79 | * 80 | * @throws Throwable 81 | */ 82 | public function ignoreIn(array &$input): void 83 | { 84 | foreach ($this->names as $name) { 85 | if (!array_key_exists($name, $input)) { 86 | continue; 87 | } 88 | 89 | $this->handleRejected(); 90 | unset($input[$name]); 91 | } 92 | } 93 | 94 | /** 95 | * @param array $input 96 | * 97 | * @throws Throwable 98 | */ 99 | public function setValueFrom(array &$input): void 100 | { 101 | foreach ($this->names as $name) { 102 | $value = $this->handlePath($input); 103 | if ($value === null && !array_key_exists($name, $input)) { 104 | continue; 105 | } 106 | 107 | $this->handleRejected(); 108 | 109 | if ($value === null) { 110 | $value = $input[$name]; 111 | unset($input[$name]); 112 | } 113 | 114 | $value = new DataTransferValue($value, $this->property, $this->validationStrategy); 115 | $this->assign($value->getValue()); 116 | 117 | return; 118 | } 119 | 120 | $this->handleRequired(); 121 | $this->handleOptional(); 122 | } 123 | 124 | /** 125 | * @param array $input 126 | * 127 | * @return mixed 128 | * @throws PcreException 129 | */ 130 | private function handlePath(array &$input): mixed 131 | { 132 | foreach ($this->property->getAttributes(Path::class) as $attribute) { 133 | /** @var Path $path */ 134 | $path = $attribute->newInstance(); 135 | $value = $path->extract($input); 136 | $key = $path->getKey(); 137 | if ($value !== null && $key !== null) { 138 | unset($input[$key]); 139 | } 140 | 141 | return $value; 142 | } 143 | 144 | return null; 145 | } 146 | 147 | private function handleRejected(): void 148 | { 149 | foreach ($this->property->getAttributes(Reject::class) as $attribute) { 150 | /** @var Reject $reject */ 151 | $reject = $attribute->newInstance(); 152 | $reject->execute(); 153 | } 154 | } 155 | 156 | private function handleRequired(): void 157 | { 158 | foreach ($this->property->getAttributes(Required::class) as $attribute) { 159 | /** @var Required $required */ 160 | $required = $attribute->newInstance(); 161 | $required->execute(); 162 | } 163 | } 164 | 165 | private function handleOptional(): void 166 | { 167 | if ($this->hasDefaultValue && $this->defaultValue !== null) { 168 | $this->assign($this->defaultValue); 169 | 170 | return; 171 | } 172 | 173 | foreach ($this->property->getAttributes(Optional::class) as $attribute) { 174 | /** @var Optional $optional */ 175 | $optional = $attribute->newInstance(); 176 | $value = $optional->getValue($this->property); 177 | 178 | $this->assign($value); 179 | 180 | return; 181 | } 182 | 183 | if (!$this->hasDefaultValue) { 184 | $this->handleMissingRequiredValue(); 185 | } else { 186 | $this->assign($this->defaultValue); 187 | } 188 | } 189 | 190 | private function getPromotedConstructorParameter(?ReflectionMethod $constructor, string $name): ?ReflectionParameter 191 | { 192 | foreach ($constructor?->getParameters() ?? [] as $parameter) { 193 | if ($parameter->isPromoted() && $parameter->getName() === $name) { 194 | return $parameter; 195 | } 196 | } 197 | 198 | return null; 199 | } 200 | 201 | private function assign(mixed $value): void 202 | { 203 | $instance = $this->property->isStatic() ? null : $this->instance; 204 | 205 | $this->property->setValue($instance, $value); 206 | } 207 | 208 | private function setNames(): void 209 | { 210 | $names = []; 211 | foreach ($this->property->getAttributes(Name::class) as $attribute) { 212 | /** @var Name $name */ 213 | $name = $attribute->newInstance(); 214 | $names[$name->getName()] = true; 215 | } 216 | 217 | if ($names === []) { 218 | $names[$this->property->getName()] = true; 219 | } 220 | 221 | foreach ($this->property->getAttributes(Alias::class) as $attribute) { 222 | /** @var Alias $alias */ 223 | $alias = $attribute->newInstance(); 224 | $names[$alias->getName()] = true; 225 | } 226 | 227 | $this->names = array_keys($names); 228 | } 229 | 230 | private function handleMissingRequiredValue(): void 231 | { 232 | match (count($this->names)) { 233 | 0, 1 => $this->validationStrategy->setFailure('Expected a value for {path}'), 234 | default => $this->validationStrategy->setFailure('Expected one of "' . implode(', ', $this->names) . '"') 235 | }; 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | max_line_length = 140 10 | tab_width = 4 11 | ij_continuation_indent_size = 8 12 | ij_formatter_off_tag = @formatter:off 13 | ij_formatter_on_tag = @formatter:on 14 | ij_formatter_tags_enabled = false 15 | ij_smart_tabs = false 16 | ij_visual_guides = none 17 | ij_wrap_on_typing = false 18 | 19 | [{*.ctp,*.hphp,*.inc,*.module,*.php,*.php4,*.php5,*.phtml}] 20 | ij_continuation_indent_size = 4 21 | ij_php_align_assignments = true 22 | ij_php_align_class_constants = false 23 | ij_php_align_group_field_declarations = false 24 | ij_php_align_inline_comments = false 25 | ij_php_align_key_value_pairs = true 26 | ij_php_align_multiline_array_initializer_expression = true 27 | ij_php_align_multiline_binary_operation = true 28 | ij_php_align_multiline_chained_methods = true 29 | ij_php_align_multiline_extends_list = true 30 | ij_php_align_multiline_for = true 31 | ij_php_align_multiline_parameters = true 32 | ij_php_align_multiline_parameters_in_calls = true 33 | ij_php_align_multiline_ternary_operation = true 34 | ij_php_align_phpdoc_comments = true 35 | ij_php_align_phpdoc_param_names = true 36 | ij_php_anonymous_brace_style = end_of_line 37 | ij_php_api_weight = 28 38 | ij_php_array_initializer_new_line_after_left_brace = false 39 | ij_php_array_initializer_right_brace_on_new_line = false 40 | ij_php_array_initializer_wrap = off 41 | ij_php_assignment_wrap = off 42 | ij_php_attributes_wrap = off 43 | ij_php_author_weight = 28 44 | ij_php_binary_operation_sign_on_next_line = false 45 | ij_php_binary_operation_wrap = off 46 | ij_php_blank_lines_after_class_header = 0 47 | ij_php_blank_lines_after_function = 1 48 | ij_php_blank_lines_after_imports = 1 49 | ij_php_blank_lines_after_opening_tag = 0 50 | ij_php_blank_lines_after_package = 1 51 | ij_php_blank_lines_around_class = 1 52 | ij_php_blank_lines_around_constants = 0 53 | ij_php_blank_lines_around_field = 0 54 | ij_php_blank_lines_around_method = 1 55 | ij_php_blank_lines_before_class_end = 0 56 | ij_php_blank_lines_before_imports = 1 57 | ij_php_blank_lines_before_method_body = 0 58 | ij_php_blank_lines_before_package = 1 59 | ij_php_blank_lines_before_return_statement = 1 60 | ij_php_blank_lines_between_imports = 0 61 | ij_php_block_brace_style = end_of_line 62 | ij_php_call_parameters_new_line_after_left_paren = false 63 | ij_php_call_parameters_right_paren_on_new_line = false 64 | ij_php_call_parameters_wrap = off 65 | ij_php_catch_on_new_line = false 66 | ij_php_category_weight = 28 67 | ij_php_class_brace_style = next_line 68 | ij_php_comma_after_last_array_element = false 69 | ij_php_concat_spaces = true 70 | ij_php_copyright_weight = 28 71 | ij_php_deprecated_weight = 28 72 | ij_php_do_while_brace_force = always 73 | ij_php_else_if_style = as_is 74 | ij_php_else_on_new_line = false 75 | ij_php_example_weight = 28 76 | ij_php_extends_keyword_wrap = off 77 | ij_php_extends_list_wrap = normal 78 | ij_php_fields_default_visibility = private 79 | ij_php_filesource_weight = 28 80 | ij_php_finally_on_new_line = false 81 | ij_php_for_brace_force = always 82 | ij_php_for_statement_new_line_after_left_paren = false 83 | ij_php_for_statement_right_paren_on_new_line = false 84 | ij_php_for_statement_wrap = off 85 | ij_php_force_short_declaration_array_style = true 86 | ij_php_getters_setters_naming_style = camel_case 87 | ij_php_getters_setters_order_style = getters_first 88 | ij_php_global_weight = 28 89 | ij_php_group_use_wrap = on_every_item 90 | ij_php_if_brace_force = always 91 | ij_php_if_lparen_on_next_line = false 92 | ij_php_if_rparen_on_next_line = false 93 | ij_php_ignore_weight = 28 94 | ij_php_import_sorting = alphabetic 95 | ij_php_indent_break_from_case = true 96 | ij_php_indent_case_from_switch = true 97 | ij_php_indent_code_in_php_tags = false 98 | ij_php_internal_weight = 28 99 | ij_php_keep_blank_lines_after_lbrace = 2 100 | ij_php_keep_blank_lines_before_right_brace = 0 101 | ij_php_keep_blank_lines_in_code = 1 102 | ij_php_keep_blank_lines_in_declarations = 1 103 | ij_php_keep_control_statement_in_one_line = false 104 | ij_php_keep_first_column_comment = false 105 | ij_php_keep_indents_on_empty_lines = false 106 | ij_php_keep_line_breaks = true 107 | ij_php_keep_rparen_and_lbrace_on_one_line = false 108 | ij_php_keep_simple_classes_in_one_line = false 109 | ij_php_keep_simple_methods_in_one_line = false 110 | ij_php_lambda_brace_style = end_of_line 111 | ij_php_license_weight = 28 112 | ij_php_line_comment_add_space = false 113 | ij_php_line_comment_at_first_column = true 114 | ij_php_link_weight = 28 115 | ij_php_lower_case_boolean_const = true 116 | ij_php_lower_case_keywords = true 117 | ij_php_lower_case_null_const = true 118 | ij_php_method_brace_style = next_line 119 | ij_php_method_call_chain_wrap = off 120 | ij_php_method_parameters_new_line_after_left_paren = false 121 | ij_php_method_parameters_right_paren_on_new_line = false 122 | ij_php_method_parameters_wrap = off 123 | ij_php_method_weight = 28 124 | ij_php_modifier_list_wrap = false 125 | ij_php_multiline_chained_calls_semicolon_on_new_line = false 126 | ij_php_namespace_brace_style = 1 127 | ij_php_new_line_after_php_opening_tag = false 128 | ij_php_null_type_position = in_the_end 129 | ij_php_package_weight = 28 130 | ij_php_param_weight = 0 131 | ij_php_parameters_attributes_wrap = off 132 | ij_php_parentheses_expression_new_line_after_left_paren = false 133 | ij_php_parentheses_expression_right_paren_on_new_line = false 134 | ij_php_phpdoc_blank_line_before_tags = false 135 | ij_php_phpdoc_blank_lines_around_parameters = true 136 | ij_php_phpdoc_keep_blank_lines = true 137 | ij_php_phpdoc_param_spaces_between_name_and_description = 1 138 | ij_php_phpdoc_param_spaces_between_tag_and_type = 1 139 | ij_php_phpdoc_param_spaces_between_type_and_name = 1 140 | ij_php_phpdoc_use_fqcn = false 141 | ij_php_phpdoc_wrap_long_lines = true 142 | ij_php_place_assignment_sign_on_next_line = false 143 | ij_php_place_parens_for_constructor = 1 144 | ij_php_property_read_weight = 28 145 | ij_php_property_weight = 28 146 | ij_php_property_write_weight = 28 147 | ij_php_return_type_on_new_line = false 148 | ij_php_return_weight = 1 149 | ij_php_see_weight = 28 150 | ij_php_since_weight = 28 151 | ij_php_sort_phpdoc_elements = true 152 | ij_php_space_after_colon = true 153 | ij_php_space_after_colon_in_named_argument = true 154 | ij_php_space_after_colon_in_return_type = true 155 | ij_php_space_after_comma = true 156 | ij_php_space_after_for_semicolon = true 157 | ij_php_space_after_quest = true 158 | ij_php_space_after_type_cast = true 159 | ij_php_space_after_unary_not = false 160 | ij_php_space_before_array_initializer_left_brace = false 161 | ij_php_space_before_catch_keyword = true 162 | ij_php_space_before_catch_left_brace = true 163 | ij_php_space_before_catch_parentheses = true 164 | ij_php_space_before_class_left_brace = true 165 | ij_php_space_before_closure_left_parenthesis = true 166 | ij_php_space_before_colon = true 167 | ij_php_space_before_colon_in_named_argument = false 168 | ij_php_space_before_colon_in_return_type = false 169 | ij_php_space_before_comma = false 170 | ij_php_space_before_do_left_brace = true 171 | ij_php_space_before_else_keyword = true 172 | ij_php_space_before_else_left_brace = true 173 | ij_php_space_before_finally_keyword = true 174 | ij_php_space_before_finally_left_brace = true 175 | ij_php_space_before_for_left_brace = true 176 | ij_php_space_before_for_parentheses = true 177 | ij_php_space_before_for_semicolon = false 178 | ij_php_space_before_if_left_brace = true 179 | ij_php_space_before_if_parentheses = true 180 | ij_php_space_before_method_call_parentheses = false 181 | ij_php_space_before_method_left_brace = true 182 | ij_php_space_before_method_parentheses = false 183 | ij_php_space_before_quest = true 184 | ij_php_space_before_short_closure_left_parenthesis = false 185 | ij_php_space_before_switch_left_brace = true 186 | ij_php_space_before_switch_parentheses = true 187 | ij_php_space_before_try_left_brace = true 188 | ij_php_space_before_unary_not = false 189 | ij_php_space_before_while_keyword = true 190 | ij_php_space_before_while_left_brace = true 191 | ij_php_space_before_while_parentheses = true 192 | ij_php_space_between_ternary_quest_and_colon = false 193 | ij_php_spaces_around_additive_operators = true 194 | ij_php_spaces_around_arrow = false 195 | ij_php_spaces_around_assignment_in_declare = false 196 | ij_php_spaces_around_assignment_operators = true 197 | ij_php_spaces_around_bitwise_operators = true 198 | ij_php_spaces_around_equality_operators = true 199 | ij_php_spaces_around_logical_operators = true 200 | ij_php_spaces_around_multiplicative_operators = true 201 | ij_php_spaces_around_null_coalesce_operator = true 202 | ij_php_spaces_around_relational_operators = true 203 | ij_php_spaces_around_shift_operators = true 204 | ij_php_spaces_around_unary_operator = false 205 | ij_php_spaces_around_var_within_brackets = false 206 | ij_php_spaces_within_array_initializer_braces = false 207 | ij_php_spaces_within_brackets = false 208 | ij_php_spaces_within_catch_parentheses = false 209 | ij_php_spaces_within_for_parentheses = false 210 | ij_php_spaces_within_if_parentheses = false 211 | ij_php_spaces_within_method_call_parentheses = false 212 | ij_php_spaces_within_method_parentheses = false 213 | ij_php_spaces_within_parentheses = false 214 | ij_php_spaces_within_short_echo_tags = true 215 | ij_php_spaces_within_switch_parentheses = false 216 | ij_php_spaces_within_while_parentheses = false 217 | ij_php_special_else_if_treatment = true 218 | ij_php_subpackage_weight = 28 219 | ij_php_ternary_operation_signs_on_next_line = false 220 | ij_php_ternary_operation_wrap = off 221 | ij_php_throws_weight = 2 222 | ij_php_todo_weight = 28 223 | ij_php_unknown_tag_weight = 28 224 | ij_php_upper_case_boolean_const = false 225 | ij_php_upper_case_null_const = false 226 | ij_php_uses_weight = 28 227 | ij_php_var_weight = 28 228 | ij_php_variable_naming_style = camel_case 229 | ij_php_version_weight = 28 230 | ij_php_while_brace_force = always 231 | ij_php_while_on_new_line = false 232 | 233 | [{*.har,*.jsb2,*.jsb3,*.json,*.lock,.babelrc,.eslintrc,.stylelintrc,bowerrc,jest.config}] 234 | ij_json_keep_blank_lines_in_code = 0 235 | ij_json_keep_indents_on_empty_lines = false 236 | ij_json_keep_line_breaks = true 237 | ij_json_space_after_colon = true 238 | ij_json_space_after_comma = true 239 | ij_json_space_before_colon = true 240 | ij_json_space_before_comma = false 241 | ij_json_spaces_within_braces = false 242 | ij_json_spaces_within_brackets = false 243 | ij_json_wrap_long_lines = false 244 | 245 | [{*.markdown,*.md}] 246 | ij_markdown_force_one_space_after_blockquote_symbol = true 247 | ij_markdown_force_one_space_after_header_symbol = true 248 | ij_markdown_force_one_space_after_list_bullet = true 249 | ij_markdown_force_one_space_between_words = true 250 | ij_markdown_keep_indents_on_empty_lines = false 251 | ij_markdown_max_lines_around_block_elements = 1 252 | ij_markdown_max_lines_around_header = 1 253 | ij_markdown_max_lines_between_paragraphs = 1 254 | ij_markdown_min_lines_around_block_elements = 1 255 | ij_markdown_min_lines_around_header = 1 256 | ij_markdown_min_lines_between_paragraphs = 1 257 | 258 | [{*.yaml,*.yml}] 259 | indent_size = 2 260 | ij_yaml_align_values_properties = do_not_align 261 | ij_yaml_autoinsert_sequence_marker = true 262 | ij_yaml_block_mapping_on_new_line = false 263 | ij_yaml_indent_sequence_value = true 264 | ij_yaml_keep_indents_on_empty_lines = false 265 | ij_yaml_keep_line_breaks = true 266 | ij_yaml_sequence_on_new_line = false 267 | ij_yaml_space_before_colon = false 268 | ij_yaml_spaces_within_braces = true 269 | ij_yaml_spaces_within_brackets = true 270 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Data Transfer Object 2 | 3 | Want to deserialize an object with data on the fly? Go for it by using the `From` trait. 4 | 5 | --- 6 | 7 | How is this package any different from [spaties](https://github.com/spatie) popular [data-transfer-object](https://github.com/spatie/data-transfer-object), you may ask? 8 | Well, it's not meant to be a replacement by any means. But while using it I've often come across some things I've missed since I knew them from [serde](https://serde.rs/), like renaming and ignoring properties, something that spatie's _data-transfer-object_ [might not get](https://github.com/spatie/data-transfer-object/issues/142#issuecomment-690418112) in the near future. 9 | So there it is, my own little DTO package :) I hope it helps someone, as it helps me in my daily work. 10 | Feel free to open issues or pull requests - any help is greatly appreciated! 11 | 12 | ### Requirements 13 | 14 | This package is designed for PHP ≥ 8.0 only since it's using [PHP 8.0 Attributes](https://stitcher.io/blog/attributes-in-php-8). 15 | 16 | # Attributes 17 | 18 | ## Name 19 | 20 | You get a parameter which is not named as the parameter in your class? `#[Name(...)]` to the rescue - just specify the name from the Request: 21 | 22 | ```php 23 | use Dgame\DataTransferObject\Annotation\Name; 24 | use Dgame\DataTransferObject\DataTransfer; 25 | 26 | final class Limit 27 | { 28 | use DataTransfer; 29 | 30 | public int $offset; 31 | #[Name('size')] 32 | public int $limit; 33 | } 34 | ``` 35 | 36 | Now the key `size` will be mapped to the property `$limit` - but keep in mind: the name `limit` is no longer known 37 | since you overwrote it with `size`. If that is not your intention, take a look at the [Alias](#alias) Attribute. 38 | 39 | ## Alias 40 | 41 | You get a parameter which is not **always** named as the parameter in your class? `#[Alias(...)]` can help you - just specify the alias from the Request: 42 | 43 | ```php 44 | use Dgame\DataTransferObject\Annotation\Alias; 45 | use Dgame\DataTransferObject\DataTransfer; 46 | 47 | final class Limit 48 | { 49 | use DataTransfer; 50 | 51 | public int $offset; 52 | #[Alias('size')] 53 | public int $limit; 54 | } 55 | ``` 56 | 57 | Now the keys `size` **and** `limit` will be mapped to the property `$limit`. You can mix `#[Name(...)]` and `#[Alias(...)]` as you want: 58 | 59 | ```php 60 | use Dgame\DataTransferObject\Annotation\Alias; 61 | use Dgame\DataTransferObject\Annotation\Name; 62 | use Dgame\DataTransferObject\DataTransfer; 63 | 64 | final class Foo 65 | { 66 | use DataTransfer; 67 | 68 | #[Name('a')] 69 | #[Alias('z')] 70 | public int $id; 71 | } 72 | ``` 73 | 74 | The keys `a` and `z` are mapped to the property `id` - but not the key `id` since you overwrote it with `a`. But the following 75 | 76 | ```php 77 | use Dgame\DataTransferObject\Annotation\Alias; 78 | use Dgame\DataTransferObject\DataTransfer; 79 | 80 | final class Foo 81 | { 82 | use DataTransfer; 83 | 84 | #[Alias('a')] 85 | #[Alias('z')] 86 | public int $id; 87 | } 88 | ``` 89 | 90 | will accept the keys `a`, `z` and `id`. 91 | 92 | ## Transformations 93 | 94 | If you want to _transform_ a value **before** it is assigned to the property, you can use Transformations. 95 | You just need to implement the _Transformation_ interface. 96 | 97 | ### Cast 98 | 99 | _Cast_ is currently the only built-in Transformation and let you apply a Type-Cast **before** the value is assigned to the property: 100 | 101 | If not told otherwise, a simple type-cast is performed. In the example below it would just call something like `$this->id = (int) $id`: 102 | 103 | ```php 104 | use Dgame\DataTransferObject\Annotation\Cast; 105 | 106 | final class Foo 107 | { 108 | use DataTransfer; 109 | 110 | #[Cast] 111 | public int $id; 112 | } 113 | ``` 114 | 115 | But that would be tried for **any** input. If you want to limit this to certain types, you can use `types`: 116 | 117 | ```php 118 | use Dgame\DataTransferObject\Annotation\Cast; 119 | 120 | final class Foo 121 | { 122 | use DataTransfer; 123 | 124 | #[Cast(types: ['string', 'float', 'bool'])] 125 | public int $id; 126 | } 127 | ``` 128 | 129 | Here the cast would only be performed if the incoming value is either an `int`, `string`, `float` or `bool`. 130 | 131 | If you want more control, you can use a static method inside of the class: 132 | 133 | ```php 134 | use Dgame\DataTransferObject\Annotation\Cast; 135 | 136 | final class Foo 137 | { 138 | use DataTransfer; 139 | 140 | #[Cast(method: 'toInt', class: self::class)] 141 | public int $id; 142 | 143 | public static function toInt(string|int|float|bool $value): int 144 | { 145 | return (int) $value; 146 | } 147 | } 148 | ``` 149 | 150 | or a function: 151 | 152 | ```php 153 | use Dgame\DataTransferObject\Annotation\Cast; 154 | 155 | function toInt(string|int|float|bool $value): int 156 | { 157 | return (int) $value; 158 | } 159 | 160 | final class Foo 161 | { 162 | use DataTransfer; 163 | 164 | #[Cast(method: 'toInt')] 165 | public int $id; 166 | } 167 | ``` 168 | 169 | If a class is given but not a `method`, by default `__invoke` will be used: 170 | 171 | ```php 172 | use Dgame\DataTransferObject\Annotation\Cast; 173 | 174 | final class Foo 175 | { 176 | use DataTransfer; 177 | 178 | #[Cast(class: self::class)] 179 | public int $id; 180 | 181 | public function __invoke(string|int|float|bool $value): int 182 | { 183 | return (int) $value; 184 | } 185 | } 186 | ``` 187 | 188 | ## Validation 189 | 190 | You want to validate the value before it is assigned? We can do that. There are a few pre-defined validations prepared, but you can easily write your own by implementing the `Validation`-interface. 191 | 192 | ### Min 193 | 194 | ```php 195 | use Dgame\DataTransferObject\Annotation\Min; 196 | use Dgame\DataTransferObject\DataTransfer; 197 | 198 | final class Limit 199 | { 200 | use DataTransfer; 201 | 202 | #[Min(0)] 203 | public int $offset; 204 | #[Min(0)] 205 | public int $limit; 206 | } 207 | ``` 208 | 209 | Both `$offset` and `$limit` must be at least have the value `0` (so they must be positive-integers). If not, an exception is thrown. You can configure the message of the exception by specifying the `message` parameter: 210 | 211 | ```php 212 | use Dgame\DataTransferObject\Annotation\Min; 213 | use Dgame\DataTransferObject\DataTransfer; 214 | 215 | final class Limit 216 | { 217 | use DataTransfer; 218 | 219 | #[Min(0, message: 'Offset must be positive!')] 220 | public int $offset; 221 | #[Min(0, message: 'Limit must be positive!')] 222 | public int $limit; 223 | } 224 | ``` 225 | 226 | ### Max 227 | 228 | ```php 229 | use Dgame\DataTransferObject\Annotation\Max; 230 | use Dgame\DataTransferObject\DataTransfer; 231 | 232 | final class Limit 233 | { 234 | use DataTransfer; 235 | 236 | #[Max(1000)] 237 | public int $offset; 238 | #[Max(1000)] 239 | public int $limit; 240 | } 241 | ``` 242 | 243 | Both `$offset` and `$limit` may not exceed `1000`. If they do, an exception is thrown. You can configure the message of the exception by specifying the `message` parameter: 244 | 245 | ```php 246 | use Dgame\DataTransferObject\Annotation\Max; 247 | use Dgame\DataTransferObject\DataTransfer; 248 | 249 | final class Limit 250 | { 251 | use DataTransfer; 252 | 253 | #[Max(1000, message: 'Offset may not be larger than 1000')] 254 | public int $offset; 255 | #[Max(1000, message: 'Limit may not be larger than 1000')] 256 | public int $limit; 257 | } 258 | ``` 259 | 260 | ### Instance 261 | 262 | Do you want to make sure that a property is an instance of a certain class or that each item in an array is an instance of that said class? 263 | 264 | ```php 265 | use Dgame\DataTransferObject\Annotation\Instance; 266 | 267 | final class Collection 268 | { 269 | #[Instance(class: Entity::class, message: 'We need an array of Entities!')] 270 | private array $entities; 271 | } 272 | ``` 273 | 274 | ### Type 275 | 276 | If you are trying to cover objects or other class instances, you should probably take a look at [Instance](#instance). 277 | 278 | As long as you specify a type for your properties, the `Type` validation is automatically added to ensure that the specified values can be assigned to the specified types. If not, a validation exception will be thrown. 279 | Without this validation, a `TypeError` would be thrown, which may not be desirable. 280 | 281 | So this code 282 | ```php 283 | final class Foo 284 | { 285 | private ?int $id; 286 | } 287 | ``` 288 | 289 | is actually seen as this: 290 | ```php 291 | use Dgame\DataTransferObject\Annotation\Type; 292 | 293 | final class Foo 294 | { 295 | #[Type(name: '?int')] 296 | private ?int $id; 297 | } 298 | ``` 299 | 300 | The following snippets are equivalent to the snippet above: 301 | 302 | ```php 303 | use Dgame\DataTransferObject\Annotation\Type; 304 | 305 | final class Foo 306 | { 307 | #[Type(name: 'int|null')] 308 | private ?int $id; 309 | } 310 | ``` 311 | 312 | ```php 313 | use Dgame\DataTransferObject\Annotation\Type; 314 | 315 | final class Foo 316 | { 317 | #[Type(name: 'int', allowsNull: true)] 318 | private ?int $id; 319 | } 320 | ``` 321 | 322 | --- 323 | 324 | If you want to change the exception message, you can do so using the `message` parameter: 325 | 326 | ```php 327 | use Dgame\DataTransferObject\Annotation\Type; 328 | 329 | final class Foo 330 | { 331 | #[Type(name: '?int', message: 'id is expected to be int or null')] 332 | private ?int $id; 333 | } 334 | ``` 335 | 336 | ### Custom 337 | 338 | Do you want your own Validation? Just implement the `Validation`-interface: 339 | 340 | ```php 341 | use Dgame\DataTransferObject\Annotation\Validation; 342 | use Dgame\DataTransferObject\DataTransfer; 343 | 344 | #[Attribute(Attribute::TARGET_PROPERTY)] 345 | final class NumberBetween implements Validation 346 | { 347 | public function __construct(private int|float $min, private int|float $max) 348 | { 349 | } 350 | 351 | public function validate(mixed $value): void 352 | { 353 | if (!is_numeric($value)) { 354 | throw new InvalidArgumentException(var_export($value, true) . ' must be a numeric value'); 355 | } 356 | 357 | if ($value < $this->min) { 358 | throw new InvalidArgumentException(var_export($value, true) . ' must be >= ' . $this->min); 359 | } 360 | 361 | if ($value > $this->max) { 362 | throw new InvalidArgumentException(var_export($value, true) . ' must be <= ' . $this->max); 363 | } 364 | } 365 | } 366 | 367 | final class ValidationStub 368 | { 369 | use DataTransfer; 370 | 371 | #[NumberBetween(18, 125)] 372 | private int $age; 373 | 374 | public function getAge(): int 375 | { 376 | return $this->age; 377 | } 378 | } 379 | ``` 380 | 381 | ## Ignore 382 | 383 | You don't want a specific key-value to override your property? Just ignore it: 384 | 385 | ```php 386 | use Dgame\DataTransferObject\Annotation\Ignore; 387 | use Dgame\DataTransferObject\DataTransfer; 388 | 389 | final class Foo 390 | { 391 | use DataTransfer; 392 | 393 | #[Ignore] 394 | public string $uuid = 'abc'; 395 | public int $id = 0; 396 | } 397 | 398 | $foo = Foo::from(['uuid' => 'xyz', 'id' => 42]); 399 | echo $foo->id; // 42 400 | echo $foo->uuid; // abc 401 | ``` 402 | 403 | ## Reject 404 | 405 | You want to go one step further than simply ignoring a value? Then `Reject` it: 406 | 407 | ```php 408 | use Dgame\DataTransferObject\Annotation\Reject; 409 | use Dgame\DataTransferObject\DataTransfer; 410 | 411 | final class Foo 412 | { 413 | use DataTransfer; 414 | 415 | #[Reject(reason: 'The attribute "uuid" is not supposed to be set')] 416 | public string $uuid = 'abc'; 417 | } 418 | 419 | $foo = Foo::from(['id' => 42]); // Works fine 420 | echo $foo->id; // 42 421 | echo $foo->uuid; // abc 422 | 423 | $foo = Foo::from(['uuid' => 'xyz', 'id' => 42]); // throws 'The attribute "uuid" is not supposed to be set' 424 | ``` 425 | 426 | ## Required 427 | 428 | Normally, a nullable-property or a property with a provided default value is treated with said default-value or null if the property cannot be assigned from the provided data. 429 | If no default-value is provided and the property is not nullable, an error is thrown in case the property was not found. 430 | But in some cases you might want to specify the reason, why the property is required or even want to require an otherwise default-able property. You can do that by using `Required`: 431 | 432 | ```php 433 | use Dgame\DataTransferObject\Annotation\Required; 434 | use Dgame\DataTransferObject\DataTransfer; 435 | 436 | final class Foo 437 | { 438 | use DataTransfer; 439 | 440 | #[Required(reason: 'We need an "id"')] 441 | public ?int $id; 442 | 443 | #[Required(reason: 'We need a "name"')] 444 | public string $name; 445 | } 446 | 447 | Foo::from(['id' => 42, 'name' => 'abc']); // Works 448 | Foo::from(['name' => 'abc']); // Fails but would work without the `Required`-Attribute since $id is nullable 449 | Foo::from(['id' => 42]); // Fails and would fail regardless of the `Required`-Attribute since $name is not nullable and has no default-value - but the reason why it is required is now more clear. 450 | ``` 451 | 452 | ## Optional 453 | 454 | The counterpart of [Required](#required). 455 | If you don't want to or can't provide a default/nullable value, `Optional` will assign the **default value** of the property-type in case of a missing value: 456 | 457 | ```php 458 | final class Foo 459 | { 460 | use DataTransfer; 461 | 462 | #[Optional] 463 | public int $id; 464 | } 465 | 466 | $foo = Foo::from([]); 467 | assert($foo->id === 0); 468 | ``` 469 | 470 | Of course you can specify which value should be used if no data is provided: 471 | 472 | ```php 473 | final class Foo 474 | { 475 | use DataTransfer; 476 | 477 | #[Optional(value: 42)] 478 | public int $id; 479 | } 480 | 481 | $foo = Foo::from([]); 482 | assert($foo->id === 42); 483 | ``` 484 | 485 | In case you're using `Optional` together with a provided default-value, the default-value has always priority: 486 | 487 | ```php 488 | final class Foo 489 | { 490 | use DataTransfer; 491 | 492 | #[Optional(value: 42)] 493 | public int $id = 23; 494 | } 495 | 496 | $foo = Foo::from([]); 497 | assert($foo->id === 23); 498 | ``` 499 | 500 | ## Numeric 501 | 502 | You have `int` or `float` properties but aren't sure if those aren't delivered as e.g. `string`? `Numeric` to the rescue! It will translate the value to a numeric representation (to `int` or `float`): 503 | 504 | ```php 505 | use Dgame\DataTransferObject\Annotation\Numeric; 506 | 507 | final class Foo 508 | { 509 | use DataTransfer; 510 | 511 | #[Numeric(message: 'id must be numeric')] 512 | public int $id; 513 | } 514 | 515 | $foo = Foo::from(['id' => '23']); 516 | assert($foo->id === 23); 517 | ``` 518 | 519 | ## Boolean 520 | 521 | You have `bool` properties but aren't sure if those aren't delivered as `string` or `int`? `Boolean` can help you with that! 522 | 523 | ```php 524 | use Dgame\DataTransferObject\Annotation\Boolean; 525 | 526 | final class Foo 527 | { 528 | use DataTransfer; 529 | 530 | #[Boolean(message: 'checked must be a bool')] 531 | public bool $checked; 532 | #[Boolean(message: 'verified must be a bool')] 533 | public bool $verified; 534 | } 535 | 536 | $foo = Foo::from(['checked' => 'yes', 'verified' => 0]); 537 | assert($foo->checked === true); 538 | assert($foo->verified === false); 539 | ``` 540 | 541 | ## Date 542 | 543 | You want a `DateTime` but got a string? No problem: 544 | 545 | ```php 546 | use Dgame\DataTransferObject\Annotation\Date; 547 | use \DateTime; 548 | 549 | final class Foo 550 | { 551 | use DataTransfer; 552 | 553 | #[Date(format: 'd.m.Y', message: 'Your birthday must be a date')] 554 | public DateTime $birthday; 555 | } 556 | 557 | $foo = Foo::from(['birthday' => '19.09.1979']); 558 | assert($foo->birthday === DateTime::createFromFormat('d.m.Y', '19.09.1979')); 559 | ``` 560 | 561 | 562 | ## In 563 | 564 | Your value must be one of a specific range or enumeration? You can ensure that with `In`: 565 | 566 | ```php 567 | use Dgame\DataTransferObject\Annotation\In; 568 | 569 | final class Foo 570 | { 571 | use DataTransfer; 572 | 573 | #[In(values: ['beginner', 'advanced', 'difficult'], message: 'Must be either "beginner", "advanced" or "difficult"')] 574 | public string $difficulty; 575 | } 576 | 577 | Foo::from(['difficulty' => 'foo']); // will throw a error, since difficulty is not in the provided values 578 | $foo = Foo::from(['difficulty' => 'advanced']); 579 | assert($foo->difficulty === 'advanced'); 580 | ``` 581 | 582 | ## NotIn 583 | 584 | Your value must **not** be one of a specific range or enumeration? You can ensure that with `NotIn`: 585 | 586 | ```php 587 | use Dgame\DataTransferObject\Annotation\NotIn; 588 | 589 | final class Foo 590 | { 591 | use DataTransfer; 592 | 593 | #[NotIn(values: ['holy', 'shit', 'wtf'], message: 'Must not be a swear word')] 594 | public string $word; 595 | } 596 | ``` 597 | 598 | ## Matches 599 | 600 | You must be sure that your values match a specific pattern? You can do that for **all scalar** values by using `Matches`: 601 | 602 | ```php 603 | use Dgame\DataTransferObject\Annotation\Matches; 604 | 605 | final class Foo 606 | { 607 | use DataTransfer; 608 | 609 | #[Matches(pattern: '/^[a-z]+\w*/', message: 'Your name must start with a-z')] 610 | public string $name; 611 | 612 | #[Matches(pattern: '/[1-9][0-9]+/', message: 'products must be at least 10')] 613 | public int $products; 614 | } 615 | 616 | Foo::from(['name' => '_', 'products' => 99]); // will throw a error, since name does not start with a-z 617 | Foo::from(['name' => 'John', 'products' => 9]); // will throw a error, since products must be at least 10 618 | ``` 619 | 620 | ## Trim 621 | 622 | You have to make sure, that `string` values are trimmed? No worries, we have `Trim`: 623 | 624 | ```php 625 | use Dgame\DataTransferObject\Annotation\Trim; 626 | 627 | final class Foo 628 | { 629 | use DataTransfer; 630 | 631 | #[Trim] 632 | public string $name; 633 | } 634 | 635 | $foo = Foo::from(['name' => ' John ']); 636 | assert($foo->name === 'John'); 637 | ``` 638 | 639 | ## Path 640 | 641 | Did you ever wanted to extract a value from a provided array? `Path` to the rescue: 642 | 643 | ```php 644 | final class Person 645 | { 646 | use DataTransfer; 647 | 648 | #[Path('person.name')] 649 | public string $name; 650 | } 651 | ``` 652 | 653 | It helps while with JSON's special `$value` attribute 654 | 655 | ```php 656 | final class Person 657 | { 658 | use DataTransfer; 659 | 660 | #[Path('married.$value')] 661 | public bool $married; 662 | } 663 | ``` 664 | 665 | and with XML's `#text`. 666 | 667 | ```php 668 | final class Person 669 | { 670 | use DataTransfer; 671 | 672 | #[Path('first.name.#text')] 673 | public string $firstname; 674 | } 675 | ``` 676 | 677 | --- 678 | 679 | But we can do even more. You can choose which parts of the field are taken 680 | 681 | ```php 682 | final class Person 683 | { 684 | use DataTransfer; 685 | 686 | #[Path('child.{born, age}')] 687 | public array $firstChild = []; 688 | } 689 | ``` 690 | 691 | and can even assign them directly to an object: 692 | 693 | ```php 694 | final class Person 695 | { 696 | use DataTransfer; 697 | 698 | public int $id; 699 | public string $name; 700 | public ?int $age = null; 701 | 702 | #[Path('ancestor.{id, name}')] 703 | public ?self $parent = null; 704 | } 705 | ``` 706 | 707 | ## SelfValidation 708 | 709 | In addition to the [customary validations](#validation) you can specify a class-wide validation after **all** assignments are done: 710 | 711 | ```php 712 | #[SelfValidation(method: 'validate')] 713 | final class SelfValidationStub 714 | { 715 | use DataTransfer; 716 | 717 | public function __construct(public int $id) 718 | { 719 | } 720 | 721 | public function validate(): void 722 | { 723 | assert($this->id > 0); 724 | } 725 | } 726 | ``` 727 | 728 | ## ValidationStrategy 729 | 730 | The default validation strategy is **fail-fast** which means an Exception is thrown as soon as an error is detected. 731 | But that might not desirable, so you can configure this with a `ValidationStrategy`: 732 | 733 | ```php 734 | #[ValidationStrategy(failFast: false)] 735 | final class Foo 736 | { 737 | use DataTransfer; 738 | 739 | #[Min(3)] 740 | public string $name; 741 | #[Min(0)] 742 | public int $id; 743 | } 744 | 745 | Foo::from(['name' => 'a', 'id' => -1]); 746 | ``` 747 | 748 | The example above would throw a combined exception that `name` is not long enough and `id` must be at least 0. 749 | You can configure this as well by extending the `ValidationStrategy` and provide a `FailureHandler` and/or a `FailureCollection`. 750 | 751 | # Property promotion 752 | 753 | In the above examples, [property promotion](https://stitcher.io/blog/constructor-promotion-in-php-8) is not always used because it is more readable that way, but property promotion is supported. So the following example 754 | 755 | ```php 756 | use Dgame\DataTransferObject\Annotation\Min; 757 | use Dgame\DataTransferObject\DataTransfer; 758 | 759 | final class Limit 760 | { 761 | use DataTransfer; 762 | 763 | #[Min(0)] 764 | public int $offset; 765 | #[Min(0)] 766 | public int $limit; 767 | } 768 | ``` 769 | 770 | can be rewritten as shown below 771 | 772 | ```php 773 | use Dgame\DataTransferObject\Annotation\Min; 774 | use Dgame\DataTransferObject\DataTransfer; 775 | 776 | final class Limit 777 | { 778 | use DataTransfer; 779 | 780 | public function __construct( 781 | #[Min(0)] public int $offset, 782 | #[Min(0)] public int $limit 783 | ) { } 784 | } 785 | ``` 786 | 787 | and it still works. 788 | 789 | # Nested object detection 790 | 791 | You have nested objects and want to deserialize them all at once? That is a given: 792 | 793 | ```php 794 | use Dgame\DataTransferObject\DataTransfer; 795 | 796 | final class Bar 797 | { 798 | public int $id; 799 | } 800 | 801 | final class Foo 802 | { 803 | use DataTransfer; 804 | 805 | public Bar $bar; 806 | } 807 | 808 | $foo = Foo::from(['bar' => ['id' => 42]]); 809 | echo $foo->bar->id; // 42 810 | ``` 811 | 812 | Have you noticed the missing `From` in `Bar`? `From` is just a little wrapper for the actual DTO. So your nested classes don't need to use it at all. 813 | 814 | There is no limit to the depth of nesting, the responsibility is yours! :) 815 | --------------------------------------------------------------------------------