skip to Main Content

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.

enter image description here

How should I go about making the custom ValueResolver work?

2

Answers


  1. Chosen as BEST ANSWER

    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 the class_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 the resolve method.

    public function resolve(Request $request, ArgumentMetadata $argument): iterable
    {
        $argumentType = $argument->getType();
    
        if (!in_array(NestedJsonDtoInterface::class, class_implements($argumentType))) {
            return [];
        }
    
        return [$this->mapRequestPayload($request, $argumentType)];
    }
    

    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.


  2. 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:

    {
      "title": "How to train your dragon",
      "description": "Ever wonder how?",
      "body": "You have to believe"
    }
    

    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 like

        #[Route('', methods: ['POST'])]
        public function store(
            #[MapRequestPayload]
            StoreArticleDto $dto
        ): JsonResponse
        {
            $article = $this->articleService->store($dto);
    
            return $this->json([
                'article' => $article,
            ], 201);
        }
    

    If you need the nesting, you could solve this by nesting the StoreArticleDto in another DTO StoreArticleRequest and this would enable you to add properties in the future while keeping #[MapRequestPayload] attribute.

    use SymfonyComponentValidatorConstraints as Assert;
    
    class StoreArticleRequest
    {
        #[AssertNotNull]
        private StoreArticleDto $article;
    
        public function getArticle(): StoreArticleDto
        {
            return $this->article;
        }
    
        public function setArticle(StoreArticleDto $article): void
        {
            $this->article = $article;
        }
    }
    

    In the controller you would simply deserialize into the StoreArticleRequest

        #[Route('', methods: ['POST'])]
        public function store(
            #[MapRequestPayload]
            StoreArticleRequest $dto
        ): JsonResponse
        {
            $article = $this->articleService->store($dto->article);
    
            return $this->json([
                'article' => $article,
            ], 201);
        }
    

    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 the symfony/serializer-pack and symfony/validator to work properly.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search