Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 119 additions & 157 deletions README.md

Large diffs are not rendered by default.

21 changes: 5 additions & 16 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,14 @@

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;
use Crtl\RequestDtoResolverBundle\RequestDtoResolver;
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;
Expand All @@ -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();

Expand All @@ -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');
Expand Down
1 change: 1 addition & 0 deletions config/services_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@
->public();

$services->set(Crtl\RequestDtoResolverBundle\Test\Fixtures\GroupProvider\TestGroupProvider::class)
->tag('validator.group_provider')
->public();
};
4 changes: 0 additions & 4 deletions phpstan.dist.neon
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,3 @@ parameters:
- src
- test

ignoreErrors:
-
identifier: missingType.property
path: %currentWorkingDirectory%/test/Fixtures/Legacy
12 changes: 6 additions & 6 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>

Expand All @@ -20,8 +16,12 @@
</php>

<testsuites>
<testsuite name="default">
<directory>test</directory>
<testsuite name="unit">
<directory>test/Unit</directory>
</testsuite>

<testsuite name="integration">
<directory>test/Integration</directory>
</testsuite>
</testsuites>

Expand Down
16 changes: 15 additions & 1 deletion src/Attribute/AbstractNestedParam.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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;

Expand Down
5 changes: 5 additions & 0 deletions src/Attribute/AbstractParam.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
5 changes: 5 additions & 0 deletions src/Attribute/FileParam.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
5 changes: 5 additions & 0 deletions src/Attribute/HeaderParam.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
10 changes: 10 additions & 0 deletions src/Attribute/RequestDto.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
}
11 changes: 11 additions & 0 deletions src/Attribute/RouteParam.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $routeParams */
Expand Down
21 changes: 14 additions & 7 deletions src/EventSubscriber/RequestDtoValidationEventSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
Expand All @@ -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);
}
}
Expand Down
36 changes: 36 additions & 0 deletions src/Factory/Exception/CircularReferenceException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/*
* This file is part of a private project.
*
* Copyright 2026 Crtl
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Crtl\RequestDtoResolverBundle\Factory\Exception;

/**
* Thrown when a circular reference is detected while hydrating a DTO.
*/
final class CircularReferenceException extends \Exception
{
/**
* @param class-string $className The class name where circularity was detected
* @param string[] $stack The hydration stack leading to the circularity
*/
public function __construct(
public readonly string $className,
public readonly array $stack,
) {
parent::__construct(sprintf(
'Circular reference detected while hydrating DTO "%s". Path: %s -> %s',
$className,
implode(' -> ', $stack),
$className,
));
}
}
35 changes: 35 additions & 0 deletions src/Factory/Exception/PropertyHydrationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

/*
* This file is part of a private project.
*
* Copyright 2026 Crtl
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Crtl\RequestDtoResolverBundle\Factory\Exception;

/**
* Thrown when hydration of property throws {@link \TypeError}.
*/
final class PropertyHydrationException extends \Exception
{
public function __construct(
public readonly string $className,
public readonly string $propertyName,
public readonly mixed $value,
public readonly \TypeError $typeError
) {
$message = sprintf(
'Unable to assign value %s to property %s::$%s.',
var_export($value, true),
$className,
$propertyName,
);
parent::__construct($message, previous: $typeError);
}
}
39 changes: 39 additions & 0 deletions src/Factory/Exception/RequestDtoHydrationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

/*
* This file is part of a private project.
*
* Copyright 2026 Crtl
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Crtl\RequestDtoResolverBundle\Factory\Exception;

use Symfony\Component\Validator\ConstraintViolationListInterface;

/**
* Thrown when a request dto cannot be hydrated because of type conflicts.
*/
final class RequestDtoHydrationException extends \Exception
{
public function __construct(
/**
* The className of the DTO that could not be hydrated.
*/
public readonly object $object,
/**
* A list of constraint violations.
*
* @var ConstraintViolationListInterface
*/
public readonly ConstraintViolationListInterface $violations,
?\Throwable $previous = null
) {
$message = sprintf('Could not hydrate DTO %s.', get_class($this->object));
parent::__construct($message, previous: $previous);
}
}
Loading