I’m developing a Symfony 7 REST API. I’m following the official docs to implement a custom argument resolver to handle mapping of the request payload format that my endpoints are receiving to DTOs (https://symfony.com/doc/current/controller/value_resolver.html#adding-a-custom-value-resolver).
Here’s an example of a request payload to an endpoint
{
"article": {
"title": "How to train your dragon",
"description": "Ever wonder how?",
"body": "You have to believe"
}
}
Here’s the custom ValueResolver
class NestedJsonValueResolver implements ValueResolverInterface
{
public function __construct(
private readonly SerializerInterface $serializer,
private readonly ValidatorInterface $validator,
) {
}
public function resolve(Request $request, ArgumentMetadata $argument): iterable
{
$argumentType = $argument->getType();
if (!in_array('NestedJsonDtoInterface', class_implements($argumentType))) {
return [];
}
return [$this->mapRequestPayload($request, $argumentType)];
}
/**
* @param Request $request
* @param class-string<NestedJsonDtoInterface> $type
*/
private function mapRequestPayload(Request $request, string $type): ?object
{
if (null === $format = $request->getContentTypeFormat()) {
throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, 'Unsupported format.');
}
if ('' === $data = $request->getContent()) {
return null;
}
try {
$nestedJsonObjectKey = $type::getNestedJsonObjectKey();
$decodedData = json_decode($data, true);
$nestedJsonObject = $decodedData[$nestedJsonObjectKey];
$payload = $this->serializer->deserialize($nestedJsonObject, $type, 'json');
$violations = $this->validator->validate($payload);
if ($violations->count() > 0) {
throw new HttpException(Response::HTTP_UNPROCESSABLE_ENTITY, implode("n", array_map(static fn ($e) => $e->getMessage(), iterator_to_array($violations))), new ValidationFailedException($payload, $violations));
}
return $payload;
} catch (UnsupportedFormatException $e) {
throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, sprintf('Unsupported format: "%s".', $format), $e);
} catch (NotEncodableValueException $e) {
throw new HttpException(Response::HTTP_BAD_REQUEST, sprintf('Request payload contains invalid "%s" data.', $format), $e);
}
}
}
Here’s my services.yaml
services:
# default configuration for services in *this* file
_defaults:
autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
# makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name
App:
resource: '../src/'
exclude:
- '../src/DependencyInjection/'
- '../src/Entity/'
- '../src/Kernel.php'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
AppValueResolverNestedJsonValueResolver:
tags:
- controller.argument_value_resolver:
name: nested_json
priority: 150
Here’s the controller action
#[Route('', methods: ['POST'])]
public function store(
#[ValueResolver('nested_json')]
StoreArticleDto $dto
): JsonResponse
{
$article = $this->articleService->store($dto);
return $this->json([
'article' => $article,
], 201);
}
And here’s the target DTO
final readonly class StoreArticleDto implements NestedJsonDtoInterface
{
private const NESTED_JSON_OBJECT_KEY = 'article';
public function __construct(
#[AssertNotNull]
#[AssertType('string')]
private ?string $title,
#[AssertNotNull]
#[AssertType('string')]
private ?string $body,
#[AssertNotNull]
#[AssertType('string')]
private ?string $description,
)
{
}
public static function getNestedJsonObjectKey(): string
{
return self::NESTED_JSON_OBJECT_KEY;
}
public function getTitle(): ?string
{
return $this->title;
}
public function getBody(): ?string
{
return $this->body;
}
public function getDescription(): ?string
{
return $this->description;
}
}
Upon hitting an endpoint the following Exception is being thrown.
How should I go about making the custom ValueResolver work?
2
Answers
If anyone would still be interested in the root of the problem, I've recently found it. The NestedJsonValueResolver class is actually behaving properly and returning an empty array in the
resolve
method because theclass_implements
function returns a strings array of fully qualified class names of the interfaces it implements and I've been checking for the existence of an unqualified class name. Here's the correct implementation of theresolve
method.In the previous implementation upon returning no resolved value the exception was being thrown as there indeed was no default value provided for the controller method parameter.
What is the purpose of using these nested DTOs? Do you plan adding other properties to it or is it just redundant nesting?
My point is, if you don’t need to nest, don’t nest. The route specifies clearly, which entity are you working with. If you dropped the nesting and made the format like this:
You could use the framework native
#[MapRequestPayload]
attribute that solves the mapping into DTO and handles validation. You could drop your custom resolver and not worry about maintaining extra code. The controller would look likeIf you need the nesting, you could solve this by nesting the
StoreArticleDto
in another DTOStoreArticleRequest
and this would enable you to add properties in the future while keeping#[MapRequestPayload]
attribute.In the controller you would simply deserialize into the
StoreArticleRequest
Anyways, I recommend using the standard
#[MapRequestPayload]
attribute over a custom one any day of the week. It is a feature introduced in 6.3 and since you are using 7.0 it is fine. JUst make sure you installed thesymfony/serializer-pack
andsymfony/validator
to work properly.