sajad torkamani

Why would you dynamically change the serialization context?

Suppose you have a Book entity where most of its field can be managed by any user, but some can be managed only by admin users:

<?php
// api/src/Entity/Book.php

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;

#[ApiResource(
    normalizationContext: ['groups' => ['book:output']],
    denormalizationContext: ['groups' => ['book:input']],
)]
class Book
{
    // ...

    /**
     * This field can be managed only by an admin
     */
    #[Groups(["book:output", "admin:input"])]
    public bool $active = false;

    /**
     * This field can be managed by any user
     */
    #[Groups(["book:output", "book:input"])]
    public string $name;

    // ...
}

Notice the admin:input serialization group. We want to detect if the current user is an admin, and if so, dynamically add the admin:input value to the deserialization context. This way, we can restrict reading or writing of specific fields only to admins.

Dynamically set the normalization/denormalization context for all instances of an entity

API Platform provides you with a ContextBuilder, that can prepare the context for serialization and deserialization. You can decorate the ContextBuilder service to override its createFromRequest method:

# api/config/services.yaml
services:
    # ...
    'App\Serializer\BookContextBuilder':
        decorates: 'api_platform.serializer.context_builder'
        arguments: [ '@App\Serializer\BookContextBuilder.inner' ]
        autoconfigure: false

This tells the Symfony service container that the App\Serializer\BookContextBuilder service replaces the default context builder service with the id api_platform.serializer.context_builder.

The arguments option will pass the given arguments to the decorating service:

<?php
// api/src/Serializer/BookContextBuilder.php

namespace App\Serializer;

use ApiPlatform\Core\Serializer\SerializerContextBuilderInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use App\Entity\Book;

final class BookContextBuilder implements SerializerContextBuilderInterface
{
    private $decorated;
    private $authorizationChecker;

    public function __construct(SerializerContextBuilderInterface $decorated, AuthorizationCheckerInterface $authorizationChecker)
    {
        $this->decorated = $decorated;
        $this->authorizationChecker = $authorizationChecker;
    }

    public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
    {
        $context = $this->decorated->createFromRequest($request, $normalization, $extractedAttributes);
        $resourceClass = $context['resource_class'] ?? null;

        if ($resourceClass === Book::class && isset($context['groups']) && $this->authorizationChecker->isGranted('ROLE_ADMIN') && false === $normalization) {
            $context['groups'][] = 'admin:input';
        }

        return $context;
    }
}

Now, if the subject is an instance of Book , the user has ROLE_ADMIN and the context is for denormalization (instead of normalization), then the admin:input gorup will be dynamically added to the denormalization context.

Dynamically set the normalization/denormalization context on a per-item basis

Suppose you want to add certain serialization groups only if the current user has access to the given book. For example, you might want to add a can_retrieve_book serialization group if the user is the owner of the book.

You can do this by registering a custom normalizer that conditionally adds a serialization group based on some logic:

<?php
// api/src/Serializer/BookAttributeNormalizer.php

namespace App\Serializer;

use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;

class BookAttributeNormalizer implements ContextAwareNormalizerInterface, NormalizerAwareInterface
{
    use NormalizerAwareTrait;

    private const ALREADY_CALLED = 'BOOK_ATTRIBUTE_NORMALIZER_ALREADY_CALLED';

    private $tokenStorage;

    public function __construct(TokenStorageInterface $tokenStorage)
    {
        $this->tokenStorage = $tokenStorage;
    }

    public function normalize($object, $format = null, array $context = [])
    {
        if ($this->userHasPermissionsForBook($object)) {
            $context['groups'][] = 'can_retrieve_book';
        }

        $context[self::ALREADY_CALLED] = true;

        return $this->normalizer->normalize($object, $format, $context);
    }

    public function supportsNormalization($data, $format = null, array $context = [])
    {
        // Make sure we're not called twice
        if (isset($context[self::ALREADY_CALLED])) {
            return false;
        }

        return $data instanceof Book;
    }

    private function userHasPermissionsForBook($object): bool
    {
        // Get permissions from user in $this->tokenStorage
        // for the current $object (book) and
        // return true or false
    }
}

The logic for checking if the normalizer is already called helps prevent recursive calls.

Sources

Leave a comment

Your email address will not be published. Required fields are marked *