I have a problem with Api Platform and custom collection operation, when I need to manually require an argument in the route.
My first need is to GET on this route: query/userjob/[USER UUID] and retrieve a collection of all jobs for the given user.
My second need is to be able to GET on query/userjob/[USER UUID]/[JOB UUID] and retrieve details for the given user's job.
It might be important to say that I have no Api resource nor entity User, so I exclude all kind of subresource mapping or query.
So, let's say i have a UserJob ApiResource mapped as below:
App\Domain\User\Projection\UserJob:
itemOperations:
get:
method: 'GET'
path: '/userjob/{userId}/{jobId}'
requirements:
userId: '%uuid_regex%'
jobId: '%uuid_regex%'
collectionOperations:
get:
method: 'GET'
path: '/userjob/{userId}'
requirements:
userId: '%uuid_regex%'
attributes:
route_prefix: "/query"
In the class, I have:
final class UserJob
{
public $id; //int Auto inc
public $userId; //a UUID
public $jobId; //a UUID
public function __construct($userId, $jobId)
{
$this->userId = $userId;
$this->jobId = $jobId;
}
public function getId(): int
{
return $this->id;
}
public function getUserId()
{
return $this->userId;
}
public function getJobId()
{
return $this->jobId
}
I built a custom data provider for this class, in which I wrote the way to get the resource from the giver parameter (userId):
public function getCollection(string $resourceClass, string $operationName = null)
{
$userId = $this->request->getCurrentRequest()->attributes->get('userId');
return $this->repository->entityManager->getRepository($resourceClass)->findByUserId($userId);
}
When i make a GET call to, let's say, query/userjob/148e3200-f793-447b-bde8-af6b7b27372c it throws an exception:
Unable to generate an IRI for App\Domain\User\Projection\UserJob
And if I debug deeper, in the IRIConverter class, I find that the original exception is thrown from Router:
Some mandatory parameters are missing ("userId") to generate a URL for route "api_user_jobs_get_collection".
Nevertheless, if i dump the result of $this->repository->entityManager->getRepository($resourceClass)->findByUserId($userId);, all the elements that i'm looking for are well fetched from database.
So my intuition is that somehow ApiPlatform process fails to build the collection IRI that we usually can find at the beginning of the payload, and which in my case would be query/userjob/148e3200-f793-447b-bde8-af6b7b27372c.
And it fails while on the normalization or serialization process, because the "extra" param of my custom operation (the user UUID) is not passed to the collection normalizer, iri converter classes, so it has no way to give to the router the missing param to build the "api_user_jobs_get_collection" route.
What am I missing here? Is this a well-known problem that has a readymade solution that I missed ?
Or do I have to look for:
decorate the IRI converter?
use a custom normalizer?
do something with composite ids?
something else?
Your use case may have more solutions, and it depends what is preferred:
decorate iri converter, as you are using identifier in collection and this is not supported out of the box by API platform, as per my knowledge. And this is best choice if url like this are the style of your api.
use custom controller action with custom url style (docs: https://api-platform.com/docs/core/controllers/#creating-custom-operations-and-controllers), best if this is rare url in your api
Annotate your ids as identifiers in your api resource class (doc: https://api-platform.com/docs/core/identifiers/#custom-identifier-normalizer)
/**
* #var Uuid
* #ApiProperty(identifier=true)
*/
public $code;
but I haven't tried this with multiple ids, and this may work only for item url.
You may try to use custom data provider (docs: https://api-platform.com/docs/core/data-providers/). This will need to be done per resource or globally (supports() method) and you will need somehow (regex?) extract ids from url from $context array in getCollection() and getItem() methods. But as ApiPlatform will try to generate item iri, you may still end up with decorating iri converter.
Note: Using id in collection url may lead to other problems, like OpenAPI documentation generation. You may consider if what you want is not filtering of collection by "id" field, nicely supported, or retrieving collection of only "your" items. Which can be done by your data provider injecting security or by doctrine query extensions if someone uses doctrine.
Related
I use the symfony serializer to deserialize REST server answers to objects.
The data returned by the server is someting like this (pseudocode, the answer itself is JSON):
// Endpoint 1
class Paginated {
public items:Object1[]
public page:int
}
// Endpoint 2
class Paginated {
public: items:Object2[]
public page:int
}
So, every answer is wrapped in the same "Paginated" object.
Sice I don't want to repeat the common members in every object I want to implement the objects in my Symfony app the same way as described in this pseudo code.
The problem is, that PHP isn't supporting generics to typehint the "items" member and the symfony serializer doesn't seem to offer something similar.
So, whats the best way to tackle this problem?
An easy way to solve the issue is by using the callbacks context option. In that callback you can just deserialize the objects you are going to pass into Paginated yourself. You would then have different callbacks for each object-type you want to support in Paginated and register it. It doesn't have to be a closure like shown in the docs, you can also use a class with __invoke() to make it easier to reuse in different places.
Another way to solve this more generically is, by writing a custom Denormalizer that implements the DenormalizerAwareInterface (so it can delegate denormalization of the nested items back to the serializer.
Much like Symfony can recognize something like Object[] as a list of Objects, you can created your own custom type convention to simulate a Generic.
Assuming you want this to be something like Paginated<Object1>, then your serializer call would probably look something like this:
$serializer->deserialize($json, Paginated::class . '<' . Object1::class . '>', 'json');
Your (de)normalizer will then support the type matching the regex. Inside the denormalize method you would then take the array structure of your json, call something like `$denormalizedItems = $this->denormalizer->denormalize($data['items'], Object1::class . '[]'); and then put them into your Paginated object. Roughly like this:
public function denormalize($data, string $type, string $format = null, array $context = [])
{
$extractedObjectType = ...; #extract class name inside <>
$data['items'] = $this->denormalizer->denormalize($data['items'], $extractedType, $format, $context);
// Option 1: Delegate denormalizing Paginated with the adjusted data
return $this->denormalizer->denormalize($data, Paginated::class, $format, $context);
// Option 2: Denormalize Paginated yourself and pass adjusted data as argument
return new Paginated($data['items'], (int) $data['page']);
}
I'm trying to use Symfony Voters and Controller Annotation to allow or restrict access to certain actions in my Symfony 4 Application.
As an example, My front-end provides the ability to delete a "Post", but only if the user has the "DELETE_POST" attribute set for that post.
The front end sends an HTTP "DELETE" action to my symfony endpoint, passing the id of the post in the URL (i.e. /api/post/delete/19).
I'm trying to use the #IsGranted Annotation, as described here.
Here's my symfony endpoint:
/**
* #Route("/delete/{id}")
* #Method("DELETE")
* #IsGranted("DELETE_POST", subject="post")
*/
public function deletePost($post) {
... some logic to delete post
return new Response("Deleting " . $post->getId());
}
Here's my Voter:
class PostVoter extends Voter {
private $attributes = array(
"VIEW_POST", "EDIT_POST", "DELETE_POST", "CREATE_POST"
);
protected function supports($attribute, $subject) {
return in_array($attribute, $this->attributes, true) && $subject instanceof Post;
}
protected function voteOnAttribute($attribute, $subject, TokenInterface $token) {
... logic to figure out if user has permissions.
return $check;
}
}
The problem I'm having is that my front end is simply sending the resource ID to my endpoint. Symfony is then resolving the #IsGranted Annotation by calling the Voters and passing in the attribute "DELETE_POST" and the post id.
The problem is, $post is just a post id, not an actual Post object. So when the Voter gets to $subject instanceof Post it returns false.
I've tried injecting Post into my controller method by changing the method signature to public function deletePost(Post $post). Of course this does not work, because javascript is sending an id in the URL, not a Post object.
(BTW: I know this type of injection should work with Doctrine, but I am not using Doctrine).
My question is how do I get #IsGranted to understand that "post" should be a post object? Is there a way to tell it to look up Post from the id passed in and evaluated based on that? Or even defer to another controller method to determine what subject="post" should represent?
Thanks.
UPDATE
Thanks to #NicolasB, I've added a ParamConverter:
class PostConverter implements ParamConverterInterface {
private $dao;
public function __construct(MySqlPostDAO $dao) {
$this->dao = $dao;
}
public function apply(Request $request, ParamConverter $configuration) {
$name = $configuration->getName();
$object = $this->dao->getById($request->get("id"));
if (!$object) {
throw new NotFoundHttpException("Post not found!");
}
$request->attributes->set($name, $object);
return true;
}
public function supports(ParamConverter $configuration) {
if ($configuration->getClass() === "App\\Model\\Objects\\Post") {
return true;
}
return false;
}
}
This appears to be working as expected. I didn't even have to use the #ParamConverter annotation to make it work. The only other change I had to make to the controller was changing the method signature of my route to public function deletePost(Post $post) (as I had tried previously - but now works due to my PostConverter).
My final two questions would be:
What exactly should I check for in the supports() method? I'm currently just checking that the class matches. Should I also be checking that $configuration->getName() == "id", to ensure I'm working with the correct field?
How might I go about making it more generic? Am I correct in assuming that anytime you inject an entity in a controller method, Symfony will call the supports method on everything that implements ParamConverterInterface?
Thanks.
What would happen if you used Doctrine is that you'd need to type-hint your $post variable. After you've done that, Doctrine's ParamConverter would take care of the rest. Right now, Symfony has no idea how about how to related your id url placeholder to your $post parameter, because it doesn't know which Entity $post refers to. By type-hinting it with something like public function deletePost(Post $post) and using a ParamConverter, Symfony would know that $post refers to the Post entity with the id from the url's id placeholder.
From the doc:
Normally, you'd expect a $id argument to show(). Instead, by creating a new argument ($post) and type-hinting it with the Post class (which is a Doctrine entity), the ParamConverter automatically queries for an object whose $id property matches the {id} value. It will also show a 404 page if no Post can be found.
The Voter would then also know what $post is and how to treat it.
Now since you are not using Doctrine, you don't have a ParamConverter by default, and as we just saw, this is the crucial element here. So what you're going to have to do is simply to define your own ParamConverter.
This page of the Symfony documentation will tell you more about how to do that, especially the last section "Creating a Converter". You will have to tell it how to convert the string "id" into a Post object using your model's logic. At first, you can make it very specific to Post objects (and you may want to refer to that one ParamConverter explicitly in the annotation using the converter="name" option). Later on once you've got a working version, you can make it work more generic.
Using SF4 & Api platform :
I would like to have a route for performing a random computation, and returning result to the client.. this seems to be simple
However I am not able to do it easily with custom operations
Here it is what I have done
I have my entity, for request & reponse, using normalization to have field only in request, and not in response
And I have my Controller with my custom operation + the corresponding route only for collection (as I don't have any id) in yaml in the api_platform/resources.yaml file
The controller is taking the entity in input and responding with it, hydrated by the result
I am getting the error :
2018-08-18T16:22:42+02:00 [critical] Uncaught PHP Exception ApiPlatform\Core\Exception\InvalidArgumentException: "Unable to generate an IRI for the item of type "App\Entity\Computation"" at /dev/git/app-api/vendor/api-platform/core/src/Bridge/Symfony/Routing/IriConverter.php line 127
But I think this is when the server is serializing the reponse, as the computation is performed (log inside are printed)
Entity\Computation.php :
class Computation
{
/**
* #var double you input
*
* #Assert\NotBlank
* #Groups({"read","write"})
*/
public $input;
/**
* #var double the result
*
* #Groups({"read"})
*/
public $result;
}
Controller\ComputationController.php :
class ComputationController
{
private $service;
public function __construct(MyService $service)
{
$this->service= $service;
}
public function __invoke(Position $data): Response
{
$this->service->compute($data);
return $data;
}
}
api_platform/resources.yaml :
resources:
App\Entity\Computation:
itemOperations: {}
collectionOperations:
compute:
method: 'POST'
path: '/compute'
controller: 'App\Controller\ComputationController'
attributes:
normalization_context:
groups: ['read']
denormalization_context:
groups: ['write']
Can someone help me with it ?
Thanks !
I found a solution, custom operation is totally not the way to do this..
In documentation of custom operation, it states:
Note: the event system should be preferred over custom controllers when applicable.
So the correct way to do simple, not related to an entity, operation is the usage of a Data Transfer Object (DTO)
You dont have "get" item operation, which is mandatory. This is why Apip cant generate iri for you.
Assumed I have a normal Symfony entity "Car", a self written service "Log" and I want to build a rest route
PUT http://mysite.de/api/{version}/cars/{oldcar}
to do something with a car given in "oldcar" stored in my database. In the body of the PUT there comes another Car object in JSON notation with the new data. Then I have following method:
/**
* #Rest\Put("/api/{version}/cars/{oldcar}")
* #ParamConverter("oldcar", class="MyBundle\Entity\Car")
* #ParamConverter("newcar", class="MyBundle\Entity\\Car", converter="fos_rest.request_body")
*/
public function putAction(Log $logger, EntityManagerInterface $em, Car $oldcar, Car $newcar)
{
... update the oldcar in the database with data from the newcar ...
}
I got this allready working!
But now I want to know: Is there a configuration of the FosRestBundle to omit the #ParamConverter annotations? In my opinion there are enough information for the framework to do this automatically without them.
Log and EntityManagerInterface can be found in the service container. oldcar's and newcar's types are defined in the method signature. Primary key for database access for oldcar is given by the route. There is only the JSON data in the body left for newcar.
Does anybody here get this working without the #ParamConverter annotations?
Please tell me if and a short idear how.
I want to inject Doctrine entities into controller actions based on the route parameters in an attempt to reduce the insane amount of code duplication inside my controllers.
For example I have the following route
product:
path: /product/edit/{productId}
defaults: { _controller: ExampleBundle:Product:edit }
Instead of my current approach
public function editAction($productId)
{
$manager = $this->getDoctrine()->getManager();
$product = $manager->getRepository('ExampleBundle:Product')
->findOneByProductId($productId);
if (!$product) {
$this->addFlash('error', 'Selected product does not exist');
return $this->redirect($this->generateUrl('products'));
}
// ...
}
I'd like this to be handled else where as it's repeated in at least 6 controller actions currently. So it would be more along the lines of
public function editAction(Product $product)
{
// ...
}
It seems this has in fact been done before and the best example I can find is done by the SensioFrameworkBundle http://symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/converters.html
I'd use this but were not using annotations in our Symfony projects so need to look at alternatives. Any suggestions on how this can be achieved?
If you read the docs carefully, you'll learn that param converters actually work without annotations:
To detect which converter is run on a parameter the following process is run:
If an explicit converter choice was made with #ParamConverter(converter="name") the converter with the given name is chosen.
Otherwise all registered parameter converters are iterated by priority. The supports() method is invoked to check if a param converter can convert the request into the required parameter. If it returns true the param converter is invoked.
In other words if you don't specify a param converter in an annotation, Symfony will iterate through all registered converters and find the most appropriate one to handle your argument (based on a type hint).
I prefer to put an annotation in order to:
be explicit
save some processing time