Symfony Serializer Component AbstractNormalizer::CALLBACKS denormalize - symfony

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;
}
}

Related

How to make property nullable for Symfony Serializer

I'm trying to deserialize to an object with property which might take an array of objects as value or be null.
I have no problem deserializing arrays but I need to deserialize null to an empty array or to null itself.
For example { "items": null }
class A {
/**
* #var null|Item[]
*/
private $items = [];
/**
* #return Item[]|null
*/
public function getItems(): ?array
{
return $this->items ?? [];
}
/**
* #param Item $param
* #return A
*/
public function addItem(Item $param)
{
if (!is_array($this->items)) $this->items = [];
if (!in_array($param, $this->items))
$this->items[] = $param;
return $this;
}
// /** tried with this as well
// * #param array|null $param
// * #return A
// */
// public function setItems(?array $param)
// {
// $this->items = $param ?? [];
// return $this;
// }
/**
* #param Item $item
* #return A
*/
public function removeItem(Item $item): A
{
if (!is_array($this->items)) $this->items = [];
if (in_array($item, $this->items))
unset($this->items[array_search($item, $this->items)]);
return $this;
}
/**
* #param Item $item
* #return bool
*/
public function hasItem(Item $item): bool
{
return in_array($item, $this->items);
}
}
Serializer looks like this
$defaultContext = [
AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER =>
function ($articles, $format, $context) {
return $articles->getId();
},
AbstractObjectNormalizer::SKIP_NULL_VALUES => false
];
$classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()));
$metadataAwareNameConverter = new MetadataAwareNameConverter($classMetadataFactory);
$encoders = [new JsonEncoder()];
$serializer = new Serializer([
new ArrayDenormalizer(),
new DateTimeNormalizer(),
new ObjectNormalizer($classMetadataFactory, $metadataAwareNameConverter, null,
new ReflectionExtractor(), null, null, $defaultContext
),
], $encoders);
$a = $serializer->deserialize('{ "items": null }', A::class, 'json');
The error I get when items is null
[Symfony\Component\Serializer\Exception\InvalidArgumentException]
Data expected to be an array, null given.
Is it possible to have nullable property?
Traced down to the Serializer source code and found three possible options to have a nullable array.
Option 1
Remove addItem, hasItem, removeItem methods and it allows to set null, array, whatever. This is less preffed solution in my case.
Option 2
Adding a constructor helps as well. https://github.com/symfony/serializer/blob/5.3/Normalizer/AbstractNormalizer.php#L381
/**
* A constructor.
* #param array|null $items
*/
public function __construct($items)
{
$this->items = $items ?? [];
}
Option 3
Extended ArrayDenormalizer and overrided denormalize method to handle nulls
public function denormalize($data, string $type, string $format = null, array $context = []): array
{
if (null === $this->denormalizer) {
throw new BadMethodCallException('Please set a denormalizer before calling denormalize()!');
}
if (!\is_array($data) && !is_null($data)) {
throw new InvalidArgumentException('Data expected to be an array or null, ' . get_debug_type($data) . ' given.');
}
if (!str_ends_with($type, '[]')) {
throw new InvalidArgumentException('Unsupported class: ' . $type);
}
if(is_null($data))
return [];
$type = substr($type, 0, -2);
$builtinType = isset($context['key_type']) ? $context['key_type']->getBuiltinType() : null;
foreach ($data as $key => $value) {
if (null !== $builtinType && !('is_' . $builtinType)($key)) {
throw new NotNormalizableValueException(sprintf('The type of the key "%s" must be "%s" ("%s" given).', $key, $builtinType, get_debug_type($key)));
}
$data[$key] = $this->denormalizer->denormalize($value, $type, $format, $context);
}
return $data;
}

How to solve exception NotNormalizableValueException during deserialization array of objects in Serializer Component?

The following error occurs during object deserialization:
Symfony\Component\Serializer\Exception\NotNormalizableValueException
The type of the "products" attribute for class "shop\manage\flexbe\objects\Lead" must be one of "shop\manage\flexbe\objects\Product[]" ("array" given).
Have JSON-object:
{
"id": "9757241",
"time": "1567105530",
// other params
"products": [
{
"title": "Product name",
"count": 1
}
]
}
In the Lead class that describes the object related methods to "products":
private $products = [];
/**
* #return Product[]
*/
public function getProducts()
{
return $this->products;
}
/**
* #param Product $product
*/
public function addProduct(Product $product): void
{
$this->products[] = $product;
}
Deserialization Code:
$normalizer = new ObjectNormalizer(null, null, new PropertyAccessor(), new ReflectionExtractor());
$serializer = new Serializer(array($normalizer), array(new JsonEncoder()));
$lead = $serializer->deserialize($data, Lead::class, 'json');
I can’t understand what the problem is. It is expected using the addProduct() method deserializer should bypass the array and add all objects to Product class like in this case.
I decided using an ArrayDenormalizer, and a setter with PHPDoc indicating the data type, an array of Products [] objects. But why the method with addProduct () did not work - the question remains.
Deserialization:
$encoder = [new JsonEncoder()];
$extractor = new PropertyInfoExtractor([], [new ReflectionExtractor()]);
$normalizer = [new ArrayDenormalizer(), new ObjectNormalizer(null, null, null, $extractor)];
$serializer = new Serializer($normalizer, $encoder);
/** #var $lead Lead */
$lead = $serializer->deserialize($data,Lead::class,'json');
Setter for products in the Lead class:
/**
* #param Product[] $products
*/
public function setProducts(array $products)
{
$this->products = $products;
}

Mailer test fails with Call to a member function getSubject() on null

In a Symfony 5.0.2 project a test of the new Mailer fails with
Error: Call to a member function getSubject() on null
The email service and test are based on symfonycast tutorials.
Adding var_dump($email); in the service immediately after $email = ...; shows object(Symfony\Bridge\Twig\Mime\TemplatedEmail)#24 (11) {..., which says there is a real object created in the service.
services.yaml:
App\Services\EmailerService:
$mailer: '#mailer'
$senderAddress: '%app.sender_address%'
Service:
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
class EmailerService
{
private $mailer;
private $sender;
public function __construct($mailer, $senderAddress)
{
$this->mailer = $mailer;
$this->sender = $senderAddress;
}
public function appMailer($mailParams)
{
$email = (new TemplatedEmail())
->from($this->sender)
->to($mailParams['recipient'])
->subject($mailParams['subject'])
->htmlTemplate($mailParams['view'])
->context($mailParams['context']);
$this->mailer->send($email);
}
}
Test:
use App\Services\EmailerService;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Mailer\MailerInterface;
class MailerTest extends TestCase
{
public function testSimpleMessage()
{
$symfonyMailer = $this->createMock(MailerInterface::class);
$symfonyMailer->expects($this->once())
->method('send');
$mailer = new EmailerService($symfonyMailer, 'admin#bogus.info', 'admin#bogus.info');
$mailParams = [
'view' => 'Email/non_user_forgotten_password.html.twig',
'context' => ['supportEmail' => 'admin#bogus.info'],
'recipient' => 'bborko#bogus.info',
'subject' => 'Test message',
];
$email = $mailer->appMailer($mailParams);
$this->assertSame('Test message', $email->getSubject());
}
}
appMailer() must return a TemplatedEmail object so you can call getSubject() on it. Currently it is returning nothing. Change it to:
public function appMailer($mailParams)
{
$email = (new TemplatedEmail())
->from($this->sender)
->to($mailParams['recipient'])
->subject($mailParams['subject'])
->htmlTemplate($mailParams['view'])
->context($mailParams['context']);
$this->mailer->send($email);
return $email; // I added this line.
}

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()
{}
}

Normalizer null reference using NormalizerAwareTrait

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
{
...
}

Resources