diff --git a/README.md b/README.md
index 0ced1bc..c682f3b 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,4 @@
+
# crtl/request-dto-resolver-bundle
[](https://codecov.io/gh/crtl/request-dto-resolver-bundle)
@@ -7,42 +8,57 @@
[](https://packagist.org/packages/crtl/request-dto-resolver-bundle)
[](https://packagist.org/packages/crtl/request-dto-resolver-bundle)
+A Symfony bundle for predictable, type-safe instantiation and validation of request DTOs.
-Symfony bundle for streamlined instantiation and validation of request DTOs.
+It removes boilerplate from controllers while staying close to Symfony’s
+native validation and argument resolving mechanisms.
## Features
-1. **Automatic DTO Handling**:
- Instantly creates and validates Data Transfer Objects (DTOs) from `Request` data, that are type-hinted in controller actions.
-2. **Symfony Validator Integration**: Leverages Symfony's built-in validator to ensure data integrity and compliance with your validation rules.
-3. **Nested DTO Support**: Handles complex request structures by supporting nested DTOs for both query and body parameters, making it easier to manage hierarchical data.
-4. **Strict Typing Support**: DTO properties can now be strictly typed, ensuring better code quality and IDE support.
-5. **Flexible Query Transformation**: Built-in support for transforming query parameters into specific types (int, float, string, bool) or via custom callbacks.
+- **Automatic DTO Resolution**
+ DTOs type-hinted in controller actions are instantiated and validated automatically.
+
+- **Native Symfony Validator Integration**
+ Uses Symfony’s `ValidatorInterface` without custom validation layers.
+
+- **Nested DTO Support**
+ Supports complex request payloads with nested DTOs for query, body, header, file and route parameters.
+- **Strict Typing Friendly**
+ DTO properties can be strictly typed for better IDE support and safer refactoring.
+
+- **Flexible Query Parameter Transformation**
+ Query parameters can be transformed to scalar types or via custom callbacks.
## Installation
```bash
composer require crtl/request-dto-resolver-bundle
-```
+````
## Configuration
-Register the bundle in your Symfony application. Add the following to your `config/bundles.php` file:
+Register the bundle in your Symfony application:
```php
+// config/bundles.php
return [
- // other bundles
- Crtl\RequestDtoResolverBundle\CrtlRequestDTOResolverBundle::class => ["all" => true],
+ // ...
+ Crtl\RequestDtoResolverBundle\CrtlRequestDtoResolverBundle::class => ["all" => true],
];
```
## Usage
-### Step 1: Create a DTO
+### Step 1: Define a Request DTO
-Create a class to represent your request data.
-Annotate the class with [`#[RequestDto]`](src/Attribute/RequestDto.php) and use the attributes below for properties to map request parameters.
+Create a DTO class and annotate it with `#[RequestDto]`.
+Use parameter attributes to map request data to properties.
+
+> **The attribute is required to identify which controller arguments should be resolved and validated.**
+
+
+#### 1.1 Strictly typed DTO
```php
namespace App\DTO;
@@ -53,199 +69,148 @@ use Crtl\RequestDtoResolverBundle\Attribute\HeaderParam;
use Crtl\RequestDtoResolverBundle\Attribute\QueryParam;
use Crtl\RequestDtoResolverBundle\Attribute\RouteParam;
use Crtl\RequestDtoResolverBundle\Attribute\RequestDto;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Constraints as Assert;
#[RequestDto]
-class ExampleDTO
+class ExampleDto
{
- // DTOs can now be strictly typed.
- // Important: Validation constraints must be correct to prevent TypeErrors
- // since hydration happens after validation.
#[BodyParam, Assert\NotBlank, Assert\Type("string")]
public string $someParam;
+
+ #[BodyParam, Assert\NotBlank, Assert\Type("string")]
+ public string $withDefaultValue = "My default value";
+
+ #[BodyParam]
+ public ?string $nullable;
- // Matches file in uploaded files
#[FileParam, Assert\NotNull]
- public mixed $file;
-
- // Matches Content-Type header in headers
+ public ?UploadedFile $file;
+
#[HeaderParam("Content-Type"), Assert\NotBlank]
public string $contentType;
-
- // QueryParam supports optional transformType: "int", "float", "string", "bool"
- // or a custom callback: fn(string $val) => ...
- #[QueryParam(name: "age", transformType: "int"), Assert\GreaterThan(18)]
+
+ // Because query params are all strings by default
+ // we can provide a type transformer to transform its type.
+ // values are converted using filter_var with the corrosponding FILTER_VALIDATE_* option.
+ #[QueryParam(transformType: "int"), Assert\GreaterThan(18)]
public int $age;
+
+ // Or provide a custom callable to tranform type
+ #[
+ QueryParam(
+ name: "age",
+ transformType: fn(string $value) => strtolower($value)
+ ),
+ Assert\GreaterThan(18)
+ ]
+ public mixed $customQueryParam;
- // Matches id
#[RouteParam, Assert\NotBlank]
public string $id;
-
+
// Nested DTOs are supported for BodyParam and QueryParam
- #[BodyParam("nested")] // Dont use Assert\Valid on nested DTOs otherwise native validation is triggered
+ // Do NOT use Assert\Valid here
+ #[BodyParam("nested")]
public ?NestedRequestDTO $nestedBodyDto;
-
- // Optionally implement constructor which accepts request object
- // Only the request is passed; properties are NOT yet initialized here.
- public function __construct(Request $request) {
-
- }
-}
-```
-> **IMPORTANT: Strict Typing**
-> While strict types are supported, validation constraint mismatches can still lead to `TypeError` in production. Always ensure your constraints (e.g., `Assert\Type`, `Assert\NotBlank`) match your property types.
-
-> > **IMPORTANT: Nested DTOs**
-> Dont use any assertions on nested DTO properties as this will trigger the native validation fow eventually breaking hydration and triggering errors.
-
-### DTO Lifecycle
-
-1. **Resolution**: `RequestDtoResolver` instantiates the DTO during the controller argument resolving phase. Only the `Request` object is passed to the constructor.
-2. **Security**: Symfony security checks (e.g., `#[IsGranted]`) are executed.
-3. **Validation & Hydration**: An event subscriber (`RequestDtoValidationEventSubscriber`) listens to `kernel.controller_arguments`. It validates the DTO data and, if successful, hydrates the DTO properties.
-
-### Step 2: Use the DTO in a Controller
-
-Inject the DTO into your controller action. It will be automatically instantiated, validated, and hydrated.
-
-```php
-namespace App\Controller;
-
-use App\DTO\ExampleDTO;
-use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
-use Symfony\Component\HttpFoundation\Response;
-use Symfony\Component\Routing\Annotation\Route;
-
-class ExampleController extends AbstractController
-{
- #[Route("/example", name: "example")]
- public function exampleAction(ExampleDTO $data): Response
+ // Optional constructor receiving the Request
+ // It is recommended to make the request argument nullable to support creation of DTOs from
+ // array data but not required when only used in HTTP contexts.
+ public function __construct(?Request $request = null)
{
- // $data is an instance of ExampleDTO with validated and hydrated request data
- return new Response("DTO received and validated successfully!");
}
}
```
-### Using RequestDtoTrait
-
-The [`RequestDtoTrait`](src/Trait/RequestDtoTrait.php) provides a default constructor that accepts the `Request` object and a `getValue(string $property)` method. This method is useful for accessing request data before hydration, which can come in handy in group sequence providers.
+> **Any type mismatches will trigger a constraint violation and thus a `RequestValidationException` is thrown.**
+#### 1.2 Mixed typed DTO
```php
-use Crtl\RequestDtoResolverBundle\Attribute\RequestDto;
-use Crtl\RequestDtoResolverBundle\Trait\RequestDtoTrait;
+namespace App\DTO;
+
use Crtl\RequestDtoResolverBundle\Attribute\BodyParam;
+use Crtl\RequestDtoResolverBundle\Attribute\FileParam;
+use Crtl\RequestDtoResolverBundle\Attribute\HeaderParam;
+use Crtl\RequestDtoResolverBundle\Attribute\QueryParam;
+use Crtl\RequestDtoResolverBundle\Attribute\RouteParam;
+use Crtl\RequestDtoResolverBundle\Attribute\RequestDto;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Validator\Constraints as Assert;
#[RequestDto]
-class MyDTO
+class ExampleDto
{
- use RequestDtoTrait;
-
- #[BodyParam]
- public string $type;
+ /**
+ * @var string
+ */
+ #[BodyParam, Assert\NotBlank]
+ public mixed $contentType;
}
```
-### Validation Group Sequences
+### 1.3 Important notes about DTO hydration
-When using [Group Sequences](https://symfony.com/doc/current/validation/sequence_provider.html) to define conditional validation, you must be careful about how you access data.
+- **Uninitialized properties**
+ If a request does not contain data for a property, that property will remain uninitialized.
+ To guarantee initialization, either:
+ - provide a default value, or
+ - add appropriate validation constraints (e.g. `NotNull`, `NotBlank`).
-**IMPORTANT: Uninitialized Properties**
+- **Hydration from arrays**
+ When a DTO is manually hydrated from an array using `RequestDtoFactory::fromArray()`, the configured `AbstractParam::$name` is ignored.
+ In this case, the DTO’s property names are always used as the source keys.
-Since hydration happens *after* validation, DTO properties are **uninitialized** when the group sequence is evaluated. Accessing them directly will throw an `Error`.
+- **Validation still runs in strict mode**
+ Even if some properties cannot be assigned due to type mismatches in strict typing mode, the DTO is still fully validated using Symfony’s validator.
+ Any resulting violations will lead to a `RequestValidationException`.
-To safely access request parameters in your group sequence logic, use `RequestDtoTrait::getValue()`:
+### 1.4 Validation group sequences
-```php
-use Crtl\RequestDtoResolverBundle\Attribute\RequestDto;
-use Crtl\RequestDtoResolverBundle\Trait\RequestDtoTrait;
-use Symfony\Component\Validator\Constraints\GroupSequence;
-use Symfony\Component\Validator\GroupSequenceProviderInterface;
+All Symfony validation group sequence variants are supported.
-#[RequestDto]
-class MyDTO implements GroupSequenceProviderInterface
-{
- use RequestDtoTrait;
+Because request data can never be trusted, **DTO properties may be uninitialized regardless of whether strict typing is used or not**.
+Missing or invalid input can prevent a property from being assigned during hydration.
- #[BodyParam]
- public string $type;
+When using validation group sequences, you must therefore ensure that properties are accessed safely by:
+- checking initialization with `isset()`, or
+- using reflection-based checks when necessary.
- public function getGroupSequence(): array|GroupSequence
- {
- // Use getValue() instead of $this->type
- $type = $this->getValue("type");
+Failing to do so may lead to runtime errors before later validation groups are evaluated.
- $groups = ["MyDTO"];
- if ($type === "special") {
- $groups[] = "Special";
- }
- return $groups;
- }
-}
-```
-
-#### Using a Group Sequence Provider Service
-
-You can also use a service to provide the group sequence. This is useful if your validation logic depends on external services (e.g., a database or configuration).
-
-1. **Create the Provider Service**:
+### Step 2: Use the DTO in a Controller
```php
-namespace App\Validator;
+namespace App\Controller;
-use App\DTO\MyDTO;
-use Symfony\Component\Validator\Constraints\GroupSequence;
-use Symfony\Component\Validator\GroupProviderInterface;
+use App\DTO\ExampleDto;
+use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Annotation\Route;
-class MyGroupSequenceProvider implements GroupProviderInterface
+class ExampleController extends AbstractController
{
- public function getGroups(object $object): array|GroupSequence
+ #[Route("/example", name: "example")]
+ public function exampleAction(ExampleDto $data): Response
{
- assert($object instanceof MyDTO)
-
- $groups = ["MyDTO"];
-
- // Use getValue() to safely access uninitialized properties
- if ($object->getValue("type") === "special") {
- $groups[] = "Special";
- }
-
- return $groups;
+ return new Response("DTO received and validated successfully!");
}
}
```
-2. **Configure the DTO**:
+---
-```php
-use App\Validator\MyGroupSequenceProvider;
-use Crtl\RequestDtoResolverBundle\Attribute\RequestDto;
-use Crtl\RequestDtoResolverBundle\Trait\RequestDtoTrait;
-use Symfony\Component\Validator\Constraints as Assert;
+### Handling Validation Errors
-#[RequestDto]
-#[Assert\GroupSequenceProvider(provider: MyGroupSequenceProvider::class)]
-class MyDTO
-{
- // Trait is important to access fields before validation
- use RequestDtoTrait;
-
- // ...
-}
-```
-
-> **Note**: `getValue()` only works in **root DTOs**. It uses reflection to resolve data from the request, which cannot access parent data in nested contexts.
-
-### Step 3: Handle Validation Errors
+On validation failure, a `RequestValidationException` is thrown.
-When validation fails, a [`Crtl\RequestDtoResolverBundle\Exception\RequestValidationException`](src/Exception/RequestValidationException.php) is thrown.
+> **The bundle registers a default exception subscriber (priority **-32**) that
+returns a `400 Bad Request` JSON response.**
-The bundle registers a default exception subscriber ([`RequestValidationExceptionEventSubscriber`](src/EventSubscriber/RequestValidationExceptionEventSubscriber.php)) with a low priority of **-32**. This ensures that validation exceptions are caught and converted into a `JsonResponse` with a `400 Bad Request` status code by default.
-
-You can still provide your own listener if you need custom error formatting:
+You can override this with your own listener:
```php
namespace App\EventListener;
@@ -261,7 +226,6 @@ class RequestValidationExceptionListener implements EventSubscriberInterface
public static function getSubscribedEvents(): array
{
return [
- // Use a priority > -32 to override the default bundle subscriber
KernelEvents::EXCEPTION => ["onKernelException", 0],
];
}
@@ -271,12 +235,10 @@ class RequestValidationExceptionListener implements EventSubscriberInterface
$exception = $event->getThrowable();
if ($exception instanceof RequestValidationException) {
- $response = new JsonResponse([
+ $event->setResponse(new JsonResponse([
"error" => "Validation failed",
"details" => $exception->getViolations(),
- ], JsonResponse::HTTP_BAD_REQUEST);
-
- $event->setResponse($response);
+ ], JsonResponse::HTTP_BAD_REQUEST));
}
}
}
@@ -284,4 +246,4 @@ class RequestValidationExceptionListener implements EventSubscriberInterface
## License
-This bundle is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.
+This bundle is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
\ No newline at end of file
diff --git a/config/services.php b/config/services.php
index 9752acc..4811506 100644
--- a/config/services.php
+++ b/config/services.php
@@ -15,6 +15,7 @@
use Crtl\RequestDtoResolverBundle\EventSubscriber\RequestDtoValidationEventSubscriber;
use Crtl\RequestDtoResolverBundle\EventSubscriber\RequestValidationExceptionEventSubscriber;
+use Crtl\RequestDtoResolverBundle\Factory\RequestDtoFactory;
use Crtl\RequestDtoResolverBundle\PropertyInfo\PropertyInfoExtractorFactory;
use Crtl\RequestDtoResolverBundle\Reflection\RequestDtoMetadataFactory;
use Crtl\RequestDtoResolverBundle\Reflection\RequestDtoParamMetadataFactory;
@@ -22,8 +23,6 @@
use Crtl\RequestDtoResolverBundle\Utility\DtoInstanceBag;
use Crtl\RequestDtoResolverBundle\Utility\DtoInstanceBagInterface;
use Crtl\RequestDtoResolverBundle\Utility\DtoReflectionHelper;
-use Crtl\RequestDtoResolverBundle\Validator\GroupSequenceExtractor;
-use Crtl\RequestDtoResolverBundle\Validator\RequestDtoValidator;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface;
@@ -43,12 +42,6 @@
$services->alias(DtoInstanceBagInterface::class, DtoInstanceBag::class);
- $services->set(GroupSequenceExtractor::class)
- ->args([
- '$groupProviderLocator' => service('service_container'),
- ])
- ->private();
-
$services->set(ReflectionExtractor::class)
->private();
@@ -71,38 +64,34 @@
$services->set(RequestDtoParamMetadataFactory::class)
->args([
- '$validator' => service('validator'),
'$reflectionHelper' => service(DtoReflectionHelper::class),
'$propertyInfoExtractor' => service('crtl_request_dto_resolver_bundle.property_extractor'),
]);
$services->set(RequestDtoMetadataFactory::class)
->args([
- '$validator' => service('validator'),
'$reflectionHelper' => service(DtoReflectionHelper::class),
'$requestDtoParamMetadataFactory' => service(RequestDtoParamMetadataFactory::class),
'$cache' => service('cache.system'),
]);
- $services->set(RequestDtoValidator::class)
+ $services->set(RequestDtoFactory::class)
->args([
- '$validator' => service('validator'),
'$metadataFactory' => service(RequestDtoMetadataFactory::class),
- '$groupSequenceExtractor' => service(GroupSequenceExtractor::class),
])
- ->public();
+ ->public();
$services->set(RequestDtoResolver::class)
->args([
'$dtoInstanceBag' => service(DtoInstanceBagInterface::class),
- '$factory' => service(RequestDtoMetadataFactory::class),
'$reflectionHelper' => service(DtoReflectionHelper::class),
+ '$requestDtoFactory' => service(RequestDtoFactory::class),
])
->tag('controller.argument_value_resolver', ['priority' => 50]);
$services->set(RequestDtoValidationEventSubscriber::class)
->args([
- '$validator' => service(RequestDtoValidator::class),
+ '$validator' => service('validator'),
'$dtoInstanceBag' => service(DtoInstanceBagInterface::class),
])
->tag('kernel.event_subscriber');
diff --git a/config/services_test.php b/config/services_test.php
index 547705e..ad38f92 100644
--- a/config/services_test.php
+++ b/config/services_test.php
@@ -21,5 +21,6 @@
->public();
$services->set(Crtl\RequestDtoResolverBundle\Test\Fixtures\GroupProvider\TestGroupProvider::class)
+ ->tag('validator.group_provider')
->public();
};
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
index a51a8d6..2bf8fb3 100644
--- a/phpstan.dist.neon
+++ b/phpstan.dist.neon
@@ -10,7 +10,3 @@ parameters:
- src
- test
- ignoreErrors:
- -
- identifier: missingType.property
- path: %currentWorkingDirectory%/test/Fixtures/Legacy
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 8621091..2d95056 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -3,11 +3,7 @@
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
colors="true"
cacheDirectory=".phpunit.cache"
- executionOrder="depends,defects"
beStrictAboutCoverageMetadata="true"
- beStrictAboutOutputDuringTests="false"
- failOnRisky="false"
- failOnWarning="false"
bootstrap="test/bootstrap.php"
>
@@ -20,8 +16,12 @@
-
- test
+
+ test/Unit
+
+
+
+ test/Integration
diff --git a/src/Attribute/AbstractNestedParam.php b/src/Attribute/AbstractNestedParam.php
index ecf21e5..ea1edae 100644
--- a/src/Attribute/AbstractNestedParam.php
+++ b/src/Attribute/AbstractNestedParam.php
@@ -43,6 +43,20 @@ protected function getDataFromRequest(Request $request): array
return $this->getInputBag($request)->all();
}
+ public function hasValueInRequest(Request $request): bool
+ {
+ if (false === $this->parent?->hasValueInRequest($request)) {
+ return false;
+ }
+
+ $data = $this->parent
+ ? $this->parent->getValueFromRequest($request)
+ : $this->getDataFromRequest($request)
+ ;
+
+ return is_array($data) && array_key_exists($this->getName(), $data);
+ }
+
public function getValueFromRequest(Request $request): mixed
{
$name = $this->getName();
@@ -53,7 +67,7 @@ public function getValueFromRequest(Request $request): mixed
$data = $this->getDataFromRequest($request);
}
- $defaultValue = null; // $this->property?->getDefaultValue();
+ $defaultValue = $this->property?->getDefaultValue();
$value = $data[$name] ?? $defaultValue;
diff --git a/src/Attribute/AbstractParam.php b/src/Attribute/AbstractParam.php
index f086a65..714f911 100644
--- a/src/Attribute/AbstractParam.php
+++ b/src/Attribute/AbstractParam.php
@@ -83,4 +83,9 @@ public function getName(): string
* Retrieves the value from the request and returns it or null if no values was found.
*/
abstract public function getValueFromRequest(Request $request): mixed;
+
+ /**
+ * Whether or not the request includes the value.
+ */
+ abstract public function hasValueInRequest(Request $request): bool;
}
diff --git a/src/Attribute/FileParam.php b/src/Attribute/FileParam.php
index b8fe9e6..8ade296 100644
--- a/src/Attribute/FileParam.php
+++ b/src/Attribute/FileParam.php
@@ -23,6 +23,11 @@
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class FileParam extends AbstractParam
{
+ public function hasValueInRequest(Request $request): bool
+ {
+ return $request->files->has($this->getName());
+ }
+
/**
* @throws \UnexpectedValueException When FileBag::get() does not return null or `UploadedFile` instance
*/
diff --git a/src/Attribute/HeaderParam.php b/src/Attribute/HeaderParam.php
index 0ed86fd..40e6bcc 100644
--- a/src/Attribute/HeaderParam.php
+++ b/src/Attribute/HeaderParam.php
@@ -22,6 +22,11 @@
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class HeaderParam extends AbstractParam
{
+ public function hasValueInRequest(Request $request): bool
+ {
+ return $request->headers->has($this->getName());
+ }
+
public function getValueFromRequest(Request $request): ?string
{
return $request->headers->get($this->getName());
diff --git a/src/Attribute/RequestDto.php b/src/Attribute/RequestDto.php
index 1e44707..37dec8e 100644
--- a/src/Attribute/RequestDto.php
+++ b/src/Attribute/RequestDto.php
@@ -22,4 +22,14 @@
#[\Attribute(\Attribute::TARGET_CLASS)]
class RequestDto
{
+ public function __construct(
+ /**
+ * Whether the DTO should be hydrated in strict mode.
+ *
+ * By defaults properties may cause type constraint violations when the required types mismatches with input.
+ * If you want to attempt to coerce values instead pass `false` instead.
+ */
+ public readonly bool $strict = true,
+ ) {
+ }
}
diff --git a/src/Attribute/RouteParam.php b/src/Attribute/RouteParam.php
index 2de2c95..3b87341 100644
--- a/src/Attribute/RouteParam.php
+++ b/src/Attribute/RouteParam.php
@@ -22,6 +22,17 @@
#[\Attribute(\Attribute::TARGET_PROPERTY)]
class RouteParam extends AbstractParam
{
+ public function hasValueInRequest(Request $request): bool
+ {
+ $key = '_route_params';
+
+ return $request->attributes->has($key)
+ && array_key_exists(
+ $this->getName(),
+ $request->attributes->get($key, []),
+ );
+ }
+
public function getValueFromRequest(Request $request): mixed
{
/** @var array $routeParams */
diff --git a/src/EventSubscriber/RequestDtoValidationEventSubscriber.php b/src/EventSubscriber/RequestDtoValidationEventSubscriber.php
index 40bea1a..a97e1a5 100644
--- a/src/EventSubscriber/RequestDtoValidationEventSubscriber.php
+++ b/src/EventSubscriber/RequestDtoValidationEventSubscriber.php
@@ -14,20 +14,19 @@
namespace Crtl\RequestDtoResolverBundle\EventSubscriber;
use Crtl\RequestDtoResolverBundle\Exception\RequestValidationException;
-use Crtl\RequestDtoResolverBundle\RequestDtoResolver;
use Crtl\RequestDtoResolverBundle\Utility\DtoInstanceBagInterface;
-use Crtl\RequestDtoResolverBundle\Validator\RequestDtoValidator;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\KernelEvents;
+use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
- * Event subscriber which validates DTOs that have been resolved before by {@link RequestDtoResolver}.
+ * Event subscriber which validates DTOs that have been resolved before by {@link \Crtl\RequestDtoResolverBundle\RequestDtoResolver}.
*/
final class RequestDtoValidationEventSubscriber implements EventSubscriberInterface
{
public function __construct(
- private RequestDtoValidator $validator,
+ private ValidatorInterface $validator,
private DtoInstanceBagInterface $dtoInstanceBag,
) {
}
@@ -46,10 +45,18 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
{
$request = $event->getRequest();
- foreach ($this->dtoInstanceBag->getRegisteredInstances($request) as $instance) {
- $violations = $this->validator->validateAndHydrate($instance, $request);
+ foreach ($this->dtoInstanceBag->getRegisteredInstances($request) as $className => $instance) {
+ // Check for stored hydration violations first
+ $hydrationViolations = $this->dtoInstanceBag->getHydrationViolations($className, $request);
- if ($violations->count()) {
+ // Standard Symfony validation on fully-hydrated DTO
+ $violations = $this->validator->validate($instance);
+
+ if (null !== $hydrationViolations) {
+ $violations->addAll($hydrationViolations);
+ }
+
+ if ($violations->count() > 0) {
throw new RequestValidationException($instance, $violations);
}
}
diff --git a/src/Factory/Exception/CircularReferenceException.php b/src/Factory/Exception/CircularReferenceException.php
new file mode 100644
index 0000000..eee5b6d
--- /dev/null
+++ b/src/Factory/Exception/CircularReferenceException.php
@@ -0,0 +1,36 @@
+ %s',
+ $className,
+ implode(' -> ', $stack),
+ $className,
+ ));
+ }
+}
diff --git a/src/Factory/Exception/PropertyHydrationException.php b/src/Factory/Exception/PropertyHydrationException.php
new file mode 100644
index 0000000..26fec10
--- /dev/null
+++ b/src/Factory/Exception/PropertyHydrationException.php
@@ -0,0 +1,35 @@
+object));
+ parent::__construct($message, previous: $previous);
+ }
+}
diff --git a/src/Factory/RequestDtoFactory.php b/src/Factory/RequestDtoFactory.php
new file mode 100644
index 0000000..94f195b
--- /dev/null
+++ b/src/Factory/RequestDtoFactory.php
@@ -0,0 +1,424 @@
+ 42, // ✅ works
+ * "age_param" => 42 // ❌ ignored
+ * ]
+ *
+ * @template TObject of object
+ *
+ * @param class-string $className fully-qualified DTO class name
+ * @param array $data input data keyed by DTO property name
+ *
+ * @return TObject hydrated DTO instance
+ *
+ * @throws RequestDtoHydrationException on type errors or nested hydration errors
+ * @throws CircularReferenceException on circular reference during nested instantiation
+ * @throws \ReflectionException on reflection/metadata failures
+ */
+ public function fromArray(string $className, array $data): object
+ {
+ return $this->createInstanceRecursive(
+ $className,
+ $data,
+ [$this, 'arrayValueProvider'],
+ [$this, 'arrayValueChecker'],
+ );
+ }
+
+ /**
+ * Creates and hydrates a Request DTO from an HTTP request.
+ *
+ * Values are resolved using the parameter attributes attached to each
+ * DTO property (e.g. {@see QueryParam}, {@see BodyParam}, {@see RouteParam}, {@see FileParam}).
+ *
+ * @template TObject of object
+ *
+ * @param class-string $className fully-qualified DTO class name
+ * @param Request $request HTTP request used as value source
+ *
+ * @return TObject hydrated DTO instance
+ *
+ * @throws RequestDtoHydrationException on type errors or nested hydration errors
+ * @throws CircularReferenceException on circular reference during nested instantiation
+ * @throws \ReflectionException on reflection/metadata failures
+ */
+ public function fromRequest(string $className, Request $request): object
+ {
+ return $this->createInstanceRecursive(
+ $className,
+ $request,
+ [$this, 'requestValueProvider'],
+ [$this, 'requestValueChecker'],
+ );
+ }
+
+ /**
+ * Internal helper used to either create DTO from request or array.
+ *
+ * @template TObject of object
+ * @template TContext of (Request|array)
+ *
+ * @param class-string $className DTO class name to instantiate
+ * @param TContext $context data source used to resolve values
+ * @param callable(AbstractParam, RequestDtoParamMetadata, TContext): mixed $valueProvider resolves a raw value for a single property
+ * @param callable(AbstractParam, RequestDtoParamMetadata, TContext): bool $valueChecker checks whether value exists in context, values are only assigned if valueChecker returns true
+ * @param AbstractParam|null $parentAttribute parent attribute for nested hydration
+ * @param RequestDtoMetadata|null $metadata pre-resolved metadata for recursion reuse
+ * @param string[] $stack DTO class stack used for circular reference detection
+ *
+ * @return TObject hydrated DTO instance
+ *
+ * @throws \ReflectionException on reflection/metadata failures
+ * @throws RequestDtoHydrationException on type errors or nested hydration errors
+ * @throws CircularReferenceException on circular reference during nested instantiation
+ */
+ private function createInstanceRecursive(
+ string $className,
+ Request|array $context,
+ callable $valueProvider,
+ callable $valueChecker,
+ ?AbstractParam $parentAttribute = null,
+ ?RequestDtoMetadata $metadata = null,
+ array $stack = [],
+ ): object {
+ if (in_array($className, $stack, true)) {
+ throw new CircularReferenceException($className, $stack);
+ }
+
+ $stack[] = $className;
+
+ $metadata ??= $this->metadataFactory->getMetadataFor($className);
+
+ $newInstanceArgs = $context instanceof Request ? [$context] : [];
+
+ /** @var TObject $object */
+ $object = $metadata->newInstance(...$newInstanceArgs);
+
+ $violations = new ConstraintViolationList();
+
+ foreach ($metadata->getPropertyMetadataGenerator() as $propertyMetadata) {
+ $propertyName = $propertyMetadata->getPropertyName();
+
+ $nestedClassName = $propertyMetadata->getNestedDtoClassName();
+
+ $attr = $propertyMetadata->getAttribute($parentAttribute);
+
+ if ($attr instanceof QueryParam) {
+ $builtInType = strtolower($propertyMetadata->getBuiltinType());
+ if (!$attr->hasTransformType() && QueryParam::isTransformType($builtInType)) {
+ $attr->setTransformType($builtInType);
+ }
+ }
+
+ $hasValue = $valueChecker($attr, $propertyMetadata, $context);
+ $value = null;
+ if ($hasValue) {
+ $value = $valueProvider($attr, $propertyMetadata, $context);
+ }
+
+ // Property is typed with nested dto
+ if (null !== $nestedClassName && $hasValue && null !== $value) {
+ $isArray = $propertyMetadata->isNestedDtoArray();
+
+ /** @var class-string $nestedClassName */
+ $nestedMetadata = $this->metadataFactory
+ ->getMetadataFor($nestedClassName);
+
+ $valueArray = $isArray ? array_values($value) : [$value];
+ $nestedViolations = new ConstraintViolationList();
+
+ $resultArray = [];
+ foreach ($valueArray as $i => $nestedValue) {
+ if (!is_array($nestedValue)) {
+ continue;
+ }
+ if ($isArray && $attr instanceof AbstractNestedParam) {
+ $attr = clone $attr;
+ $attr->setIndex($i);
+ }
+
+ try {
+ $resultArray[] = $this->createInstanceRecursive(
+ $nestedClassName,
+ // @phpstan-ignore argument.type
+ $context instanceof Request
+ ? $context
+ : $nestedValue,
+ $valueProvider,
+ $valueChecker,
+ $attr,
+ $nestedMetadata,
+ $stack,
+ );
+ } catch (RequestDtoHydrationException $e) {
+ $nestedValueViolations = $this->prefixViolations(
+ $e->violations,
+ // Append index to prefix only when in array mode
+ $isArray ? ($propertyName."[$i]") : $propertyName,
+ );
+
+ // Collect all nested violations first before exiting outside of loop to ensure all nested items are validated.
+ $nestedViolations->addAll($nestedValueViolations);
+ }
+ }
+
+ if ($nestedViolations->count() > 0) {
+ $violations->addAll($nestedViolations);
+ continue; // continue with next property
+ }
+
+ $value = $isArray ? $resultArray : $resultArray[0];
+ }
+
+ if (is_null($value) && $propertyMetadata->isNullable()) {
+ continue;
+ }
+
+ if (!$hasValue) {
+ continue;
+ }
+
+ try {
+ $this->assignProperty($object, $metadata, $propertyMetadata, $value);
+ } catch (PropertyHydrationException $e) {
+ $violation = $this->createTypeErrorViolation(
+ $e->typeError,
+ $object,
+ $propertyMetadata,
+ $value,
+ );
+ $violations->add($violation);
+ }
+ }
+
+ if ($violations->count() > 0) {
+ throw new RequestDtoHydrationException($object, $violations);
+ }
+
+ return $object;
+ }
+
+ /**
+ * Helper to assign value to property and handle any occurring {@link \TypeError} by transforming them into {@link ConstraintViolation}.
+ *
+ * @throws PropertyHydrationException When the property could not be assigned
+ */
+ private function assignProperty(
+ object $object,
+ RequestDtoMetadata $classMetadata,
+ RequestDtoParamMetadata $propertyMetadata,
+ mixed $value,
+ ): void {
+ try {
+ $propertyName = $propertyMetadata->getPropertyName();
+ $classMetadata->assignPropertyValue($object, $propertyName, $value);
+ } catch (\TypeError $e) {
+ throw new PropertyHydrationException(get_class($object), $propertyMetadata->getPropertyName(), $value, $e);
+ }
+ }
+
+ /**
+ * Creates custom constraint violation for type errors occuring when assigning values to types properties and the type is not matching.
+ *
+ * @param \TypeError $error The type error that was thrown
+ * @param object $object The object being validated
+ * @param RequestDtoParamMetadata $propertyMetadata The metadata of the property being validated
+ */
+ private function createTypeErrorViolation(
+ \TypeError $error,
+ object $object,
+ RequestDtoParamMetadata $propertyMetadata,
+ mixed $invalidValue = null
+ ): ConstraintViolation {
+ $errorInfo = new TypeErrorInfo($error);
+
+ $template = 'This value should be of type {{ expected }}, {{ given }} given.1';
+
+ $params = [
+ '{{ expected }}' => $errorInfo->expectedType,
+ '{{ given }}' => $errorInfo->actualType,
+ ];
+
+ $message = $this->translator?->trans($template, $params, 'validators')
+ ?? str_replace(array_keys($params), array_values($params), $template);
+
+ return new ConstraintViolation(
+ $message,
+ $template,
+ $params,
+ $object,
+ $propertyMetadata->getPropertyName(),
+ $invalidValue,
+ );
+ }
+
+ /**
+ * Prefixes property paths of constraint violations with parent property name.
+ */
+ private function prefixViolations(ConstraintViolationListInterface $violations, string $prefix): ConstraintViolationList
+ {
+ $prefixedList = new ConstraintViolationList();
+ foreach ($violations as $violation) {
+ $prefixedList->add($this->prefixViolation($violation, $prefix));
+ }
+
+ return $prefixedList;
+ }
+
+ private function prefixViolation(ConstraintViolationInterface $violation, string $prefix): ConstraintViolation
+ {
+ $path = $prefix;
+ $violationPath = $violation->getPropertyPath();
+
+ if (!empty($violationPath) && !str_starts_with($violationPath, '[')) {
+ $violationPath = '.'.$violationPath;
+ }
+
+ $path .= $violationPath;
+
+ return new ConstraintViolation(
+ $violation->getMessage(),
+ $violation->getMessageTemplate(),
+ $violation->getParameters(),
+ $violation->getRoot(),
+ $path,
+ $violation->getInvalidValue(),
+ $violation->getPlural(),
+ $violation->getCode(),
+ $violation->getConstraint(),
+ $violation->getCause(),
+ );
+ }
+
+ /**
+ * @param array $data
+ */
+ private function arrayValueProvider(
+ AbstractParam $attr,
+ RequestDtoParamMetadata $paramMetadata,
+ array $data
+ ): mixed {
+ $propertyName = $paramMetadata->getPropertyName();
+ $value = $data[$propertyName] ?? null;
+
+ if ($attr instanceof QueryParam) {
+ $value = $attr->transformValue($value);
+ }
+
+ return $value;
+ }
+
+ /**
+ * @param array $data
+ */
+ private function arrayValueChecker(
+ AbstractParam $attr,
+ RequestDtoParamMetadata $paramMetadata,
+ array $data
+ ): bool {
+ return array_key_exists($paramMetadata->getPropertyName(), $data);
+ }
+
+ private function requestValueProvider(
+ AbstractParam $attr,
+ RequestDtoParamMetadata $paramMetadata,
+ Request $request,
+ ): mixed {
+ return $attr->getValueFromRequest($request);
+ }
+
+ private function requestValueChecker(
+ AbstractParam $attr,
+ RequestDtoParamMetadata $paramMetadata,
+ Request $request,
+ ): bool {
+ return $attr->hasValueInRequest($request);
+ }
+}
diff --git a/src/Reflection/RequestDtoMetadata.php b/src/Reflection/RequestDtoMetadata.php
index cbb65f9..e15f11e 100644
--- a/src/Reflection/RequestDtoMetadata.php
+++ b/src/Reflection/RequestDtoMetadata.php
@@ -13,8 +13,7 @@
namespace Crtl\RequestDtoResolverBundle\Reflection;
-use Crtl\RequestDtoResolverBundle\Attribute\AbstractParam;
-use Symfony\Component\Validator\Constraints\GroupSequence;
+use Crtl\RequestDtoResolverBundle\Attribute\RequestDto;
use Symfony\Component\Validator\Mapping\ClassMetadataInterface;
class RequestDtoMetadata
@@ -25,9 +24,9 @@ class RequestDtoMetadata
private array $propertyMetadata = [];
/**
- * @var array
+ * @var \ReflectionClass|null
*/
- private array $constrainedProperties = [];
+ private ?\ReflectionClass $reflectionClass = null;
/**
* @param RequestDtoParamMetadata[] $propertyMetadata
@@ -46,20 +45,10 @@ public function __construct(
* @var RequestDtoParamMetadata[]
*/
array $propertyMetadata,
-
- /**
- * Validator metadata of the Request DTO.
- *
- * @var ClassMetadataInterface
- */
- private readonly ClassMetadataInterface $validatorMetadata,
) {
- foreach ($propertyMetadata as $propertyMetadata) {
- $this->propertyMetadata[$propertyMetadata->getPropertyName()] = $propertyMetadata;
-
- if ($propertyMetadata->isConstrained()) {
- $this->constrainedProperties[$propertyMetadata->getPropertyName()] = $propertyMetadata;
- }
+ foreach ($propertyMetadata as $propMetadata) {
+ $propertyName = $propMetadata->getPropertyName();
+ $this->propertyMetadata[$propertyName] = $propMetadata;
}
}
@@ -76,7 +65,6 @@ public function __serialize(): array
return [
'className' => $this->className,
'propertyMetadata' => $this->propertyMetadata,
- 'validatorMetadata' => $this->validatorMetadata,
];
}
@@ -92,14 +80,7 @@ public function __serialize(): array
public function __unserialize(array $data): void
{
$this->className = $data['className'];
- $this->validatorMetadata = $data['validatorMetadata'];
$this->propertyMetadata = $data['propertyMetadata'];
-
- foreach ($this->propertyMetadata as $metadata) {
- if ($metadata->isConstrained()) {
- $this->constrainedProperties[$metadata->getPropertyName()] = $metadata;
- }
- }
}
/**
@@ -114,23 +95,14 @@ public function getPropertyMetadata(string $propertyName): ?RequestDtoParamMetad
* Returns reflection class of request dto.
*
* @return \ReflectionClass
+ *
+ * @throws \ReflectionException
*/
public function getReflectionClass(): \ReflectionClass
{
- return new \ReflectionClass($this->className);
- }
+ $this->reflectionClass ??= new \ReflectionClass($this->className);
- /**
- * @return \Symfony\Component\Validator\Constraint[]
- */
- public function getClassConstraints(): array
- {
- return $this->validatorMetadata->getConstraints();
- }
-
- public function getAbstractParamAttributeFromProperty(\ReflectionProperty $property, ?AbstractParam $parent = null): ?AbstractParam
- {
- return $this->propertyMetadata[$property->getName()]->getAttribute($parent);
+ return $this->reflectionClass;
}
public function getPropertyMetadataGenerator(): \Generator
@@ -140,31 +112,29 @@ public function getPropertyMetadataGenerator(): \Generator
}
}
- public function getValidatorMetadata(): ClassMetadataInterface
- {
- return $this->validatorMetadata;
- }
-
/**
- * @return array|null
+ * @throws \ReflectionException
+ * @throws \TypeError
*/
- public function getGroupSequence(): ?array
+ public function assignPropertyValue(object $object, string $property, mixed $value): void
{
- $sequence = $this->validatorMetadata->getGroupSequence();
- if ($sequence instanceof GroupSequence) {
- return $sequence->groups;
- }
+ $reflectionClass = new \ReflectionClass($object);
+ $attrs = $reflectionClass->getAttributes(RequestDto::class, \ReflectionAttribute::IS_INSTANCEOF);
- return $sequence;
- }
+ $strict = false;
- public function isConstrainedProperty(string|\ReflectionProperty $propertyName): bool
- {
- if ($propertyName instanceof \ReflectionProperty) {
- $propertyName = $propertyName->getName();
+ if (count($attrs) > 0) {
+ /** @var RequestDto $attr */
+ $attr = $attrs[0]->newInstance();
+ $strict = $attr->strict;
}
- return array_key_exists($propertyName, $this->constrainedProperties);
+ if ($strict) {
+ $object->$property = $value;
+ } else {
+ $reflectionClass->getProperty($property)
+ ->setValue($object, $value);
+ }
}
/**
@@ -172,12 +142,12 @@ public function isConstrainedProperty(string|\ReflectionProperty $propertyName):
*
* @throws \ReflectionException
*/
- public function newInstance(...$args): ?object
+ public function newInstance(...$args): object
{
$class = $this->getReflectionClass();
return $class->getConstructor()
- ? $class->newInstanceArgs($args)
+ ? $class->newInstance(...$args)
: $class->newInstanceWithoutConstructor()
;
}
diff --git a/src/Reflection/RequestDtoMetadataFactory.php b/src/Reflection/RequestDtoMetadataFactory.php
index bacc84e..041fd42 100644
--- a/src/Reflection/RequestDtoMetadataFactory.php
+++ b/src/Reflection/RequestDtoMetadataFactory.php
@@ -15,15 +15,12 @@
use Crtl\RequestDtoResolverBundle\Utility\DtoReflectionHelper;
use Psr\Cache\CacheItemPoolInterface;
-use Symfony\Component\Validator\Mapping\ClassMetadataInterface;
-use Symfony\Component\Validator\Validator\ValidatorInterface;
class RequestDtoMetadataFactory
{
use WithCacheTrait;
public function __construct(
- private readonly ValidatorInterface $validator,
private readonly DtoReflectionHelper $reflectionHelper,
private readonly RequestDtoParamMetadataFactory $requestDtoParamMetadataFactory,
?CacheItemPoolInterface $cache = null,
@@ -33,6 +30,8 @@ public function __construct(
/**
* @param class-string $className
+ *
+ * @throws \ReflectionException
*/
public function getMetadataFor(string $className): RequestDtoMetadata
{
@@ -42,9 +41,10 @@ public function getMetadataFor(string $className): RequestDtoMetadata
}
$reflectionClass = new \ReflectionClass($className);
- $validatorMetadata = $this->validator->getMetadataFor($className);
- assert($validatorMetadata instanceof ClassMetadataInterface, 'Validator metadata for '.$className.' could not be retrieved');
+ if (!$reflectionClass->isInstantiable()) {
+ throw new \LogicException(sprintf('DTO class "%s" must be instantiable.', $reflectionClass->getName()));
+ }
$properties = $this->reflectionHelper->getAttributedProperties($reflectionClass);
@@ -53,22 +53,9 @@ public function getMetadataFor(string $className): RequestDtoMetadata
$propertyMetadata[] = $this->requestDtoParamMetadataFactory->getMetadataFor($property);
}
- // // TODO: Think about a better way of liniting because this only works on the first level
- // // due to nested metadata only being instantiated on hydration.
- // foreach ($properties as $property) {
- // // Ensure no union or intersection types are used for nested dto params
- // $this->reflectionHelper->getDtoClassNameFromReflectionProperty($property);
- // }
-
- // $constrainedPropertyNames = $validatorMetadata->getConstrainedProperties();
-
- // $propertyNames = array_map(fn (\ReflectionProperty $prop) => $prop->getName(), $properties);
$metadata = new RequestDtoMetadata(
$className,
$propertyMetadata,
- // $propertyNames,
- // array_intersect($constrainedPropertyNames, $propertyNames),
- $validatorMetadata,
);
$this->cacheValue($className, $metadata);
diff --git a/src/Reflection/RequestDtoParamMetadata.php b/src/Reflection/RequestDtoParamMetadata.php
index 37b0347..cf369ac 100644
--- a/src/Reflection/RequestDtoParamMetadata.php
+++ b/src/Reflection/RequestDtoParamMetadata.php
@@ -14,15 +14,14 @@
namespace Crtl\RequestDtoResolverBundle\Reflection;
use Crtl\RequestDtoResolverBundle\Attribute\AbstractParam;
-use Symfony\Component\TypeInfo\Type;
class RequestDtoParamMetadata
{
/**
- * @var \ReflectionClass
+ * @var \ReflectionClass|null
*/
- private \ReflectionClass $reflectionClass;
- private \ReflectionProperty $reflectionProperty;
+ private ?\ReflectionClass $reflectionClass = null;
+ private ?\ReflectionProperty $reflectionProperty = null;
public function __construct(
/**
@@ -59,13 +58,6 @@ public function __construct(
*/
private readonly string $builtInType,
- /**
- * Whether the property is constrained by validation.
- *
- * @var bool
- */
- private readonly bool $isConstrained,
-
/**
* Typehint classname of nested dto class or null if not a dto.
*
@@ -88,11 +80,6 @@ public function getBuiltinType(): string
return $this->builtInType;
}
- public function isConstrained(): bool
- {
- return $this->isConstrained;
- }
-
public function isNestedDtoArray(): bool
{
return $this->isNestedDtoArray;
@@ -124,6 +111,11 @@ public function isNullable(): bool
return $this->isNullable;
}
+ public function hasDefaultValue(): bool
+ {
+ return $this->getReflectionProperty()->hasDefaultValue();
+ }
+
public function getDefaultValue(): mixed
{
return $this->getReflectionProperty()->getDefaultValue();
@@ -134,24 +126,25 @@ public function getDefaultValue(): mixed
*/
public function getReflectionClass(): \ReflectionClass
{
- if (!isset($this->reflectionClass)) {
- $this->reflectionClass = new \ReflectionClass($this->className);
- }
+ $this->reflectionClass ??= new \ReflectionClass($this->className);
return $this->reflectionClass;
}
+ /**
+ * @throws \ReflectionException
+ */
public function getReflectionProperty(): \ReflectionProperty
{
- if (!isset($this->reflectionProperty)) {
- $this->reflectionProperty = $this->getReflectionClass()->getProperty($this->propertyName);
- }
+ $this->reflectionProperty ??= $this->getReflectionClass()->getProperty($this->propertyName);
return $this->reflectionProperty;
}
/**
* Returns the AbstractParam attribute for this property, optionally with a parent.
+ *
+ * @throws \ReflectionException
*/
public function getAttribute(?AbstractParam $parent = null): AbstractParam
{
@@ -191,7 +184,6 @@ public function __serialize(): array
'className' => $this->className,
'propertyName' => $this->propertyName,
'builtInType' => $this->builtInType,
- 'isConstrained' => $this->isConstrained,
'nestedDtoClassName' => $this->nestedDtoClassName,
'isNestedDtoArray' => $this->isNestedDtoArray,
'isNullable' => $this->isNullable,
@@ -214,7 +206,6 @@ public function __unserialize(array $data): void
$this->className = $data['className'];
$this->propertyName = $data['propertyName'];
$this->builtInType = $data['builtInType'];
- $this->isConstrained = $data['isConstrained'];
$this->nestedDtoClassName = $data['nestedDtoClassName'];
$this->isNestedDtoArray = $data['isNestedDtoArray'];
$this->isNullable = $data['isNullable'];
diff --git a/src/Reflection/RequestDtoParamMetadataFactory.php b/src/Reflection/RequestDtoParamMetadataFactory.php
index a2a0150..d8f9fa1 100644
--- a/src/Reflection/RequestDtoParamMetadataFactory.php
+++ b/src/Reflection/RequestDtoParamMetadataFactory.php
@@ -20,14 +20,11 @@
use Symfony\Component\TypeInfo\Type\CollectionType;
use Symfony\Component\TypeInfo\Type\ObjectType;
use Symfony\Component\TypeInfo\Type\UnionType;
-use Symfony\Component\Validator\Mapping\ClassMetadataInterface;
-use Symfony\Component\Validator\Validator\ValidatorInterface;
class RequestDtoParamMetadataFactory
{
public function __construct(
- private ValidatorInterface $validator,
- private DtoReflectionHelper $reflectionHelper,
+ private readonly DtoReflectionHelper $reflectionHelper,
private readonly PropertyInfoExtractorInterface $propertyInfoExtractor,
) {
}
@@ -35,18 +32,13 @@ public function __construct(
public function getMetadataFor(\ReflectionProperty $property): RequestDtoParamMetadata
{
$className = $property->getDeclaringClass()->getName();
- $cacheKey = $className.'.'.$property->getName();
-
- /** @var ClassMetadataInterface $validatorClassMetadata */
- $validatorClassMetadata = $this->validator->getMetadataFor($className);
- $constraintedProperties = $validatorClassMetadata->getConstrainedProperties();
-
$propertyName = $property->getName();
try {
$type = $this->propertyInfoExtractor->getType($className, $propertyName);
} catch (\InvalidArgumentException $e) {
- // Compabitibility fix because somehow type-info does not support unions with mixed.
+ // @codeCoverageIgnoreStart
+ // Compatibility fix because somehow type-info does not support unions with mixed.
if ('Cannot create union with "mixed" standalone type.' !== $e->getMessage()) {
throw $e;
}
@@ -58,6 +50,7 @@ public function getMetadataFor(\ReflectionProperty $property): RequestDtoParamMe
// Defensive fallback for invalid PHPDoc unions involving `mixed`
$type = Type::mixed();
+ // @codeCoverageIgnoreEnd
}
$typeDescription = TypeHelper::describe($type);
@@ -92,8 +85,7 @@ public function getMetadataFor(\ReflectionProperty $property): RequestDtoParamMe
$metadata = new RequestDtoParamMetadata(
$property->getDeclaringClass()->getName(),
$propertyName,
- $typeDescription['builtInType'],
- in_array($propertyName, $constraintedProperties, true),
+ strtolower($typeDescription['builtInType']),
$definetlyClassString,
$isArrayType,
// check if type is nullable and fall back to true when no type specified
diff --git a/src/Reflection/WithCacheTrait.php b/src/Reflection/WithCacheTrait.php
index 06e768f..293ec96 100644
--- a/src/Reflection/WithCacheTrait.php
+++ b/src/Reflection/WithCacheTrait.php
@@ -43,8 +43,6 @@ public function getCacheKey(string $className): string
/**
* @param class-string $className
- *
- * @throws \Psr\Cache\InvalidArgumentException
*/
private function getCacheItem(string $className): ?CacheItemInterface
{
@@ -62,8 +60,6 @@ private function getCacheItem(string $className): ?CacheItemInterface
/**
* @param class-string $className
- *
- * @throws \Psr\Cache\InvalidArgumentException
*/
private function getCachedValue(string $className): ?object
{
@@ -78,8 +74,6 @@ private function getCachedValue(string $className): ?object
/**
* @param class-string $className
- *
- * @throws \Psr\Cache\InvalidArgumentException
*/
private function cacheValue(string $className, object $metadata): void
{
diff --git a/src/RequestDtoResolver.php b/src/RequestDtoResolver.php
index e2caf5d..c9ed0dc 100644
--- a/src/RequestDtoResolver.php
+++ b/src/RequestDtoResolver.php
@@ -13,7 +13,8 @@
namespace Crtl\RequestDtoResolverBundle;
-use Crtl\RequestDtoResolverBundle\Reflection\RequestDtoMetadataFactory;
+use Crtl\RequestDtoResolverBundle\Factory\Exception\RequestDtoHydrationException;
+use Crtl\RequestDtoResolverBundle\Factory\RequestDtoFactory;
use Crtl\RequestDtoResolverBundle\Utility\DtoInstanceBagInterface;
use Crtl\RequestDtoResolverBundle\Utility\DtoReflectionHelper;
use Symfony\Component\HttpFoundation\Request;
@@ -30,8 +31,8 @@
{
public function __construct(
private DtoInstanceBagInterface $dtoInstanceBag,
- private RequestDtoMetadataFactory $factory,
private DtoReflectionHelper $reflectionHelper,
+ private RequestDtoFactory $requestDtoFactory,
) {
}
@@ -50,12 +51,11 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable
return [];
}
- /** @var class-string $type */
- $metadata = $this->factory->getMetadataFor($type);
- $object = $metadata->newInstance($request);
-
- if (null === $object) {
- throw new \RuntimeException('Failed to instantiate request dto '.$type);
+ try {
+ $object = $this->requestDtoFactory->fromRequest($type, $request);
+ } catch (RequestDtoHydrationException $e) {
+ $object = $e->object;
+ $this->dtoInstanceBag->registerHydrationViolations(get_class($object), $e->violations, $request);
}
$this->dtoInstanceBag->registerInstance($object, $request);
diff --git a/src/Trait/RequestDtoTrait.php b/src/Trait/RequestDtoTrait.php
deleted file mode 100644
index 62b29cc..0000000
--- a/src/Trait/RequestDtoTrait.php
+++ /dev/null
@@ -1,63 +0,0 @@
-getProperty($property);
-
- // Return property when initialized
- if ($reflectionProperty->isInitialized($this)) {
- $value = $reflectionProperty->getValue($this);
-
- if ($value !== $reflectionProperty->getDefaultValue()) {
- return $value;
- }
- }
-
- if (null === $this->request) {
- throw new \LogicException('Request must be set before calling getValue().');
- }
-
- $attrs = $reflectionProperty->getAttributes(AbstractParam::class, \ReflectionAttribute::IS_INSTANCEOF);
-
- if (empty($attrs)) {
- return $this->request->request->get($property);
- }
-
- /** @var AbstractParam $attr */
- $attr = $attrs[0]->newInstance();
- $attr->setProperty($reflectionProperty);
-
- return $attr->getValueFromRequest($this->request);
- }
-}
diff --git a/src/Utility/DtoInstanceBag.php b/src/Utility/DtoInstanceBag.php
index a87b410..42891b8 100644
--- a/src/Utility/DtoInstanceBag.php
+++ b/src/Utility/DtoInstanceBag.php
@@ -14,6 +14,7 @@
namespace Crtl\RequestDtoResolverBundle\Utility;
use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Validator\ConstraintViolationListInterface;
final class DtoInstanceBag implements DtoInstanceBagInterface
{
@@ -39,6 +40,25 @@ public function registerInstance(object $instance, Request $request): void
));
}
+ public function registerHydrationViolations(
+ string $className,
+ ConstraintViolationListInterface $violations,
+ Request $request
+ ): void {
+ /** @var array $existing */
+ $existing = $request->attributes->get(self::DTO_HYDRATION_VIOLATIONS_KEY, []);
+ $existing[$className] = $violations;
+ $request->attributes->set(self::DTO_HYDRATION_VIOLATIONS_KEY, $existing);
+ }
+
+ public function getHydrationViolations(string $className, Request $request): ?ConstraintViolationListInterface
+ {
+ /** @var array $violations */
+ $violations = $request->attributes->get(self::DTO_HYDRATION_VIOLATIONS_KEY, []);
+
+ return $violations[$className] ?? null;
+ }
+
/**
* @param class-string $className
*/
diff --git a/src/Utility/DtoInstanceBagInterface.php b/src/Utility/DtoInstanceBagInterface.php
index b9155ea..0d07871 100644
--- a/src/Utility/DtoInstanceBagInterface.php
+++ b/src/Utility/DtoInstanceBagInterface.php
@@ -14,7 +14,11 @@
namespace Crtl\RequestDtoResolverBundle\Utility;
use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Validator\ConstraintViolationListInterface;
+/**
+ * @codeCoverageIgnore
+ */
interface DtoInstanceBagInterface
{
/**
@@ -24,6 +28,13 @@ interface DtoInstanceBagInterface
*/
public const DTO_INSTANCES_ATTRIBUTE_KEY = '_request_dto_instances';
+ /**
+ * Name of the request attribute that stores hydration violations.
+ *
+ * @internal
+ */
+ public const DTO_HYDRATION_VIOLATIONS_KEY = '_request_dto_hydration_violations';
+
/**
* @return array
*/
@@ -31,6 +42,25 @@ public function getRegisteredInstances(Request $request): array;
public function registerInstance(object $instance, Request $request): void;
+ /**
+ * Register constraint violation that occured during hydration of DTO.
+ *
+ * These constraints are normally violated when using strict typed DTOs
+ * with mismatching request data.
+ *
+ * @param class-string $className
+ */
+ public function registerHydrationViolations(
+ string $className,
+ ConstraintViolationListInterface $violations,
+ Request $request
+ ): void;
+
+ /**
+ * @param class-string $className
+ */
+ public function getHydrationViolations(string $className, Request $request): ?ConstraintViolationListInterface;
+
/**
* @param class-string $className
*/
diff --git a/src/Utility/DtoReflectionHelper.php b/src/Utility/DtoReflectionHelper.php
index bf6fa6b..0f29449 100644
--- a/src/Utility/DtoReflectionHelper.php
+++ b/src/Utility/DtoReflectionHelper.php
@@ -89,6 +89,8 @@ public function getDtoClassNameFromReflectionProperty(
* Checks whether given object, relfection class or class name is a request dto.
*
* @throws \ReflectionException
+ *
+ * @phpstan-assert-if-true class-string|object $class
*/
public function isRequestDto(string|object $class): bool
{
diff --git a/src/Utility/TypeErrorInfo.php b/src/Utility/TypeErrorInfo.php
index 34f58e8..cf2f91f 100644
--- a/src/Utility/TypeErrorInfo.php
+++ b/src/Utility/TypeErrorInfo.php
@@ -74,7 +74,7 @@ public function __construct(
if (self::ERROR_TYPE_PROPERTY === $errorType) {
preg_match(
- '/Cannot assign (\w+) to property ([^ ]+) of type (\w+)/',
+ '/Cannot assign ([\w\\\]+) to property ([^ ]+) of type (\w+)/',
$message,
$m,
);
diff --git a/src/Utility/TypeHelper.php b/src/Utility/TypeHelper.php
index ee8cdf9..347cd9c 100644
--- a/src/Utility/TypeHelper.php
+++ b/src/Utility/TypeHelper.php
@@ -18,6 +18,11 @@
use Symfony\Component\TypeInfo\Type\NullableType;
use Symfony\Component\TypeInfo\Type\UnionType;
+/**
+ * Internal helper used to process type info {@link Type}.
+ *
+ * @codeCoverageIgnore
+ */
final class TypeHelper
{
/**
diff --git a/src/Validator/GroupSequenceExtractor.php b/src/Validator/GroupSequenceExtractor.php
deleted file mode 100644
index 6407982..0000000
--- a/src/Validator/GroupSequenceExtractor.php
+++ /dev/null
@@ -1,76 +0,0 @@
- $groups
- *
- * @return string[]|mixed[]|GroupSequence
- *
- * @throws \Psr\Container\ContainerExceptionInterface
- * @throws \Psr\Container\NotFoundExceptionInterface
- */
- public function getGroupSequence(object $object, array $groups, ClassMetadataInterface $metadata): array|GroupSequence
- {
- $className = $metadata->getClassName();
-
- // Return groups when not in default group
- if (!in_array($className, $groups) || !in_array(Constraint::DEFAULT_GROUP, $groups)) {
- return $groups;
- }
-
- if ($metadata->hasGroupSequence()) {
- // The group sequence is statically defined for the class
- return $metadata->getGroupSequence();
- } elseif ($metadata->isGroupSequenceProvider()) {
- // @phpstan-ignore function.alreadyNarrowedType
- if (method_exists($metadata, 'getGroupProvider') && null !== $provider = $metadata->getGroupProvider()) {
- if (null === $this->groupProviderLocator) {
- throw new \LogicException('A group provider locator is required when using group provider.');
- }
-
- /** @var GroupProviderInterface $provider */
- $provider = $this->groupProviderLocator->get($provider);
- $group = $provider->getGroups($object);
- } else {
- assert($object instanceof GroupSequenceProviderInterface);
- // The group sequence is dynamically obtained from the validated
- // object
- $group = $object->getGroupSequence();
- }
-
- if (!$group instanceof GroupSequence) {
- $group = new GroupSequence($group);
- }
-
- return $group;
- }
-
- return $groups;
- }
-}
diff --git a/src/Validator/RequestDtoValidator.php b/src/Validator/RequestDtoValidator.php
deleted file mode 100644
index ac185bd..0000000
--- a/src/Validator/RequestDtoValidator.php
+++ /dev/null
@@ -1,313 +0,0 @@
- 0,
- 'string' => '',
- 'float' => 0.0,
- 'bool' => false,
- 'array' => [],
- 'object' => null,
- 'null' => null,
- 'mixed' => null,
- 'iterable' => [],
- ];
-
- public function __construct(
- private readonly ValidatorInterface $validator,
- private readonly RequestDtoMetadataFactory $metadataFactory,
- private readonly GroupSequenceExtractor $groupSequenceExtractor,
- private readonly ?TranslatorInterface $translator = null,
- ) {
- }
-
- /**
- * Validates the given DTO and hydrates it if validation is successfull.
- *
- * Main differences to default validator are:
- * - DTO is not hydrated until the first validation group sequence passed successfully.
- * - Propertie constraints are validated first
- * - Class constraints are validated last after hydration.
- * - TypeErrors thrown during hydration are converted to custom constraint violations.
- *
- * @param object $dto
- * @param array|null $groups
- */
- public function validateAndHydrate($dto, Request $request, ?array $groups = null): ConstraintViolationListInterface
- {
- return $this->validateAndHydrateRecursive($dto, $request, $groups);
- }
-
- /**
- * @param array|null $groups
- */
- private function validateAndHydrateRecursive(
- object $dto,
- Request $request,
- ?array $groups = null,
- ?AbstractParam $parent = null,
- ?RequestDtoMetadata $dtoMetadata = null
- ): ConstraintViolationListInterface {
- $className = get_class($dto);
- $dtoMetadata ??= $this->metadataFactory->getMetadataFor($className);
-
- if (empty($groups)) {
- $groups = [Constraint::DEFAULT_GROUP, $className];
- }
-
- $groupSequence = $dtoMetadata->getGroupSequence() ?? $groups;
-
- $violations = new ConstraintViolationList();
-
- /** @var array $hydrationCache */
- $hydrationCache = [];
-
- $hydrated = false;
-
- $newGroupSequence = $this->groupSequenceExtractor->getGroupSequence($dto, $groups, $dtoMetadata->getValidatorMetadata());
-
- $groupSequence = $newGroupSequence instanceof GroupSequence ? $newGroupSequence->groups : $newGroupSequence;
-
- foreach ($groupSequence as $groupToProcess) {
- $groupViolations = new ConstraintViolationList();
-
- foreach ($dtoMetadata->getPropertyMetadataGenerator() as $propertyMetadata) {
- $reflectionProperty = $propertyMetadata->getReflectionProperty();
- $propertyName = $reflectionProperty->getName();
- $attr = $dtoMetadata->getAbstractParamAttributeFromProperty($reflectionProperty, $parent);
-
- $dtoType = $propertyMetadata->getNestedDtoClassName();
- /** @var ConstraintViolationList|null $propertyViolations */
- $propertyViolations = null;
-
- if ($attr instanceof QueryParam) {
- $builtInType = strtolower($propertyMetadata->getBuiltinType());
- if (!$attr->hasTransformType() && QueryParam::isTransformType($builtInType)) {
- $attr->setTransformType($builtInType);
- }
- }
-
- $value = $attr->getValueFromRequest($request) ?? $propertyMetadata->getDefaultValue();
-
- // When the property is a nested RequestDTO we can recurse into custom validation and skip regular validator validation, because:
- // class can either be valid or invalid, of a nested dto is not valid it therefore can also not be assigned to the property.
- // therefore no other validation is required.
- if (null !== $dtoType) {
- if (null !== $value) {
- $isArray = $propertyMetadata->isNestedDtoArray();
-
- $nestedClassName = $dtoType;
- /** @var class-string $nestedClassName */
- $nestedMetadata = $this->metadataFactory->getMetadataFor($nestedClassName);
-
- $valueArray = $isArray ? $value : [$value];
-
- $propertyViolations = new ConstraintViolationList();
- $resultArray = [];
- foreach ($valueArray as $i => $nestedValue) {
- $instance = $nestedMetadata->newInstance($request);
-
- $nestedAttr = clone $attr;
- if ($isArray && $nestedAttr instanceof AbstractNestedParam) {
- $nestedAttr->setIndex($i);
- }
-
- $nestedViolations = $this->validateAndHydrateRecursive(
- $instance,
- $request,
- is_array($groupToProcess) ? $groupToProcess : [$groupToProcess],
- $nestedAttr,
- $nestedMetadata,
- );
-
- // Reset invalid object
- if ($nestedViolations->count() > 0) {
- $propertyViolations = $this->prefixViolations(
- $propertyViolations,
- // Append index to prefix only when in array mode
- $isArray ? ($propertyName."[$i]") : $propertyName,
- );
- $instance = null;
- }
-
- $resultArray[] = $instance;
- $propertyViolations->addAll($nestedViolations);
- }
-
- // Reset invalid object
- if ($propertyViolations->count() > 0) {
- $value = null;
- } else {
- $value = $isArray ? $resultArray : $resultArray[0];
- }
- }
- } else {
- // only validate constrained properties
- if ($dtoMetadata->isConstrainedProperty($reflectionProperty)) {
- $propertyViolations = $this->validator->validatePropertyValue(
- $className,
- $propertyName,
- $value,
- $groupToProcess,
- );
- $propertyViolations = $this->prefixViolations($propertyViolations, $propertyName);
- }
- }
-
- if (null !== $propertyViolations && $propertyViolations->count() > 0) {
- $groupViolations->addAll(
- $propertyViolations,
- );
- } else {
- // Store callable to apply later when validation completed successfully
- $hydrationCache[] = [
- 'value' => $value,
- 'name' => $propertyName,
- 'metadata' => $propertyMetadata,
- 'reflectionProperty' => $reflectionProperty,
- ];
- }
- }
-
- // 2. Short-circuit: If any property failed in this group, stop everything
- if ($groupViolations->count() > 0) {
- return $groupViolations;
- }
-
- // After the first sequence passed validation successfully we assume data is valid and hydrate the object
- if (false === $hydrated) {
- $hydrated = true;
- $hydrationViolations = new ConstraintViolationList();
- foreach ($hydrationCache as $cacheItem) {
- try {
- $cacheItem['reflectionProperty']->setValue($dto, $cacheItem['value']);
- } catch (\TypeError $e) {
- if (TypeErrorInfo::ERROR_TYPE_PROPERTY !== TypeErrorInfo::getErrorTypeFromTypeError($e)) {
- throw $e;
- }
- $hydrationViolations->add(
- $this->createTypeErrorViolation($e, $dto, $cacheItem['metadata'], $cacheItem['value']),
- );
- }
- }
-
- if ($hydrationViolations->count() > 0) {
- return $hydrationViolations;
- }
- }
-
- $classViolations = $this->validator
- ->validate($dto, $dtoMetadata->getClassConstraints(), $groupToProcess);
-
- if ($classViolations->count() > 0) {
- return $classViolations;
- }
- }
-
- return $violations;
- }
-
- /**
- * Creates custom constraint violation for type errors occuring when assigning values to types properties and the type is not matching.
- *
- * @param \TypeError $error The type error that was thrown
- * @param object $object The object being validated
- * @param RequestDtoParamMetadata $propertyMetadata The metadata of the property being validated
- */
- private function createTypeErrorViolation(
- \TypeError $error,
- object $object,
- RequestDtoParamMetadata $propertyMetadata,
- mixed $invalidValue = null
- ): ConstraintViolation {
- $errorInfo = new TypeErrorInfo($error);
-
- $template = 'This value should be of type {{ expected }}, {{ given }} given.';
-
- $params = [
- '{{ expected }}' => $errorInfo->expectedType,
- '{{ given }}' => $errorInfo->actualType,
- ];
-
- $message = $this->translator?->trans($template, $params, 'validators') ?? $template;
-
- return new ConstraintViolation(
- $message,
- $template,
- $params,
- $object,
- $propertyMetadata->getPropertyName(),
- $invalidValue,
- );
- }
-
- /**
- * Prefixes property paths of constraint violations with parent property name.
- */
- private function prefixViolations(ConstraintViolationListInterface $violations, string $prefix): ConstraintViolationList
- {
- $prefixedList = new ConstraintViolationList();
- foreach ($violations as $violation) {
- $path = $prefix;
- $violationPath = $violation->getPropertyPath();
-
- if (!empty($violationPath) && !str_starts_with($violationPath, '[')) {
- $violationPath = '.'.$violationPath;
- }
-
- $path .= $violationPath;
-
- $prefixedList->add(new ConstraintViolation(
- $violation->getMessage(),
- $violation->getMessageTemplate(),
- $violation->getParameters(),
- $violation->getRoot(),
- $path,
- $violation->getInvalidValue(),
- $violation->getPlural(),
- $violation->getCode(),
- $violation->getConstraint(),
- $violation->getCause(),
- ));
- }
-
- return $prefixedList;
- }
-}
diff --git a/test/Fixtures/Controller/DtoWithDefaultsController.php b/test/Fixtures/Controller/DtoWithDefaultsController.php
deleted file mode 100644
index 11cdbcf..0000000
--- a/test/Fixtures/Controller/DtoWithDefaultsController.php
+++ /dev/null
@@ -1,25 +0,0 @@
-getValue('first')) {
+ if ('validate_second' === $this->first) {
$groups[] = 'First';
}
diff --git a/test/Fixtures/Legacy/ExampleDto.php b/test/Fixtures/Dto/Legacy/ExampleDto.php
similarity index 86%
rename from test/Fixtures/Legacy/ExampleDto.php
rename to test/Fixtures/Dto/Legacy/ExampleDto.php
index d5cb5b5..ef27aee 100644
--- a/test/Fixtures/Legacy/ExampleDto.php
+++ b/test/Fixtures/Dto/Legacy/ExampleDto.php
@@ -11,7 +11,7 @@
declare(strict_types=1);
-namespace Crtl\RequestDtoResolverBundle\Test\Fixtures\Legacy;
+namespace Crtl\RequestDtoResolverBundle\Test\Fixtures\Dto\Legacy;
use Crtl\RequestDtoResolverBundle\Attribute\BodyParam;
use Crtl\RequestDtoResolverBundle\Attribute\FileParam;
@@ -19,6 +19,7 @@
use Crtl\RequestDtoResolverBundle\Attribute\QueryParam;
use Crtl\RequestDtoResolverBundle\Attribute\RequestDto;
use Crtl\RequestDtoResolverBundle\Attribute\RouteParam;
+use Crtl\RequestDtoResolverBundle\Test\Fixtures\Dto\Nested\NestedChildMixedDto;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Constraints as Assert;
@@ -51,10 +52,10 @@ class ExampleDto
// Nested DTOs are supported for BodyParam and QueryParam
#[BodyParam('nested'), Assert\Valid]
- public ?NestedChildDTO $nestedBodyDto;
+ public ?NestedChildMixedDto $nestedBodyDto;
#[QueryParam('nested')]
- public ?NestedChildDTO $nestedQueryParamDto;
+ public ?NestedChildMixedDto $nestedQueryParamDto;
// Optionally implement constructor which accepts request object
public function __construct(public readonly Request $request)
diff --git a/test/Fixtures/Dto/MixedAllParamsDto.php b/test/Fixtures/Dto/MixedAllParamsDto.php
new file mode 100644
index 0000000..192fe0b
--- /dev/null
+++ b/test/Fixtures/Dto/MixedAllParamsDto.php
@@ -0,0 +1,221 @@
+
- */
- #[QueryParam]
- #[Assert\NotBlank]
- #[Assert\Type('array')]
- #[Assert\All([
- new Assert\Type('string')
- ])]
- public array $queryParamArrayAssoc = ['key_1' => 'value_1', 'key_2' => 'value_2'];
-
- #[BodyParam]
- #[Assert\NotBlank]
- #[Assert\Type('string')]
- public string $bodyParamString = 'body-param-string-default';
- #[BodyParam]
- #[Assert\NotBlank]
- #[Assert\Type('int')]
- public int $bodyParamInt = 42;
- #[BodyParam]
- #[Assert\NotBlank]
- #[Assert\Type('float')]
- public float $bodyParamFloat = 3.14;
- #[BodyParam]
- #[Assert\NotBlank]
- #[Assert\Type('bool')]
- public bool $bodyParamBool = true;
-
- /**
- * @var string[]
- */
- #[BodyParam]
- #[Assert\NotBlank]
- #[Assert\Type('array')]
- #[Assert\All([
- new Assert\Type('string')
- ])]
- public array $bodyParamArrayNueric = ['default', 'array'];
-
- /**
- * @var array
- */
- #[BodyParam]
- #[Assert\NotBlank]
- #[Assert\Type('array')]
- #[Assert\All([
- new Assert\Type('string')
- ])]
- public array $bodyParamArrayAssoc = ['key_1' => 'value_1', 'key_2' => 'value_2'];
-
- #[HeaderParam('X-Custom-Header')]
- #[Assert\NotBlank]
- #[Assert\Type('string')]
- public string $headerParamString = 'header-param-string-default';
-}
diff --git a/test/Fixtures/GroupProvider/TestGroupProvider.php b/test/Fixtures/GroupProvider/TestGroupProvider.php
index 0ae39bc..8d75bc8 100644
--- a/test/Fixtures/GroupProvider/TestGroupProvider.php
+++ b/test/Fixtures/GroupProvider/TestGroupProvider.php
@@ -13,7 +13,7 @@
namespace Crtl\RequestDtoResolverBundle\Test\Fixtures\GroupProvider;
-use Crtl\RequestDtoResolverBundle\Test\Fixtures\DtoWithGroupSequenceProvider;
+use Crtl\RequestDtoResolverBundle\Test\Fixtures\Dto\DtoWithGroupSequenceProvider;
use Symfony\Component\Validator\Constraints\GroupSequence;
use Symfony\Component\Validator\GroupProviderInterface;
@@ -25,7 +25,7 @@ public function getGroups(object $object): array|GroupSequence
$groups = [self::class];
- if ('validate_second' === $object->getValue('first')) {
+ if ('validate_second' === $object->first) {
$groups[] = 'First';
}
diff --git a/test/Integration/RequestDtoFactoryIntegrationTest.php b/test/Integration/RequestDtoFactoryIntegrationTest.php
new file mode 100644
index 0000000..0ea5b97
--- /dev/null
+++ b/test/Integration/RequestDtoFactoryIntegrationTest.php
@@ -0,0 +1,543 @@
+|Request,
+ * expected: array,
+ * className: class-string,
+ * }
+ * @phpstan-type ViolationTestCaseArgs array{
+ * method: string,
+ * context: array|Request,
+ * expected: string[],
+ * className: class-string,
+ * }
+ */
+final class RequestDtoFactoryIntegrationTest extends KernelTestCase
+{
+ private RequestDtoFactory $factory;
+
+ protected function setUp(): void
+ {
+ self::bootKernel();
+
+ /** @var RequestDtoFactory $factory */
+ $factory = self::getContainer()->get(RequestDtoFactory::class);
+ $this->factory = $factory;
+ }
+
+ /**
+ * @return iterable|Request}>
+ */
+ public static function throwsReflectionExceptionWhenClassDoesNotExist(): iterable
+ {
+ yield 'fromRequest' => ['fromRequest', new Request()];
+ yield 'fromArray' => ['fromArray', []];
+ }
+
+ /**
+ * @param array|Request $context
+ */
+ #[DataProvider('throwsReflectionExceptionWhenClassDoesNotExist')]
+ public function testThrowsReflectionExceptionWhenClassDoesNotExist(string $method, array|Request $context): void
+ {
+ $this->expectException(\ReflectionException::class);
+ $this->factory->$method('NonExistingClass', $context);
+ }
+
+ /**
+ * @return iterable|Request}>
+ */
+ public static function throwsCircularReferenceExceptionWhenNestedCircularReferenceIsDetectedProvider(): iterable
+ {
+ yield 'fromArray with single child' => [
+ 'method' => 'fromArray',
+ 'context' => ['prop' => []],
+ ];
+ yield 'fromArray with array child' => [
+ 'method' => 'fromArray',
+ 'context' => ['array' => [[]]],
+ ];
+ yield 'fromRequest with single child' => [
+ 'method' => 'fromRequest',
+ 'context' => new Request(request: ['prop' => []]),
+ ];
+ yield 'fromRequest with array child' => [
+ 'method' => 'fromRequest',
+ 'context' => new Request(request: ['array' => [[]]]),
+ ];
+ }
+
+ /**
+ * @param array|Request $context
+ */
+ #[DataProvider('throwsCircularReferenceExceptionWhenNestedCircularReferenceIsDetectedProvider')]
+ public function testThrowsCircularReferenceExceptionWhenNestedCircularReferenceIsDetected(string $method, array|Request $context): void
+ {
+ self::expectException(CircularReferenceException::class);
+ $this->factory->$method(CircularReferencingRequestDto::class, $context);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function correctlyHydratesRequestDtosProvider(): iterable
+ {
+ // TODO: implement data providers, optionally add more entries.
+ yield 'mixed fromRequest' => [
+ 'method' => 'fromRequest',
+ 'context' => new Request(request: ['prop' => []]),
+ 'expected' => [],
+ 'className' => MixedRequestDto::class,
+ ];
+ yield 'mixed fromArray' => [
+ 'method' => 'fromArray',
+ 'context' => [],
+ 'expected' => [],
+ 'className' => MixedRequestDto::class,
+ ];
+
+ // TODO: implement data providers, optionally add more entries.
+ yield 'strict fromRequest' => [
+ 'method' => 'fromRequest',
+ 'context' => new Request(request: ['prop' => []]),
+ 'expected' => [],
+ 'className' => StrictRequestDto::class,
+ ];
+ yield 'strict fromArray' => [
+ 'method' => 'fromArray',
+ 'context' => [],
+ 'expected' => [],
+ 'className' => StrictRequestDto::class,
+ ];
+
+ // TODO: implement data provider
+ yield 'non-strict fromRequest' => [
+ 'method' => 'fromRequest',
+ 'context' => new Request(request: ['prop' => []]),
+ 'expected' => [],
+ 'className' => NonStrictRequestDto::class,
+ ];
+ yield 'non-strict fromArray' => [
+ 'method' => 'fromArray',
+ 'context' => [],
+ 'expected' => [],
+ 'className' => NonStrictRequestDto::class,
+ ];
+
+ yield 'deep nested fromRequest with single child' => [
+ 'method' => 'fromRequest',
+ 'context' => new Request(request: [
+ 'child' => [
+ 'child' => [
+ 'childName' => 'name',
+ ]
+ ]
+ ]),
+ 'expected' => [
+ 'child' => [
+ 'child' => [
+ 'childName' => 'name',
+ ]
+ ]
+ ],
+ 'className' => RequestDtoWithDeepNesting::class,
+ ];
+ yield 'deep nested fromRequest with multiple children' => [
+ 'method' => 'fromRequest',
+ 'context' => new Request(request: [
+ 'children' => [
+ [
+ 'children' => [[
+ 'childName' => 'name',
+ ]]
+ ]
+ ]
+ ]),
+ 'expected' => [
+ 'children' => [
+ [
+ 'children' => [[
+ 'childName' => 'name',
+ ]]
+ ]
+ ]
+ ],
+ 'className' => RequestDtoWithDeepNesting::class,
+ ];
+ yield 'deep nested fromArray with single child' => [
+ 'method' => 'fromArray',
+ 'context' => [
+ 'child' => [
+ 'child' => [
+ 'childName' => 'name',
+ ]
+ ]
+ ],
+ 'expected' => [
+ 'child' => [
+ 'child' => [
+ 'childName' => 'name',
+ ]
+ ]
+ ],
+ 'className' => RequestDtoWithDeepNesting::class,
+ ];
+ yield 'deep nested fromArray with multiple children' => [
+ 'method' => 'fromArray',
+ 'context' => [
+ 'children' => [
+ [
+ 'children' => [[
+ 'childName' => 'name',
+ ]]
+ ]
+ ]
+ ],
+ 'expected' => [
+ 'children' => [
+ [
+ 'children' => [[
+ 'childName' => 'name',
+ ]]
+ ]
+ ]
+ ],
+ 'className' => RequestDtoWithDeepNesting::class,
+ ];
+
+ yield 'shallow nested fromRequest with single child' => [
+ 'method' => 'fromRequest',
+ 'context' => new Request(request: [
+ 'child' => [
+ 'childName' => 'name',
+ ]
+ ]),
+ 'expected' => [
+ 'child' => [
+ 'childName' => 'name',
+ ]
+ ],
+ 'className' => RequestDtoWithShallowNesting::class,
+ ];
+ yield 'shallow nested fromRequest with multiple children' => [
+ 'method' => 'fromRequest',
+ 'context' => new Request(request: [
+ 'children' => [[
+ 'childName' => 'name',
+ ]]
+ ]),
+ 'expected' => [
+ 'children' => [[
+ 'childName' => 'name',
+ ]]
+ ],
+ 'className' => RequestDtoWithShallowNesting::class,
+ ];
+ yield 'shallow nested fromArray with single child' => [
+ 'method' => 'fromArray',
+ 'context' => [
+ 'child' => [
+ 'childName' => 'name',
+ ]
+ ],
+ 'expected' => [
+ 'child' => [
+ 'childName' => 'name',
+ ]
+ ],
+ 'className' => RequestDtoWithShallowNesting::class,
+ ];
+ yield 'shallow nested fromArray with multiple children' => [
+ 'method' => 'fromArray',
+ 'context' => [
+ 'children' => [[
+ 'childName' => 'name',
+ ]]
+ ],
+ 'expected' => [
+ 'children' => [[
+ 'childName' => 'name',
+ ]]
+ ],
+ 'className' => RequestDtoWithShallowNesting::class,
+ ];
+
+ yield 'transform query param fromRequest' => [
+ 'method' => 'fromRequest',
+ 'context' => new Request([
+ 'queryInt' => '123',
+ 'queryNullableInt' => '321',
+ 'queryFloat' => '1.2',
+ 'queryNullableFloat' => '3.14',
+ 'queryBool' => 'yes',
+ 'queryNullableBool' => 'false',
+ ]),
+ 'expected' => [
+ 'queryInt' => 123,
+ 'queryNullableInt' => 321,
+ 'queryFloat' => 1.2,
+ 'queryNullableFloat' => 3.14,
+ 'queryBool' => true,
+ 'queryNullableBool' => false,
+ ],
+ 'className' => TransformQueryParamRequestDto::class,
+ ];
+ yield 'transform query param fromArray' => [
+ 'method' => 'fromArray',
+ 'context' => [
+ 'queryInt' => '123',
+ 'queryNullableInt' => '321',
+ 'queryFloat' => '1.2',
+ 'queryNullableFloat' => '3.14',
+ 'queryBool' => 'yes',
+ 'queryNullableBool' => 'false',
+ ],
+ 'expected' => [
+ 'queryInt' => 123,
+ 'queryNullableInt' => 321,
+ 'queryFloat' => 1.2,
+ 'queryNullableFloat' => 3.14,
+ 'queryBool' => true,
+ 'queryNullableBool' => false,
+ ],
+ 'className' => TransformQueryParamRequestDto::class,
+ ];
+ }
+
+ /**
+ * @param array|Request $context
+ * @param array $expected
+ * @param class-string $className
+ */
+ #[DataProvider('correctlyHydratesRequestDtosProvider')]
+ public function testCorrectlyHydratesRequestDtos(
+ string $method,
+ array|Request $context,
+ array $expected,
+ string $className,
+ ): void
+ {
+ $dto = $this->factory->$method($className, $context);
+ self::assertInstanceOf($className, $dto);
+ self::objectMatchesExpectedStructure($dto, $expected);
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function throwsRequestDtoHydrationExceptionWithPropertyTypeViolationsWhenHydratingTypedDtoWithInvalidTypesProvider(): iterable
+ {
+ yield 'fromArray with invalid body scalar types' => [
+ 'method' => 'fromArray',
+ 'context' => [
+ 'bodyString' => ['not', 'a', 'string'],
+ 'bodyInt' => 'not-a-number',
+ 'bodyFloat' => 'not-a-number',
+ 'bodyBool' => ['not-a-bool'],
+ 'bodyArray' => 'not-an-array',
+ ],
+ 'expected' => ['bodyString', 'bodyInt', 'bodyFloat', 'bodyBool', 'bodyArray'],
+ 'className' => StrictRequestDto::class,
+ ];
+
+ yield 'fromRequest with invalid body scalar types' => [
+ 'method' => 'fromRequest',
+ 'context' => new Request(request: [
+ 'bodyString' => ['not', 'a', 'string'],
+ 'bodyInt' => 'not-a-number',
+ 'bodyFloat' => 'not-a-number',
+ 'bodyBool' => ['not-a-bool'],
+ 'bodyArray' => 'not-an-array',
+ ]),
+ 'expected' => ['bodyString', 'bodyInt', 'bodyFloat', 'bodyBool', 'bodyArray'],
+ 'className' => StrictRequestDto::class,
+ ];
+
+ yield 'fromArray with single invalid property among valid ones' => [
+ 'method' => 'fromArray',
+ 'context' => [
+ 'bodyString' => 'valid-string',
+ 'bodyInt' => ['not-an-int'],
+ ],
+ 'expected' => ['bodyInt'],
+ 'className' => StrictRequestDto::class,
+ ];
+
+ yield 'fromArray with invalid types across param sources' => [
+ 'method' => 'fromArray',
+ 'context' => [
+ 'bodyInt' => ['not-an-int'],
+ ],
+ 'expected' => ['bodyInt'],
+ 'className' => StrictRequestDto::class,
+ ];
+
+ yield 'fromRequest with invalid types across param sources' => [
+ 'method' => 'fromRequest',
+ 'context' => new Request(
+ query: ['queryString' => ['not-a-string']],
+ request: ['bodyInt' => ['not-an-int']],
+ ),
+ 'expected' => ['bodyInt', 'queryString'],
+ 'className' => StrictRequestDto::class,
+ ];
+
+ yield 'fromArray with null for non-nullable properties' => [
+ 'method' => 'fromArray',
+ 'context' => [
+ 'bodyString' => null,
+ 'bodyInt' => null,
+ ],
+ 'expected' => ['bodyString', 'bodyInt'],
+ 'className' => StrictRequestDto::class,
+ ];
+
+ yield 'fromArray with deep nested dto' => [
+ 'method' => 'fromArray',
+ 'context' => [
+ 'child' => [
+ 'child' => [
+ 'childName' => 1,
+ ],
+ 'children' => [
+ ['childName' => 1],
+ ]
+ ],
+ 'children' => [
+ [
+ 'child' => [
+ 'childName' => 1,
+ ],
+ 'children' => [
+ ['childName' => 1],
+ null,
+ ]
+ ],
+ ],
+ ],
+ 'expected' => [
+ 'child.child.childName',
+ 'child.children[0].childName',
+ 'children[0].child.childName',
+ 'children[0].children[0].childName',
+ ],
+ 'className' => RequestDtoWithDeepNesting::class,
+ ];
+ }
+
+ /**
+ * @param array|Request $context
+ * @param string[] $expectedViolations
+ * @param class-string $className
+ */
+ #[DataProvider('throwsRequestDtoHydrationExceptionWithPropertyTypeViolationsWhenHydratingTypedDtoWithInvalidTypesProvider')]
+ public function testThrowsRequestDtoHydrationExceptionWithPropertyTypeViolationsWhenHydratingTypedDtoWithInvalidTypes(
+ string $method,
+ array|Request $context,
+ array $expectedViolations,
+ string $className,
+ ): void
+ {
+ self::expectException(RequestDtoHydrationException::class);
+ try {
+ $this->factory->$method($className, $context);
+ } catch (RequestDtoHydrationException $e) {
+ self::assertRequestDtoHydrationException($e, $expectedViolations);
+ throw $e;
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private static function mapViolationsToPath(ConstraintViolationListInterface $violations): array
+ {
+ $result = [];
+ foreach ($violations as $violation) {
+ $path = $violation->getPropertyPath();
+ $result[$path] ??= [];
+ $result[$path][] = $violation;
+ }
+
+ return $result;
+ }
+
+ /**
+ * @template TKey of array-key
+ * @template TVal
+ *
+ * @param array $keys
+ * @param array $array
+ */
+ private static function assertArrayHasKeys(array $keys, array $array): void
+ {
+ foreach ($keys as $key) {
+ self::assertArrayHasKey($key, $array);
+ }
+ }
+
+ /**
+ * Helper to assert that error contains violations for given fields.
+ *
+ * @param string[]|array $expected property paths of expected violations or a map of field names to expected violation count
+ */
+ private static function assertRequestDtoHydrationException(RequestDtoHydrationException $e, array $expected): void
+ {
+ $violations = self::mapViolationsToPath($e->violations);
+ self::assertCount(count($expected), $violations);
+ foreach ($expected as $key => $value) {
+ if (is_int($key)) {
+ $key = $value;
+ }
+
+ self::assertArrayHasKey($key, $violations);
+
+ // Only assert count when array is assoc
+ if (is_int($value)) { // @phpstan-ignore-line
+ self::assertCount($value, $violations[$key]); // @phpstan-ignore-line
+ }
+ }
+ }
+
+ /**
+ * @param array $expected
+ */
+ private static function objectMatchesExpectedStructure(object $object, array $expected): void
+ {
+ self::assertSame(
+ $expected,
+ json_decode(json_encode($object) ?: '', true),
+ );
+ }
+}
diff --git a/test/Integration/RequestDtoResolverBundleIntegrationTest.php b/test/Integration/RequestDtoResolverBundleIntegrationTest.php
index 6013876..3d21d54 100644
--- a/test/Integration/RequestDtoResolverBundleIntegrationTest.php
+++ b/test/Integration/RequestDtoResolverBundleIntegrationTest.php
@@ -13,15 +13,16 @@
namespace Crtl\RequestDtoResolverBundle\Test\Integration;
-use Crtl\RequestDtoResolverBundle\Test\Fixtures\CollectionPathTestDto;
use Crtl\RequestDtoResolverBundle\Test\Fixtures\Controller\MixedDtoWithDefaultsController;
use Crtl\RequestDtoResolverBundle\Test\Fixtures\Controller\MultipleFilesTestController;
use Crtl\RequestDtoResolverBundle\Test\Fixtures\Controller\StrictTypesDtoController;
-use Crtl\RequestDtoResolverBundle\Test\Fixtures\DtoWithGroupSequenceProvider;
-use Crtl\RequestDtoResolverBundle\Test\Fixtures\DtoWithNestedDtoArray;
-use Crtl\RequestDtoResolverBundle\Test\Fixtures\GroupSequenceProviderDTO;
-use Crtl\RequestDtoResolverBundle\Test\Fixtures\Legacy\ExampleDto;
-use Crtl\RequestDtoResolverBundle\Test\Fixtures\TypeConflictingDto;
+use Crtl\RequestDtoResolverBundle\Test\Fixtures\Dto\CollectionPathTestDto;
+use Crtl\RequestDtoResolverBundle\Test\Fixtures\Dto\DtoWithGroupSequenceProvider;
+use Crtl\RequestDtoResolverBundle\Test\Fixtures\Dto\GroupSequenceProviderDTO;
+use Crtl\RequestDtoResolverBundle\Test\Fixtures\Dto\Legacy\ExampleDto;
+use Crtl\RequestDtoResolverBundle\Test\Fixtures\Dto\Nested\DtoWithNestedDtoArray;
+use Crtl\RequestDtoResolverBundle\Test\Fixtures\Dto\NonStrictTypeConflictingDto;
+use Crtl\RequestDtoResolverBundle\Test\Fixtures\Dto\TypeConflictingDto;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
@@ -93,17 +94,51 @@ public function testHydratesAndValidatesARequestDtoAndCallsTheController(): void
self::assertNull($data['nullableArray']);
}
+ public function testReturns400WhenHydrationFails(): void
+ {
+ self::bootKernel();
+ $kernel = self::$kernel;
+
+ // Sending null for non-nullable typed properties causes hydration TypeErrors
+ $payload = [
+ 'string' => null, // TypeError: cannot assign null to string
+ 'int' => null, // TypeError: cannot assign null to int
+ 'float' => null, // TypeError: cannot assign null to float
+ 'bool' => null, // TypeError: cannot assign null to bool
+ 'array' => null, // TypeError: cannot assign null to array
+ ];
+
+ $request = Request::create(
+ uri: '/_test',
+ method: 'POST',
+ server: [
+ 'CONTENT_TYPE' => 'application/json',
+ 'HTTP_ACCEPT' => 'application/json',
+ ],
+ content: json_encode($payload, JSON_THROW_ON_ERROR),
+ );
+
+ $controller = new StrictTypesDtoController();
+
+ $request->attributes->set('_controller', $controller);
+
+ $response = $kernel->handle($request);
+
+ self::assertValidationErrorResponse($response, ['string', 'int', 'float', 'bool', 'array']);
+ }
+
public function testReturns400WhenValidationFails(): void
{
self::bootKernel();
$kernel = self::$kernel;
+ // Values with correct types but failing validation constraints
$payload = [
'string' => '', // NotBlank violation
- 'int' => null, // NotBlank considers 0 as blank -> violation (Symfony behavior)
- 'float' => null, // NotBlank considers 0.0 as blank -> violation
- 'bool' => false, // NotBlank considers false as blank -> violation
- 'array' => [], // NotBlank considers empty array as blank -> violation
+ 'int' => 0, // NotBlank considers 0 as blank
+ 'float' => 0.0, // NotBlank considers 0.0 as blank
+ 'bool' => false, // NotBlank considers false as blank
+ 'array' => [], // NotBlank considers empty array as blank
];
$request = Request::create(
@@ -120,11 +155,7 @@ public function testReturns400WhenValidationFails(): void
$request->attributes->set('_controller', $controller);
- // Replace with your concrete exception type:
- // e.g. \Crtl\RequestDtoResolverBundle\Exception\RequestDtoValidationException::class
-
$response = $kernel->handle($request);
- var_dump($response->getContent());
self::assertValidationErrorResponse($response, ['string', 'int', 'float', 'bool', 'array']);
}
@@ -222,7 +253,6 @@ public function __invoke(ExampleDto $dto): JsonResponse
$request->attributes->set('_controller', $controller);
$response = $kernel->handle($request);
- echo $response->getContent();
self::assertSame(400, $response->getStatusCode());
}
@@ -303,7 +333,6 @@ public function __invoke(GroupSequenceProviderDTO $dto): JsonResponse
$request->attributes->set('_controller', $controller);
$response = $kernel->handle($request);
- echo $response->getContent();
self::assertSame($expectedStatus, $response->getStatusCode());
@@ -408,7 +437,6 @@ public function __invoke(CollectionPathTestDto $dto): JsonResponse
$request->attributes->set('_controller', $controller);
$response = $kernel->handle($request);
- var_dump($response->getContent());
self::assertValidationErrorResponse($response, [
'property[0][key]',
@@ -503,7 +531,6 @@ public function testMixedDtoWithDefaultsIsHydratedCorrectlyAndRetainsDefaults():
$response = $kernel->handle($request);
$data = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
- print_r($data);
self::assertSame(200, $response->getStatusCode());
// Provided values
@@ -576,7 +603,13 @@ public function testTypeConflictingDtoReturnsValidationErrorsWhenPropertyTypeMis
server: [
'CONTENT_TYPE' => 'application/json',
],
- content: json_encode([], JSON_THROW_ON_ERROR),
+ content: json_encode([
+ 'arrayProperty' => 'string',
+ 'intProperty' => 'string',
+ 'floatProperty' => 'string',
+ 'boolProperty' => 'string',
+ 'stringProperty' => 1
+ ], JSON_THROW_ON_ERROR),
);
$controller = new class {
@@ -595,6 +628,49 @@ public function __invoke(TypeConflictingDto $dto): JsonResponse
$this->assertValidationErrorResponse($response, ['arrayProperty', 'intProperty', 'floatProperty', 'boolProperty', 'stringProperty']);
}
+ public function testValuesAreCoercedDuringAssignmentWhenDtoIsNotStrict(): void
+ {
+ self::bootKernel();
+ $kernel = self::$kernel;
+
+ // Empty request
+ $request = Request::create(
+ uri: '/_test_mixed',
+ method: 'POST',
+ server: [
+ 'CONTENT_TYPE' => 'application/json',
+ ],
+ content: json_encode([
+ 'intProperty' => '1',
+ 'floatProperty' => '1.2',
+ 'boolProperty' => '0',
+ 'stringProperty' => 1
+ ], JSON_THROW_ON_ERROR),
+ );
+
+ $controller = new class {
+ public function __invoke(NonStrictTypeConflictingDto $dto): JsonResponse
+ {
+ return new JsonResponse(get_object_vars($dto));
+ }
+ };
+
+ $request->attributes->set('_controller', $controller);
+
+ $response = $kernel->handle($request);
+
+ self::assertSame(200, $response->getStatusCode());
+ $data = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
+ self::assertArrayHasKey('intProperty', $data);
+ self::assertSame(1, $data['intProperty']);
+ self::assertArrayHasKey('floatProperty', $data);
+ self::assertSame(1.2, $data['floatProperty']);
+ self::assertArrayHasKey('boolProperty', $data);
+ self::assertFalse($data['boolProperty']);
+ self::assertArrayHasKey('stringProperty', $data);
+ self::assertSame('1', $data['stringProperty']);
+ }
+
/**
* @param string[] $fields
*
diff --git a/test/Integration/RequestDtoValidatorIntegrationTest.php b/test/Integration/RequestDtoValidatorIntegrationTest.php
deleted file mode 100644
index 904365d..0000000
--- a/test/Integration/RequestDtoValidatorIntegrationTest.php
+++ /dev/null
@@ -1,300 +0,0 @@
-password) && isset($object->passwordConfirm) && $object->password !== $object->passwordConfirm) {
- $context->buildViolation('Passwords do not match')
- ->addViolation();
- }
- }
-}
-
-#[RequestDto]
-final class NestedParentDto
-{
- #[QueryParam('title')]
- #[Assert\NotBlank]
- public string $title = '';
-
- #[QueryParam('child')]
- #[Assert\Valid]
- public BasicTestDto $child;
-}
-
-#[RequestDto]
-#[Assert\GroupSequence(['Basic', 'Strict', 'GroupSequenceDto'])]
-final class GroupSequenceDto
-{
- #[QueryParam('name')]
- #[Assert\NotBlank(groups: ['Basic'])]
- public string $name;
-
- #[QueryParam('email')]
- #[Assert\Email(groups: ['Strict'])]
- public string $email;
-}
-
-#[RequestDto]
-final class BodyParamDto
-{
- #[BodyParam('name')]
- #[Assert\NotBlank]
- public string $name;
-
- #[BodyParam('email')]
- #[Assert\Email]
- public string $email;
-}
-
-#[RequestDto]
-final class MixedParamDto
-{
- #[QueryParam('q')]
- #[Assert\NotBlank]
- public string $query;
-
- #[BodyParam('b')]
- #[Assert\NotBlank]
- public string $body;
-}
-
-#[RequestDto]
-final class GroupsDto
-{
- #[QueryParam('name')]
- #[Assert\NotBlank(groups: ['Registration'])]
- public string $name;
-
- #[QueryParam('age')]
- #[Assert\GreaterThan(18, groups: ['Adult'])]
- public int $age;
-}
-
-final class RequestDtoValidatorIntegrationTest extends TestCase
-{
- private RequestDtoValidator $validator;
-
- protected function setUp(): void
- {
- $innerValidator = Validation::createValidatorBuilder()
- ->enableAttributeMapping()
- ->getValidator();
-
- $extractorFactory = new PropertyInfoExtractorFactory(
- new PhpDocExtractor(),
- new ReflectionExtractor(),
- );
-
- $propertyInfoExtractor = $extractorFactory->create();
-
- $reflectionHelper = new DtoReflectionHelper();
- $paramMetadataFactory = new RequestDtoParamMetadataFactory($innerValidator, $reflectionHelper, $propertyInfoExtractor);
- $metadataFactory = new RequestDtoMetadataFactory($innerValidator, $reflectionHelper, $paramMetadataFactory);
- $groupSequenceExtractor = new GroupSequenceExtractor();
- $this->validator = new RequestDtoValidator($innerValidator, $metadataFactory, $groupSequenceExtractor);
- }
-
- // @phpstan-ignore method.unused
- private function debugDumpViolations(ConstraintViolationListInterface $violations): void
- {
- foreach ($violations as $violation) {
- dump($violation->getPropertyPath().': '.$violation->getMessage());
- }
- }
-
- public function testValidateAndHydrateSuccess(): void
- {
- $dto = new BasicTestDto();
- $request = new Request(['name' => 'John Doe', 'age' => 25]);
-
- $violations = $this->validator->validateAndHydrate($dto, $request, []);
-
- $this->assertCount(0, $violations);
- $this->assertEquals('John Doe', $dto->name);
- $this->assertEquals(25, $dto->age);
- }
-
- public function testValidateAndHydratePropertyFailure(): void
- {
- $dto = new BasicTestDto();
- // age is too young, name is too short
- $request = new Request(['name' => 'Jo', 'age' => 15]);
-
- $violations = $this->validator->validateAndHydrate($dto, $request, []);
-
- $this->assertGreaterThan(0, $violations->count());
- // Properties should NOT be set because validation failed
- $this->assertFalse(isset($dto->name));
- $this->assertFalse(isset($dto->age));
- }
-
- public function testClassConstraintFailure(): void
- {
- $dto = new ClassConstraintDto();
- $request = new Request(['password' => 'foo', 'passwordConfirm' => 'bar']);
-
- $violations = $this->validator->validateAndHydrate($dto, $request, []);
-
- $this->assertCount(1, $violations);
- $this->assertEquals('Passwords do not match', $violations[0]->getMessage());
- // Properties SHOULD be set because property-level validation passed
- $this->assertEquals('foo', $dto->password);
- $this->assertEquals('bar', $dto->passwordConfirm);
- }
-
- public function testNestedDtoValidation(): void
- {
- $dto = new NestedParentDto();
- $request = new Request([
- 'title' => 'Parent Title',
- 'child' => [
- 'name' => 'Child Name',
- 'age' => 20,
- ],
- ]);
-
- $violations = $this->validator->validateAndHydrate($dto, $request, []);
- $this->assertCount(0, $violations);
- $this->assertEquals('Parent Title', $dto->title);
- $this->assertEquals('Child Name', $dto->child->name);
- $this->assertEquals(20, $dto->child->age);
- }
-
- public function testNestedDtoFailure(): void
- {
- $dto = new NestedParentDto();
- $request = new Request([
- 'title' => 'Parent Title',
- 'child' => [
- 'name' => 'Jo', // too short
- 'age' => 20,
- ],
- ]);
-
- $violations = $this->validator->validateAndHydrate($dto, $request, []);
-
- $this->assertGreaterThan(0, $violations->count());
- // child should NOT be set because its internal validation failed
- $this->assertFalse(isset($dto->child));
- }
-
- public function testGroupSequenceShortCircuit(): void
- {
- $dto = new GroupSequenceDto();
- // name is empty (fails Basic group), email is invalid (fails Strict group)
- $request = new Request(['name' => '', 'email' => 'invalid-email']);
-
- $violations = $this->validator->validateAndHydrate($dto, $request, []);
-
- // It should only report violation for 'name' because it short-circuits after the first group in sequence fails
- $this->assertCount(1, $violations);
- // Note: currently property path is empty due to how validatePropertyValue is called in RequestDtoValidator
- // $this->assertEquals('name', $violations[0]->getPropertyPath());
- }
-
- public function testBodyParamValidation(): void
- {
- $dto = new BodyParamDto();
- $request = new Request([], ['name' => 'John', 'email' => 'john@example.com']);
-
- $violations = $this->validator->validateAndHydrate($dto, $request, []);
-
- $this->assertCount(0, $violations);
- $this->assertEquals('John', $dto->name);
- $this->assertEquals('john@example.com', $dto->email);
- }
-
- public function testMixedParamsValidation(): void
- {
- $dto = new MixedParamDto();
- $request = new Request(['q' => 'search'], ['b' => 'content']);
-
- $violations = $this->validator->validateAndHydrate($dto, $request, []);
-
- $this->assertCount(0, $violations);
- $this->assertEquals('search', $dto->query);
- $this->assertEquals('content', $dto->body);
- }
-
- public function testValidationGroups(): void
- {
- $dto = new GroupsDto();
- $request = new Request(['name' => '', 'age' => 15]);
-
- // Validate only Registration group -> name should fail, age should be ignored
- $violations = $this->validator->validateAndHydrate($dto, $request, ['Registration']);
- $this->assertCount(1, $violations);
-
- // Validate only Adult group -> age should fail, name should be ignored
- $dto = new GroupsDto();
- $violations = $this->validator->validateAndHydrate($dto, $request, ['Adult']);
- $this->assertCount(1, $violations);
-
- // Validate both -> both should fail
- $dto = new GroupsDto();
- $violations = $this->validator->validateAndHydrate($dto, $request, ['Registration', 'Adult']);
- // Note: RequestDtoValidator processes groups one by one and stops if a group has violations.
- // If 'Registration' fails, it might stop there if it's considered a sequence or if that's how it's implemented.
- // Actually looking at code: it iterates groups and returns if $groupViolations > 0.
- $this->assertCount(1, $violations);
- }
-}
diff --git a/test/Unit/Attribute/AbstractParamTest.php b/test/Unit/Attribute/AbstractParamTest.php
index 0a16dcb..0c9bef9 100644
--- a/test/Unit/Attribute/AbstractParamTest.php
+++ b/test/Unit/Attribute/AbstractParamTest.php
@@ -24,6 +24,11 @@ final class TestClass
final class TestParam extends AbstractParam
{
+ public function hasValueInRequest(Request $request): bool
+ {
+ return $request->request->has($this->getName());
+ }
+
public function getValueFromRequest(Request $request): mixed
{
return $request->request->get($this->getName());
diff --git a/test/Unit/Attribute/BodyParamTest.php b/test/Unit/Attribute/BodyParamTest.php
index 934df8b..07b69d8 100644
--- a/test/Unit/Attribute/BodyParamTest.php
+++ b/test/Unit/Attribute/BodyParamTest.php
@@ -68,4 +68,47 @@ public function testReadsValueFromJsonRequest(): void
self::assertSame('John Doe', $value);
}
+
+ public function testHasValueInRequestReturnsWhetherValueIsExistsInRequest(): void
+ {
+ $request = new Request(request: ['param' => 'value']);
+
+ $param = new BodyParam('param');
+
+ $this->assertTrue($param->hasValueInRequest($request));
+ $this->assertFalse($param->hasValueInRequest(new Request()));
+ }
+
+ public function testHasValueInRequestWithNestedParam(): void
+ {
+ $parent = new BodyParam('parent');
+ $child = new BodyParam('child');
+ $request = new Request(request: [
+ 'parent' => [
+ 'child' => 'value',
+ ]
+ ]);
+
+ $child->setParent($parent);
+
+ $this->assertTrue($child->hasValueInRequest($request));
+ $this->assertFalse($child->hasValueInRequest(new Request()));
+ }
+
+ public function testHasValueInRequestWithNestedArrayParam(): void
+ {
+ $parent = new BodyParam('parent');
+ $child = new BodyParam('child');
+ $request = new Request(request: [
+ 'parent' => [
+ ['child' => 'value']
+ ]
+ ]);
+
+ $parent->setIndex(0);
+ $child->setParent($parent);
+
+ $this->assertTrue($child->hasValueInRequest($request));
+ $this->assertFalse($child->hasValueInRequest(new Request()));
+ }
}
diff --git a/test/Unit/Attribute/FileParamTest.php b/test/Unit/Attribute/FileParamTest.php
index 95a9b3c..7575de1 100644
--- a/test/Unit/Attribute/FileParamTest.php
+++ b/test/Unit/Attribute/FileParamTest.php
@@ -29,7 +29,7 @@ public function testGetValueFromRequestReturnsUploadedFileFromRequestFilesBag():
$paramName = 'test_file';
$uploadedFile = $this->createMock(UploadedFile::class);
- $request = new Request([], [], [], [], [$paramName => $uploadedFile]);
+ $request = new Request(files: [$paramName => $uploadedFile]);
$fileParam = new FileParam($paramName);
@@ -46,4 +46,17 @@ public function testGetValueFromRequestReturnsNullIfFileIsMissing(): void
$this->assertNull($fileParam->getValueFromRequest($request));
}
+
+ public function testHasValueInRequestReturnsWhetherValueIsExistsInRequest(): void
+ {
+ $paramName = 'test_file';
+ $uploadedFile = $this->createMock(UploadedFile::class);
+
+ $request = new Request(files: [$paramName => $uploadedFile]);
+
+ $fileParam = new FileParam($paramName);
+
+ $this->assertTrue($fileParam->hasValueInRequest($request));
+ $this->assertFalse($fileParam->hasValueInRequest(new Request()));
+ }
}
diff --git a/test/Unit/Attribute/HeaderParamTest.php b/test/Unit/Attribute/HeaderParamTest.php
index 948288d..ae6fe98 100644
--- a/test/Unit/Attribute/HeaderParamTest.php
+++ b/test/Unit/Attribute/HeaderParamTest.php
@@ -24,7 +24,7 @@ public function testGetValueFromRequestReturnsHeaderValueFromRequestHeadersBag()
$paramName = 'test_header';
$paramValue = 'test_value';
- $request = new Request([], [], [], [], [], ['HTTP_'.strtoupper($paramName) => $paramValue]);
+ $request = new Request(server: ['HTTP_'.strtoupper($paramName) => $paramValue]);
$headerParam = new HeaderParam($paramName);
@@ -41,4 +41,17 @@ public function testGetValueFromRequestReturnsNullIfHeaderIsMissing(): void
$this->assertNull($headerParam->getValueFromRequest($request));
}
+
+ public function testHasValueInRequestReturnsWhetherValueIsExistsInRequest(): void
+ {
+ $paramName = 'test_header';
+ $paramValue = 'test_value';
+
+ $request = new Request(server: ['HTTP_'.strtoupper($paramName) => $paramValue]);
+
+ $param = new HeaderParam($paramName);
+
+ $this->assertTrue($param->hasValueInRequest($request));
+ $this->assertFalse($param->hasValueInRequest(new Request()));
+ }
}
diff --git a/test/Unit/Attribute/QueryParamTest.php b/test/Unit/Attribute/QueryParamTest.php
index 7d5cb6e..fd6816b 100644
--- a/test/Unit/Attribute/QueryParamTest.php
+++ b/test/Unit/Attribute/QueryParamTest.php
@@ -100,4 +100,47 @@ public function testGetValueFromRequestReturnsArrayWithoutTransformationIfValueI
$queryParam = new QueryParam('ids', 'int');
$this->assertSame(['1', '2'], $queryParam->getValueFromRequest($request));
}
+
+ public function testHasValueInRequestReturnsWhetherValueIsExistsInRequest(): void
+ {
+ $request = new Request(['param' => 'value'], [], [], [], [], []);
+
+ $param = new QueryParam('param');
+
+ $this->assertTrue($param->hasValueInRequest($request));
+ $this->assertFalse($param->hasValueInRequest(new Request()));
+ }
+
+ public function testHasValueInRequestWithNestedParam(): void
+ {
+ $parent = new QueryParam('parent');
+ $child = new QueryParam('child');
+ $request = new Request([
+ 'parent' => [
+ 'child' => 'value',
+ ]
+ ]);
+
+ $child->setParent($parent);
+
+ $this->assertTrue($child->hasValueInRequest($request));
+ $this->assertFalse($child->hasValueInRequest(new Request()));
+ }
+
+ public function testHasValueInRequestWithNestedArrayParam(): void
+ {
+ $parent = new QueryParam('parent');
+ $child = new QueryParam('child');
+ $request = new Request([
+ 'parent' => [
+ ['child' => 'value']
+ ]
+ ]);
+
+ $parent->setIndex(0);
+ $child->setParent($parent);
+
+ $this->assertTrue($child->hasValueInRequest($request));
+ $this->assertFalse($child->hasValueInRequest(new Request()));
+ }
}
diff --git a/test/Unit/Attribute/RequestDtoTest.php b/test/Unit/Attribute/RequestDtoTest.php
new file mode 100644
index 0000000..6cc2f9b
--- /dev/null
+++ b/test/Unit/Attribute/RequestDtoTest.php
@@ -0,0 +1,41 @@
+strict);
+ }
+
+ public function testConstructorAcceptsStrictOption(): void
+ {
+ $instance = new RequestDto(strict: true);
+ self::assertTrue($instance->strict);
+
+ $instance = new RequestDto(strict: false);
+ self::assertFalse($instance->strict);
+ }
+}
diff --git a/test/Unit/Attribute/RouteParamTest.php b/test/Unit/Attribute/RouteParamTest.php
index 8a9638a..0740099 100644
--- a/test/Unit/Attribute/RouteParamTest.php
+++ b/test/Unit/Attribute/RouteParamTest.php
@@ -24,7 +24,7 @@ public function testGetValueFromRequestReturnsRouteParameterFromRequestAttribute
$paramName = 'test_route';
$paramValue = 'test_value';
- $request = new Request([], [], ['_route_params' => [$paramName => $paramValue]]);
+ $request = new Request(attributes: ['_route_params' => [$paramName => $paramValue]]);
$routeParam = new RouteParam($paramName);
@@ -35,7 +35,7 @@ public function testGetValueFromRequestReturnsNullIfRouteParameterIsMissing(): v
{
$paramName = 'missing_route';
- $request = new Request([], [], ['_route_params' => []]);
+ $request = new Request(attributes: ['_route_params' => []]);
$routeParam = new RouteParam($paramName);
@@ -52,4 +52,17 @@ public function testGetValueFromRequestReturnsNullIfNoRouteParametersArePresent(
$this->assertNull($routeParam->getValueFromRequest($request));
}
+
+ public function testHasValueInRequestReturnsWhetherValueIsExistsInRequest(): void
+ {
+ $paramName = 'test_route';
+ $paramValue = 'test_value';
+
+ $request = new Request(attributes: ['_route_params' => [$paramName => $paramValue]]);
+
+ $param = new RouteParam($paramName);
+
+ $this->assertTrue($param->hasValueInRequest($request));
+ $this->assertFalse($param->hasValueInRequest(new Request()));
+ }
}
diff --git a/test/Unit/EventSubscriber/RequestDtoValidationEventSubscriberTest.php b/test/Unit/EventSubscriber/RequestDtoValidationEventSubscriberTest.php
index e923444..10cc0e9 100644
--- a/test/Unit/EventSubscriber/RequestDtoValidationEventSubscriberTest.php
+++ b/test/Unit/EventSubscriber/RequestDtoValidationEventSubscriberTest.php
@@ -16,7 +16,6 @@
use Crtl\RequestDtoResolverBundle\EventSubscriber\RequestDtoValidationEventSubscriber;
use Crtl\RequestDtoResolverBundle\Exception\RequestValidationException;
use Crtl\RequestDtoResolverBundle\Utility\DtoInstanceBagInterface;
-use Crtl\RequestDtoResolverBundle\Validator\RequestDtoValidator;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
@@ -24,18 +23,20 @@
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\KernelInterface;
+use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\ConstraintViolationListInterface;
+use Symfony\Component\Validator\Validator\ValidatorInterface;
final class RequestDtoValidationEventSubscriberTest extends TestCase
{
private RequestDtoValidationEventSubscriber $subscriber;
- private RequestDtoValidator&MockObject $validatorMock;
+ private ValidatorInterface&MockObject $validatorMock;
private DtoInstanceBagInterface&MockObject $bag;
protected function setUp(): void
{
- $this->validatorMock = $this->createMock(RequestDtoValidator::class);
+ $this->validatorMock = $this->createMock(ValidatorInterface::class);
$this->bag = $this->createMock(DtoInstanceBagInterface::class);
$this->subscriber = new RequestDtoValidationEventSubscriber(
$this->validatorMock,
@@ -73,10 +74,12 @@ public function testOnKernelControllerArgumentsThrowsRequestValidationExceptionW
\stdClass::class => $testDto,
]);
+ $this->bag->method('getHydrationViolations')->willReturn(null);
+
$violations = $this->createMock(ConstraintViolationListInterface::class);
$violations->method('count')->willReturn(1);
- $this->validatorMock->method('validateAndHydrate')->with($testDto)->willReturn($violations);
+ $this->validatorMock->method('validate')->with($testDto)->willReturn($violations);
$event = $this->createTestEvent(HttpKernelInterface::MAIN_REQUEST, $request);
@@ -84,7 +87,7 @@ public function testOnKernelControllerArgumentsThrowsRequestValidationExceptionW
$this->subscriber->onKernelControllerArguments($event);
}
- public function testOnKernelControllerArgumentsDoesNothingWhenDTOIsValid(): void
+ public function testOnKernelControllerArgumentsMergesHydrationViolationsWithValidatorViolations(): void
{
$testDto = new \stdClass();
@@ -94,15 +97,49 @@ public function testOnKernelControllerArgumentsDoesNothingWhenDTOIsValid(): void
]);
$violations = $this->createMock(ConstraintViolationListInterface::class);
- $violations
+ $violations->method('count')->willReturn(1);
+
+ $this->bag->method('getHydrationViolations')
+ ->with(\stdClass::class, $request)
+ ->willReturn($violations);
+
+ $validatorViolations = $this->createMock(ConstraintViolationListInterface::class);
+ $this->validatorMock
+ ->expects(self::once())
+ ->method('validate')
+ ->with($testDto)
+ ->willReturn($validatorViolations)
+ ;
+
+ $validatorViolations->method('count')->willReturn(1);
+ $validatorViolations
->expects(self::once())
- ->method('count')
- ->willReturn(0)
+ ->method('addAll')
+ ->with($violations)
;
+ $event = $this->createTestEvent(HttpKernelInterface::MAIN_REQUEST, $request);
+
+ self::expectException(RequestValidationException::class);
+ $this->subscriber->onKernelControllerArguments($event);
+ }
+
+ public function testOnKernelControllerArgumentsDoesNothingWhenDTOIsValid(): void
+ {
+ $testDto = new \stdClass();
+
+ $request = new Request();
+ $this->bag->method('getRegisteredInstances')->with($request)->willReturn([
+ \stdClass::class => $testDto,
+ ]);
+
+ $this->bag->method('getHydrationViolations')->willReturn(null);
+
+ $violations = new ConstraintViolationList();
+
$this->validatorMock
->expects(self::once())
- ->method('validateAndHydrate')
+ ->method('validate')
->with($testDto)
->willReturn($violations)
;
diff --git a/test/Unit/Reflection/RequestDtoMetadataFactoryTest.php b/test/Unit/Reflection/RequestDtoMetadataFactoryTest.php
index 734fd3c..2e4323c 100644
--- a/test/Unit/Reflection/RequestDtoMetadataFactoryTest.php
+++ b/test/Unit/Reflection/RequestDtoMetadataFactoryTest.php
@@ -23,12 +23,9 @@
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use Symfony\Component\Validator\Mapping\ClassMetadataInterface;
-use Symfony\Component\Validator\Validator\ValidatorInterface;
final class RequestDtoMetadataFactoryTest extends TestCase
{
- private ValidatorInterface&MockObject $validator;
-
private DtoReflectionHelper&MockObject $reflectionHelper;
private RequestDtoParamMetadataFactory&MockObject $paramMetadataFactory;
@@ -37,10 +34,9 @@ final class RequestDtoMetadataFactoryTest extends TestCase
protected function setUp(): void
{
- $this->validator = $this->createMock(ValidatorInterface::class);
$this->reflectionHelper = $this->createMock(DtoReflectionHelper::class);
$this->paramMetadataFactory = $this->createMock(RequestDtoParamMetadataFactory::class);
- $this->factory = new RequestDtoMetadataFactory($this->validator, $this->reflectionHelper, $this->paramMetadataFactory);
+ $this->factory = new RequestDtoMetadataFactory($this->reflectionHelper, $this->paramMetadataFactory);
}
public function testGetMetadataForReturnsRequestDtoMetadataForGivenClassName(): void
@@ -48,11 +44,6 @@ public function testGetMetadataForReturnsRequestDtoMetadataForGivenClassName():
$className = DummyDto::class;
$validatorMetadata = $this->createMock(ClassMetadataInterface::class);
- $this->validator->expects($this->once())
- ->method('getMetadataFor')
- ->with($className)
- ->willReturn($validatorMetadata);
-
$prop1 = new \ReflectionProperty($className, 'prop1');
$prop2 = new \ReflectionProperty($className, 'prop2');
@@ -63,16 +54,13 @@ public function testGetMetadataForReturnsRequestDtoMetadataForGivenClassName():
$this->paramMetadataFactory->expects($this->exactly(2))
->method('getMetadataFor')
->willReturnCallback(function (\ReflectionProperty $prop) {
- return new RequestDtoParamMetadata($prop->getDeclaringClass()->getName(), $prop->getName(), 'mixed', 'prop1' === $prop->getName());
+ return new RequestDtoParamMetadata($prop->getDeclaringClass()->getName(), $prop->getName(), 'mixed');
});
$metadata = $this->factory->getMetadataFor($className);
$this->assertEquals($className, $metadata->getReflectionClass()->getName());
$this->assertEquals(['prop1', 'prop2'], array_keys(iterator_to_array($metadata->getPropertyMetadataGenerator())));
- $this->assertTrue($metadata->isConstrainedProperty('prop1'));
- $this->assertFalse($metadata->isConstrainedProperty('prop2'));
- $this->assertSame($validatorMetadata, $metadata->getValidatorMetadata());
}
public function testGetMetadataForReturnsCachedMetadataOnCacheHit(): void
@@ -81,7 +69,7 @@ public function testGetMetadataForReturnsCachedMetadataOnCacheHit(): void
$cacheItem = $this->createMock(CacheItemInterface::class);
$cachedMetadata = $this->createMock(RequestDtoMetadata::class);
- $factory = new RequestDtoMetadataFactory($this->validator, $this->reflectionHelper, $this->paramMetadataFactory, $cache);
+ $factory = new RequestDtoMetadataFactory($this->reflectionHelper, $this->paramMetadataFactory, $cache);
$className = DummyDto::class;
$cacheKey = $factory->getCacheKey($className); // str_replace('\\', '_', $className).'_'.str_replace('\\', '_', RequestDtoMetadata::class);
@@ -99,8 +87,6 @@ public function testGetMetadataForReturnsCachedMetadataOnCacheHit(): void
->method('get')
->willReturn($cachedMetadata);
- $this->validator->expects($this->never())->method('getMetadataFor');
-
$metadata = $factory->getMetadataFor($className);
$this->assertSame($cachedMetadata, $metadata);
@@ -111,7 +97,7 @@ public function testGetMetadataForCreatesAndCachesMetadataOnCacheMiss(): void
$cache = $this->createMock(CacheItemPoolInterface::class);
$cacheItem = $this->createMock(CacheItemInterface::class);
- $factory = new RequestDtoMetadataFactory($this->validator, $this->reflectionHelper, $this->paramMetadataFactory, $cache);
+ $factory = new RequestDtoMetadataFactory($this->reflectionHelper, $this->paramMetadataFactory, $cache);
$className = DummyDto::class;
$cacheKey = $factory->getCacheKey($className); // str_replace('\\', '_', $className).'_'.str_replace('\\', '_', RequestDtoMetadata::class);
@@ -125,13 +111,6 @@ public function testGetMetadataForCreatesAndCachesMetadataOnCacheMiss(): void
->method('isHit')
->willReturn(false);
- $validatorMetadata = $this->createMock(ClassMetadataInterface::class);
-
- $this->validator->expects($this->once())
- ->method('getMetadataFor')
- ->with($className)
- ->willReturn($validatorMetadata);
-
$this->reflectionHelper->expects($this->once())
->method('getAttributedProperties')
->willReturn([]);
@@ -148,9 +127,6 @@ public function testGetMetadataForCreatesAndCachesMetadataOnCacheMiss(): void
public function testGetMetadataForThrowsRuntimeExceptionWhenUnsupportedTypeIsEncountered(): void
{
$className = DummyDto::class;
- $validatorMetadata = $this->createMock(ClassMetadataInterface::class);
-
- $this->validator->method('getMetadataFor')->willReturn($validatorMetadata);
$prop1 = new \ReflectionProperty($className, 'prop1');
@@ -170,11 +146,24 @@ public function testGetMetadataForThrowsRuntimeExceptionWhenUnsupportedTypeIsEnc
$this->factory->getMetadataFor($className);
}
+
+ public function testGetMetadataForThrowsLogicExceptionWhenClassIsNotInstantiable(): void
+ {
+ $this->expectException(\LogicException::class);
+ $this->factory->getMetadataFor(PrivateConstructor::class);
+ }
}
-class DummyDto
+final class DummyDto
{
public string $prop1;
public string $prop2;
}
+
+final class PrivateConstructor
+{
+ private function __construct()
+ {
+ }
+}
diff --git a/test/Unit/Reflection/RequestDtoMetadataTest.php b/test/Unit/Reflection/RequestDtoMetadataTest.php
index a6fe1c1..1ceaf94 100644
--- a/test/Unit/Reflection/RequestDtoMetadataTest.php
+++ b/test/Unit/Reflection/RequestDtoMetadataTest.php
@@ -18,96 +18,27 @@
use Crtl\RequestDtoResolverBundle\Reflection\RequestDtoMetadata;
use Crtl\RequestDtoResolverBundle\Reflection\RequestDtoParamMetadata;
use PHPUnit\Framework\TestCase;
-use Symfony\Component\Validator\Constraints\GroupSequence;
-use Symfony\Component\Validator\Mapping\ClassMetadata;
-use Symfony\Component\Validator\Mapping\ClassMetadataInterface;
final class RequestDtoMetadataTest extends TestCase
{
public function testMetadataAccessorsReturnCorrectValues(): void
{
- $validatorMetadata = $this->createMock(ClassMetadataInterface::class);
-
$metadata = new RequestDtoMetadata(
DummyRequestDto::class,
[
- new RequestDtoParamMetadata(DummyRequestDto::class, 'prop1', 'string', false),
- new RequestDtoParamMetadata(DummyRequestDto::class, 'prop2', 'string', false),
+ new RequestDtoParamMetadata(DummyRequestDto::class, 'prop1', 'string'),
+ new RequestDtoParamMetadata(DummyRequestDto::class, 'prop2', 'string'),
],
- $validatorMetadata,
);
$this->assertCount(2, iterator_to_array($metadata->getPropertyMetadataGenerator()));
- $this->assertSame($validatorMetadata, $metadata->getValidatorMetadata());
+ $this->assertSame(DummyRequestDto::class, $metadata->getClassName());
$this->assertEquals(DummyRequestDto::class, $metadata->getReflectionClass()->getName());
}
- public function testIsConstrainedPropertyReturnsTrueIfPropertyHasConstraints(): void
- {
- $metadata = new RequestDtoMetadata(
- DummyRequestDto::class,
- [
- new RequestDtoParamMetadata(DummyRequestDto::class, 'prop1', 'mixed', true),
- new RequestDtoParamMetadata(DummyRequestDto::class, 'prop2', 'mixed', false),
- ],
- $this->createMock(ClassMetadataInterface::class),
- );
-
- $this->assertTrue($metadata->isConstrainedProperty('prop1'));
- $this->assertTrue($metadata->isConstrainedProperty($metadata->getPropertyMetadata('prop1')->getReflectionProperty()));
-
- $this->assertFalse($metadata->isConstrainedProperty('prop2'));
- $this->assertFalse($metadata->isConstrainedProperty($metadata->getPropertyMetadata('prop2')->getReflectionProperty()));
-
- $this->assertFalse($metadata->isConstrainedProperty('nonExistent'));
- }
-
- public function testGetGroupSequenceReturnsArrayIfItIsSetAsArrayInValidatorMetadata(): void
- {
- $validatorMetadata = $this->createMock(ClassMetadataInterface::class);
- $validatorMetadata->method('getGroupSequence')->willReturn(null);
-
- $metadata = new RequestDtoMetadata(
- DummyRequestDto::class,
- [],
- $validatorMetadata,
- );
-
- $this->assertNull($metadata->getGroupSequence());
- }
-
- public function testGetGroupSequenceReturnsArrayFromGroupSequenceObjectInValidatorMetadata(): void
- {
- $groupSequence = new GroupSequence(['Group1', 'Group2']);
- $validatorMetadata = $this->createMock(ClassMetadataInterface::class);
- $validatorMetadata->method('getGroupSequence')->willReturn($groupSequence);
-
- $metadata = new RequestDtoMetadata(
- DummyRequestDto::class,
- [],
- $validatorMetadata,
- );
-
- $this->assertEquals(['Group1', 'Group2'], $metadata->getGroupSequence());
- }
-
- public function testGetGroupSequenceReturnsNullIfNoSequenceIsDefinedInValidatorMetadata(): void
- {
- $validatorMetadata = $this->createMock(ClassMetadataInterface::class);
- $validatorMetadata->method('getGroupSequence')->willReturn(null);
-
- $metadata = new RequestDtoMetadata(
- DummyRequestDto::class,
- [],
- $validatorMetadata,
- );
-
- $this->assertNull($metadata->getGroupSequence());
- }
-
public function testNewInstancePassesArgumentsToDTOConstructorCorrectly(): void
{
- $metadata = new RequestDtoMetadata(RequestDtoWithConstructor::class, [], $this->createMock(ClassMetadataInterface::class));
+ $metadata = new RequestDtoMetadata(RequestDtoWithConstructor::class, []);
$instance = $metadata->newInstance('test-param', 1, 2);
$this->assertInstanceOf(RequestDtoWithConstructor::class, $instance);
@@ -116,14 +47,12 @@ public function testNewInstancePassesArgumentsToDTOConstructorCorrectly(): void
public function testMetadataCanBeSerializedAndUnserializedPreservingAllProperties(): void
{
- $validatorMetadata = new ClassMetadata(DummyRequestDto::class);
$metadata = new RequestDtoMetadata(
DummyRequestDto::class,
[
- new RequestDtoParamMetadata(DummyRequestDto::class, 'prop1', 'mixed', true),
- new RequestDtoParamMetadata(DummyRequestDto::class, 'prop2', 'mixed', false),
+ new RequestDtoParamMetadata(DummyRequestDto::class, 'prop1', 'mixed'),
+ new RequestDtoParamMetadata(DummyRequestDto::class, 'prop2', 'mixed'),
],
- $validatorMetadata,
);
// Access properties to populate internal state if any
@@ -135,53 +64,6 @@ public function testMetadataCanBeSerializedAndUnserializedPreservingAllPropertie
$this->assertInstanceOf(RequestDtoMetadata::class, $unserialized);
$this->assertEquals($metadata->getReflectionClass()->getName(), $unserialized->getReflectionClass()->getName());
$this->assertCount(2, iterator_to_array($unserialized->getPropertyMetadataGenerator()));
- $this->assertTrue($unserialized->isConstrainedProperty('prop1'));
- $this->assertEquals($validatorMetadata->getClassName(), $unserialized->getValidatorMetadata()->getClassName());
- }
-
- public function testGetAbstractParamAttributeFromPropertyReturnsNullIfNoAttributeIsFoundOnProperty(): void
- {
- $metadata = new RequestDtoMetadata(
- DummyRequestDto::class,
- [
- new RequestDtoParamMetadata(DummyRequestDto::class, 'prop1', 'mixed', true),
- new RequestDtoParamMetadata(DummyRequestDto::class, 'prop2', 'mixed', false),
- ],
- $this->createMock(ClassMetadataInterface::class),
- );
-
- $property = $metadata->getPropertyMetadata('prop1')->getReflectionProperty();
-
- $this->expectException(\LogicException::class);
- $this->expectExceptionMessage('Property Crtl\RequestDtoResolverBundle\Test\Unit\Reflection\DummyRequestDto::$prop1 is missing an AbstractParam attribute.');
-
- $metadata->getAbstractParamAttributeFromProperty($property);
- }
-
- public function testGetAbstractParamAttributeFromPropertyTriggersWarningWhenMultipleAttributesAreFoundOnProperty(): void
- {
- $metadata = new RequestDtoMetadata(
- DtoWithMultipleAttributes::class,
- [
- new RequestDtoParamMetadata(DtoWithMultipleAttributes::class, 'prop', 'string', false),
- ],
- $this->createMock(ClassMetadataInterface::class),
- );
-
- $property = $metadata->getPropertyMetadata('prop')->getReflectionProperty();
-
- set_error_handler(function ($errno, $errstr) {
- $this->assertEquals(E_USER_WARNING, $errno);
- $this->assertStringContainsString('Property Crtl\RequestDtoResolverBundle\Test\Unit\Reflection\DtoWithMultipleAttributes::$prop has more than one AbstractParam attribute', $errstr);
-
- return true;
- }, E_USER_WARNING);
-
- $attribute = $metadata->getAbstractParamAttributeFromProperty($property);
-
- restore_error_handler();
-
- $this->assertInstanceOf(QueryParam::class, $attribute);
}
}
diff --git a/test/Unit/Reflection/RequestDtoParamMetadataFactoryTest.php b/test/Unit/Reflection/RequestDtoParamMetadataFactoryTest.php
new file mode 100644
index 0000000..5671dbf
--- /dev/null
+++ b/test/Unit/Reflection/RequestDtoParamMetadataFactoryTest.php
@@ -0,0 +1,141 @@
+reflectionHelper = $this->createMock(DtoReflectionHelper::class);
+ $this->propertyInfoExtractor = $this->createMock(TestExtractorInterface::class);
+ $this->factory = new RequestDtoParamMetadataFactory(
+ $this->reflectionHelper,
+ $this->propertyInfoExtractor,
+ );
+ }
+
+ public function testGetMetadataForBuildsMetadataForConstrainedProperty(): void
+ {
+ $className = ParamMetadataFactoryDummyDto::class;
+ $property = new \ReflectionProperty($className, 'constrainedProp');
+
+ $this->propertyInfoExtractor->expects($this->once())
+ ->method('getType')
+ ->with($className, 'constrainedProp')
+ ->willReturn(Type::string());
+
+ $this->reflectionHelper->expects($this->never())
+ ->method('isRequestDto');
+
+ $metadata = $this->factory->getMetadataFor($property);
+
+ $this->assertSame('string', $metadata->getBuiltinType());
+ $this->assertFalse($metadata->isNestedDtoArray());
+ $this->assertNull($metadata->getNestedDtoClassName());
+ $this->assertFalse($metadata->isNullable());
+ }
+
+ public function testGetMetadataForDetectsNestedDtoInArrayType(): void
+ {
+ $className = ParamMetadataFactoryDummyDto::class;
+ $property = new \ReflectionProperty($className, 'nestedArray');
+
+ $this->propertyInfoExtractor->expects($this->once())
+ ->method('getType')
+ ->with($className, 'nestedArray')
+ ->willReturn(Type::array(Type::object(ParamMetadataFactoryNestedDto::class)));
+
+ $this->reflectionHelper->expects($this->once())
+ ->method('isRequestDto')
+ ->with(ParamMetadataFactoryNestedDto::class)
+ ->willReturn(true);
+
+ $metadata = $this->factory->getMetadataFor($property);
+
+ $this->assertSame('array', $metadata->getBuiltinType());
+ $this->assertTrue($metadata->isNestedDtoArray());
+ $this->assertSame(ParamMetadataFactoryNestedDto::class, $metadata->getNestedDtoClassName());
+ $this->assertFalse($metadata->isNullable());
+ }
+
+ public function testGetMetadataForTriggersWarningOnMixedUnionFallback(): void
+ {
+ $className = ParamMetadataFactoryDummyDto::class;
+ $property = new \ReflectionProperty($className, 'mixedUnion');
+
+ $this->propertyInfoExtractor->expects($this->once())
+ ->method('getType')
+ ->with($className, 'mixedUnion')
+ ->willThrowException(new \InvalidArgumentException('Cannot create union with "mixed" standalone type.'));
+
+ $warningTriggered = false;
+ set_error_handler(function ($errno, $errstr) use (&$warningTriggered) {
+ if (E_USER_WARNING === $errno && str_contains($errstr, 'Unable to guess type for mixed union type')) {
+ $warningTriggered = true;
+
+ return true;
+ }
+
+ return false;
+ });
+
+ $metadata = $this->factory->getMetadataFor($property);
+ restore_error_handler();
+
+ $this->assertTrue($warningTriggered);
+ $this->assertSame('mixed', $metadata->getBuiltinType());
+ $this->assertFalse($metadata->isNestedDtoArray());
+ $this->assertNull($metadata->getNestedDtoClassName());
+ $this->assertTrue($metadata->isNullable());
+ }
+}
+
+final class ParamMetadataFactoryDummyDto
+{
+ public string $constrainedProp;
+
+ /**
+ * @var mixed[]
+ */
+ public array $nestedArray;
+
+ // @phpstan-ignore missingType.property
+ public $mixedUnion;
+}
+
+final class ParamMetadataFactoryNestedDto
+{
+}
diff --git a/test/Unit/Reflection/RequestDtoParamMetadataTest.php b/test/Unit/Reflection/RequestDtoParamMetadataTest.php
index dec16e3..c3e2c60 100644
--- a/test/Unit/Reflection/RequestDtoParamMetadataTest.php
+++ b/test/Unit/Reflection/RequestDtoParamMetadataTest.php
@@ -23,44 +23,35 @@ final class RequestDtoParamMetadataTest extends TestCase
{
public function testGetClassNameReturnsCorrectClassName(): void
{
- $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed', false);
+ $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed');
$this->assertEquals(ParamMetadataDummyDto::class, $metadata->getClassName());
}
public function testGetPropertyNameReturnsCorrectPropertyName(): void
{
- $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed', false);
+ $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed');
$this->assertEquals('prop', $metadata->getPropertyName());
}
- public function testIsConstrainedReturnsCorrectValue(): void
- {
- $metadataTrue = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'string', true);
- $this->assertTrue($metadataTrue->isConstrained());
-
- $metadataFalse = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'string', false);
- $this->assertFalse($metadataFalse->isConstrained());
- }
-
public function testGetNestedDtoClassNameReturnsCorrectClassName(): void
{
- $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed', true, ParamMetadataNestedDto::class);
+ $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed', ParamMetadataNestedDto::class);
$this->assertEquals(ParamMetadataNestedDto::class, $metadata->getNestedDtoClassName());
- $metadataNull = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed', true, null);
+ $metadataNull = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed', null);
$this->assertNull($metadataNull->getNestedDtoClassName());
}
public function testGetReflectionClassReturnsCorrectReflectionClass(): void
{
- $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed', false);
+ $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed');
$reflection = $metadata->getReflectionClass();
$this->assertEquals(ParamMetadataDummyDto::class, $reflection->getName());
}
public function testGetReflectionPropertyReturnsCorrectReflectionProperty(): void
{
- $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed', true);
+ $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed');
$reflection = $metadata->getReflectionProperty();
$this->assertEquals('prop', $reflection->getName());
$this->assertEquals(ParamMetadataDummyDto::class, $reflection->getDeclaringClass()->getName());
@@ -68,7 +59,7 @@ public function testGetReflectionPropertyReturnsCorrectReflectionProperty(): voi
public function testGetAttributeReturnsAbstractParamAttribute(): void
{
- $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed', true);
+ $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed');
$attribute = $metadata->getAttribute();
$this->assertInstanceOf(QueryParam::class, $attribute);
$this->assertEquals('prop', $attribute->getName());
@@ -76,7 +67,7 @@ public function testGetAttributeReturnsAbstractParamAttribute(): void
public function testGetAttributeSetsParentAttributeWhenProvided(): void
{
- $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed', true);
+ $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed');
$parent = new BodyParam();
$attribute = $metadata->getAttribute($parent);
@@ -87,7 +78,7 @@ public function testGetAttributeSetsParentAttributeWhenProvided(): void
public function testGetAttributeThrowsLogicExceptionWhenAttributeIsMissing(): void
{
- $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'noAttributeProp', 'mixed', true);
+ $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'noAttributeProp', 'mixed');
$this->expectException(\LogicException::class);
$this->expectExceptionMessage('Property Crtl\RequestDtoResolverBundle\Test\Unit\Reflection\ParamMetadataDummyDto::$noAttributeProp is missing an AbstractParam attribute.');
$metadata->getAttribute();
@@ -95,7 +86,7 @@ public function testGetAttributeThrowsLogicExceptionWhenAttributeIsMissing(): vo
public function testGetAttributeTriggersWarningWhenMultipleAttributesArePresent(): void
{
- $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'multipleAttributesProp', 'mixed', true);
+ $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'multipleAttributesProp', 'mixed');
$warningTriggered = false;
set_error_handler(function ($errno, $errstr) use (&$warningTriggered) {
@@ -117,7 +108,7 @@ public function testGetAttributeTriggersWarningWhenMultipleAttributesArePresent(
public function testSetValueSetsPropertyValueOnDto(): void
{
- $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed', true);
+ $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed');
$dto = new ParamMetadataDummyDto();
$metadata->setValue($dto, 'new value');
$this->assertEquals('new value', $dto->prop);
@@ -125,10 +116,10 @@ public function testSetValueSetsPropertyValueOnDto(): void
public function testIsNullable(): void
{
- $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed', true);
+ $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed');
self::assertFalse($metadata->isNullable());
- $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed', true, isNullable: true);
+ $metadata = new RequestDtoParamMetadata(ParamMetadataDummyDto::class, 'prop', 'mixed', isNullable: true);
self::assertTrue($metadata->isNullable());
}
@@ -138,7 +129,6 @@ public function testSerializeAndUnserializeWorksCorrectly(): void
ParamMetadataDummyDto::class,
'prop',
'string',
- true,
ParamMetadataNestedDto::class,
true,
true,
@@ -151,14 +141,61 @@ public function testSerializeAndUnserializeWorksCorrectly(): void
$this->assertEquals($metadata->getClassName(), $unserialized->getClassName());
$this->assertEquals($metadata->getPropertyName(), $unserialized->getPropertyName());
$this->assertEquals($metadata->getBuiltinType(), $unserialized->getBuiltinType());
- $this->assertEquals($metadata->isConstrained(), $unserialized->isConstrained());
$this->assertEquals($metadata->getNestedDtoClassName(), $unserialized->getNestedDtoClassName());
$this->assertEquals($metadata->isNestedDtoArray(), $unserialized->isNestedDtoArray());
$this->assertEquals($metadata->isNullable(), $unserialized->isNullable());
}
+
+ public function testHasDefaultValueReturnsTrueWhenPropertyHasDefaultValue(): void
+ {
+ $metadata = new RequestDtoParamMetadata(
+ ParamMetadataDummyDto::class,
+ 'withDefaultValue',
+ 'string',
+ null,
+ false,
+ false,
+ );
+
+ self::assertTrue($metadata->hasDefaultValue());
+
+ $metadata = new RequestDtoParamMetadata(
+ ParamMetadataDummyDto::class,
+ 'prop',
+ 'string',
+ null,
+ false,
+ false,
+ );
+ self::assertFalse($metadata->hasDefaultValue());
+ }
+
+ public function testGetDefaultValueReturnsDefaultValue(): void
+ {
+ $metadata = new RequestDtoParamMetadata(
+ ParamMetadataDummyDto::class,
+ 'withDefaultValue',
+ 'string',
+ null,
+ false,
+ false,
+ );
+
+ self::assertSame('string', $metadata->getDefaultValue());
+
+ $metadata = new RequestDtoParamMetadata(
+ ParamMetadataDummyDto::class,
+ 'prop',
+ 'string',
+ null,
+ false,
+ false,
+ );
+ self::assertNull($metadata->getDefaultValue());
+ }
}
-class ParamMetadataDummyDto
+final class ParamMetadataDummyDto
{
#[QueryParam]
public string $prop;
@@ -168,8 +205,10 @@ class ParamMetadataDummyDto
#[QueryParam]
#[BodyParam]
public string $multipleAttributesProp;
+
+ public string $withDefaultValue = 'string';
}
-class ParamMetadataNestedDto
+final class ParamMetadataNestedDto
{
}
diff --git a/test/Unit/RequestDtoResolverTest.php b/test/Unit/RequestDtoResolverTest.php
index 775bbe8..b149b95 100644
--- a/test/Unit/RequestDtoResolverTest.php
+++ b/test/Unit/RequestDtoResolverTest.php
@@ -12,28 +12,13 @@
declare(strict_types=1);
/** @noinspection PhpClassCantBeUsedAsAttributeInspection */
-/** @noinspection PhpClassCantBeUsedAsAttributeInspection */
-/** @noinspection PhpClassCantBeUsedAsAttributeInspection */
-/** @noinspection PhpUnhandledExceptionInspection */
-/** @noinspection PhpUnhandledExceptionInspection */
-/** @noinspection PhpUnhandledExceptionInspection */
-/** @noinspection PhpUnhandledExceptionInspection */
-/** @noinspection PhpUnhandledExceptionInspection */
-/** @noinspection PhpUnhandledExceptionInspection */
-/** @noinspection PhpUnhandledExceptionInspection */
-/** @noinspection PhpUnhandledExceptionInspection */
-/** @noinspection PhpUnhandledExceptionInspection */
-/** @noinspection PhpUnhandledExceptionInspection */
-/** @noinspection PhpUnhandledExceptionInspection */
-/** @noinspection PhpUnhandledExceptionInspection */
-
/** @noinspection PhpUnhandledExceptionInspection */
namespace Crtl\RequestDtoResolverBundle\Test\Unit;
use Crtl\RequestDtoResolverBundle\Attribute;
-use Crtl\RequestDtoResolverBundle\Reflection\RequestDtoMetadata;
-use Crtl\RequestDtoResolverBundle\Reflection\RequestDtoMetadataFactory;
+use Crtl\RequestDtoResolverBundle\Factory\Exception\RequestDtoHydrationException;
+use Crtl\RequestDtoResolverBundle\Factory\RequestDtoFactory;
use Crtl\RequestDtoResolverBundle\RequestDtoResolver;
use Crtl\RequestDtoResolverBundle\Utility\DtoInstanceBagInterface;
use Crtl\RequestDtoResolverBundle\Utility\DtoReflectionHelper;
@@ -42,6 +27,7 @@
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
+use Symfony\Component\Validator\ConstraintViolationList;
#[Attribute\RequestDto]
final class UnitRequestDTO
@@ -55,7 +41,7 @@ final class RequestDtoResolverTest extends TestCase
private DtoInstanceBagInterface&MockObject $bag;
private DtoReflectionHelper&MockObject $reflectionHelper;
- private RequestDtoMetadataFactory&MockObject $metadataFactory;
+ private RequestDtoFactory&MockObject $factory;
/**
* @throws Exception
@@ -64,8 +50,8 @@ protected function setUp(): void
{
$this->bag = $this->createMock(DtoInstanceBagInterface::class);
$this->reflectionHelper = $this->createMock(DtoReflectionHelper::class);
- $this->metadataFactory = $this->createMock(RequestDtoMetadataFactory::class);
- $this->resolver = new RequestDtoResolver($this->bag, $this->metadataFactory, $this->reflectionHelper);
+ $this->factory = $this->createMock(RequestDtoFactory::class);
+ $this->resolver = new RequestDtoResolver($this->bag, $this->reflectionHelper, $this->factory);
}
public function testResolveReturnsEmptyArrayIfArgumentTypeIsNotRequestDto(): void
@@ -84,28 +70,34 @@ public function testResolveReturnsEmptyArrayIfArgumentTypeIsNotRequestDto(): voi
$this->assertEmpty($result);
}
- public function testResolveThrowsRuntimeExceptionIfDTOInstantiationFails(): void
+ public function testResolveReturnsArrayWithDTOAndRegistersItInDtoInstanceBag(): void
{
- $metadataMock = $this->createMock(RequestDtoMetadata::class);
- $metadataMock->expects(self::once())
- ->method('newInstance')->willReturn(null);
-
- $this->metadataFactory->expects(self::once())
- ->method('getMetadataFor')->willReturn($metadataMock);
+ $request = new Request();
+ $argument = new ArgumentMetadata('test', UnitRequestDTO::class, false, false, null);
$this->reflectionHelper->expects($this->once())
->method('isRequestDto')
->with(UnitRequestDTO::class)
->willReturn(true);
- $argument = new ArgumentMetadata('test', UnitRequestDTO::class, false, false, null);
+ $dto = new UnitRequestDTO();
+ $this->factory->expects($this->once())
+ ->method('fromRequest')
+ ->with(UnitRequestDTO::class, $request)
+ ->willReturn($dto);
- self::expectException(\RuntimeException::class);
- self::expectExceptionMessage('Failed to instantiate request dto '.UnitRequestDTO::class);
- $this->resolver->resolve(new Request(), $argument);
+ $this->bag->expects($this->once())
+ ->method('registerInstance')
+ ->with($this->isInstanceOf(UnitRequestDTO::class), $request);
+
+ $result = $this->resolver->resolve($request, $argument);
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertInstanceOf(UnitRequestDTO::class, $result[0]);
}
- public function testResolveReturnsArrayWithDTOAndRegistersItInDtoInstanceBag(): void
+ public function testResolveStoresHydrationViolationsWhenFactoryThrows(): void
{
$request = new Request();
$argument = new ArgumentMetadata('test', UnitRequestDTO::class, false, false, null);
@@ -115,25 +107,25 @@ public function testResolveReturnsArrayWithDTOAndRegistersItInDtoInstanceBag():
->with(UnitRequestDTO::class)
->willReturn(true);
- $metadata = $this->createMock(RequestDtoMetadata::class);
- $metadata->expects($this->once())
- ->method('newInstance')
- ->with($request)
- ->willReturn(new UnitRequestDTO());
+ $dto = new UnitRequestDTO();
+ $violations = new ConstraintViolationList();
- $this->metadataFactory->expects($this->once())
- ->method('getMetadataFor')
- ->with(UnitRequestDTO::class)
- ->willReturn($metadata);
+ $this->factory->expects($this->once())
+ ->method('fromRequest')
+ ->willThrowException(new RequestDtoHydrationException($dto, $violations));
+
+ $this->bag->expects($this->once())
+ ->method('registerHydrationViolations')
+ ->with(UnitRequestDTO::class, $violations, $request);
$this->bag->expects($this->once())
->method('registerInstance')
- ->with($this->isInstanceOf(UnitRequestDTO::class), $request);
+ ->with($dto, $request);
$result = $this->resolver->resolve($request, $argument);
$this->assertIsArray($result);
$this->assertCount(1, $result);
- $this->assertInstanceOf(UnitRequestDTO::class, $result[0]);
+ $this->assertSame($dto, $result[0]);
}
}
diff --git a/test/Unit/Trait/RequestDtoTraitTest.php b/test/Unit/Trait/RequestDtoTraitTest.php
deleted file mode 100644
index d43482d..0000000
--- a/test/Unit/Trait/RequestDtoTraitTest.php
+++ /dev/null
@@ -1,128 +0,0 @@
-request = $request;
- }
-}
-final class RequestDtoTraitTest extends TestCase
-{
- public function testGetValueReturnsRequestBodyValueWhenNoAttributeIsPresent(): void
- {
- $request = new Request(request: ['noAttr' => 'body-value']);
- $dto = new RequestDtoTraitTestDto($request);
-
- $this->assertEquals('body-value', $dto->getValue('noAttr'));
- }
-
- public function testGetValueReturnsQueryParamValue(): void
- {
- $request = new Request(query: ['queryAttr' => 'query-value']);
- $dto = new RequestDtoTraitTestDto($request);
-
- $this->assertEquals('query-value', $dto->getValue('queryAttr'));
- }
-
- public function testGetValueReturnsBodyParamValue(): void
- {
- $request = new Request(request: ['bodyAttr' => 'body-value']);
- $dto = new RequestDtoTraitTestDto($request);
-
- $this->assertEquals('body-value', $dto->getValue('bodyAttr'));
- }
-
- public function testGetValueReturnsHeaderParamValue(): void
- {
- $request = new Request();
- $request->headers->set('X-Test-Header', 'header-value');
- $dto = new RequestDtoTraitTestDto($request);
-
- $this->assertEquals('header-value', $dto->getValue('headerAttr'));
- }
-
- public function testGetValueReturnsRouteParamValue(): void
- {
- $request = new Request(attributes: ['_route_params' => ['id' => 'route-value']]);
- $dto = new RequestDtoTraitTestDto($request);
-
- $this->assertEquals('route-value', $dto->getValue('routeAttr'));
- }
-
- public function testGetValueReturnsFileParamValue(): void
- {
- $file = $this->createMock(UploadedFile::class);
- $request = new Request(files: ['fileAttr' => $file]);
- $dto = new RequestDtoTraitTestDto($request);
-
- $this->assertSame($file, $dto->getValue('fileAttr'));
- }
-
- public function testGetValueThrowsLogicExceptionWhenRequestIsNull(): void
- {
- $dto = new RequestDtoTraitTestDto(null);
-
- $this->expectException(\LogicException::class);
- $this->expectExceptionMessage('Request must be set before calling getValue().');
-
- $dto->getValue('noAttr');
- }
-
- public function testGetValueThrowsReflectionExceptionWhenPropertyDoesNotExist(): void
- {
- $request = new Request();
- $dto = new RequestDtoTraitTestDto($request);
-
- $this->expectException(\ReflectionException::class);
-
- $dto->getValue('nonExistentProperty');
- }
-
- public function testGetValueReturnsPropertyValueIfInitialized(): void
- {
- $request = $this->createMock(Request::class);
- $dto = new RequestDtoTraitTestDto($request);
- $dto->noAttr = 'test';
-
- $this->assertEquals('test', $dto->getValue('noAttr'));
- }
-}
diff --git a/test/Unit/Utility/DtoInstanceBagTest.php b/test/Unit/Utility/DtoInstanceBagTest.php
index 744145b..f4a0673 100644
--- a/test/Unit/Utility/DtoInstanceBagTest.php
+++ b/test/Unit/Utility/DtoInstanceBagTest.php
@@ -16,6 +16,7 @@
use Crtl\RequestDtoResolverBundle\Utility\DtoInstanceBag;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\Validator\ConstraintViolationList;
final class DtoInstanceBagTest extends TestCase
{
@@ -79,4 +80,24 @@ public function testGetRegisteredInstanceReturnsInstanceOrNullIfNotFound(): void
$this->assertSame($instance, $this->bag->getRegisteredInstance(get_class($instance), $this->request));
}
+
+ public function testRegisterAndGetHydrationViolations(): void
+ {
+ $violations = new ConstraintViolationList();
+
+ $this->assertNull($this->bag->getHydrationViolations(\stdClass::class, $this->request));
+
+ $this->bag->registerHydrationViolations(\stdClass::class, $violations, $this->request);
+
+ $this->assertSame($violations, $this->bag->getHydrationViolations(\stdClass::class, $this->request));
+ }
+
+ public function testGetHydrationViolationsReturnsNullForUnregisteredClass(): void
+ {
+ $violations = new ConstraintViolationList();
+ $this->bag->registerHydrationViolations(\stdClass::class, $violations, $this->request);
+
+ // @phpstan-ignore argument.type
+ $this->assertNull($this->bag->getHydrationViolations('NonExistentClass', $this->request));
+ }
}
diff --git a/test/Unit/Utility/DtoReflectionHelperTest.php b/test/Unit/Utility/DtoReflectionHelperTest.php
index 8683b92..72718c7 100644
--- a/test/Unit/Utility/DtoReflectionHelperTest.php
+++ b/test/Unit/Utility/DtoReflectionHelperTest.php
@@ -19,8 +19,8 @@
use Crtl\RequestDtoResolverBundle\Attribute\QueryParam;
use Crtl\RequestDtoResolverBundle\Attribute\RequestDto;
use Crtl\RequestDtoResolverBundle\Attribute\RouteParam;
-use Crtl\RequestDtoResolverBundle\Test\Fixtures\AllParamTypesDTO;
-use Crtl\RequestDtoResolverBundle\Test\Fixtures\NestedChildDTO;
+use Crtl\RequestDtoResolverBundle\Test\Fixtures\Dto\AllParamTypesDTO;
+use Crtl\RequestDtoResolverBundle\Test\Fixtures\Dto\Nested\NestedChildStrictDto;
use Crtl\RequestDtoResolverBundle\Utility\DtoReflectionHelper;
use PHPUnit\Framework\TestCase;
@@ -37,10 +37,13 @@ public function testGetDtoClassNameFromReflectionProperty(): void
{
$class = new #[RequestDto] class {
#[BodyParam]
- public ?NestedChildDTO $nested;
+ public ?NestedChildStrictDto $nested;
#[BodyParam]
public ?string $notDto;
+
+ // @phpstan-ignore missingType.property
+ public $noType;
};
$reflectionClass = new \ReflectionClass($class);
@@ -48,11 +51,15 @@ public function testGetDtoClassNameFromReflectionProperty(): void
$property = $reflectionClass->getProperty('nested');
$type = $this->helper->getDtoClassNameFromReflectionProperty($property);
$this->assertNotNull($type);
- $this->assertEquals(NestedChildDTO::class, $type->getName());
+ $this->assertEquals(NestedChildStrictDto::class, $type->getName());
$property = $reflectionClass->getProperty('notDto');
$type = $this->helper->getDtoClassNameFromReflectionProperty($property);
$this->assertNull($type);
+
+ $property = $reflectionClass->getProperty('noType');
+ $type = $this->helper->getDtoClassNameFromReflectionProperty($property);
+ $this->assertNull($type);
}
public function testGetDtoClassNameFromReflectionPropertyThrowsRuntimeExceptionForUnionAndIntersectionTypes(): void
@@ -60,14 +67,14 @@ public function testGetDtoClassNameFromReflectionPropertyThrowsRuntimeExceptionF
$unionClass = new #[RequestDto]
class {
#[BodyParam]
- public NestedChildDTO|string $unionProperty;
+ public NestedChildStrictDto|string $unionProperty;
};
$intersectionClass = new #[RequestDto]
class {
#[BodyParam]
// @phpstan-ignore property.unresolvableNativeType
- public NestedChildDTO&\Stringable $intersectionProperty;
+ public NestedChildStrictDto&\Stringable $intersectionProperty;
};
$unionReflectionClass = new \ReflectionClass($unionClass);
@@ -139,10 +146,14 @@ public function testIsPropertyAttributed(): void
public function testIsRequestDto(): void
{
+ // @phpstan-ignore method.alreadyNarrowedType
$this->assertTrue($this->helper->isRequestDto(AllParamTypesDTO::class));
+ // @phpstan-ignore method.alreadyNarrowedType
$this->assertTrue($this->helper->isRequestDto(new AllParamTypesDTO()));
+ // @phpstan-ignore method.alreadyNarrowedType
$this->assertTrue($this->helper->isRequestDto(new \ReflectionClass(AllParamTypesDTO::class)));
+ // @phpstan-ignore method.alreadyNarrowedType
$this->assertFalse($this->helper->isRequestDto(\stdClass::class));
}
}
diff --git a/test/Unit/Validator/GroupSequenceExtractorTest.php b/test/Unit/Validator/GroupSequenceExtractorTest.php
deleted file mode 100644
index 127db89..0000000
--- a/test/Unit/Validator/GroupSequenceExtractorTest.php
+++ /dev/null
@@ -1,175 +0,0 @@
-createMock(ClassMetadataInterface::class);
- $metadata->method('getClassName')->willReturn(\stdClass::class);
-
- $result = $extractor->getGroupSequence($object, $groups, $metadata);
-
- $this->assertEquals($groups, $result);
- }
-
- public function testGetGroupSequenceReturnsGroupsIfClassNameNotInGroups(): void
- {
- $extractor = new GroupSequenceExtractor();
- $object = new \stdClass();
- $groups = [Constraint::DEFAULT_GROUP];
- $metadata = $this->createMock(ClassMetadataInterface::class);
- $metadata->method('getClassName')->willReturn('SomeOtherClass');
-
- $result = $extractor->getGroupSequence($object, $groups, $metadata);
-
- $this->assertEquals($groups, $result);
- }
-
- public function testGetGroupSequenceReturnsStaticSequenceFromMetadata(): void
- {
- $extractor = new GroupSequenceExtractor();
- $object = new \stdClass();
- $className = \stdClass::class;
- $groups = [$className, Constraint::DEFAULT_GROUP];
- $sequence = new GroupSequence(['Group1', 'Group2']);
-
- $metadata = $this->createMock(ClassMetadataInterface::class);
- $metadata->method('getClassName')->willReturn($className);
- $metadata->method('hasGroupSequence')->willReturn(true);
- $metadata->method('getGroupSequence')->willReturn($sequence);
-
- $result = $extractor->getGroupSequence($object, $groups, $metadata);
-
- $this->assertSame($sequence, $result);
- }
-
- public function testGetGroupSequenceReturnsSequenceFromGroupSequenceProviderInterface(): void
- {
- $extractor = new GroupSequenceExtractor();
- $object = $this->createMock(GroupSequenceProviderInterface::class);
- $className = get_class($object);
- $groups = [$className, Constraint::DEFAULT_GROUP];
- $sequence = ['Group1', 'Group2'];
-
- $object->method('getGroupSequence')->willReturn($sequence);
-
- $metadata = $this->createMock(ClassMetadataInterface::class);
- $metadata->method('getClassName')->willReturn($className);
- $metadata->method('hasGroupSequence')->willReturn(false);
- $metadata->method('isGroupSequenceProvider')->willReturn(true);
-
- // @phpstan-ignore function.alreadyNarrowedType
- if (method_exists($metadata, 'getGroupProvider')) {
- $metadata->method('getGroupProvider')->willReturn(null);
- }
-
- $result = $extractor->getGroupSequence($object, $groups, $metadata);
-
- $this->assertInstanceOf(GroupSequence::class, $result);
- $this->assertEquals($sequence, $result->groups);
- }
-
- public function testGetGroupSequenceReturnsSequenceFromGroupProviderInterfaceViaLocator(): void
- {
- // @phpstan-ignore function.alreadyNarrowedType
- if (!method_exists(ClassMetadataInterface::class, 'getGroupProvider')) {
- self::markTestSkipped('External GroupProviderInterface not available');
- }
-
- $locator = $this->createMock(ContainerInterface::class);
- $extractor = new GroupSequenceExtractor($locator);
-
- $object = new \stdClass();
- $className = \stdClass::class;
- $groups = [$className, Constraint::DEFAULT_GROUP];
- $providerClass = 'App\Validator\MyGroupProvider';
- $sequence = ['GroupA', 'GroupB'];
-
- $metadata = $this->createMock(ClassMetadataInterface::class);
- $metadata->method('getClassName')->willReturn($className);
- $metadata->method('hasGroupSequence')->willReturn(false);
- $metadata->method('isGroupSequenceProvider')->willReturn(true);
- $metadata->method('getGroupProvider')->willReturn($providerClass);
-
- $provider = $this->createMock(GroupProviderInterface::class);
- $provider->method('getGroups')->with($object)->willReturn($sequence);
-
- $locator->method('get')->with($providerClass)->willReturn($provider);
-
- $result = $extractor->getGroupSequence($object, $groups, $metadata);
-
- $this->assertInstanceOf(GroupSequence::class, $result);
- $this->assertEquals($sequence, $result->groups);
- }
-
- public function testGetGroupSequenceThrowsLogicExceptionWhenLocatorIsMissing(): void
- {
- // @phpstan-ignore function.alreadyNarrowedType
- if (!method_exists(ClassMetadataInterface::class, 'getGroupProvider')) {
- self::markTestSkipped('External GroupProviderInterface not available');
- }
-
- $extractor = new GroupSequenceExtractor(null);
-
- $object = new \stdClass();
- $className = \stdClass::class;
- $groups = [$className, Constraint::DEFAULT_GROUP];
- $providerClass = 'App\Validator\MyGroupProvider';
-
- $metadata = $this->createMock(ClassMetadataInterface::class);
- $metadata->method('getClassName')->willReturn($className);
- $metadata->method('hasGroupSequence')->willReturn(false);
- $metadata->method('isGroupSequenceProvider')->willReturn(true);
- // @phpstan-ignore function.alreadyNarrowedType
- if (method_exists($metadata, 'getGroupProvider')) {
- $metadata->method('getGroupProvider')->willReturn($providerClass);
- }
-
- $this->expectException(\LogicException::class);
- $this->expectExceptionMessage('A group provider locator is required when using group provider.');
-
- $extractor->getGroupSequence($object, $groups, $metadata);
- }
-
- public function testGetGroupSequenceReturnsGroupsIfNoSequenceOrProviderDefined(): void
- {
- $extractor = new GroupSequenceExtractor();
- $object = new \stdClass();
- $className = \stdClass::class;
- $groups = [$className, Constraint::DEFAULT_GROUP];
-
- $metadata = $this->createMock(ClassMetadataInterface::class);
- $metadata->method('getClassName')->willReturn($className);
- $metadata->method('hasGroupSequence')->willReturn(false);
- $metadata->method('isGroupSequenceProvider')->willReturn(false);
-
- $result = $extractor->getGroupSequence($object, $groups, $metadata);
-
- $this->assertEquals($groups, $result);
- }
-}