I passed on 2.6.x ApiPlatform recently and I have a SwaggerDecorator service to generate some custom documentation.
As I could see now we have to decorate api_platform.openapi.factory in order to customize our documentation.
Problem : as before it was a normalizer, we had the format and context passed to the custom decorator :
public function normalize($object, $format = null, array $context = []): array
and each normalize/denormalize context was perform to generate the documentation.
I try to find a way to reproduce that with decorating the new OpenAPiFactory
Questions :
how can I get the format now ?
how can I get my custom documentation working for all output context ?
relative documentation links just have a single example :
[https://api-platform.com/docs/core/openapi/]
actual stack
PHP 7.4.8
Symfony 5.2.x
ApiPlatform 2.6.x
actual try code :
public function __invoke(array $context = []): OpenApi
{
$result = $this->cache->get('openapi_documentation', function (ItemInterface $item) use ($context) {
// Build default doc
$this->docs = $this->decorated->__invoke($context);
if (!$this->docs instanceof OpenApi) {
return $this->docs;
}
// Complete API docudmentation if files exist in schemas configuration directory
$schemas = $this->docs->getComponents()->getSchemas();
foreach ($schemas ?? [] as $k => &$schema) {
$schemaNameExploded = explode(':', $k);
$objectName = strstr($schemaNameExploded[0], '-', true) ?: $schemaNameExploded[0];
$docFile = $this->confDir.'/packages/api_platform/schemas/'.$objectName.'.yaml';
$this->completeDoc($schema, $docFile);
}
});
return $result;
}
/**
* Complete the openapi documentation of $schema with $docFile.
*
* #param array|\ArrayObject $schema
*
* #return mixed
*/
private function completeDoc(&$schema, string $docFile)
{
if (!file_exists($docFile)) {
return $schema;
} else {
$docFileParsed = (array) Yaml::parseFile($docFile);
}
// Treatment of the description
if (isset($docFileParsed['description'])) {
$schema['description'] = $docFileParsed['description'];
}
// Treatment of properties
if (isset($schema['properties'])) {
$newProperties = $docFileParsed['properties'] ?? [];
foreach ($schema['properties'] as $k => &$currentProperty) {
if (!empty($newProperties[$k])) {
$currentPropertyArray = $currentProperty instanceof \ArrayObject ? $currentProperty->getArrayCopy() : $currentProperty;
$currentProperty = new \ArrayObject(array_replace($currentPropertyArray, $newProperties[$k]));
}
}
}
}
thx
Related
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
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'd like to create a simple bundle to handle some multilingual pages in a website with translated slugs.
Based on translatable, sluggable and i18nrouting
implemented an entity (Page) with title, content, slug fields + locale property as the doc says
created a new Page set its title and content then translated it by $page->setTranslatableLocale('de'); and set those fields again with the german values, so that the data in the tables looks fine, they are all there
implemented the controller with type hinting signature: public function showAction(Page $page)
generated some urls in the template by: {{ path("page_show", {"slug": "test", "_locale": "en"}) }} and {{ path("page_show", {"slug": "test-de", "_locale": "de"}) }}, routes are generated fine, they look correct (/en/test and /de/test-de)
clicking on them:
Only the "en" translation works, the "de" one fails:
MyBundle\Entity\Page object not found.
How to tell Symfony or the Doctrine or whatever bundle to use the current locale when retrieving the Page? Do I have to create a ParamConverter then put a custom DQL into it the do the job manually?
Thanks!
Just found another solution which I think is much nicer and i'm going to use that one!
Implemented a repository method and use that in the controller's annotation:
#ParamConverter("page", class="MyBundle:Page", options={"repository_method" = "findTranslatedOneBy"})
public function findTranslatedOneBy(array $criteria, array $orderBy = null)
{
$page = $this->findOneBy($criteria, $orderBy);
if (!is_null($page)) {
return $page;
}
$qb = $this->getEntityManager()
->getRepository('Gedmo\Translatable\Entity\Translation')
->createQueryBuilder('t');
$i = 0;
foreach ($criteria as $name => $value) {
$qb->orWhere('t.field = :n'. $i .' AND t.content = :v'. $i);
$qb->setParameter('n'. $i, $name);
$qb->setParameter('v'. $i, $value);
$i++;
}
/** #var \Gedmo\Translatable\Entity\Translation[] $trs */
$trs = $qb->groupBy('t.locale', 't.foreignKey')->getQuery()->getResult();
return count($trs) == count($criteria) ? $this->find($trs[0]->getForeignKey()) : null;
}
It has one disadvantage there is no protection against same translated values ...
I found out a solution which i'm not sure the best, but works.
Implemented a PageParamConverter:
class PageParamConverter extends DoctrineParamConverter
{
const PAGE_CLASS = 'MyBundle:Page';
public function apply(Request $request, ParamConverter $configuration)
{
try {
return parent::apply($request, $configuration);
} catch (NotFoundHttpException $e) {
$slug = $request->get('slug');
$name = $configuration->getName();
$class = $configuration->getClass();
$em = $this->registry->getManagerForClass($class);
/** #var \Gedmo\Translatable\Entity\Translation $tr */
$tr = $em->getRepository('Gedmo\Translatable\Entity\Translation')
->findOneBy(['content' => $slug, 'field' => 'slug']);
if (is_null($tr)) {
throw new NotFoundHttpException(sprintf('%s object not found.', $class));
}
$page = $em->find($class, $tr->getForeignKey());
$request->attributes->set($name, $page);
}
return true;
}
public function supports(ParamConverter $configuration)
{
$name = $configuration->getName();
$class = $configuration->getClass();
return parent::supports($configuration) && $class == self::PAGE_CLASS;
}
}
TranslationWalker nicely gets the entity in active locale:
class PagesRepository extends \Doctrine\ORM\EntityRepository
{
public function findTranslatedBySlug(string $slug)
{
$queryBuilder = $this->createQueryBuilder("p");
$queryBuilder
->where("p.slug = :slug")
->setParameter('slug', $slug)
;
$query = $queryBuilder->getQuery();
$query->setHint(
Query::HINT_CUSTOM_OUTPUT_WALKER,
'Gedmo\\Translatable\\Query\\TreeWalker\\TranslationWalker'
);
return $query->getSingleResult();
}
}
And in controller
/**
* #Entity("page", expr="repository.findTranslatedBySlug(slug)")
* #param $page
*
* #return Response
*/
public function slug(Pages $page)
{
// thanks to #Entity annotation (Sensio\Bundle\FrameworkExtraBundle\Configuration\Entity)
// Pages entity is automatically retrieved by slug
return $this->render('content/index.html.twig', [
'page' => $page
]);
}
There is a standard feature in sonata-admin-bundle to export data using exporter; But how to make export current entity AND mapped ManyToOne entity with it?
Basically what I want, is to download exactly same data as defined in ListFields.
UPD: In docs, there is only todo
UPD2: I've found one solution, but I do not think it is the best one:
/**
* Add some fields from mapped entities; the simplest way;
* #return array
*/
public function getExportFields() {
$fieldsArray = $this->getModelManager()->getExportFields($this->getClass());
//here we add some magic :)
$fieldsArray[] = 'user.superData';
$fieldsArray[] = 'user.megaData';
return $fieldsArray;
}
I created own source iterator inherited from DoctrineORMQuerySourceIterator.
If value in method getValue is array or instance of Traversable i call method getValue recursive to get value for each "Many" entity:
protected function getValue($value)
{
//if value is array or collection, creates string
if (is_array($value) or $value instanceof \Traversable) {
$result = [];
foreach ($value as $item) {
$result[] = $this->getValue($item);
}
$value = implode(',', $result);
//formated datetime output
} elseif ($value instanceof \DateTime) {
$value = $this->dateFormater->format($value);
} elseif (is_object($value)) {
$value = (string) $value;
}
return $value;
}
In your admin class you must override method getDataSourceIterator to return your own iterator.
This
$this->getModelManager()->getExportFields($this->getClass());
returns all entity items. Better practice is to create explicit list of exported items in method getExportFields()
public function getExportFields()
{
return [
$this->getTranslator()->trans('item1_label_text') => 'entityItem1',
$this->getTranslator()->trans('item2_label_text') => 'entityItem2.subItem',
//subItem after dot is specific value from related entity
....
Key in array is used for export table headers (here is traslated).
Accessing my route /message/new i'm going to show a form for sending a new message to one or more customers. Form model has (among others) a collection of Customer entities:
class MyFormModel
{
/**
* #var ArrayCollection
*/
public $customers;
}
I'd like to implement automatic customers selection using customers GET parameters, like this:
message/new?customers=2,55,543
This is working now by simply splitting on , and do a query for getting customers:
public function newAction(Request $request)
{
$formModel = new MyFormModel();
// GET "customers" parameter
$customersIds = explode($request->get('customers'), ',');
// If something was found in "customers" parameter then get entities
if(!empty($customersIds)) :
$repo = $this->getDoctrine()->getRepository('AcmeHelloBundle:Customer');
$found = $repo->findAllByIdsArray($customersIds);
// Assign found Customer entities
$formModel->customers = $found;
endif;
// Go on showing the form
}
How can i do the same using Symfony 2 converters? Like:
public function newAction(Request $request, $selectedCustomers)
{
}
Answer to my self: there is not such thing to make you life easy. I've coded a quick and dirty (and possibly buggy) solution i'd like to share, waiting for a best one.
EDIT WARNING: this is not going to work with two parameter converters with the same class.
Url example
/mesages/new?customers=2543,3321,445
Annotations:
/**
* #Route("/new")
* #Method("GET|POST")
* #ParamConverter("customers",
* class="Doctrine\Common\Collections\ArrayCollection", options={
* "finder" = "getFindAllWithMobileByUserQueryBuilder",
* "entity" = "Acme\HelloBundle\Entity\Customer",
* "field" = "id",
* "delimiter" = ",",
* }
* )
*/
public function newAction(Request $request, ArrayCollection $customers = null)
{
}
Option delimiter is used to split GET parameter while id is used for adding a WHERE id IN... clause. There are both optional.
Option class is only used as a "signature" to tell that converter should support it. entity has to be a FQCN of a Doctrine entity while finder is a repository method to be invoked and should return a query builder (default one provided).
Converter
class ArrayCollectionConverter implements ParamConverterInterface
{
/**
* #var \Symfony\Component\DependencyInjection\ContainerInterface
*/
protected $container;
public function __construct(ContainerInterface $container)
{
$this->container = $container;
}
function apply(Request $request, ConfigurationInterface $configuration)
{
$name = $configuration->getName();
$options = $this->getOptions($configuration);
// Se request attribute to an empty collection (as default)
$request->attributes->set($name, new ArrayCollection());
// If request parameter is missing or empty then return
if(is_null($val = $request->get($name)) || strlen(trim($val)) === 0)
return;
// If splitted values is an empty array then return
if(!($items = preg_split('/\s*'.$options['delimiter'].'\s*/', $val,
0, PREG_SPLIT_NO_EMPTY))) return;
// Get the repository and logged user
$repo = $this->getEntityManager()->getRepository($options['entity']);
$user = $this->getSecurityContext->getToken()->getUser();
if(!$finder = $options['finder']) :
// Create a new default query builder with WHERE user_id clause
$builder = $repo->createQueryBuilder('e');
$builder->andWhere($builder->expr()->eq("e.user", $user->getId()));
else :
// Call finder method on repository
$builder = $repo->$finder($user);
endif;
// Edit the builder and add WHERE IN $items clause
$alias = $builder->getRootAlias() . "." . $options['field'];
$wherein = $builder->expr()->in($alias, $items);
$result = $builder->andwhere($wherein)->getQuery()->getResult();
// Set request attribute and we're done
$request->attributes->set($name, new ArrayCollection($result));
}
public function supports(ConfigurationInterface $configuration)
{
$class = $configuration->getClass();
// Check if class is ArrayCollection from Doctrine
if('Doctrine\Common\Collections\ArrayCollection' !== $class)
return false;
$options = $this->getOptions($configuration);
$manager = $this->getEntityManager();
// Check if $options['entity'] is actually a Dcontrine one
try
{
$manager->getClassMetadata($options['entity']);
return true;
}
catch(\Doctrine\ORM\Mapping\MappingException $e)
{
return false;
}
}
protected function getOptions(ConfigurationInterface $configuration)
{
return array_replace(
array(
'entity' => null,
'finder' => null,
'field' => 'id',
'delimiter' => ','
),
$configuration->getOptions()
);
}
/**
* #return \Doctrine\ORM\EntityManager
*/
protected function getEntityManager()
{
return $this->container->get('doctrine.orm.default_entity_manager');
}
/**
* #return \Symfony\Component\Security\Core\SecurityContext
*/
protected function getSecurityContext()
{
return $this->container->get('security.context');
}
}
Service definition
arraycollection_converter:
class: Acme\HelloBundle\Request\ArrayCollectionConverter
arguments: ['#service_container']
tags:
- { name: request.param_converter}
It's late, but according to latest documentation about #ParamConverter, you can achieve it follow way:
* #ParamConverter("users", class="AcmeBlogBundle:User", options={
* "repository_method" = "findUsersByIds"
* })
you just need make sure that repository method can handle comma (,) separated values