It's more like question than issue.
I could not find any documentation about custom subresources and I want to know, is it legal to do so ()
I have already some subresources on my base entity i.e.
/api/projects/{id}/tasks
And I need to select all suborders once more, with a really complicated logic. (Via several big subquery in plain sql.) All returned orders still related to project/{id}. And it looks logically to have something like regular subresource.
/api/projects/{id}/featured_tasks
I make action
/**
* @Route(
* path="/api/projects/{id}/featured_tasks",
* name="api_projects_featured_task_get",
* defaults={"_api_resource_class"=Task::class, "_api_subresource_operation_name"="subresource", "_api_subresource_context"={
* "identifiers"={{"id", Project::class, true}}
* }},
* requirements={"id": "\d+"}
* )
* @Method("GET")
*
* @param Request $request
* @param int $id
*
* @return Task[]
*/
public function getAction(
Request $request,
EntityManagerInterface $em,
int $id
) {
return $em->getRepository(Task::class)->executeReallyComplexSelectingByProjectId($id);
}
And edit project entity
/**
* @ApiResource(
* attributes={
* "filters"={"project.date_filter"},
* "normalization_context"={"groups"={"project", "project-read"}},
* "denormalization_context"={"groups"={"project", "project-write"}}
* },
* itemOperations={
* "get"={"method"="GET"},
* "put"={"method"="PUT"},
* "delete"={"method"="DELETE"},
* "featured_task"={"route_name"="api_projects_featured_task_get"}
* }
* )
*/
public class Project
{
}
I have to add itemOpetaions to see action in swagger. And this is what makes me think that whole approach is wrong and illegal.
It's looks like working now, but I'm afraid it will totally breaks down in future releases/bug fixes.
Looks like having two subresources for the same class (the way I described it before) make " @id " fields of collections the same for all subcollections.
/api/projects/1/tasks // @id === '/api/projects/1/featured_tasks'
/api/projects/1/featured_tasks // @id === '/api/projects/1/featured_tasks'
This is because of two routes have "_api_resource_class"=Task::class, "_api_subresource_operation_name"="subresource" i suppose.
It is planning make two subresources linked the same class available?
A subresource is linked to a property of the base class.
To me it looks like the route /api/projects/{id}/featured_tasks should just be a custom collection operation. It should not be marked as subresource.
In my understanding collection operation on Project entity should return Project[] not Task[].
More over my custom operation makes sense only with Project entity. So project id must be part of my route.
I tried use a collection first, but custom collection operation can't have any arguments in route, like {id} of projects in my case.
https://github.com/api-platform/core/blob/master/src/Bridge/Symfony/Routing/IriConverter.php#L114
Thus collection operation failed on @id generation.
That is why in my opinion it is looks more like custom subresource, than custom collection operation.
I tried use a collection first, but custom collection operation can't have any arguments in route, like {id} of projects in my case.
They can, in the custom action just inject the RequestStack.
That is why in my opinion it is looks more like custom subresource, than custom collection operation.
/api/projects/{id}/tasks is your subresource (tasks belonging to the project X). If you want to do some custom subresource I'd advise you to use a custom collection operation (after all the subresource in jour case is nothing more then a collection operation with a different data provider).
I apologize for the long response.
I tried use a collection first, but custom collection operation can't have any arguments in route, like {id} of projects in my case.
They can, in the custom action just inject the RequestStack.
It is not about getting id in controller, it is about error when @id generates while serialisation. Custom collection operation was my first thought and i try like so:
/*
* @ApiResource(
* collectionOperations={
* "get"={"method"="GET"},
* "post"={"method"="POST"},
* "featured_task"={
* "route_name"="api_projects_featured_task_get"
* },
* }
* )
* ...
*/
class Project
{
}
```php
/**
* @Route(
* path="/api/projects/{id}/featured_tasks",
* name="api_projects_featured_task_get",
* defaults={"_api_resource_class"=Task::class, "_api_collection_operation_name"="subresource"},
* requirements={"id": "d+"}
* )
* @Method("GET")
*
* @param Request $request
* @param int $id
*
* @return Task[]
*/
public function getFeaturedTaskByGroupAction(
Request $request,
EntityManagerInterface $em,
int $id
) {
return $em->getRepository(Task::class)
->getFeaturedTask($id, 3);
}
```json
{
"@context": "/api/contexts/Error",
"@type": "hydra:Error",
"hydra:title": "An error occurred",
"hydra:description": "Unable to generate an IRI for \"AppBundle\\Entity\\Task\".",
"trace": [
{
"namespace": "",
"short_class": "",
"class": "",
"type": "",
"function": "",
"file": "/vendor/api-platform/core/src/Bridge/Symfony/Routing/IriConverter.php",
"line": 116,
"args": []
},
{
"namespace": "ApiPlatform\\Core\\Bridge\\Symfony\\Routing",
"short_class": "IriConverter",
"class": "ApiPlatform\\Core\\Bridge\\Symfony\\Routing\\IriConverter",
"type": "->",
"function": "getIriFromResourceClass",
"file": "/vendor/api-platform/core/src/Hydra/Serializer/CollectionNormalizer.php",
"line": 81,
"args": [
[
"string",
"AppBundle\\Entity\\Task"
]
]
},
{
"namespace": "ApiPlatform\\Core\\Hydra\\Serializer",
"short_class": "CollectionNormalizer",
"class": "ApiPlatform\\Core\\Hydra\\Serializer\\CollectionNormalizer",
"type": "->",
"function": "normalize",
"file": "/vendor/api-platform/core/src/Hydra/Serializer/PartialCollectionViewNormalizer.php",
"line": 48,
"args": [
[
"array",
[]
],
[
"string",
"jsonld"
],
[
"array",
{
"groups": [
"array",
[
[
"string",
"task"
],
[
"string",
"task-read"
],
[
"string",
"user"
],
[
"string",
"user-read"
]
]
],
"collection_operation_name": [
"string",
"subresource"
],
"operation_type": [
"string",
"collection"
],
"resource_class": [
"string",
"AppBundle\\Entity\\Task"
],
"request_uri": [
"string",
"/api/projects/1/featured_tasks"
],
"resources": [
"object",
"class@anonymous\u0000/vendor/api-platform/core/src/EventListener/SerializeListener.php0x10c9e27d9"
],
"jsonld_has_context": [
"boolean",
true
],
"api_sub_level": [
"boolean",
true
]
}
]
]
},
{
"namespace": "ApiPlatform\\Core\\Hydra\\Serializer",
"short_class": "PartialCollectionViewNormalizer",
"class": "ApiPlatform\\Core\\Hydra\\Serializer\\PartialCollectionViewNormalizer",
"type": "->",
"function": "normalize",
"file": "/vendor/api-platform/core/src/Hydra/Serializer/CollectionFiltersNormalizer.php",
"line": 65,
"args": [
[
"array",
[]
],
[
"string",
"jsonld"
],
[
"array",
{
"groups": [
"array",
[
[
"string",
"task"
],
[
"string",
"task-read"
],
[
"string",
"user"
],
[
"string",
"user-read"
]
]
],
"collection_operation_name": [
"string",
"subresource"
],
"operation_type": [
"string",
"collection"
],
"resource_class": [
"string",
"AppBundle\\Entity\\Task"
],
"request_uri": [
"string",
"/api/projects/1/featured_tasks"
],
"resources": [
"object",
"class@anonymous\u0000/vendor/api-platform/core/src/EventListener/SerializeListener.php0x10c9e27d9"
]
}
]
]
},
{
"namespace": "ApiPlatform\\Core\\Hydra\\Serializer",
"short_class": "CollectionFiltersNormalizer",
"class": "ApiPlatform\\Core\\Hydra\\Serializer\\CollectionFiltersNormalizer",
"type": "->",
"function": "normalize",
"file": "/vendor/symfony/symfony/src/Symfony/Component/Serializer/Serializer.php",
"line": 142,
"args": [
[
"array",
[]
],
[
"string",
"jsonld"
],
[
"array",
{
"groups": [
"array",
[
[
"string",
"task"
],
[
"string",
"task-read"
],
[
"string",
"user"
],
[
"string",
"user-read"
]
]
],
"collection_operation_name": [
"string",
"subresource"
],
"operation_type": [
"string",
"collection"
],
"resource_class": [
"string",
"AppBundle\\Entity\\Task"
],
"request_uri": [
"string",
"/api/projects/1/featured_tasks"
],
"resources": [
"object",
"class@anonymous\u0000/vendor/api-platform/core/src/EventListener/SerializeListener.php0x10c9e27d9"
]
}
]
]
},
{
"namespace": "Symfony\\Component\\Serializer",
"short_class": "Serializer",
"class": "Symfony\\Component\\Serializer\\Serializer",
"type": "->",
"function": "normalize",
"file": "/vendor/symfony/symfony/src/Symfony/Component/Serializer/Serializer.php",
"line": 115,
"args": [
[
"array",
[]
],
[
"string",
"jsonld"
],
[
"array",
{
"groups": [
"array",
[
[
"string",
"task"
],
[
"string",
"task-read"
],
[
"string",
"user"
],
[
"string",
"user-read"
]
]
],
"collection_operation_name": [
"string",
"subresource"
],
"operation_type": [
"string",
"collection"
],
"resource_class": [
"string",
"AppBundle\\Entity\\Task"
],
"request_uri": [
"string",
"/api/projects/1/featured_tasks"
],
"resources": [
"object",
"class@anonymous\u0000/vendor/api-platform/core/src/EventListener/SerializeListener.php0x10c9e27d9"
]
}
]
]
},
{
"namespace": "Symfony\\Component\\Serializer",
"short_class": "Serializer",
"class": "Symfony\\Component\\Serializer\\Serializer",
"type": "->",
"function": "serialize",
"file": "/vendor/api-platform/core/src/EventListener/SerializeListener.php",
"line": 69,
"args": [
[
"array",
[]
],
[
"string",
"jsonld"
],
[
"array",
{
"groups": [
"array",
[
[
"string",
"task"
],
[
"string",
"task-read"
],
[
"string",
"user"
],
[
"string",
"user-read"
]
]
],
"collection_operation_name": [
"string",
"subresource"
],
"operation_type": [
"string",
"collection"
],
"resource_class": [
"string",
"AppBundle\\Entity\\Task"
],
"request_uri": [
"string",
"/api/projects/1/featured_tasks"
],
"resources": [
"object",
"class@anonymous\u0000/vendor/api-platform/core/src/EventListener/SerializeListener.php0x10c9e27d9"
]
}
]
]
},
{
"namespace": "ApiPlatform\\Core\\EventListener",
"short_class": "SerializeListener",
"class": "ApiPlatform\\Core\\EventListener\\SerializeListener",
"type": "->",
"function": "onKernelView",
"file": null,
"line": null,
"args": [
[
"object",
"Symfony\\Component\\HttpKernel\\Event\\GetResponseForControllerResultEvent"
],
[
"string",
"kernel.view"
],
[
"object",
"Symfony\\Component\\HttpKernel\\Debug\\TraceableEventDispatcher"
]
]
},
{
"namespace": "",
"short_class": "",
"class": "",
"type": "",
"function": "call_user_func",
"file": "/vendor/symfony/symfony/src/Symfony/Component/EventDispatcher/Debug/WrappedListener.php",
"line": 104,
"args": [
[
"array",
[
[
"object",
"ApiPlatform\\Core\\EventListener\\SerializeListener"
],
[
"string",
"onKernelView"
]
]
],
[
"object",
"Symfony\\Component\\HttpKernel\\Event\\GetResponseForControllerResultEvent"
],
[
"string",
"kernel.view"
],
[
"object",
"Symfony\\Component\\HttpKernel\\Debug\\TraceableEventDispatcher"
]
]
},
{
"namespace": "Symfony\\Component\\EventDispatcher\\Debug",
"short_class": "WrappedListener",
"class": "Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener",
"type": "->",
"function": "__invoke",
"file": null,
"line": null,
"args": [
[
"object",
"Symfony\\Component\\HttpKernel\\Event\\GetResponseForControllerResultEvent"
],
[
"string",
"kernel.view"
],
[
"object",
"Symfony\\Component\\EventDispatcher\\ContainerAwareEventDispatcher"
]
]
},
{
"namespace": "",
"short_class": "",
"class": "",
"type": "",
"function": "call_user_func",
"file": "/vendor/symfony/symfony/src/Symfony/Component/EventDispatcher/EventDispatcher.php",
"line": 212,
"args": [
[
"object",
"Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
],
[
"object",
"Symfony\\Component\\HttpKernel\\Event\\GetResponseForControllerResultEvent"
],
[
"string",
"kernel.view"
],
[
"object",
"Symfony\\Component\\EventDispatcher\\ContainerAwareEventDispatcher"
]
]
},
{
"namespace": "Symfony\\Component\\EventDispatcher",
"short_class": "EventDispatcher",
"class": "Symfony\\Component\\EventDispatcher\\EventDispatcher",
"type": "->",
"function": "doDispatch",
"file": "/vendor/symfony/symfony/src/Symfony/Component/EventDispatcher/EventDispatcher.php",
"line": 44,
"args": [
[
"array",
[
[
"object",
"Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
],
[
"object",
"Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
],
[
"object",
"Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
],
[
"object",
"Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
],
[
"object",
"Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
],
[
"object",
"Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
],
[
"object",
"Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
],
[
"object",
"Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
],
[
"object",
"Symfony\\Component\\EventDispatcher\\Debug\\WrappedListener"
]
]
],
[
"string",
"kernel.view"
],
[
"object",
"Symfony\\Component\\HttpKernel\\Event\\GetResponseForControllerResultEvent"
]
]
},
{
"namespace": "Symfony\\Component\\EventDispatcher",
"short_class": "EventDispatcher",
"class": "Symfony\\Component\\EventDispatcher\\EventDispatcher",
"type": "->",
"function": "dispatch",
"file": "/vendor/symfony/symfony/src/Symfony/Component/EventDispatcher/Debug/TraceableEventDispatcher.php",
"line": 139,
"args": [
[
"string",
"kernel.view"
],
[
"object",
"Symfony\\Component\\HttpKernel\\Event\\GetResponseForControllerResultEvent"
]
]
},
{
"namespace": "Symfony\\Component\\EventDispatcher\\Debug",
"short_class": "TraceableEventDispatcher",
"class": "Symfony\\Component\\EventDispatcher\\Debug\\TraceableEventDispatcher",
"type": "->",
"function": "dispatch",
"file": "/vendor/symfony/symfony/src/Symfony/Component/HttpKernel/HttpKernel.php",
"line": 158,
"args": [
[
"string",
"kernel.view"
],
[
"object",
"Symfony\\Component\\HttpKernel\\Event\\GetResponseForControllerResultEvent"
]
]
},
{
"namespace": "Symfony\\Component\\HttpKernel",
"short_class": "HttpKernel",
"class": "Symfony\\Component\\HttpKernel\\HttpKernel",
"type": "->",
"function": "handleRaw",
"file": "/vendor/symfony/symfony/src/Symfony/Component/HttpKernel/HttpKernel.php",
"line": 68,
"args": [
[
"object",
"Symfony\\Component\\HttpFoundation\\Request"
],
[
"integer",
1
]
]
},
{
"namespace": "Symfony\\Component\\HttpKernel",
"short_class": "HttpKernel",
"class": "Symfony\\Component\\HttpKernel\\HttpKernel",
"type": "->",
"function": "handle",
"file": "/vendor/symfony/symfony/src/Symfony/Component/HttpKernel/Kernel.php",
"line": 169,
"args": [
[
"object",
"Symfony\\Component\\HttpFoundation\\Request"
],
[
"integer",
1
],
[
"boolean",
true
]
]
},
{
"namespace": "Symfony\\Component\\HttpKernel",
"short_class": "Kernel",
"class": "Symfony\\Component\\HttpKernel\\Kernel",
"type": "->",
"function": "handle",
"file": "/www/back/app_dev.php",
"line": 27,
"args": [
[
"object",
"Symfony\\Component\\HttpFoundation\\Request"
]
]
},
{
"namespace": "",
"short_class": "",
"class": "",
"type": "",
"function": "require",
"file": "/vendor/symfony/symfony/src/Symfony/Bundle/WebServerBundle/Resources/router.php",
"line": 42,
"args": [
[
"string",
"/www/back/app_dev.php"
]
]
}
]
}
I attached already in my previous post link to source. https://github.com/api-platform/core/blob/master/src/Bridge/Symfony/Routing/IriConverter.php#L114 When generate @id for a collection operation there is pass empty array of wildcards => there is no way to have any wildcards in route on custom collection operation. Am I wrong?
If I make it custom item operation
* itemOperations={
* "get"={"method"="GET"},
* "put"={"method"="PUT"},
* "delete"={"method"="DELETE"},
* "featured_task"={
* "route_name"="api_projects_featured_task_get",
* },
* }
Then my id's becomes mess
Get /api/projects/3/closed_tasks results in
{
"@context": "/api/contexts/Task",
"@id": "/api/tasks",
"@type": "hydra:Collection",
"hydra:member": [
{
"@id": "/api/projects/45/closed_tasks",
"@type": "Task",
"id": 45,
"project": "/api/projects/3"
},
{
"@id": "/api/projects/37/closed_tasks",
"@type": "Task",
"id": 37,
"project": "/api/projects/3"
},
...
]
}
Wrong @id of collection itself, wrong @id of each Task.
I want to get some tasks, but this collection of tasks have sense only in context of Project => Project's id must be part of route. How could I solve this?
Hey I was about to ask the same kind of question. I think the solution here should be similar in both cases.
I have 2 entities OldCity and NewCity
and /api/old_cities and /api/new_cities paths.
I also want to create custom operation for /api/old_cities/{id}/converted_city, so the given path returns serialized NewCity, but this sub-resource cannot be created on simple joined tables mappings. I also need to find the entity on custom logic.
What is the best way to resolve this issue?
A custom operation with a data provider.
Thankfully I managed to do it with the custom operation as well. The "Unable to generate an IRI" error came from other issues.
Hey, I have the same issue with one of my resource and the "Unable to generate an IRI for [my entity]" . I have a custom operation (with a api_resource_class specified with this entity name) .. it's trigger this issue on the get collection. If I remove the resource_class in the controller (for the custom operation) the get collection is OK (but my custom operation no)
I may have found a hack for this.
I have 3 entities:
there is a many to many betwen store and country and another between country and product
#routes.yaml
get_products_by_store:
path: /api/stores/{id}/products
controller: App\Controller\Catalog\ProductController::getAllProductsByStore
methods: 'GET'
defaults:
_api_resource_class: App\Entity\Catalog\AbstractProduct
_api_subresource_operation_name: 'get_products_by_store'
_api_subresource_context:
collection: true
#property is isseted but not not used, so i set it to whatever relation i had
property: 'website'
identifiers:
- ['id', 'App\Entity\Website\Store', true]
```YAML
AppEntityWebsiteStore:
collectionOperations:
get_products_by_store:
route_name: 'get_products_by_store'
swagger_context:
parameters:
- {'name':'id', 'required': true, 'type':'integer','in':'path'}
```PHP
public function getAllProductsByStore(
$id,
ProductRepository $productRepository
) {
return $productRepository->findAllByStore(
$id
)->getQuery()->getResult();
}
This seems to be answered, for new question or issue please open a new issue, Thanks!
Most helpful comment
I may have found a hack for this.
I have 3 entities:
there is a many to many betwen store and country and another between country and product
```YAML
api platform configuration
AppEntityWebsiteStore:
collectionOperations:
get_products_by_store:
route_name: 'get_products_by_store'
swagger_context:
parameters:
- {'name':'id', 'required': true, 'type':'integer','in':'path'}