Subrecursos
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeTenemos dos formas distintas de obtener los tesoros dragón de un usuario. La primera, podríamos obtener el User y leer su propiedad dragonTreasures. La segunda es a través del filtro que hemos añadido hace un momento. En la API, eso parece owner=/api/users/4 en la operación de recogida de tesoros GET.
Esta es mi forma habitual de obtener los datos... porque si quiero obtener tesoros, tiene sentido utilizar una ruta treasures. Además, si un usuario posee muchos tesoros, ¡eso nos dará paginación!
Pero a veces puedes optar por añadir una forma especial de obtener un recurso o una colección de recursos... casi como una URL de vanidad. Por ejemplo, imagina que, para obtener esta misma colección, queremos que el usuario pueda ir a/api/users/4/treasures.jsonld. Eso, por supuesto, no funciona. Pero se puede hacer. Esto se llama un subrecurso, y los subrecursos son mucho más agradables en la API Platform 3.
Añadir un Subrecurso a través de otro ApiResource
Bien, pensemos. Esta ruta devolverá tesoros. Así que para añadir este subrecurso, tenemos que actualizar la clase DragonTreasure.
¿Cómo? Añadiendo un segundo atributo ApiResource. Ya tenemos este principal, así que ahora añade uno nuevo. Pero esta vez, controla la URL con una opción uriTemplate ajustada exactamente a lo que queremos: /users/{user_id} para la parte del comodín (veremos cómo se utiliza en un momento) y luego /treasures.
Ya está Bueno... añade también .{_format}. Esto es opcional, pero es la magia que nos permite "hacer trampas" y añadir este .jsonld al final de la URL.
| // ... lines 1 - 54 | |
| ( | |
| uriTemplate: '/users/{user_id}/treasures.{_format}', | |
| // ... line 57 | |
| ) | |
| // ... lines 59 - 62 | |
| class DragonTreasure | |
| { | |
| // ... lines 65 - 222 | |
| } |
A continuación, añade operations... porque no necesitamos los seis... en realidad sólo necesitamos uno. Entonces, digamos [new GetCollection()] porque devolveremos una colección de tesoros.
| // ... lines 1 - 54 | |
| ( | |
| uriTemplate: '/users/{user_id}/treasures.{_format}', | |
| operations: [new GetCollection()], | |
| ) | |
| // ... lines 59 - 62 | |
| class DragonTreasure | |
| { | |
| // ... lines 65 - 222 | |
| } |
Vale, ¡vamos a ver qué ha hecho esto! Vuelve a la documentación y actualízala. De repente tenemos... ¡tres recursos y éste tiene la URL correcta!
Ah, y tenemos tres recursos porque, si recuerdas, hemos personalizado elshortName. Cópialo y pégalo en el nuevo ApiResource para que coincidan. Y para contentar a PhpStorm, los pondré en orden.
| // ... lines 1 - 54 | |
| ( | |
| uriTemplate: '/users/{user_id}/treasures.{_format}', | |
| shortName: 'Treasure', | |
| operations: [new GetCollection()], | |
| ) | |
| // ... lines 60 - 63 | |
| class DragonTreasure | |
| { | |
| // ... lines 66 - 223 | |
| } |
Ahora cuando actualicemos... ¡perfecto! ¡Eso es lo que queríamos!
Comprender las uriVariables
Ahora tenemos una nueva operación para obtener tesoros. Pero, ¿funciona? Dice que recuperará una colección de recursos de tesoros, así que eso está bien. Pero... tenemos un problema. Piensa que tenemos que pasar el id de un DragonTreasure... ¡pero debería ser el id de un User! E incluso si pasamos algo, como 4... y pulsamos "Ejecutar" ... ¡mira la URL! Ni siquiera ha utilizado el 4: ¡sigue teniendo{user_id} en la URL! Así que, por supuesto, vuelve con un error 404.
El problema es que tenemos que ayudar a API Platform a entender qué significa {user_id}. Tenemos que decirle que ése es el id del usuario y que debe utilizarlo para consultar WHERE owner_id es igual al valor.
Para ello, añade una nueva opción llamada uriVariables. Aquí es donde describimos cualquier "comodín" de tu URL. Pasa user_id ajustado a un objeto new Link(). Hay varios... queremos el de ApiPlatform\Metadata.
| // ... lines 1 - 11 | |
| use ApiPlatform\Metadata\Link; | |
| // ... lines 13 - 55 | |
| ( | |
| uriTemplate: '/users/{user_id}/treasures.{_format}', | |
| shortName: 'Treasure', | |
| operations: [new GetCollection()], | |
| uriVariables: [ | |
| 'user_id' => new Link( | |
| // ... lines 62 - 63 | |
| ), | |
| ], | |
| ) | |
| // ... lines 67 - 70 | |
| class DragonTreasure | |
| { | |
| // ... lines 73 - 230 | |
| } |
Este objeto necesita dos cosas. Primero, apuntar a la clase a la que se refiere {user_id}. Hazlo pasando una opción fromClass establecida en User::class.
| // ... lines 1 - 11 | |
| use ApiPlatform\Metadata\Link; | |
| // ... lines 13 - 55 | |
| ( | |
| uriTemplate: '/users/{user_id}/treasures.{_format}', | |
| shortName: 'Treasure', | |
| operations: [new GetCollection()], | |
| uriVariables: [ | |
| 'user_id' => new Link( | |
| // ... line 62 | |
| fromClass: User::class, | |
| ), | |
| ], | |
| ) | |
| // ... lines 67 - 70 | |
| class DragonTreasure | |
| { | |
| // ... lines 73 - 230 | |
| } |
En segundo lugar, necesitamos definir qué propiedad de User apunta a DragonTreasurepara que pueda averiguar cómo estructurar la consulta. Para ello, establece fromPropertyen treasures. Así, dentro de User, estamos diciendo que esta propiedad describe la relación. Ah, pero lo he estropeado todo: la propiedad es dragonTreasures.
| // ... lines 1 - 11 | |
| use ApiPlatform\Metadata\Link; | |
| // ... lines 13 - 55 | |
| ( | |
| uriTemplate: '/users/{user_id}/treasures.{_format}', | |
| shortName: 'Treasure', | |
| operations: [new GetCollection()], | |
| uriVariables: [ | |
| 'user_id' => new Link( | |
| fromProperty: 'dragonTreasures', | |
| fromClass: User::class, | |
| ), | |
| ], | |
| ) | |
| // ... lines 67 - 70 | |
| class DragonTreasure | |
| { | |
| // ... lines 73 - 230 | |
| } |
Vale, vuelve y actualiza. Debajo de la ruta... ¡sí! Dice "Identificador de usuario". Volvamos a poner 4, le damos a "Ejecutar" y... ya está. ¡Ahí están los cinco tesoros de este usuario!
Y en la otra pestaña del navegador... si refrescamos... ¡funciona!
Cómo se hace la consulta
Entre bastidores, gracias a Link, API Platform realiza básicamente la siguiente consulta:
SELECT * FROM dragon_treasure WHERE owner_id =
lo que pasemos por {user_id}. Sabe cómo hacer esa consulta mirando la relación Doctrine y averiguando qué columna utilizar. Es superinteligente.
De hecho, podemos verlo en el perfilador. Ve a /_profiler, haz clic en nuestra petición... y, aquí abajo, vemos 2 consultas... que son básicamente iguales: la 2ª se utiliza para el "total de elementos" para la paginación.
Si haces clic en "Ver consulta formateada" en la consulta principal... ¡es aún más compleja de lo que esperaba! Tiene un INNER JOIN... pero básicamente está seleccionando todos los datos de tesoros de dragones donde owner_id = el ID de ese usuario.
¿Qué pasa con toProperty?
Por cierto, si echas un vistazo a la documentación, también hay una forma de configurar todo esto a través del otro lado de la relación: diciendo toProperty: 'owner'.
Esto sigue funcionando... y funciona exactamente igual. Pero yo recomiendo seguir confromProperty, que es coherente y, creo, más claro. El toProperty sólo es necesario si no has mapeado el lado inverso de una relación... como si no hubiera una propiedad dragonTreasures en User. A menos que te encuentres en esa situación, quédate con fromProperty.
¡No olvides la normalizaciónContexto!
Todo esto funciona muy bien, excepto por un pequeño problema. Si vuelves a mirar los datos, ¡muestra los campos equivocados! Lo devuelve todo, como id yisPublished.
Se supone que no deben incluirse gracias a nuestros grupos de normalización. Pero como no hemos especificado ningún grupo de normalización en el nuevo ApiResource, el serializador lo devuelve todo.
Para solucionarlo, copia el normalizationContext y pégalo aquí abajo. No tenemos que preocuparnos por denormalizationContext porque no tenemos ninguna operación que haga ninguna desnormalización.
| // ... lines 1 - 11 | |
| use ApiPlatform\Metadata\Link; | |
| // ... lines 13 - 55 | |
| ( | |
| uriTemplate: '/users/{user_id}/treasures.{_format}', | |
| shortName: 'Treasure', | |
| operations: [new GetCollection()], | |
| uriVariables: [ | |
| 'user_id' => new Link( | |
| fromProperty: 'dragonTreasures', | |
| fromClass: User::class, | |
| ), | |
| ], | |
| normalizationContext: [ | |
| 'groups' => ['treasure:read'], | |
| ], | |
| ) | |
| // ... lines 70 - 73 | |
| class DragonTreasure | |
| { | |
| // ... lines 76 - 233 | |
| } |
Si refrescamos ahora... ¡lo tenemos!
Una única ruta de subrecursos
Vamos a añadir un subrecurso más para ver un caso ligeramente distinto. Primero te mostraré la URL que quiero. Tenemos un tesoro con el ID 11. Esto significa que podemos ir a /api/treasures/11.jsonld para verlo. Ahora quiero poder añadir /owner al final para obtener el usuario al que pertenece este tesoro. Ahora mismo, eso no funciona .... así que ¡manos a la obra!
Como el recurso que se devolverá es un User, esa es la clase que necesita el nuevo Recurso API.
Sobre ella, añade #[ApiResource()] con uriTemplate ajustado a/treasures/{treasure_id} para el comodín (aunque puede llamarse como quieras), seguido de /owner.{_format}.
| // ... lines 1 - 24 | |
| ( | |
| uriTemplate: '/treasures/{treasure_id}/owner.{_format}', | |
| // ... lines 27 - 34 | |
| ) | |
| // ... lines 36 - 38 | |
| class User implements UserInterface, PasswordAuthenticatedUserInterface | |
| { | |
| // ... lines 41 - 187 | |
| } |
A continuación, pasa uriVariables con treasure_id establecido en new Link() - el deApiPlatform\Metadata. Dentro, fija fromClass a DragonTreasure::class. Y como la propiedad dentro de DragonTreasure que hace referencia a esta relación esowner, añade fromProperty: 'owner'.
| // ... lines 1 - 7 | |
| use ApiPlatform\Metadata\Link; | |
| // ... lines 9 - 24 | |
| ( | |
| uriTemplate: '/treasures/{treasure_id}/owner.{_format}', | |
| // ... line 27 | |
| uriVariables: [ | |
| 'treasure_id' => new Link( | |
| fromProperty: 'owner', | |
| fromClass: DragonTreasure::class, | |
| ), | |
| ], | |
| // ... line 34 | |
| ) | |
| // ... lines 36 - 38 | |
| class User implements UserInterface, PasswordAuthenticatedUserInterface | |
| { | |
| // ... lines 41 - 187 | |
| } |
También sabemos que vamos a necesitar el normalizationContext... así que cópialo... y pégalo aquí. Por último, sólo queremos una operación: una operación GET para devolver un único User. Así que añade operations ajustado a [new Get()].
| // ... lines 1 - 6 | |
| use ApiPlatform\Metadata\Get; | |
| use ApiPlatform\Metadata\Link; | |
| // ... lines 9 - 24 | |
| ( | |
| uriTemplate: '/treasures/{treasure_id}/owner.{_format}', | |
| operations: [new Get()], | |
| uriVariables: [ | |
| 'treasure_id' => new Link( | |
| fromProperty: 'owner', | |
| fromClass: DragonTreasure::class, | |
| ), | |
| ], | |
| normalizationContext: ['groups' => ['user:read']], | |
| ) | |
| // ... lines 36 - 38 | |
| class User implements UserInterface, PasswordAuthenticatedUserInterface | |
| { | |
| // ... lines 41 - 187 | |
| } |
¡Ya está! Vuelve a la documentación, actualízala y echa un vistazo en "Usuario". ¡Sí! ¡Tenemos una nueva operación! E incluso ve que el comodín es un "identificador DragonTreasure".
Si actualizamos la otra pestaña... ¡funciona!
Vale, equipo, he mentido al decir que éste era el último tema porque... ¡es hora del tema extra! A continuación: vamos a crear automáticamente un área de administración basada en React a partir de nuestros documentos de la API. Vaya.
24 Comments
Just a nitpick at Api platform I guess (Using Symfony 6.2 from the project code but api platform 3.2 as that is what is installed with 'composer require api'):
The subresource works, I can enter '4' in the docs for the user_id field and I get all treasures that have /api/users/4 as owner...
... but it is still called 'Treasure identifier' in my docs (even after a cache clear), while it's called 'User identifier' in your tutorial?
Same small issue here.
I found this comment which sounds like the solution: https://github.com/api-platform/core/issues/5343#issuecomment-1400434403
I am not going to try this fix right now since this is just tutorial code so I am not sure if it works.
Remember that it's just an array you are passing to an Attribute Class.
As in, you could make a class somewhere that returns that array from a static function, and just reference to that function from inside the ApiResource block. That way you cut down on the amount of openapi stuff that's there, specially if it's just for touching up the documentation.
Another method is to decorate the
api_platform.openapi.factoryservice.In the __invoke() method of your decorator:
Now your decorator does nothing, it's a no-op. But before the
return $openApicall, you can modify the returned OpenApi object, to do as you wish. As in, fix descriptions, change names, add (more) example payloads, add whole operations, etc..We use it to add custom endpoints from other bundles, or from the auth system, to add it to the OpenApi dump so it's a neat complete documentation, even filled with endpoints and resources that don't come from API platform.
Hey Joris-Mak,
Thanks for sharing this trick with others!
Cheers!
Hi,
I was wondering how to make posts to subresources because you don't show it in the screencast and it does not appear anywhere in the docs.
for example lets say I have a company-employee relationship and want to create new employees for a specific company like /api/companies/1/employees
This is my resource:
But when i post I get this error:
Can someone explain to me how to POST a sub-resource that then gets automatically associated with it's base resource via the id of the base resource given as url parameter?
Hey @Fireball!
Sorry for the very slow reply, life is getting in the way of my availability, and sometimes the team saves tough questions like this for me :).
I maybe don't know the answer either, but I do have some thoughts:
1) You won't like this answer, but if it were me, I would just POST to the non-subresource URL and save yourself the trouble
2) Buuuuuut, let's look deeper at
POST /api/companies/1/employees. It looks like API Platform is trying to load the ONECompany, then getting surprised by the many results. The way you've structured the request makes sense to me, but you could also consider that what you're trying to do is "modify" the resource located at/api/companies/1/employees(modify the employees resource for the companies). Again, what you're doing makes sense to me, and maybe the solution is some simple tweak to youroperations(though I don't see it). My point is, API Platform might want you toPATCHto/api/companies/1/employeesand send the entire payload of all employees. Again, I realize that's not what you want, just trying to shine some possible reasons for why API Platform may not be playing nicely.... or maybe there IS a simple solution and someone can tell us ;). That'd be the best.
Cheers!
Hi Ryan,
thank you for your answer! A few days later I found the solution but forgot I posed here the question.
There is a Provider in ApiPlatform that is marked as experimental and internal but solves exactly this problem.
It's this one:
ApiPlatform\State\CreateProviderI needed it for the following case:
I have an Entity that can have one or more related files which are modeled as Entities using VichUploader. Now while it would work, it would be a bit ugly to mix multipart/form-data with additional variables like the id of the related parent collection, so I wanted to pass the id via url like POST
/api/post/12/files(my upload Endpoint). Files can't be edited, only created and deleted.I created a copy of the CreateProvider in my own code in case it gets deleted in a future ApiPlatform update.
Hi, how to make a subressouces with a ManyToMany relation ? Thanks in advance :)
Hey Julien,
With ManyToMany you need to make this relationship first. We talk about this kind of relationship here: https://symfonycasts.com/screencast/doctrine-relations/many-to-many - but you may want to use this approach with OneToMany / ManyToOne to be able to save some extra fields on ManyToMany relationship, see: https://symfonycasts.com/screencast/doctrine-relations/complex-many-to-many
I hope that helps!
Cheers!
Hey there,
unfortunately, the subresource produced an semantical error.
But I was able to fix it.
My entities are no treasures and users, but items and facets.
So, wehen I did this on the item entity:
I got: "[Semantical Error] line 0, col 40 near 'facets IN (SELECT': Error: Invalid PathExpression. StateFieldPathExpression or SingleValuedAssociationField expected."
This seems to be an SQL related issue, which I do not really understand
BUT! There is a fix! Using toClass and toProperty Attributes:
Working fine now :)
So if any of you got that problem, try this.
Hey @ulfgar-hammerschlag
That's unexpected :) - Perhaps it is due to how your entities relate to each other, but I can't know for certain. Anyways, thank you for sharing it!
Cheers!
When you created the dragon treasures sub resource the sql query used a sub query which I feel is less than ideal. Is there a particular reason it defaults to that approach? And how would I override that?
Hey @Ammar!
I don't know the specifics about why the query is done this way, but yes, I believe in the latest version of API Platform you can override this - see https://github.com/api-platform/core/pull/5732
However, I've never done this - so I'm passing along the hint but I'm not sure if this is exactly what you want. But, I'd love to know if it helps!
Cheers!
Hey Ryan, I have some question regarding subresource (maybe that's not even the solution to my problem):
So we're building a Web-App where a user can manage one or more projects, and the has DIFFERENT roles/permissions for every project. In one he is the manager and sees almost everything, in the other, he is just a normal user and sees only his own stuff. How should I handle the request/uris and permission?
1) With Subresources like
/project/123/task/456, so it's perfectly clear how to access the data, but every ApiResouce needs to be prefixed with "project/:projectId". How do I know the User has the sufficient privileges to access the project or the task? I can't use Default Role Voter, cause it uses the User->roles property that is unaware of the current project (like "is_granted")2) With Filters as payload (but won't work for GET requests, or?)
3) Maybe some kind of state, like a POST selectProject {id: 123} that sets some kind of session value that is automatically injected in every query (in a QueryExtension)
All in all I think it should be possible with the above ideas, but it feels like a lot of effort
Hey @Sebastian-K!
Hmm, interesting! Subresources are cool - and are MUCH nicer than in API Platform 2 (they were kind of a hacked addon the, but they're a first-class citizen now). And so, we can definitely use them. But we also may not need to.
Look at the URL:
/project/123/task/456. That's gorgeous! But/task/456is technically just as functional. If eachTaskhas a relation to itsProject, then from/task/456, we can look up the project from theTaskand then see if the currently-authenticated user is an owner or not. Actually, even if I used subresources, I'd do the same thing: subresources are ultimately a bit of a "vanity" URL. At the end of the day, the object being loaded isTaskwith id456.So, for security, I'd create a custom voter (you're right that the default Role voter doesn't work when you need to decide permission based on some data - like I DO have permission to see Task 456, but not Task 123). Fortunately, we show this a bit in the next episode - https://symfonycasts.com/screencast/api-platform-security/access-control-voter - we first (in earlier chapters) show security using an expression, then we refactor it to a voter here. This strategy I think would work the same whether you decided to use a subresource or not. The
/project/123part of the URL just isn't that important (again, when you go to/project/123/task/456, it really just queries for Task456and THEN you run security checks. I DO think, though you could verify, that if a mischievous user changed the URL to/project/111/task/456, whereTaskDOES belong toProject123, then it would result in a 404).For "collection" resources, the strategy for filtering is slightly different - we talk about it here - https://symfonycasts.com/screencast/api-platform-security/query-extension
This part MAY differ slightly based on if you're using a subresource or not - but I'm not entirely sure:
A) If you do
/tasks, then you can use a query extension like above to modify the query to only return tasks that are related to projects that the current user should have access to.B) If you do
/project/123/tasks, then API Platform will automatically only show tasks for project 123. But, what if the user doesn't have access toProject123 at all? I'm actually not entirely sure how to handle this. The simplest solution is to, like with (A), create a query extension "to only return tasks that are related to projects that the current user should have access to". In that case, if the user doesn't have access toProject123, the query would effectively be:So you'd filter to only tasks for projects the user should be able to see... and if that doesn't include project 123, it would result in null rows. The other way to do it would be to make
/projects/123/tasksreturn a 404, but I'm not entirely sure how to do that :).Let me know if this helps!
Cheers!
Thanks for the reply. Gave me new points to think about
I would now have defined
/users/{user_id}/treasures.{_format}inUserand/treasures/{treasure_id}/owner.{_format}inDragonTreasure.Is there a reason why this is so twisted, purely from the grouping of the namespaces I find it so very strange.
Hey @urk!
I assume you're referring to how the sub-resources almost seem "backward" in the class they live in, right? Like the
/users/{user_id}/treasures.{_format}is a "subresource under user"... and yet we put it intoDragonTreasure.I agree that it's a bit weird... but I think it would be weird the other way too. No perfect option :). The logic is that, because
/users/{user_id}/treasures.{_format}will return "dragon treasures",. that's the class it should live on. It's almost like this is just a "vanity URL" / a different way to fetch dragon treasures. Of course, the downside is that the operations that we think of as "operations under /api/users" are split between multiple classes.Anyway, I hope that gives some explanation at least!
Cheers!
Hey Ryan
Yes, you heard me correctly and I know what you mean.
It depends from which side you look at it. But it doesn't really have a technical reason. Thank you.
Thanks and cheers, Urs
How can I generate the route from the $iriConverter class? That is, I don't want to hardcode the route (for the same reason I use path() in twig and never hard-code the route)
Version 2 of API Platform had a concept of subresources, version 3 doesn't, but I'm not sure what to pass to create the route.
Hey @Tac-Tacelosky!
Hmm, that's a good question! I've not done this yet, but... the
getIriFromResource()method has a 3rd argumentOperation $operation = null. But it looks a little tricky.First, I think you need to give your operation a name - apparently you can add
name: 'foo'inside an operation - like anew GetCollection(name: 'my_subresource_get_collection'). Then, to fetch that object, I think you can do this:Give that a try - I might not have things quite right - a bit of digging and guessing to find this - a new part of the code for me!
Cheers!
Hey!
Thanks a lot for this tutorial, but I found nothing about security in here. I mean especially the way to protect only read relations of a user for the user itself. Will there be another tutorial for handling voters etc.?
Thank you for your answer!
Hey Thomas,
yes, you're right, in this tutorial we don't talk about security, that's the topic of our next tutorial https://symfonycasts.com/screencast/api-platform3-security
it's going to be released soon :)
Cheers!
Wow - that's what I hoped!
Thank you for replying!
"Houston: no signs of life"
Start the conversation!