Symfony 6 with serializer component - deserialize array of objects - symfony

Install:
composer require symfony/property-info
composer require symfony/property-access
1 Create own serializer service like
use Symfony\Component\Serializer\Serializer as SymfonySerializer;
class Serializer
{
private SymfonySerializer $serializer;
public function __construct()
{
$this->serializer = new SymfonySerializer(
[
new ArrayDenormalizer(),
new ObjectNormalizer(null, null, null, new ReflectionExtractor())
], ['json' => new JsonEncoder()]
);
}
public function deserialize(string $data, string $type, string $format, array $context = [])
{
return $this->serializer->deserialize($data, $type, $format, $context);
}
}
Create 3 models: Parent,Owner,User
In your model which you put here:
$parent = $this->serializer->deserialize($request->getContent(), Parent::class, 'json');
to get array of objects you need to have property like:
private array $users = [];
default value is neccessary!
and 3 methods like in this documentation:
https://symfony.com/doc/current/components/property_access.html#writing-to-array-properties
addUser, hasUsers and removeUser
Be carefoul....HAS method name must be plural
This own service will work if deserialized JSON has scalar values, objects and also array of objects:
"owner": {
"firstname": "xxx",
"lastname": "xxxx"
}
"users":[
{
"firstname": "xxx",
"lastname":"yyy"
},
{
"firstname": "zzzz",
"lastname":"wwww"
}
]
Pls click arrow up if this answer is useful. Thanks

Related

API Platform: How to normalize a collection of embedded entities in GraphQL?

I'm trying to make a collection of subresources selectable in GraphQL (with pagination). I'd like to be able to query:
query {
getA(id: '/api/A/1') {
aId
subresources {
totalCount
pageInfo {
endCursor
startCursor
hasNextPage
hasPreviousPage
}
edges {
node {
bId
}
}
}
}
}
and get the result:
{
aId: 1,
subresources: {
"totalCount": XX,
"pageInfo": {
"endCursor": "MQ==",
"startCursor": "MA==",
"hasNextPage": true,
"hasPreviousPage": false
},
edges: [
{
node: {
bId: 11
}
},
{
node: {
bId: 12
}
},
{
node: {
bId: 13
}
}
]
}
}
I'm not using Doctrine at all- I'm using custom data providers. The problem I'm encountering is that even when I return an A entity from DataProvider::getItem() that has an array of B subresources, I get an empty array for subresources in GraphQL. I get the correct data in REST though.
I'm following the instructions given in SymfonyCasts and I found a related API Platform issue, but I'm still having no luck.
I traced through API Platform core and I think it has to do with how the entity is normalized in GraphQL. Specifically, an empty array is returned in ItemNormalizer::normalizeCollectionOfRelations(). However, there's a comment saying "to-many are handled directly by the GraphQL resolver" but I'm not sure what that refers to.
Here's the entity code.
use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use Symfony\Component\Serializer\Annotation\Groups;
#[ApiResource(
graphql: ['item_query', 'collection_query', 'create', 'update', 'delete'],
collectionOperations: ['get', 'post'],
itemOperations: ['get', 'put', 'patch', 'delete'],
normalizationContext: ['groups' => ['read']],
denormalizationContext: ['groups' => ['write']],
)]
class A {
#[ApiProperty(identifier: true)]
#[Groups(['read', 'write'])]
public ?int $aId = null,
/** #var B[] */
#[ApiProperty(readableLink: true, writableLink: true)]
#[Groups(['read', 'write'])]
public $subresources = []
}
And:
#[ApiResource(
graphql: ['item_query', 'collection_query', 'create', 'update', 'delete'],
collectionOperations: ['get', 'post'],
itemOperations: ['get', 'put', 'patch', 'delete'],
normalizationContext: ['groups' => ['read']],
denormalizationContext: ['groups' => ['write']],
)]
class B {
#[ApiProperty(identifier: true)]
#[Groups(['read', 'write'])]
public ?int $bId = null,
}
My ADataProvider:
public function getItem(string $resourceClass, $id, string $operationName = null, array $context = []): A {
$bs = $this->bDataProvider->getCollection(B::class, null, []);
return new A(123, $bs);
}
My BDataProvider:
/**
* #return ArrayPaginator<B>
*/
public function getCollection(string $resourceClass, string $operationName = null, array $context = []): ArrayPaginator {
return ArrayPaginator::fromList([new B(11), new B(12), new B(13)]);
}
ArrayPaginator implements IteratorAggregate and PaginatorInterface.
Specifically I see this error:
{
"errors": [
{
"debugMessage": "Collection returned by the collection data provider must implement ApiPlatform\\Core\\DataProvider\\PaginatorInterface or ApiPlatform\\Core\\DataProvider\\PartialPaginatorInterface.",
"message": "Internal server error",
"extensions": {
"category": "internal"
},
"locations": [
{
"line": 29,
"column": 5
}
],
"path": [
"a",
"b"
],
"trace": [
{
"file": "/homedir/core/src/GraphQl/Resolver/Stage/SerializeStage.php",
"line": 100,
"call": "ApiPlatform\\Core\\GraphQl\\Resolver\\Stage\\SerializeStage::serializeCursorBasedPaginatedCollection(array(0), array(5), array(6))"
},
TLDR: How does one use annotations (or YAML) to make attributes that are collections of subresources selectable in GraphQL?
Any help/ideas are appreciated, thanks for reading!
Found a solution: the ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface needs to be implemented by the BDataProvider.
It gets used in the ReadStage of api platform's graphql resolver. Surprisingly, it's found nowhere in the REST resolver, so this won't get called on a REST request.
The only method that needs to be implemented is getSubresource(). My basic first implementation looks like this:
public function getSubresource(string $resourceClass, array $identifiers, array $context, string $operationName = null) {
if ($context['collection']) {
return $this->getCollection($resourceClass, $operationName, $context);
}
$id = // get your id from $identifiers;
return $this->getItem($resourceClass, $id, $operationName, $context);
}
This isn't found in the docs unfortunately, but there are a few pulls (1, 2) open to add it.

How to modify data before the denormalization and send It back?

I'd like to have advice on the best practice to achieve something.
I've a JSON output of an entity called "Session" with 3 related entities (platform, user, course ) and I'd like to use the nested way to create those 3 related if they don't exist.
But If they do exist I'd like to add their IRI (or ID) to the JSON output before API Platform does the magic. (or another way to achieve this behavior)
I naively thought that I should bind to the pre_denormalization event but I've no idea how to return the data to the event.
Here is what I've got so far.
public static function getSubscribedEvents()
{
return [
KernelEvents::REQUEST => ['onSessionDenormalize', EventPriorities::PRE_DESERIALIZE],
];
}
public function onSessionDenormalize(RequestEvent $event)
{
$data = $event->getRequest()->getContent();
}
public function modifyPayLoad($data) {
$dataObject = json_decode($data);
$platform = $dataObject->platform;
$user = $dataObject->user;
$course = $dataObject->course;
if($this->platformRepository->findOneBy(['slug' => $platform->slug])) {
$platformID = $this->courseRepository->findOneBy(['slug' => $platform->slug])->getId();
$dataObject->id = $platformID;
if($this->userRepository->findOneBy(['email' => $user->slug])) {
$dataObject->id = $this->userRepository->findOneBy(['email' => $user->slug])->getId();
$dataObject->user->platform->id = $platformID;
}
if($this->courseRepository->findOneBy(['slug' => $course->slug])) {
$dataObject->id = $this->courseRepository->findOneBy(['slug' => $course->slug])->getId();
$dataObject->course->platform->id = $platformID;
}
}
return json_encode($dataObject);
}
And the JSON:
{
"user": {
"firstname": "string",
"lastname": "string",
"address": "string",
"city": "string",
"email": "string",
"zipCode": int,
"hubspotID": int
},
"course": {
"id": "string",
"title": "string",
"platform": {
"slug": "string",
"name": "string"
}
},
"startDate": "2022-01-09T23:59:00.000Z",
"endDate": "2022-02-09T23:59:00.000Z",
"hubspotDealId": int
}
I can't get the ID in this JSON since those information are provided by a Puppeteer APP, or I should do 3 request to check if the related entity exist first, which is not adviced I think.
I also tried to change the identifier on the user, course and platform but In both cases, I've duplicate entries in database
I manage to do what I want with a custom denormalizer.
So I can post and update data comming from tierce source without ID.
class SessionDenormalizer implements DenormalizerAwareInterface, ContextAwareDenormalizerInterface
{
use DenormalizerAwareTrait;
public function __construct(
private UserRepository $userRepository,
private PlatformRepository $platformRepository,
private CourseRepository $courseRepository,
private SessionRepository $sessionRepository,
)
{
}
private const ALREADY_CALLED = 'SESSION_DENORMALIZER_ALREADY_CALLED';
public function supportsDenormalization($data, string $type, string $format = null, array $context = []): bool
{
if (isset($context[self::ALREADY_CALLED])) {
return false;
}
return $type === Session::class;
}
public function denormalize($data, string $type, string $format = null, array $context = [])
{
if (isset(
$data["user"]["email"],
$data["course"]["slug"],
$data["course"]["platform"]["slug"],
)) {
$user = $this->userRepository->findOneBy(["email" => $data["user"]["email"]]);
$course = $this->courseRepository->findOneBy(["slug" => $data["course"]["slug"]]);
$platform = $this->platformRepository->findOneBy(["slug" => $data["course"]["platform"]["slug"]]);
if ($user && $course && $platform) {
$data["user"]["#id"] = "/v1/users/" . $user?->getId();
$data["course"]["#id"] = "/v1/courses/" . $course?->getId();
$data["course"]["platform"]["#id"] = "/v1/platforms/" . $platform?->getId();
$session = $this->sessionRepository->findOneBy(["cpfID" => $data["cpfID"]]);
if($session) {
$data["#id"] = "/v1/sessions/" . $session->getId();
if(isset($context["collection_operation_name"])) {
$context["collection_operation_name"] = "put";
}
if(isset($context['api_allow_update'])) {
$context['api_allow_update'] = true;
}
}
}
}
$context[self::ALREADY_CALLED] = true;
return $this->denormalizer->denormalize($data , $type , $format , $context);
}
}
Services.yaml :
'app.session.denormalizer.json':
class: 'App\Serializer\Denormalizer\SessionDenormalizer'
tags:
- { name: 'serializer.normalizer', priority: 64 }

NSwag add x-enum-varnames tag to all enums

Is it possible to add x-enum-varnames to all enums in NSwag? I have this example but then you have to specify all the enums. Is there a more generic solution? Also, this example creates a second instance ResultCode2 which I don't understand.
https://github.com/RicoSuter/NSwag/issues/1993
public class NSwagProcessor : IOperationProcessor
{
public bool Process( OperationProcessorContext context )
{
JsonSchema schema = JsonSchema.FromType<ResultCode>();
if( schema.ExtensionData == null )
{
schema.ExtensionData = new Dictionary<string, object>();
}
string[] enumerationNames = new string[ schema.EnumerationNames.Count ];
schema.EnumerationNames.CopyTo( enumerationNames, 0 );
schema.ExtensionData.Add( "x-enum-varnames", enumerationNames );
if( context.Settings.TypeMappers.Any( t => t.MappedType == typeof( ResultCode ) ) == false )
{
context.Settings.TypeMappers.Add( new ObjectTypeMapper( typeof( ResultCode ), schema ) );
}
return true;
}
}
...
services.AddOpenApiDocument( config => {
config.OperationProcessors.Add( new NSwagProcessor() );
} );
This creates:
"ResultCode": {
"type": "integer",
"description": "",
"x-enumNames": [
"Error",
"Success"
],
"enum": [
0,
1
]
},
...
"ResultCode2": {
"title": "ResultCode",
"type": "integer",
"description": "",
"x-enumNames": [
"Error",
"Success"
],
"enum": [
0,
1
],
"x-enum-varnames": [
"Error",
"Success"
]
},
I found another solution, you can add a Schema processor to the NSwag openapi generation. By adding the following schema processor:
public class XEnumVarnamesNswagSchemaProcessor : ISchemaProcessor
{
public void Process(SchemaProcessorContext context)
{
if (context.Type.IsEnum)
{
if (context.Schema.ExtensionData is not null)
{
context.Schema.ExtensionData.Add("x-enum-varnames", context.Schema.EnumerationNames.ToArray());
}
else
{
context.Schema.ExtensionData = new Dictionary<string, object>()
{
{"x-enum-varnames", context.Schema.EnumerationNames.ToArray()}
};
}
}
}
}
An extra field will be added to all openapi enum schemas/types.
You need to add the processor to the NSwag service in Startup.cs.
services.AddOpenApiDocument(configuration =>
{
configuration.SchemaProcessors.Add(new XEnumVarnamesNswagSchemaProcessor());
});
I think the best solution for me was to set json serialization of enums to string:
services.AddControllers().AddJsonOptions( options =>
{
options.JsonSerializerOptions.Converters.Add( new JsonStringEnumConverter() );
});

How configure JMSSerializer in Symfony to serialize custom class to/from int?

I am working on a Symfony 3.4 based web app project which uses JMSSerializer to serialize different custom classes to JSON to send this data to mobile apps.
How can I serialize/deserialize a custom class to/from to int?
Assume we have the following classes:
<?php
// AppBundle/Entity/...
class NotificationInfo {
public $date; // DateTime
public $priority; // Int 1-10
public $repeates; // Boolean
public function toInt() {
// Create a simple Int value
// date = 04/27/2020
// priority = 5
// repeats = true
// ==> int value = 4272020 5 1 = 427202051
}
public function __construnct($intValue) {
// ==> Split int value into date, priority and repeats...
}
}
class ToDoItem {
public $title;
public $tags;
public $notificationInfo;
}
// AppBundle/Resources/config/serializer/Entiy.ToDoItem.yml
AppBundle\Entity\ToDoItem:
exclusion_policy: ALL
properties:
title:
type: string
expose: true
tags:
type: string
expose: true
notificationInfo:
type: integer
expose: true
So the class NotificationInfo also has function to create it from int and to serialize it to in. How to tell the serializer that it should serialize the value of $notificationInfo to int?
I could use the following instead:
notificationInfo:
type: AppBundle\Entity\NotificationInfo
expose: true
However in this case I need to configure the serialization of NotificationInfo where I can only specify which property should serialized to which value...
EDIT:
This is the JSON I would like to create:
{
"title": "Something ToDO",
"tags": "some,tags",
"notificationInfo": 427202051
}
This is what I am NOT looking for:
{
"title": "Something ToDO",
"tags": "some,tags",
"notificationInfo": {
"intValue": 427202051
}
}
After a lot more digging I found the following solution for my problem: I added a custom serialization Handler which tells JMSSerializer how to handle my custom class:
class NotificationInfoHandler implements SubscribingHandlerInterface {
public static function getSubscribingMethods() {
return [
[
'direction' => GraphNavigator::DIRECTION_SERIALIZATION,
'format' => 'json',
'type' => 'NotificationInfo',
'method' => 'serializeNotificationInfoToJson',
],
[
'direction' => GraphNavigator::DIRECTION_DESERIALIZATION,
'format' => 'json',
'type' => 'NotificationInfo',
'method' => 'deserializeNotificationInfoToJson',
],
;
public function serializeNotificationInfoToJson(JsonSerializationVisitor $visitor, NotificationInfo $info, array $type, Context $context) {
return $info->toInt();
}
public function deserializeNotificationInfoToJson(JsonDeserializationVisitor $visitor, $infoAsInt, array $type, Context $context) {
return (is_int($infoAsInt) ? NotificationInfo::fromInt($infoAsInt) : NotificationInfo::emptyInfo());
}
}
Thanks to autowire the handler is automatically added and can be used in the serializer metadata:
notificationInfo:
type: NotificationInfo
expose: true
you can use VirtualProperty method to add any method of you class
into json response
use JMS\Serializer\Annotation as Serializer;
class NotificationInfo
{
/**
* #return int
* #Serializer\VirtualProperty()
* #Serializer\SerializedName("formatedLocation")
*/
public function toInt()
{
return 4272020;
}
}

class not found when unit testing a custom module

I'm trying to write tests for a custom module I've written on Drupal 8 and keep getting an error and at this point I'm out of ideas. Here is the error:
Error: Class 'Drupal\mypackage\Services\Config\MyClassServiceConfig' not found
The PhpUnit class is under
modules\custom\mypackage\tests\src\Unit\mypackageUserAuthTest
Here is the code
class mypackageUserAuthTest extends UnitTestCase
{
protected $user;
protected $loginService;
public function setUp()
{
parent::setUp();
$this->loginService = new LoginService();
$this->user = [
'username' => 'xxx',
'password' => 'xxx',
'deviceId' => 'xxx',
'some-token' => 'xxx'
];
}
/** #test */
public function that_we_can_authenticate_a_user()
{
$IsUserLoggedIn = $this->loginService->login($this->user['username'], $this->user['password']);
$this->assertTrue($IsUserLoggedIn);
}
Now the method login in loginService code
<?php
namespace Drupal\mypackage\Rest;
use GuzzleHttp\Exception\ClientException;
use Drupal\mypackage\Services\RestServiceFactory;
use Drupal\mypackage\Services\Config\MyClassServiceConfig;
class LoginService
{
public function login($username, $password)
{
$configs = new MyClassServiceConfig(null, "mobile", "v1");
$client = RestServiceFactory::create($configs);
try {
$response = $client->post('login', [
'json' => [
'username' => $username,
'password' => $password,
'deviceId' => 'onepiece',
],
]);
return json_decode($response->getBody(), true);
} catch (ClientException $exception) {
switch ($$exception->getResponse()->getStatusCode()) {
case 402: // This only applies to co members
throw new SubscriptionRequiredException();
case 403:
throw new BlockedAccountException();
case 409:
throw new DuplicateEmailException();
case 410:
throw new PasswordDoesNotExistException();
}
throw $exception;
}
}
}
pwd result on MyClassServiceConfig class directory
/var/www/cms/web/modules/custom/mypackage/src/Services/Config
But it seems to fail on the line $configs = new MyClassServiceConfig(null, "mobile", "v1"); with the previously mentioned error :
1) Drupal\Tests\mypackage\Unit\mypackageUserAuthTest::that_we_can_authenticate_a_user
Error: Class 'Drupal\mypackage\Services\Config\MyClassServiceConfig' not found
Btw, I'm using drupal-project structure (https://github.com/drupal-composer/drupal-project)
So I spent days checking the path but it seemed that the files were not loading so I ended up adding the custom module to autload-dev part composer.json.
"autoload": {
"classmap": [
"scripts/composer/ScriptHandler.php"
],
"files": ["load.environment.php"]
},
"autoload-dev": {
"psr-4": { "Drupal\\mypackage\\": "web/modules/custom/mypackage" }
},
Now at least it seems to load the module as I'm getting an other error related to Drupal Container
\Drupal::$container is not initialized yet. \Drupal::setContainer() must be called with a real container.
It is an old question, the same thing happened to me, as I managed to solve it in my case it was as follows:
In the comment of the class where the tests are carried out, something similar to this should go:
The #coversDefaultClass annotation must go with the namespace of the class to test.
/**
* #coversDefaultClass \Drupal\my_module\MyModuleClassName
* #group my_module
*/
class MyModuleCaseTest extends UnitTestCase {
}
Maybe it will serve someone else

Resources