Custom GET operation for API platform generates wrong documentation - symfony

I have added the following operation under TeachingClass entity.
App\Entity\TeachingClass:
collectionOperations:
# ...
itemOperations:
# ...
get_learning_skills:
method: GET
path: /auth/v1/teaching-class/{id}/learning-skills
resourceClass: 'App\Entity\LearningSkill' # Doesn't seem to work
controller: App\Controller\Api\LearningSkillApiController
normalization_context:
groups: ['learning_skill_list']
security: 'is_granted("HAS_TEACHING_CLASS_ACCESS", object)'
swagger_context:
summary: "Retrieves the collection of LearningSkill resources belonging to a specific TeachingClass."
description: "LearningSkills belonging to a specific TeachingClass"
The end-point correctly returns a collection of LearningSkill entities by the configured controller:
<?php
namespace App\Controller\Api;
use App\Entity\LearningSkill;
use App\Entity\TeachingClass;
use App\Repository\LearningSkillRepository;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
/**
* Class LearningSkillApiController.
*/
class LearningSkillApiController
{
private $learningSkillRepository;
public function __construct(LearningSkillRepository $learningSkillRepository)
{
$this->learningSkillRepository = $learningSkillRepository;
}
public function __invoke(TeachingClass $data)
{
return $this->byTeachingClass($data);
}
private function byTeachingClass(TeachingClass $teachingClass)
{
return $this->learningSkillRepository->findByTeachingClass($teachingClass);
}
}
However, my problem is that the generated API doc is wrong:
How do I make the documentation reflect that the response is a collection of LearningSkill entities (instead of a TeachingClass entity)?

I had the same problem with the report in the chapter9-api branch of my tutorial, which outputs instances of DayTotalsPerEmployee instead of the class the endpoint is on. My solution was to make a SwaggerDecorator. Below is one adapted for your operation.
It also sets the descriptions in components schemas referred to by the response 200 content. This is based on the assumption that your response is a collection response. It apip thinks it is an item response there may be some more work to to to make the swagger docs describe a collection response.
<?php
namespace App\Swagger;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
final class SwaggerDecorator implements NormalizerInterface
{
private $decorated;
public function __construct(NormalizerInterface $decorated)
{
$this->decorated = $decorated;
}
public function normalize($object, string $format = null, array $context = [])
{
$summary = 'The collection of LearningSkill resources belonging to a specific TeachingClass.';
$docs = $this->decorated->normalize($object, $format, $context);
$docs['paths']['/auth/v1/teaching-class/{id}/learning-skills']['get']['responses']['200']['description'] = 'LearningSkills collection response';
$responseContent = $docs['paths']['/auth/v1/teaching-class/{id}/learning-skills']['get']['responses']['200']['content'];
$this->setByRef($docs, $responseContent['application/ld+json']['schema']['properties']['hydra:member']['items']['$ref'],
'description', $summary);
$this->setByRef($docs, $responseContent['application/json']['schema']['items']['$ref'],
'description', $summary);
return $docs;
}
public function supportsNormalization($data, string $format = null)
{
return $this->decorated->supportsNormalization($data, $format);
}
private function setByRef(&$docs, $ref, $key, $value)
{
$pieces = explode('/', substr($ref, 2));
$sub =& $docs;
foreach ($pieces as $piece) {
$sub =& $sub[$piece];
}
$sub[$key] = $value;
}
}
To configure the service add the following to api/config/services.yaml:
'App\Swagger\SwaggerDecorator':
decorates: 'api_platform.swagger.normalizer.api_gateway'
arguments: [ '#App\Swagger\SwaggerDecorator.inner' ]
autoconfigure: false

Related

Api-platform, JWT token and endpoints sending back data owned by the identified user

I'm using PHP symfony with API-platform with JWT token (through LexikJWTAuthenticationBundle), latest version as of today.
I've read quite a lot of things and I know how to do the basic stuff:
Create an API exposing my entities,
Protect certain endpoints with JWT
Protecting certain endpoints with user_roles
What I'm trying to do now is to have the API only sends back data that belongs to a user instead of simply sending back everything contained in the database and represented by an entity. I've based my work on this but this does not take into account the JWT token and I don't know how to use the token in the UserFilter class : https://api-platform.com/docs/core/filters/#using-doctrine-orm-filters
Here is my Book entity :
<?php
// api/src/Entity/Book.php
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use App\Entity\User;
use App\Attribute\UserAware;
/** A book. */
#[ORM\Entity]
#[ApiResource(operations: [
new Get(),
new GetCollection(),
new Post(),
new Put(),
new Patch(),
new Delete()
])]
#[UserAware(userFieldName: "id")]
class Book
{
/** The id of this book. */
#[ORM\Id, ORM\Column, ORM\GeneratedValue]
private ?int $id = null;
/** The ISBN of this book (or null if doesn't have one). */
#[ORM\Column(nullable: true)]
#[Assert\Isbn]
public ?string $isbn = null;
/** The title of this book. */
#[ORM\Column]
#[Assert\NotBlank]
public string $title = '';
/** The description of this book. */
#[ORM\Column(type: 'text')]
#[Assert\NotBlank]
public string $description = '';
/** The author of this book. */
#[ORM\Column]
#[Assert\NotBlank]
public string $author = '';
/** The publication date of this book. */
#[ORM\Column(type: 'datetime')]
#[Assert\NotNull]
public ?\DateTime $publicationDate = null;
/** #var Review[] Available reviews for this book. */
#[ORM\OneToMany(targetEntity: Review::class, mappedBy: 'book', cascade: ['persist', 'remove'])]
public iterable $reviews;
#[ORM\Column(length: 255, nullable: true)]
private ?string $publisher = null;
/** The book this user is about. */
#[ORM\ManyToOne(inversedBy: 'books')]
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id')]
#[Assert\NotNull]
public ?User $user = null;
public function __construct()
{
$this->reviews = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getPublisher(): ?string
{
return $this->publisher;
}
public function setPublisher(?string $publisher): self
{
$this->publisher = $publisher;
return $this;
}
}
Here is my UserFilter class :
<?php
// api/src/Filter/UserFilter.php
namespace App\Filter;
use App\Attribute\UserAware;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use App\Entity\User;
final class UserFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
{
// The Doctrine filter is called for any query on any entity
// Check if the current entity is "user aware" (marked with an attribute)
$userAware = $targetEntity->getReflectionClass()->getAttributes(UserAware::class)[0] ?? null;
$fieldName = $userAware?->getArguments()['userFieldName'] ?? null;
if ($fieldName === '' || is_null($fieldName)) {
return '';
}
try {
$userId = $this->getParameter('id');
// Don't worry, getParameter automatically escapes parameters
} catch (\InvalidArgumentException $e) {
// No user id has been defined
return '';
}
if (empty($fieldName) || empty($userId)) {
return '';
}
return sprintf('%s.%s = %s', $targetTableAlias, $fieldName, $userId);
}
}
Here is my UserAware class :
<?php
// api/Annotation/UserAware.php
namespace App\Attribute;
use Attribute;
#[Attribute(Attribute::TARGET_CLASS)]
final class UserAware
{
public $userFieldName;
}
I added this to my config/packages/api_platform.yaml file:
doctrine:
orm:
filters:
user_filter:
class: App\Filter\UserFilter
enabled: true
It obviously does not work, since I'm not making the bridge between the JWT token and the filter, but I have no idea how to do it. What am I missing?
The current results I have is that the GET /api/books sends back all the books stored in the database instead of sending only the ones belonging to the JWT authenticated user.
EDIT:
And for those who want the answer for ManyToMany related entities here it is : Api-platform, filtering collection result based on JWT identified user on a ManyToMany relational entity
Instead of Doctrine Filter, you could use Doctrine Extension as described here.
In your case it would need:
Create the doctrine extension:
<?php
// api/src/Doctrine/CurrentUserExtension.php
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Book;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\Security;
final class CurrentUserExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
private $security;
public function __construct(Security $security)
{
$this->security = $security;
}
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, Operation $operation = null, array $context = []): void
{
$this->addWhere($queryBuilder, $resourceClass);
}
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, Operation $operation = null, array $context = []): void
{
$this->addWhere($queryBuilder, $resourceClass);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
{
if (Book::class !== $resourceClass || $this->security->isGranted('ROLE_ADMIN') || null === $user = $this->security->getUser()) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.user = :current_user', $rootAlias));
$queryBuilder->setParameter('current_user', $user->getId());
}
}
The main logic is in the addWhere() method:
applies only if you are dealing with Book entity (but you could extend the idea to a list of entities here)
check if the user is granted admin (if so here it skips the extension, allowing admin to fetch all books)
skip if the user isn't authenticated (you should prevent this access with firewall or security arribute in your endpoints)
Then it adds a where condition to the SQL query to filter by userId (or any other condition you'll need)
Don't forget to eanble your filter:
# api/config/services.yaml
services:
# ...
'App\Doctrine\CurrentUserExtension':
tags:
- { name: api_platform.doctrine.orm.query_extension.collection }
- { name: api_platform.doctrine.orm.query_extension.item }

How to hide item from collection depending on some field value?

I override (custom operation and service) the DELETE operation of my app to avoid deleting data from DB. What I do is I update a field value: isDeleted === true.
Here is my controller :
class ConferenceDeleteAction extends BaseAction
{
public function __invoke(EntityService $entityService, Conference $data)
{
$entityService->markAsDeleted($data, Conference::class);
}
...
My service :
class EntityService extends BaseService
{
public function markAsDeleted(ApiBaseEntity $data, string $className)
{
/**
* #var ApiBaseEntity $entity
*/
$entity = $this->em->getRepository($className)
->findOneBy(["id" => $data->getId()]);
if ($entity === null || $entity->getDeleted()) {
throw new NotFoundHttpException('Unable to find this resource.');
}
$entity->setDeleted(true);
if ($this->dataPersister->supports($entity)) {
$this->dataPersister->persist($entity);
} else {
throw new BadRequestHttpException('An error occurs. Please do try later.');
}
}
}
How can I hide the "deleted" items from collection on GET verb (filter them from the result so that they aren't visible) ?
Here is my operation for GET verb, I don't know how to handle this :
class ConferenceListAction extends BaseAction
{
public function __invoke(Request $request, $data)
{
return $data;
}
}
I did something; I'm not sure it's a best pratice.
Since when we do :
return $data;
in our controller, API Platform has already fetch data and fill $data with.
So I decided to add my logic before the return; like :
public function __invoke(Request $request, $data)
{
$cleanDatas = [];
/**
* #var Conference $conf
*/
foreach ($data as $conf) {
if (!$conf->getDeleted()) {
$cleanDatas[] = $conf;
}
}
return $cleanDatas;
}
So now I only have undeleted items. Feel free to let me know if there is something better.
Thanks.
Custom controllers are discouraged in the docs. You are using Doctrine ORM so you can use a Custom Doctrine ORM Extension:
// api/src/Doctrine/ConferenceCollectionExtension.php
namespace App\Doctrine;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Entity\Conference;
use Doctrine\ORM\QueryBuilder;
final class CarCollectionExtension implements QueryCollectionExtensionInterface
{
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null): void
{
if ($resourceClass != Conference::class) return;
$rootAlias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere("$rootAlias.isDeleted = false OR $rootAlias.isDeleted IS NULL);
}
}
This will automatically be combined with any filters, sorting and pagination of collection operations with method GET.
You can make this Extension specific to an operation by adding to the if statement something like:
|| $operationName == 'conference_list'
If you're not using the autoconfiguration, you have to register the custom extension:
# api/config/services.yaml
services:
# ...
'App\Doctrine\ConferenceCollectionExtension':
tags:
- { name: api_platform.doctrine.orm.query_extension.collection }
If you also want to add a criterium for item operations, see the docs on Extensions

Denormalizer on MongoDb Embedded Document in Symfony API Platform

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

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

Symfony3 - Serializing nested entities

So I have a couple doctrine entities, a Subscription and a Subscriber. There are many Subscriptions to a single subscriber (manyToOne). I wrote custom normalizers for both entities, but am having trouble getting the Subscriber to show up in the Subscription once it has been normalized to JSON.
The only way I've been able to get it to work is by passing the 'Subscriber' normalizer to the 'Subscription' normailizer. It seems like I should just be able to use the SerializerAwareNormalizer Trait, or something like that, to have Symfony recursively normalize my related entities.
services:
acme.marketing.api.normalizer.subscription:
class: acme\MarketingBundle\Normalizer\SubscriptionNormalizer
arguments: ['#acme.marketing.api.normalizer.subscriber']
public: false
tags:
- { name: serializer.normalizer }
acme.marketing.api.normalizer.subscriber:
class: acme\MarketingBundle\Normalizer\SubscriberNormalizer
public: false
tags:
- { name: serializer.normalizer }
and the normalizer...
<?php
namespace acme\MarketingBundle\Normalizer;
use acme\MarketingBundle\Entity\Subscription;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class SubscriptionNormalizer implements NormalizerInterface
{
private $subscriberNormalizer;
public function __construct($subscriberNormalizer)
{
$this->subscriberNormalizer = $subscriberNormalizer;
}
public function normalize($subscription, $format = null, array $context = [])
{
/* #var $subscription Subscription */
$subscriber = $subscription->getSubscriber();
return [
"id" => $subscription->getId(),
"subscriber" => $this->subscriberNormalizer->normalize($subscriber, $format)
];
}
public function supportsNormalization($data, $format = null)
{
return $data instanceof Subscription;
}
}
Is there a better way to accomplish this?
Spent a few hours on google and couldn't figure it out. Post on SO and 5 minutes later hit the right google link :(. Answer seems to be to implement NormalizerAwareInterface on the custom normalizer, and then use the NormalizerAwareTrait to get access to the normalizer for nested entities.
<?php
namespace acme\MarketingBundle\Normalizer;
use acme\MarketingBundle\Entity\Subscription;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
class SubscriptionNormalizer implements NormalizerInterface, NormalizerAwareInterface
{
use NormalizerAwareTrait;
public function normalize($subscription, $format = null, array $context = [])
{
return [
"id" => $subscription->getId(),
"subscriber" => $this->normalizer->normalize($subscription->getSubscriber())
];
}
public function supportsNormalization($data, $format = null)
{
return $data instanceof Subscription;
}
}

Resources