Normalizer null reference using NormalizerAwareTrait - symfony

I have two objects with a Parent-Child relationship. For each object I have a custom normalizer as can be seen below:
ChildNormalizer.php
use Symfony\Component\Serializer\Normalizer\scalar;
class ChildNormalizer
{
public function normalize($object, $format = null, array $context = array())
{
return [
"name" => $object->getName(),
"age" => $object->getAge(),
...
];
}
public function supportsNormalization($data, $format = null)
{
return ($data instanceof Child) && is_object($data);
}
}
ParentNormalizer.php
use Symfony\Component\Serializer\Encoder\NormalizationAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\scalar;
class ParentNormalizer implements NormalizerInterface, NormalizationAwareInterface
{
use NormalizerAwareTrait;
public function normalize($object, $format = null, array $context = array())
{
return [
...
"children" => array_map(
function ($child) use ($format, $context) {
return $this->normalizer->normalize($child);
}, $object->getChildren()
),
...
];
}
public function supportsNormalization($data, $format = null)
{
return ($data instanceof Parent) && is_object($data);
}
}
When I try to serialize the Parent object which in turn normalizes the Child object I get the following exception:
Call to a member function normalize() on null
Have I missed a configuration step, or what the hell am I doing wrong?

Solved the issue, I was implementing the wrong *AwareInterface.
The code works perfectly if the ParentNormalizer implements NormalizerAwareInterface rather than NormalizationAwareInterface.
use Symfony\Component\Serializer\Encoder\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\scalar;
class ParentNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
...
}

Related

Custom constraint validator doesn't allow dependency injection

My constraint:
<?php
declare(strict_types=1);
namespace App\Infrastructure\Domain\Model\Company;
use Symfony\Component\Validator\Constraint;
#[\Attribute]
class ContainsUnsupportedSymbols extends Constraint
{
public string $message = 'The phone "{string}" contains an illegal characters. It can only contain numbers and/or + sign.';
public string $mode;
}
And my validator:
<?php
namespace App\Infrastructure\Domain\Model\Company;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;
use Symfony\Contracts\Translation\TranslatorInterface;
class ContainsUnsupportedSymbolsValidator extends ConstraintValidator
{
private TranslatorInterface $translator;
public function __construct(
TranslatorInterface $translator
)
{
}
public function validate($value, Constraint $constraint)
{
if (!$constraint instanceof ContainsUnsupportedSymbols) {
throw new UnexpectedTypeException($constraint, ContainsUnsupportedSymbols::class);
}
// custom constraints should ignore null and empty values to allow
// other constraints (NotBlank, NotNull, etc.) to take care of that
if (null === $value || '' === $value) {
return;
}
if (!is_string($value)) {
// throw this exception if your validator cannot handle the passed type so that it can be marked as invalid
throw new UnexpectedValueException($value, 'string');
}
if (!preg_match('/^\+?[0-9]{3}-?[0-9]{6,12}$/', $value, $matches)) {
// the argument must be a string or an object implementing __toString()
$this->context->buildViolation(
$this->translator->trans($constraint->message, ['{{ string }}', $value])
)->addViolation();
}
}
}
This is where I use my constraint:
'phone' => [
new Assert\NotBlank(),
new Assert\Length(
min: 1,
max: 255,
),
new ContainsUnsupportedSymbols()
],
I have to translate the error message but error is thrown because of the constructor not passed parameter.
Error:
Too few arguments to function
App\Infrastructure\Domain\Model\Company\ContainsUnsupportedSymbolsValidator::__construct(),
0 passed in
/var/www/html/vendor/symfony/validator/ContainerConstraintValidatorFactory.php
on line 52 and exactly 1 expected
You forgot to use your constructor to set the value of $translator
Edit your __construct method:
private TranslatorInterface $translator;
public function __construct(
TranslatorInterface $translator
)
{
$this->translator = $translator;
}

Custom serialized value for boolean properties on Symfony Serializer

I'm using the Symfony Serializer 3.3 bundle to convert and object to XML.
And I want boolean type returned as Y or N, instead of 1 or 0, and I don't want to change the accessor method.
Here's an example:
namespace Acme;
class Person
{
private $name;
private $enabled;
public function getName()
{
return $this->name;
}
public function setName($name)
{
$this->name = $name;
}
public function isEnabled()
{
return $this->enabled;
}
public function setEnabled($enabled)
{
$this->enabled = $enabled;
}
}
$person = new Acme\Person();
$person->setName('foo');
$person->setEnabled(true);
$serializer->serialize($person, 'xml');
getting result:
<?xml version="1.0"?>
<response>
<name>foo</name>
<enabled>1</enabled> <!-- bad value -->
</response>
desired result:
<?xml version="1.0"?>
<response>
<name>foo</name>
<enabled>Y</enabled> <!-- goodvalue -->
</response>
You can register a new jms type formatted_boolean
<?php
declare(strict_types=1);
namespace App\Util\Serializer\Normalizer;
use JMS\Serializer\Context;
use JMS\Serializer\GraphNavigatorInterface;
use JMS\Serializer\Handler\SubscribingHandlerInterface;
use JMS\Serializer\XmlSerializationVisitor;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
class BoolHandler implements SubscribingHandlerInterface
{
public static function getSubscribingMethods(): array
{
return [
[
'direction' => GraphNavigatorInterface::DIRECTION_SERIALIZATION,
'format' => XmlEncoder::FORMAT,
'type' => 'formatted_boolean',
'method' => 'serializeToXml',
],
];
}
public function serializeToXml(
XmlSerializationVisitor $visitor,
$value,
array $type,
Context $context = null
) {
return $value ? 'Y' : 'N';
}
}
But in this case, you have to add #JMS\Type(name="formatted_boolean") for each boolean property
You can do this by event subscriber. It affects all boolean properties
<?php
declare(strict_types=1);
namespace App\EventListener\Serializer\Entity;
use JMS\Serializer\EventDispatcher\Events;
use JMS\Serializer\EventDispatcher\EventSubscriberInterface;
use JMS\Serializer\EventDispatcher\ObjectEvent;
use JMS\Serializer\Metadata\StaticPropertyMetadata;
use JMS\Serializer\Metadata\VirtualPropertyMetadata;
use Symfony\Component\PropertyAccess\PropertyAccessor;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
class BoolSubscriber implements EventSubscriberInterface
{
/**
* #return array<array<string, mixed>>
*/
public static function getSubscribedEvents(): array
{
return [
[
'event' => Events::POST_SERIALIZE,
'method' => 'onPostSerialize',
'format' => XmlEncoder::FORMAT,
'priority' => 0,
],
];
}
public function onPostSerialize(ObjectEvent $event): void
{
$visitor = $event->getVisitor();
$class = get_class($event->getObject());
$reflectionExtractor = new ReflectionExtractor();
$properties = $reflectionExtractor->getProperties($class);
$propertyAccessor = new PropertyAccessor();
foreach ($properties as $property) {
$types = $reflectionExtractor->getTypes($class, $property);
$type = $types[0] ?? null;
if ($type instanceof Type && $type->getBuiltinType() == Type::BUILTIN_TYPE_BOOL) {
$metadata = new VirtualPropertyMetadata($class, $property);
if ($visitor->hasData($metadata->name)) {
$value = $propertyAccessor->getValue($event->getObject(), $property) ? 'Y' : 'N';
$visitor->visitProperty(
new StaticPropertyMetadata($class, $metadata->name, $value),
$value
);
}
}
}
}
}

Symfony Serializer Component AbstractNormalizer::CALLBACKS denormalize

I am trying to use Serialize with callbacks like in https://symfony.com/doc/current/components/serializer.html#using-callbacks-to-serialize-properties-with-object-instances . But it doesn't seem to go into the callback. I can ignore attributes with 'IGNORED_ATTRIBUTES' just fine, just CALLBACKS not working. What could I be doing wrong?
$dateCallback = function ($innerObject, $outerObject, string $attributeName, string $format = null, array $context = []) {
dump('foo');
return 'faa';
};
$defaultContext = [
AbstractNormalizer::CALLBACKS => [
'order_date' => $dateCallback,
]
];
$normalizer = new ObjectNormalizer(null, null, null, null, null, null, $defaultContext);
$serializer = new Serializer([$normalizer], []);
$order = $serializer->denormalize($data, Orderform::class, 'array');
The data is a simple array.
$data = ['order_date' => '2020-07-07',
'order_number' => '123'];
I would expect the $dateCallback to be called. But it doesn't seem to do that. The Orderform entity is getting populated but not with the value I would expect from the callback.
I tried making all this with json and xml too, since array doesn't show up in the documentation (but it works except for the callback)
Symfony 4.4
I didn't debug your code, instead I made a working version of the deserialization example based on the docs, hope it's serve as guide:
<?php
include 'vendor/autoload.php';
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Encoder\XmlEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Normalizer\GetSetMethodNormalizer;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\Serializer;
//
//Inializing the Serializer, enconders and callbacks
//
$encoders = [new XmlEncoder(), new JsonEncoder()];
$dateCallback = function ($innerObject, $outerObject, string $attributeName, string $format = null, array $context = []) {
return $innerObject instanceof \DateTime ? $innerObject->format('Y-m-d H:i:sP') : '';
};
$defaultContext = [
AbstractNormalizer::CALLBACKS => [
'createdAt' => $dateCallback,
]
];
$normalizer = [new ObjectNormalizer(null, null, null, null, null, null, $defaultContext)];
$serializer = new Serializer($normalizer, $encoders);
//
//Creating Object Person that will be serialized
//
$person = new Person();
$person->setName('foo');
$person->setAge(99);
$person->setSportsperson(false);
$person->setCreatedAt(new \DateTime());
$arrayContent = $serializer->serialize($person, 'json');
// $arrayContent contains ["name" => "foo","age" => 99,"sportsperson" => false,"createdAt" => null]
//print_r($arrayContent); // or return it in a Response
// Lets deserialize it
$desrializedPerson = $serializer->deserialize($arrayContent, Person::class, 'json');
var_dump($desrializedPerson);
//
// Foo Bar stuff
//
// Entity Person declaration
class Person
{
private $age;
private $name;
private $sportsperson;
private $createdAt;
// Getters
public function getName()
{
return $this->name;
}
public function getAge()
{
return $this->age;
}
public function getCreatedAt()
{
return $this->createdAt;
}
// Issers
public function isSportsperson()
{
return $this->sportsperson;
}
// Setters
public function setName($name)
{
$this->name = $name;
}
public function setAge($age)
{
$this->age = $age;
}
public function setSportsperson($sportsperson)
{
$this->sportsperson = $sportsperson;
}
public function setCreatedAt($createdAt)
{
$this->createdAt = $createdAt;
}
}

Normalize a collection with API Platform

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 = [])
{
// ...
}
}

Why returnValueMap() is returning NULL

Trying to mock a doctrine repository inside a test, the returnValueMap() is always returning NULL when used with the findOneBy method.
I have mocked two entities then tried to mock their repository with a given return value map. The test fails and debugging shows that the returnValueMap() is returning NULL.
Here is the class to be tested (the denormalizer)
<?php
declare(strict_types=1);
namespace App\Serializer;
use App\Entity\AdditionalService;
use App\Repository\AdditionalServiceRepository;
use Dto\AdditionalServiceCollection;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
class AdditionalServiceCollectionDenormalizer implements DenormalizerInterface
{
/** #var AdditionalServiceRepository */
private $additionalServiceRepository;
public function __construct(AdditionalServiceRepository $additionalServiceRepository)
{
$this->additionalServiceRepository = $additionalServiceRepository;
}
public function denormalize($mappedCsvRow, $class, $format = null, array $context = [])
{
$addtionalServicesCollection = new AdditionalServiceCollection();
foreach ($mappedCsvRow as $fieldName => $fieldValue) {
/** #var AdditionalService $additionalService */
$additionalService = $this->additionalServiceRepository->findOneBy(['name'=>$fieldName]);
if ($additionalService) {
$addtionalServicesCollection->add($additionalService->getId(), $fieldValue);
}
}
return $addtionalServicesCollection;
}
public function supportsDenormalization($data, $type, $format = null)
{
return $type instanceof AdditionalServiceCollection;
}
}
Here is my test class:
<?php
namespace App\Tests\Import\Config;
use App\Entity\AdditionalService;
use App\Repository\AdditionalServiceRepository;
use App\Serializer\AdditionalServiceCollectionDenormalizer;
use PHPUnit\Framework\TestCase;
use Dto\AdditionalServiceCollection;
class AddionalServiceCollectionDenormalizerTest extends TestCase
{
public function provider()
{
$expected = new AdditionalServiceCollection();
$expected->add(1, 22.1)->add(2, 3.1);
return [
[['man_1' => 22.1], $expected],
[['recycling' => 3.1], $expected],
];
}
/**
* #dataProvider provider
* #covers \App\Serializer\AdditionalServiceCollectionDenormalizer::denormalize
*/
public function testDenormalize(array $row, AdditionalServiceCollection $exptected)
{
$manOneService = $this->createMock(AdditionalService::class);
$manOneService->expects($this->any())->method('getId')->willReturn(1);
$recycling = $this->createMock(AdditionalService::class);
$recycling->expects($this->any())->method('getId')->willReturn(2);
$additionalServicesRepoMock = $this
->getMockBuilder(AdditionalServiceRepository::class)
->setMethods(['findOneBy'])
->disableOriginalConstructor()
->getMock();
$additionalServicesRepoMock
->expects($this->any())
->method('findOneBy')
->will($this->returnValueMap(
[
['name'=>['man_1'], $manOneService],
['name'=>['recycling'], $recycling],
]
));
$denormalizer = new AdditionalServiceCollectionDenormalizer($additionalServicesRepoMock);
self::assertEquals($exptected, $denormalizer->denormalize($row, AdditionalServiceCollection::class));
}
}
I had a hard time debugging the PHPUnit library, to figure out finally that it is the findOneBy() method that expects two arguments, among which the second one is optional (set to null)
The willReturnMap() method is as follows:
/**
* Stubs a method by returning a value from a map.
*/
class ReturnValueMap implements Stub
{
/**
* #var array
*/
private $valueMap;
public function __construct(array $valueMap)
{
$this->valueMap = $valueMap;
}
public function invoke(Invocation $invocation)
{
$parameterCount = \count($invocation->getParameters());
foreach ($this->valueMap as $map) {
if (!\is_array($map) || $parameterCount !== (\count($map) - 1)) {
continue;
}
$return = \array_pop($map);
if ($invocation->getParameters() === $map) {
return $return;
}
}
return;
}
I suspected the method was always returning with null because of the unmet condition $parameterCount !== (\count($map) - 1).
A breakpoint confirmed my doubts, and also revealed that $invocation->getParameters() dumps as follows:
array(2) {
[0] =>
array(1) {
'name' =>
string(5) "man_1"
}
[1] =>
NULL
}
Hence, I had to explicitely pass null as second argument.
So finally the working map had to be:
$this->additionalServicesRepoMock
->method('findOneBy')
->willReturnMap([
[['name' => 'man_1'], null, $manOneService],
[['name' => 'recycling'], null, $recyclingService],
]);
It looks like the parameter of returnValueMap() in testDenormalize() needs brackets to make it indexed array.
Here's a slightly modified version of code snippet from the PHPUnit's document:
<?php
namespace App\Tests;
use PHPUnit\Framework\TestCase;
class ReturnValueMapTest extends TestCase
{
public function testReturnValueMapWithAssociativeArray()
{
$stub = $this->createMock(SomeClass::class);
$map = [
[
'name' => ['man_1'],
'Hello'
],
];
$stub->method('doSomething')
->will($this->returnValueMap($map));
// This will fail as doSomething() returns null
$this->assertSame('Hello', $stub->doSomething(['name' => ['man_1']]));
}
public function testReturnValueMapWithIndexedArray()
{
$stub = $this->createMock(SomeClass::class);
$map = [
[
['name' => ['man_1']], // Notice the difference
'Hello'
],
];
$stub->method('doSomething')
->will($this->returnValueMap($map));
$this->assertSame('Hello', $stub->doSomething(['name' => ['man_1']]));
}
}
class SomeClass
{
public function doSomething()
{}
}

Resources