API Platform: How to change the serialization context dynamically
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 ContextBuilde
r 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
Thanks for your comment 🙏. Once it's approved, it will appear here.
Leave a comment