Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Subresources

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.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

We have two different ways to get the dragon treasures for a user. First, we could fetch the User and read its dragonTreasures property. The second is via the filter that we added a moment ago. In the API, that looks like owner=/api/users/4 on the GET collection operation for treasures.

This is my go-to way of getting the data... because if I want to fetch treasures, it make sense to use a treasures endpoint. Besides, if a user owns a lot of treasures, that'll give us pagination!

But you may sometimes choose to add a special way to fetch a resource or collection of resources... almost like a vanity URL. For example, imagine that, to get this same collection, we want the user to be able to go to /api/users/4/treasures.jsonld. That, of course, doesn't work. But it can be done. This is called a subresource, and subresources are much nicer in API platform 3.

Adding a Subresource via Another ApiResource

Okay, let's think. This endpoint will return treasures. So to add this subresource, we need to update the DragonTreasure class.

How? By adding a second ApiResource attribute. We already have this main one, so now add a new one. But this time, control the URL with a uriTemplate option set to exactly what we want: /users/{user_id} for the wildcard part (we'll see how that's used in a moment) then /treasures.

That's it! Well... also add .{_format}. This is optional, but that's the magic that lets us "cheat" and add this .jsonld to the end of the URL.

... lines 1 - 54
#[ApiResource(
uriTemplate: '/users/{user_id}/treasures.{_format}',
... line 57
)]
... lines 59 - 62
class DragonTreasure
{
... lines 65 - 222
}

Next, add operations... because we don't need all six... we really need just one. So, say [new GetCollection()] because we will return a collection of treasures.

... lines 1 - 54
#[ApiResource(
uriTemplate: '/users/{user_id}/treasures.{_format}',
operations: [new GetCollection()],
)]
... lines 59 - 62
class DragonTreasure
{
... lines 65 - 222
}

Ok, let's see what this did! Head back to the documentation and refresh. Suddenly we have... three resources and this one has the correct URL!

Oh, and we have three resources because, if you recall, we customized the shortName. Copy that and paste it onto the new ApiResource so they match. And to make PhpStorm happy, I'll put them in order.

... lines 1 - 54
#[ApiResource(
uriTemplate: '/users/{user_id}/treasures.{_format}',
shortName: 'Treasure',
operations: [new GetCollection()],
)]
... lines 60 - 63
class DragonTreasure
{
... lines 66 - 223
}

Now when we refresh... perfect! That's what we want!

Understanding uriVariables

We now have a new operation for fetching treasures. But does it work? It says that it will retrieve a collection of treasure resources, so that's good. But... we have a problem. It thinks that we need to pass the id of a DragonTreasure... but it should be the id of a User! And even if we pass something, like 4... and hit "Execute"... look at the URL! It didn't even use the 4: it still has {user_id} in the URL! So of course it comes back with a 404 error.

The problem is that we need to help API Platform understand what {user_id} means. We need to tell it that this is the id of the user and that it should use that to query WHERE owner_id equals the value.

To do that, add a new option called uriVariables. This is where we describe any "wildcards" in your URL. Pass user_id set to a new Link() object. There are multiple... we want the one from ApiPlatform\Metadata.

... lines 1 - 11
use ApiPlatform\Metadata\Link;
... lines 13 - 55
#[ApiResource(
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
}

This object needs two things. First, point to the class that the {user_id} is referring to. Do that by passing a fromClass option set to User::class.

... lines 1 - 11
use ApiPlatform\Metadata\Link;
... lines 13 - 55
#[ApiResource(
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
}

Second, we need to define which property on User points to DragonTreasure so that it can figure out how to structure the query. To do this, set fromProperty to treasures. So, inside User, we're saying that this property describes the relationship. Oh, but I totally messed that up: the property is dragonTreasures.

... lines 1 - 11
use ApiPlatform\Metadata\Link;
... lines 13 - 55
#[ApiResource(
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
}

Ok, cruise back over and refresh. Under the endpoint... yea! It says "User identifier". Let's put 4 in there again, hit "Execute" and... got it. There are the five treasures for this user!

And in the other browser tab... if we refresh... it works!

How the Query is Made

Behind the scenes, thanks to the Link, API Platform basically makes the following query:

SELECT * FROM dragon_treasure WHERE owner_id =

whatever we pass for {user_id}. It knows how to make that query by looking at the Doctrine relationship and figuring out which column to use. It's super smart.

We can actually see this in the profiler. Go to /_profiler, click on our request... and, down here, we see 2 queries... which are basically the same: the 2nd is used for the "total items" for pagination.

If you click "View formatted query" on the main query... it's even more complex than I expected! It has an INNER JOIN... but it's basically selecting all the dragon treasures data where owner_id = the ID of that user.

What about toProperty?

By the way, if you look at the documentation, there's also a way to set all of this up via the other side of the relationship: by saying toProperty: 'owner'.

This still works... and works exactly the same. But I recommend sticking with fromProperty, which is consistent and, I think, more clear. The toProperty is needed only if you didn't map the inverse side of a relationship... like if there was no dragonTreasures property on User. Unless you have that situation, stick with fromProperty.

Don't Forget normalizationContext!

This is all working nicely except for one small problem. If you look back at the data, it shows the wrong fields! It's returning everything, like id and isPublished.

Those aren't supposed to be included thanks of our normalization groups. But since we haven't specified any normalization groups on the new ApiResource, the serializer returns everything.

To fix this, copy the normalizationContext and paste it down here. We don't need to worry about denormalizationContext because we don't have any operations that do any denormalizing.

... lines 1 - 11
use ApiPlatform\Metadata\Link;
... lines 13 - 55
#[ApiResource(
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
}

If we refresh now... got it!

A Single Subresource Endpoint

Let's add one more subresource to see a slightly different case. I'll show you the URL I want first. We have a treasure with ID 11. This means we can go to /api/treasures/11.jsonld to see that. Now I want to be able to add /owner to the end to get the user that owns this treasure. Right now, that doesn't work.... so let's get to work!

Because the resource that will be returned is a User, that's the class that needs the new API Resource.

Above it, add #[ApiResource()] with uriTemplate set to /treasures/{treasure_id} for the wildcard (though this can be called anything), followed by /owner.{_format}.

... lines 1 - 24
#[ApiResource(
uriTemplate: '/treasures/{treasure_id}/owner.{_format}',
... lines 27 - 34
)]
... lines 36 - 38
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
... lines 41 - 187
}

Next pass uriVariables with treasure_id set to a new Link() - the one from ApiPlatform\Metadata. Inside, set fromClass to DragonTreasure::class. And since the property inside DragonTreasure that refers to this relationship is owner, add fromProperty: 'owner'.

... lines 1 - 7
use ApiPlatform\Metadata\Link;
... lines 9 - 24
#[ApiResource(
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
}

We also know that we're going to need the normalizationContext... so copy that... and paste it here. Finally, we only want one operation: a GET operation to return a single User. So, add operations set to [new Get()].

... lines 1 - 6
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Link;
... lines 9 - 24
#[ApiResource(
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
}

That should do it! Move back over to the documentation, refresh, and take a look under "User". Yep! We have a new operation! And it even sees that the wildcard is a "DragonTreasure identifier".

If we go refresh the other tab... it works!

Ok team, I lied about this being the last topic because... it's bonus topic time! Next: let's create a React-based admin area automatically from our API docs. Woh.

Leave a comment!

11
Login or Register to join the conversation
Sebastian-K Avatar
Sebastian-K Avatar Sebastian-K | posted 1 month ago | edited

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

Reply

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/456 is technically just as functional. If each Task has a relation to its Project, then from /task/456, we can look up the project from the Task and 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 is Task with id 456.

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/123 part of the URL just isn't that important (again, when you go to /project/123/task/456, it really just queries for Task 456 and 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, where Task DOES belong to Project 123, 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 to Project 123 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 to Project 123, the query would effectively be:

Find tasks WHERE project = 123 (this part is added by API Platform thanks to the subresource) AND (your custom part to filter to only projects the current user has access to).

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/tasks return a 404, but I'm not entirely sure how to do that :).

Let me know if this helps!

Cheers!

1 Reply
Sebastian-K Avatar

Thanks for the reply. Gave me new points to think about

Reply
urk Avatar

I would now have defined /users/{user_id}/treasures.{_format} in User and /treasures/{treasure_id}/owner.{_format} in DragonTreasure.
Is there a reason why this is so twisted, purely from the grouping of the namespaces I find it so very strange.

Reply

Hey @urk!

Is there a reason why this is so twisted, purely from the grouping of the namespaces I find it so very strange.

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 into DragonTreasure.

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!

Reply
urk Avatar

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

Reply
Tac-Tacelosky Avatar
Tac-Tacelosky Avatar Tac-Tacelosky | posted 4 months ago | edited

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)

#[ApiResource(
    uriTemplate: '/users/{user_id}/treasures.{_format}',
    shortName: 'Treasure',
    operations: [new GetCollection()],
    uriVariables: [
        'user_id' => new Link(
            fromProperty: 'dragonTreasures',
            fromClass: User::class,
        ),
    ],
)]

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.

$userId = 4;
$url = $iriConverter->getSubresourceIriFromResourceClass(
    $user::class,
    [
        'subresource_identifiers' => ['id' => $userId],
        'subresource_resources' => [Treasure::class => null],
    ]
); 

assert($url == '/users/4/treasures')
Reply

Hey @Tac-Tacelosky!

Hmm, that's a good question! I've not done this yet, but... the getIriFromResource() method has a 3rd argument Operation $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 a new GetCollection(name: 'my_subresource_get_collection'). Then, to fetch that object, I think you can do this:

public function someAction(ResourceMetadataCollectionFactoryInterface $resourceMetadata, IriConverterInterface $iriConverter)
{
    $operation = $resourceMetadata->getOperation('my_subresource_get_collection');
    $url = iriConverter->getIriFromResource($yourObject, UrlGeneratorInterface::ABS_PATH, $operation);

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!

Reply

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!

Reply

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!

1 Reply

Wow - that's what I hoped!
Thank you for replying!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^3.0", // v3.0.8
        "doctrine/annotations": "^1.0", // 1.14.2
        "doctrine/doctrine-bundle": "^2.8", // 2.8.0
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.0
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.64.1
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.15.3
        "symfony/asset": "6.2.*", // v6.2.0
        "symfony/console": "6.2.*", // v6.2.3
        "symfony/dotenv": "6.2.*", // v6.2.0
        "symfony/expression-language": "6.2.*", // v6.2.2
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.3
        "symfony/property-access": "6.2.*", // v6.2.3
        "symfony/property-info": "6.2.*", // v6.2.3
        "symfony/runtime": "6.2.*", // v6.2.0
        "symfony/security-bundle": "6.2.*", // v6.2.3
        "symfony/serializer": "6.2.*", // v6.2.3
        "symfony/twig-bundle": "6.2.*", // v6.2.3
        "symfony/ux-react": "^2.6", // v2.6.1
        "symfony/validator": "6.2.*", // v6.2.3
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.0
        "symfony/yaml": "6.2.*" // v6.2.2
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "symfony/debug-bundle": "6.2.*", // v6.2.1
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/stopwatch": "6.2.*", // v6.2.0
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.4
        "zenstruck/foundry": "^1.26" // v1.26.0
    }
}
userVoice