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

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

Related

Symfony 6 with serializer component - deserialize array of objects

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

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.

api_platform produces Error "no handler found for uri [/index/_doc/_search] and method [POST]"

When trying to implement elasticsearch (v7.9.3) via the fos_elastica-bundle (v6.0.0) into my Symfony (v5.3.10) - App with api_platform (v2.6.6), I keep on getting this error:
"{"error":"no handler found for uri [//posts/_doc/_search] and method [POST]"}",
My api_platform.yaml reads:
api_platform:
[...]
elasticsearch:
hosts: [ '%env(ELASTICSEARCH_URL)%' ]
mapping:
App\Document\Post:
index: posts
and my fos_elastica.yaml:
fos_elastica:
clients:
default: { url: '%env(ELASTICSEARCH_URL)%' }
indexes:
posts:
properties:
id:
"type": "keyword"
source: ~
title: ~
description: ~
body: ~
children: ~
tags: ~
originalContent: ~
persistence:
driver: mongodb
model: App\Document\Post
By debugging the fos-elastica Bundle, I found out that the Elastica-Connector correctly triggers a [POST]-Request to "/posts/_doc/_search" with this request body:
{"sort":[{"id":{"order":"asc"}}],"query":{"match_all":{}},"size":30,"from":0}
If I use the Kibana Dev Tools Console and trigger an identical request
POST /posts/_doc/_search
{"sort":[{"id":{"order":"asc"}}],"query":{"match_all":{}},"size":30,"from":60}
I do get results from elasticsearch as expected:
#! Deprecation: [types removal] Specifying types in search requests is deprecated.
{
"took" : 12,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 3082,
"relation" : "eq"
},
"max_score" : null,
"hits" : [
{
"_index" : "posts",
"_type" : "_doc",
[...]
Apart from the deprecation notice, everything seems fine.
Does anyone have an idea why the api_platform integration of the fos_elastica-bundle does not work as expected and keeps on returning the "no handler found"-error message?
I have now helped myself by creating a custom ApiResource - filter
#[ApiFilter(FulltextFilter::class, arguments: ['index' => 'post'], properties: ['body','description','tag'])]
My custom filter implements ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Filter\FilterInterface, directly communicates with the ElasticSearch server, sends a query to search the specified index (posts) and adds another match()-directive to the aggregationBuilder with a set of IDs matching the original search:
<?php
declare(strict_types=1);
namespace App\Filter;
use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Filter\FilterInterface;
use Doctrine\ODM\MongoDB\Aggregation\Builder;
use Elastica\Result;
use Elastica\Client;
use Elastica\Query;
use Symfony\Component\PropertyInfo\Type;
/**
* Filter the collection by given properties.
*
*/
final class FulltextFilter implements FilterInterface
{
protected $index = '';
protected $properties = [];
protected $client;
protected $searchParameterName;
protected $maxResultsParameterName;
const DEFAULT_MAX_RESULTS = 200;
public function __construct(Client $client, string $index = '', string $maxResultsParameterName = 'amount', string $searchParameterName = 'query', array $properties = []) {
$this->index = $index;
$this->properties = $properties;
$this->client = $client;
$this->searchParameterName = $searchParameterName;
$this->maxResultsParameterName = $maxResultsParameterName;
}
public function getFilteredIds($searchterm, $index = null, $properties = null, $maxResults = null) {
$matches = [];
if (is_null($properties)) {
$properties = array_keys($this->properties);
}
foreach ($properties as $propertyName) {
array_push($matches, ['match'=>[$propertyName => $searchterm]]);
}
$queryObject = ['query' => ['bool' => ['should' => $matches]]];
$queryObject['size'] = (int) $maxResults >0 ? (int) $maxResults : self::DEFAULT_MAX_RESULTS;
$query = new Query();
$response = $this->client->getIndex($index ?? $this->index)
->search($query->setRawQuery($queryObject))
->getResults();
return array_map(function(Result $result) {return $result->getHit()['_source']['id'];}, $response);
}
public function apply(Builder $aggregationBuilder, string $resourceClass, string $operationName = null, array &$context = [])
{
$maxResults = $context['filters'][$this->maxResultsParameterName] ?? null;
$searchterm = $context['filters'][$this->searchParameterName] ?? false;
if ($searchterm !== false) {
$aggregationBuilder->match()->field('id')->in($this->getFilteredIds($searchterm, null, null, $maxResults));
}
}
public function getDescription(string $resourceClass): array
{
return [];
}
}
This solution might not be as elegant as using the ElasticSearch-Connector natively provided by api_platform, but it is fairly performant and it works.
However, if someone comes up with a solution to fix the depicted ES-Connector issue with api_platform, please feel free to share it.
The problem is that, FOS Elastica requires an ES URL with an ending slash. But Api Platform requires a URL without ending slash.
We usually define the URL in .env file and then recall it in config files.
To solve the problem, we could define the URL in .env without endling slash and add the slash to the FOS Elastica config.
# .env
###> friendsofsymfony/elastica-bundle ###
ELASTICSEARCH_URL=http://localhost:9200
###< friendsofsymfony/elastica-bundle ###
# config/packages/api_platform.yaml
api_platform:
elasticsearch:
enabled: true
hosts: [ '%env(ELASTICSEARCH_URL)%' ]
# config/packages/fos_elastica.yaml
fos_elastica:
clients:
default: { url: '%env(ELASTICSEARCH_URL)%/' }

Codeception: How to stub return of a function in a Symfony module

I need to write APItests for a project(on Symfony) using Codeception. My API test is functional and using Symfony+REST modules.
The Api method I'm testing uses an external service call. I just stub the response of the class method, but the stub does not work and the real class method is called.
What do I need to fix in my test code or maybe I need to use another way?
Config codeception.yml
namespace: App\Tests
paths:
tests: tests
output: tests/_output
data: tests/_data
support: tests/_support
envs: tests/_envs
actor_suffix: Tester
bootstrap: codeception_bootstrap.php
extensions:
enabled:
- Codeception\Extension\RunFailed
params:
- .env.test
Config api.suite.yml
actor: ApiTester
modules:
enabled:
- Symfony:
app_path: 'src'
environment: 'test'
- Doctrine2:
depends: Symfony
cleanup: true
- REST:
url: /api/v1/
depends: Symfony
- \App\Tests\Helper\Api
Test code /tests/api/TestCest.php
namespace App\Tests\Api;
use App\Tests\ApiTester;
class TestApiCest extends BaseApiCest
{
public function addMetrics(ApiTester $I)
{
$this->mockB2bService($I);
$I->sendPost('/request', $body);
$I->seeResponseCodeIs(201);
}
}
Code for stubing
/tests/_support/Helper/ApiHelper.php
namespace App\Tests\Helper;
use Codeception\Module;
use Symfony\Component\DependencyInjection\ContainerInterface;
class ApiHelper extends Module
{
public function getContainer(): ContainerInterface
{
/** #var Module\Symfony $symfony */
$symfony = $this->getModule('Symfony');
return $symfony->kernel->getContainer();
}
}
/api/BaseApiCest.php
namespace App\Tests\Api;
use App\Module\Service\B2BService\B2bResponse;
use App\Tests\ApiTester;
use App\Tests\Helper\ApiHelper;
use Codeception\Stub;
abstract class BaseApiCest
{
protected ApiHelper $apiHelper;
protected function _inject(ApiHelper $apiUser)
{
$this->apiHelper = $apiUser;
}
protected function mockB2bService(ApiTester $I): void
{
$container = $this->apiHelper->getContainer();
$serviceId = '\App\Module\Service\B2BService\B2bService';
$auth = Stub::make(
\App\Module\Service\B2BService\B2bService::class, [
'createSomeActive' => new B2bResponse(['success' => true, 'message' => '', 'code' => 200])
]);
$container->set($serviceId, $auth);
$I->persistPermanentService($serviceId);
}
Class I try to stub
/src/Module/Service/B2bService.php
class B2bService implements B2bServiceInterface
{
public function createSomeActive(SomeParams $params): B2bResponse
{
$response = $this->httpClient->post('/somerequest', $requestBody);
$decodedResponse = $this->jsonDecodeResponse($response);
return $decodedResponse;
//if sucsess then return ['success' => true, 'message' => '', 'code' => 200]
}
}

access entity property from type_options

In my entity I defined a field color with a callback. The colors can only be selected in the COLORS list (const in this class)
/**
* #ORM\Entity(repositoryClass="App\Repository\EventTagRepository")
*/
class EventTag
{
const COLORS = [
"primary"=>"primary",
"secondary"=>"secondary",
"success"=> "success",
"danger"=>"danger",
"warning"=>"warning",
"info"=>"info",
"light"=>"light",
"dark"=>"dark"
];
/**
* #ORM\Column(type="string", length=255)
* #Assert\Choice(callback="getColors")
*/
private $color;
public function getColors()
{
return $this::COLORS;
}
When I'm creating the form in easy-admin, I'd like to access this callback in the choice type options to prevent the user to choose a wrong color.
EventTag:
class: App\Entity\EventTag
list:
actions: ['-delete']
form:
fields:
- { type: 'group', label: 'Content', icon: 'pencil-alt', columns: 8 }
- 'name'
- { property: 'color', type: 'choice', type_options: { expanded: false, multiple: false, choices: 'colors'} }
Unfortunately in the type_options I didn't find a way to access the entity properties, instead of searching for getColors(), IsColors(), hasColors() methods, it only reads the string.
Is it possible to do it another way ?
The callback refers to an entity const:
you can use
#Assert\Choice(choices=EventTag::COLORS)
in the PHP entity and
choices: App\Entity\EventTag::COLORS
in the YAML config
the callback refers to a more specific value:
you need to manually extend the AdminController
public function createCategoryEntityFormBuilder($entity, $view)
{
$formBuilder = parent::createEntityFormBuilder($entity, $view);
$field = $formBuilder->get('type');
$options = $field->getOptions();
$attr = $field->getAttributes();
$options['choices'] = $formBuilder->getData()->getTypeLabels();
$formBuilder->add($field->getName(), ChoiceType::class, $options);
$formBuilder->get($field->getName())
->setAttribute('easyadmin_form_tab', $attr['easyadmin_form_tab'])
->setAttribute('easyadmin_form_group', $attr['easyadmin_form_group']);
return $formBuilder;
}
As $formBuilder->add erases attributes we need to set them again manually. Probably it can be skipped if you are not using Groups/Tabs, otherwise it will throw Exception saying that field was already rendered.

Resources