Warning
The fusonic/http-kernel-extensions
package has been discontinued in favour of a dedicated Symfony bundle located at
fusonic/http-kernel-bundle
(see fusonic/php-http-kernel-bundle).
This package is only compatible up to and including Symfony 6.3 and will not receive any further feature updates. Please consider switching to the new bundle to benefit from future changes.
Please refer to UPGRADE-1.0.md for guidance on how to upgrade to the new bundle.
This library contains a variety of extensions to the Symfony HttpKernel component. See below for details on each extension this lib provides and how it works.
Currently primary development takes place at a private repository at Gitlab.com. The project on github.com is updated regularly, but does not include any issues managed at Gitlab. However, we are happily accepting issues and pull requests on Github as well! Feel free to open an issue or merge request. If we see broader community engagement in the future, we may consider switching our primary development to Github.
Use composer to install the lib from packagist.
composer require fusonic/http-kernel-extensions
In Symfony there exists a thing
called argument resolvers. They can be used
to set the value of controller action arguments before the actions get called. There exists e.g.
the RequestValueResolver
which will inject the current request as an argument in the called action. Similar to this we
created our own argument resolver, but it does a few more things than just injecting an object.
Our RequestDtoResolver
can be used to map requests data directly to objects. Instead of manually getting all the
information from your request and putting it in an object or - god forbid - passing around generic data arrays, this
class will leverage the Symfony Serializer to map requests to objects and by that enable you to have custom objects to
transport the request data (aka data transfer objects) from your controller to your business logic. In addition, it will
also validate the resulting object with Symfony Validation if you set validation annotations.
- Mapping will happen for parameters accompanied by
the
Fusonic\HttpKernelExtensions\Attribute\FromRequest
attribute. Alternatively the attribute can also be set on the class of the parameter (see example below). - Strong type checks will be enforced for PUT, POST, PATCH and DELETE during serialization and it will result in an error if the types in the request body don't match the expected ones in the DTO.
- Type enforcement will be disabled for all other requests e.g. GET as query parameters will always be transferred as string.
- The request body will be combined with route parameters for PUT, POST, PATCH and DELETE requests (query parameters will be ignored in this case).
- The query parameters will be combined with route parameters for all other requests (request body will be ignored in this case).
- Route parameters will always override query parameters or request body values with the same name.
- After deserializing the request to an object, validation will take place.
- A
BadRequestHttpException
will be thrown when- the resulting DTO object is invalid according to Symfony Validation
- the request body can't be deserialized
- the request contains invalid JSON
- the request contains valid JSON but the hierarchy levels exceeds 512
- If you are using the ConstraintViolationErrorHandler error handler, a ConstraintViolationException will be thrown if the validation of your object fails. You can also implement your own handler by implementing the ErrorHandlerInterface.
- Depending on the given content type it will either parse the request body as a regular form or parse the content as JSON if the content type is set accordingly.
Supposing you are using a full Symfony setup you have to register the resolver as a service in your services.yaml
as
shown below to be called by Symfony.
Fusonic\HttpKernelExtensions\Controller\RequestDtoResolver:
tags:
- { name: controller.argument_value_resolver, priority: 50 }
Create your DTO like e.g. our UpdateFooDto
here. All the validation stuff is optional but getters and setters are
needed by the serializer.
// ...
final class UpdateFooDto
{
#[Assert\NotNull]
#[Assert\Positive]
private int $id;
#[Assert\NotBlank]
private string $clientVersion;
#[Assert\NotNull]
private array $browserInfo;
public function getClientVersion(): string
{
return $this->clientVersion;
}
public function setClientVersion(string $clientVersion): void
{
$this->clientVersion = $clientVersion;
}
public function getBrowserInfo(): array
{
return $this->browserInfo;
}
public function setBrowserInfo(array $browserInfo): void
{
$this->browserInfo = $browserInfo;
}
public function getId(): int
{
return $this->id;
}
public function setId(int $id): void
{
$this->id = $id;
}
}
Finally, add the DTO with the RequestDtoArgument
to your controller action. Routing parameters are optional as well of
course.
// ...
use Fusonic\HttpKernelExtensions\Attribute\FromRequest;
final class FooController extends AbstractController
{
/**
* @Route("/{id}/update", methods={"POST"}, requirements={"id"="\d+"})
*/
public function updateAction(#[FromRequest] UpdateFooDto $dto): Response
{
// do something with your $dto here
}
}
Alternatively you can also add the attribute to the DTO class itself instead of the parameter in the controller action if you prefer it this way.
// ...
use Fusonic\HttpKernelExtensions\Attribute\FromRequest;
#[FromRequest]
final class UpdateFooDto
{
// ...
}
// ...
final class FooController extends AbstractController
{
/**
* @Route("/{id}/update", methods={"POST"}, requirements={"id"="\d+"})
*/
public function updateAction(UpdateFooDto $dto): Response
{
// do something with your $dto here
}
}
By default, any json
or form
request body types will be parsed accordingly. To override this behaviour you could
inject your own request body parsers (by implementing Fusonic\HttpKernelExtensions\Request\BodyParser\RequestBodyParserInterface
)
into an implementation of Fusonic\HttpKernelExtensions\Request\RequestDataCollectorInterface
, which is injected into the
Fusonic\HttpKernelExtensions\Controller\RequestDtoResolver
. Inside the RequestDataCollectorInterface
you can
also modify the behaviour of how and which values are used from the Request
object.
The extension provides a default error handler (http-kernel-extensions/src/ErrorHandler/ConstraintViolationErrorHandler.php
) which
handles common de-normalization errors that should be considered type errors. It will create a
Fusonic\HttpKernelExtensions\Exception\ConstraintViolationException
ConstraintViolationException
which can be used with the provided Fusonic\HttpKernelExtensions\Normalizer\ConstraintViolationExceptionNormalizer
ConstraintViolationExceptionNormalizer.
This normalizer is uses on Symfony's built-in Symfony\Component\Serializer\Normalizer\ConstraintViolationListNormalizer
and enhances it
with some extra information: an errorCode
and messageTemplate
. Both useful for parsing validation errors on the client side.
If that does not match your needs you can simply provide your own error handler by implementing
the Fusonic\HttpKernelExtensions\ErrorHandler\ErrorHandlerInterface
and passing it to the RequestDtoResolver
.
You have to register the normalizer as a service like this:
Fusonic\HttpKernelExtensions\Normalizer\ConstraintViolationExceptionNormalizer:
arguments:
- "@serializer.normalizer.constraint_violation_list"
tags:
- { name: serializer.normalizer }
In Symfony you can use an exception listener or subscriber to eventually convert the ConstraintViolationException
into an actual response using
the Fusonic\HttpKernelExtensions\Normalizer\ConstraintViolationExceptionNormalizer
. For example:
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Fusonic\HttpKernelExtensions\Exception\ConstraintViolationException;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
final class ExceptionSubscriber implements EventSubscriberInterface {
public function __construct(private NormalizerInterface $normalizer)
{
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::EXCEPTION => 'onKernelException',
];
}
public function onKernelException(ExceptionEvent $event): void
{
$throwable = $event->getThrowable();
if ($throwable instanceof ConstraintViolationException) {
$data = $this->normalizer->normalize($throwable);
$event->setResponse(new JsonResponse($data, 422));
}
}
}
Check the Events and Event Listeners for details.
There are cases where you want to add data to your DTOs but not through the consumer of the API but, for example, depending on the currently logged in user. You could do that manually after you received your DTO in the controller, get the user, set the user for the DTO and then move on with the processing. As you set it after the creation of the DTO you cannot work with the validation and have to make it nullable as well. And you might have to do some additional checks in your business logic afterwards to ensure everything you need is set.
Or you just create and register a provider, implement (and test) it once and be done with it. All providers will be called by the RequestDtoResolver
, retrieve the needed data for the supported DTO, set it in your DTO and then the validation will take place. By the time you get it in your controller it's complete and validated. How do you do that?
- Create a provider and implement the two methods of the
ContextAwareProvideInterface
.
<?php
// ...
final class UserIdAwareProvider implements ContextAwareProviderInterface
{
public function __construct(private UserProviderInterface $userProvider)
{
}
public function supports(object $dto): bool
{
return $dto instanceof UserIdAwareInterface;
}
public function provide(object $dto): void
{
if(!($dto instanceof UserIdAwareInterface)){
throw new \LogicException('Object is no instance of '.UserIdAwareInterface::class);
}
$user = $this->userProvider->getUser();
$dto->withUserId($user->getId());
}
}
- Create the interface to mark the class you support and set the data.
<?php
//...
interface UserIdAwareInterface
{
public function withUserId(int $id): void;
}
- Implement the interface in the DTO.
- Finally, pass the providers into the resolver. If you are using Symfony you will be doing that in the
services.yaml
and it will look similar to this.
#...
_instanceof:
Fusonic\HttpKernelExtensions\Provider\ContextAwareProviderInterface:
tags:
- {name: fusonic.http_kernel_extensions.context_aware_provider}
Fusonic\HttpKernelExtensions\Controller\RequestDtoResolver:
tags:
- { name: controller.argument_value_resolver, priority: 50 }
arguments:
- '@serializer'
- '@validator'
- null
- !tagged_iterator fusonic.http_kernel_extensions.context_aware_provider