I am attempting to run a denormalizer (data in) on an embedded MongoDB document with Symfony 4.4 using the API Platform bundle. This works as expected for normalization (data out), but for the denormalization process, nothing is fired on the embedded data, just on the parent data.
If this is the way it works, then I may need to move the logic for denormalization into the parent. Or perhaps I am just doing something wrong. What I am attempting to accomplish is throw exceptions on inbound requests that contain fields that have been deprecated. The classes which parse the annotations and scan the attributes work as expected, it's just determining where to plug it in and I was hoping the denormalization process on embedded documents would work.
Here is my services.yaml:
'App\Serializer\InvestmentNormalizer':
arguments: [ '#security.authorization_checker' ]
tags:
- { name: 'serializer.normalizer', priority: 64 }
'App\Serializer\InvestmentDenormalizer':
tags:
- { name: 'serializer.denormalizer', priority: 64 }
'App\Serializer\ProjectNormalizer':
tags:
- { name: 'serializer.normalizer', priority: 64 }
'App\Serializer\ProjectDenormalizer':
tags:
- { name: 'serializer.denormalizer', priority: 64 }
Then my denormalizer class which never gets executed:
class ProjectDenormalizer implements DenormalizerInterface
{
private const ALREADY_CALLED = 'PROJECT_DENORMALIZER_ALREADY_CALLED';
public function denormalize($data, $class, $format = null, array $context = [])
{
$context[self::ALREADY_CALLED] = true;
return $this->removeDeprecatedFields($data);
}
public function supportsDenormalization($data, $type, $format = null)
{
if (isset($context[self::ALREADY_CALLED])) {
return false;
}
return $type == get_class(new Project());
}
private function removeDeprecatedFields(array $normalizedData) : array
{
$apiPropertyReader = new AnnotationReader(Project::class, ApiProperty::class);
$deprecatedProperties = $apiPropertyReader->readAllHavingAttribute('deprecationReason');
$errors = [];
foreach (array_keys($deprecatedProperties) as $deprecatedPropertyName) {
if (!isset($normalizedData[$deprecatedPropertyName])) {
continue;
}
$errors[] = $deprecatedPropertyName . ' has been deprecated';
}
if (!empty($errors)) {
throw new DeprecatedFieldException(implode('. ', $errors));
}
return $normalizedData;
}
}
If you look at the docs you would find that serializer component does not have any serializer.denormalizer service,
thus your classes are not detected by auto discovery. Symfony Service Tags
You need to follow and implement Normalizer which implements both normalizer and denormalizer logic in single class and register it as normalizer Normalizer & Encoder usages
Then name convention is confusing as it sounds but your normalizer takes care of denormalizing if it has DenormalizerInterface and norrmalizing if it has NormalizerInfterface, by tagging your serializing logic to appropriate method, they will be called accordingly.
API platform it self has examples on how both works : decorating-a-serializer-and-adding-extra-data
Here is how you decorate normalizer in api platform :
api/config/services.yaml
services:
'App\Serializer\ApiNormalizer':
decorates: 'api_platform.jsonld.normalizer.item'
arguments: [ '#App\Serializer\ApiNormalizer.inner' ]
Or you can register this normalizer as per symfony way :
config/services.yaml
services:
get_set_method_normalizer:
class: Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer
tags: [serializer.normalizer]
Implementation:
namespace App\Serializer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\SerializerAwareInterface;
use Symfony\Component\Serializer\SerializerInterface;
final class ApiNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
{
private $decorated;
public function __construct(NormalizerInterface $decorated)
{
if (!$decorated instanceof DenormalizerInterface) {
throw new \InvalidArgumentException(sprintf('The decorated normalizer must implement the %s.', DenormalizerInterface::class));
}
$this->decorated = $decorated;
}
public function supportsNormalization($data, $format = null)
{
return $this->decorated->supportsNormalization($data, $format);
}
public function normalize($object, $format = null, array $context = [])
{
$data = $this->decorated->normalize($object, $format, $context);
if (is_array($data)) {
$data['date'] = date(\DateTime::RFC3339);
}
return $data;
}
public function supportsDenormalization($data, $type, $format = null)
{
return $this->decorated->supportsDenormalization($data, $type, $format);
}
public function denormalize($data, $class, $format = null, array $context = [])
{
return $this->decorated->denormalize($data, $class, $format, $context);
}
public function setSerializer(SerializerInterface $serializer)
{
if($this->decorated instanceof SerializerAwareInterface) {
$this->decorated->setSerializer($serializer);
}
}
}
You can refractor your logic and create Normalizer Class for each entity. Regardless of what DB you use, for PHP and Symfony it's all objects.
Go through full docs here to understand how its implemented :Serializer Docs
Related
I override (custom operation and service) the DELETE operation of my app to avoid deleting data from DB. What I do is I update a field value: isDeleted === true.
Here is my controller :
class ConferenceDeleteAction extends BaseAction
{
public function __invoke(EntityService $entityService, Conference $data)
{
$entityService->markAsDeleted($data, Conference::class);
}
...
My service :
class EntityService extends BaseService
{
public function markAsDeleted(ApiBaseEntity $data, string $className)
{
/**
* #var ApiBaseEntity $entity
*/
$entity = $this->em->getRepository($className)
->findOneBy(["id" => $data->getId()]);
if ($entity === null || $entity->getDeleted()) {
throw new NotFoundHttpException('Unable to find this resource.');
}
$entity->setDeleted(true);
if ($this->dataPersister->supports($entity)) {
$this->dataPersister->persist($entity);
} else {
throw new BadRequestHttpException('An error occurs. Please do try later.');
}
}
}
How can I hide the "deleted" items from collection on GET verb (filter them from the result so that they aren't visible) ?
Here is my operation for GET verb, I don't know how to handle this :
class ConferenceListAction extends BaseAction
{
public function __invoke(Request $request, $data)
{
return $data;
}
}
I did something; I'm not sure it's a best pratice.
Since when we do :
return $data;
in our controller, API Platform has already fetch data and fill $data with.
So I decided to add my logic before the return; like :
public function __invoke(Request $request, $data)
{
$cleanDatas = [];
/**
* #var Conference $conf
*/
foreach ($data as $conf) {
if (!$conf->getDeleted()) {
$cleanDatas[] = $conf;
}
}
return $cleanDatas;
}
So now I only have undeleted items. Feel free to let me know if there is something better.
Thanks.
Custom controllers are discouraged in the docs. You are using Doctrine ORM so you can use a Custom Doctrine ORM Extension:
// api/src/Doctrine/ConferenceCollectionExtension.php
namespace App\Doctrine;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Entity\Conference;
use Doctrine\ORM\QueryBuilder;
final class CarCollectionExtension implements QueryCollectionExtensionInterface
{
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null): void
{
if ($resourceClass != Conference::class) return;
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere("$rootAlias.isDeleted = false OR $rootAlias.isDeleted IS NULL);
}
}
This will automatically be combined with any filters, sorting and pagination of collection operations with method GET.
You can make this Extension specific to an operation by adding to the if statement something like:
|| $operationName == 'conference_list'
If you're not using the autoconfiguration, you have to register the custom extension:
# api/config/services.yaml
services:
# ...
'App\Doctrine\ConferenceCollectionExtension':
tags:
- { name: api_platform.doctrine.orm.query_extension.collection }
If you also want to add a criterium for item operations, see the docs on Extensions
I manage to get a filtered collection of my Note entities with API Platform, using the #ApiFilter(SearchFilter::class) annotation.
Now I want to convert the json response which is an hydra collection
Example :
{
"#context": "/api/contexts/Note",
"#id": "/api/notes",
"#type": "hydra:Collection",
"hydra:member": []
}
to an archive containing one file by Note and return its metadata.
Example :
{
"name": "my_archive.zip",
"size": 12000,
"nb_of_notes": 15
}
I want to keep the SearchFilter benefits. Is the Normalization the good way to go ?
How to declare the normalizer ? How to access the collection/array of Notes in my normalize() method ?
According to the documentation symfony custom_normalizer , you can create a custom normalizer for your Note entity (for example NoteNormalizer). In the supportsNormalization method your must precise that the normalizer will only affect your Note entity by providing Note entity class. So in the normalize method, you will get each item of your ArrayCollection of Note. If you want to be sure, you can make a dump to $data variable (dd($data)) inside this normalize method, and you will have the first element of you ArrayCollection.
that's how I tried to understand it.
namespace App\Serializer;
use App\Entity\Note;
use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
class NoteNormalizer implements ContextAwareNormalizerInterface
{
private $normalizer;
public function __construct(ObjectNormalizer $normalizer) // Like in documentation you can inject here some customer service or symfony service
{
$this->normalizer = $normalizer;
}
public function normalize($topic, $format = null, array $context = [])
{
$data = $this->normalizer->normalize($topic, $format, $context);
$data['name'] = 'some name';
$data['size'] = 12000;
$data['nb_of_notes'] = 15;
return $data;
}
public function supportsNormalization($data, $format = null, array $context = [])
{
return $data instanceof Note;
}
}
Or if you want you can use this command to generate it automatically :
php bin/console make:serializer:normalizer
And give the name : NoteNormalizer
Simply create a "collection Normalizer" :
note: works the same for vanilla symfony projects too.
namespace App\Serializer;
use App\Entity\Note;
use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
class NoteCollectionNormalizer implements ContextAwareNormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public function supportsNormalization($data, $format = null, array $context = []): bool
{
if(!is_array($data) || (!current($data) instanceof Note)) {
return false;
}
return true;
}
/**
* #param Note[] $collection
*/
public function normalize($collection, $format = null, array $context = [])
{
// ...
}
}
I have added the following operation under TeachingClass entity.
App\Entity\TeachingClass:
collectionOperations:
# ...
itemOperations:
# ...
get_learning_skills:
method: GET
path: /auth/v1/teaching-class/{id}/learning-skills
resourceClass: 'App\Entity\LearningSkill' # Doesn't seem to work
controller: App\Controller\Api\LearningSkillApiController
normalization_context:
groups: ['learning_skill_list']
security: 'is_granted("HAS_TEACHING_CLASS_ACCESS", object)'
swagger_context:
summary: "Retrieves the collection of LearningSkill resources belonging to a specific TeachingClass."
description: "LearningSkills belonging to a specific TeachingClass"
The end-point correctly returns a collection of LearningSkill entities by the configured controller:
<?php
namespace App\Controller\Api;
use App\Entity\LearningSkill;
use App\Entity\TeachingClass;
use App\Repository\LearningSkillRepository;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
/**
* Class LearningSkillApiController.
*/
class LearningSkillApiController
{
private $learningSkillRepository;
public function __construct(LearningSkillRepository $learningSkillRepository)
{
$this->learningSkillRepository = $learningSkillRepository;
}
public function __invoke(TeachingClass $data)
{
return $this->byTeachingClass($data);
}
private function byTeachingClass(TeachingClass $teachingClass)
{
return $this->learningSkillRepository->findByTeachingClass($teachingClass);
}
}
However, my problem is that the generated API doc is wrong:
How do I make the documentation reflect that the response is a collection of LearningSkill entities (instead of a TeachingClass entity)?
I had the same problem with the report in the chapter9-api branch of my tutorial, which outputs instances of DayTotalsPerEmployee instead of the class the endpoint is on. My solution was to make a SwaggerDecorator. Below is one adapted for your operation.
It also sets the descriptions in components schemas referred to by the response 200 content. This is based on the assumption that your response is a collection response. It apip thinks it is an item response there may be some more work to to to make the swagger docs describe a collection response.
<?php
namespace App\Swagger;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
final class SwaggerDecorator implements NormalizerInterface
{
private $decorated;
public function __construct(NormalizerInterface $decorated)
{
$this->decorated = $decorated;
}
public function normalize($object, string $format = null, array $context = [])
{
$summary = 'The collection of LearningSkill resources belonging to a specific TeachingClass.';
$docs = $this->decorated->normalize($object, $format, $context);
$docs['paths']['/auth/v1/teaching-class/{id}/learning-skills']['get']['responses']['200']['description'] = 'LearningSkills collection response';
$responseContent = $docs['paths']['/auth/v1/teaching-class/{id}/learning-skills']['get']['responses']['200']['content'];
$this->setByRef($docs, $responseContent['application/ld+json']['schema']['properties']['hydra:member']['items']['$ref'],
'description', $summary);
$this->setByRef($docs, $responseContent['application/json']['schema']['items']['$ref'],
'description', $summary);
return $docs;
}
public function supportsNormalization($data, string $format = null)
{
return $this->decorated->supportsNormalization($data, $format);
}
private function setByRef(&$docs, $ref, $key, $value)
{
$pieces = explode('/', substr($ref, 2));
$sub =& $docs;
foreach ($pieces as $piece) {
$sub =& $sub[$piece];
}
$sub[$key] = $value;
}
}
To configure the service add the following to api/config/services.yaml:
'App\Swagger\SwaggerDecorator':
decorates: 'api_platform.swagger.normalizer.api_gateway'
arguments: [ '#App\Swagger\SwaggerDecorator.inner' ]
autoconfigure: false
So I have a couple doctrine entities, a Subscription and a Subscriber. There are many Subscriptions to a single subscriber (manyToOne). I wrote custom normalizers for both entities, but am having trouble getting the Subscriber to show up in the Subscription once it has been normalized to JSON.
The only way I've been able to get it to work is by passing the 'Subscriber' normalizer to the 'Subscription' normailizer. It seems like I should just be able to use the SerializerAwareNormalizer Trait, or something like that, to have Symfony recursively normalize my related entities.
services:
acme.marketing.api.normalizer.subscription:
class: acme\MarketingBundle\Normalizer\SubscriptionNormalizer
arguments: ['#acme.marketing.api.normalizer.subscriber']
public: false
tags:
- { name: serializer.normalizer }
acme.marketing.api.normalizer.subscriber:
class: acme\MarketingBundle\Normalizer\SubscriberNormalizer
public: false
tags:
- { name: serializer.normalizer }
and the normalizer...
<?php
namespace acme\MarketingBundle\Normalizer;
use acme\MarketingBundle\Entity\Subscription;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class SubscriptionNormalizer implements NormalizerInterface
{
private $subscriberNormalizer;
public function __construct($subscriberNormalizer)
{
$this->subscriberNormalizer = $subscriberNormalizer;
}
public function normalize($subscription, $format = null, array $context = [])
{
/* #var $subscription Subscription */
$subscriber = $subscription->getSubscriber();
return [
"id" => $subscription->getId(),
"subscriber" => $this->subscriberNormalizer->normalize($subscriber, $format)
];
}
public function supportsNormalization($data, $format = null)
{
return $data instanceof Subscription;
}
}
Is there a better way to accomplish this?
Spent a few hours on google and couldn't figure it out. Post on SO and 5 minutes later hit the right google link :(. Answer seems to be to implement NormalizerAwareInterface on the custom normalizer, and then use the NormalizerAwareTrait to get access to the normalizer for nested entities.
<?php
namespace acme\MarketingBundle\Normalizer;
use acme\MarketingBundle\Entity\Subscription;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class SubscriptionNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public function normalize($subscription, $format = null, array $context = [])
{
return [
"id" => $subscription->getId(),
"subscriber" => $this->normalizer->normalize($subscription->getSubscriber())
];
}
public function supportsNormalization($data, $format = null)
{
return $data instanceof Subscription;
}
}
I'm trying to implement a custom Voter.
From the controller I call it this way:
$prj = $this->getDoctrine()->getRepository('AppBundle:Project')->findOneById($id);
if (false === $this->get('security.authorization_checker')->isGranted('responsible', $prj)) {
throw new AccessDeniedException('Unauthorised access!');
}
The first line properly retrieves the Project object (I checked with a dump).
The problem occurs inside the voter
<?php
namespace AppBundle\Security\Authorization\Voter;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\User\UserInterface;
class ProjectVoter implements VoterInterface
{
const RESPONSIBLE = 'responsible';
const ACCOUNTABLE = 'accountable';
const SUPPORT = 'support';
const CONSULTED = 'consulted';
const INFORMED = 'informed';
public function supportsAttribute($attribute)
{
return in_array($attribute, array(
self::RESPONSIBLE,
self::ACCOUNTABLE,
self::SUPPORT,
self::CONSULTED,
self::INFORMED,
));
}
public function supportsClass($class)
{
$supportedClass = 'AppBundle\Entity\Project';
return $supportedClass === $class || is_subclass_of($class, $supportedClass);
}
/**
* #var \AppBundle\Entity\Project $project
*/
public function vote(TokenInterface $token, $project, array $attributes)
{
// check if class of this object is supported by this voter
if (!$this->supportsClass(get_class($project))) {
return VoterInterface::ACCESS_ABSTAIN;
}
// check if the voter is used correct, only allow one attribute
// this isn't a requirement, it's just one easy way for you to
// design your voter
if (1 !== count($attributes)) {
throw new \InvalidArgumentException(
'Only one attribute is allowed'
); //in origin it was 'for VIEW or EDIT, which were the supported attributes
}
// set the attribute to check against
$attribute = $attributes[0];
// check if the given attribute is covered by this voter
if (!$this->supportsAttribute($attribute)) {
return VoterInterface::ACCESS_ABSTAIN;
}
// get current logged in user
$user = $token->getUser();
// make sure there is a user object (i.e. that the user is logged in)
if (!$user instanceof UserInterface) {
return VoterInterface::ACCESS_DENIED;
}
$em = $this->getDoctrine()->getManager();
$projects = $em->getRepository('AppBundle:Project')->findPrjByUserAndRole($user, $attribute);
foreach ($projects as $key => $prj) {
if ($prj['id'] === $project['id'])
{
$granted = true;
$index = $key; // save the index of the last time a specifif project changed status
}
}
if($projects[$index]['is_active']===true) //if the last status is active
return VoterInterface::ACCESS_GRANTED;
else
return VoterInterface::ACCESS_DENIED;
}
}
I get the following error
Attempted to call method "getDoctrine" on class
"AppBundle\Security\Authorization\Voter\ProjectVoter".
I understand that the controller extends Controller, that is why I can use "getDoctrine" there. How can I have access to my DB from inside the Voter?
I solved it. This is pretty curious: I spend hours or days on a problem, then post a question here, and I solve it myself within an hour :/
I needed to add the following in my voter class:
public function __construct(EntityManager $em)
{
$this->em = $em;
}
I needed to add the following on top:
use Doctrine\ORM\EntityManager;
I also needed to add the arguments in the service.yml
security.access.project_voter:
class: AppBundle\Security\Authorization\Voter\ProjectVoter
arguments: [ #doctrine.orm.entity_manager ]
public: false
tags:
- { name: security.voter }