Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

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

At some point, an API client... which might just be our JavaScript, will probably want to get a list of all of the cheeseListings for a specific User. And... we can already do this in two different ways: search for a specific owner here via our filter... or fetch the specific User and look at its cheeseListings property.

If you think about it, a CheeseListing almost feels like a "child" resource of a User: cheese listings belong to users. And for that reason, some people might like to be able to fetch the cheese listings for a user by going to a URL like this: /api/users/4/cheeses... or something similar.

But... that doesn't work. This idea is called a "subresource". Right now, each resource has its own, sort of, base URL: /api/cheeses and /api/users. But it is possible to, kind of, "move" cheeses under users.

Here's how: in User, find the $cheeseListings property and add @ApiSubresource.

... lines 1 - 6
use ApiPlatform\Core\Annotation\ApiSubresource;
... lines 8 - 26
class User implements UserInterface
{
... lines 29 - 62
/**
... lines 64 - 66
* @ApiSubresource()
*/
private $cheeseListings;
... lines 70 - 190
}

Let's go refresh the docs! Woh! We have a new endpoint! /api/users/{id}/cheese_listings. It shows up in two places... because it's kind of related to users... and kind of related to cheese listings. The URL is cheese_listings by default, but that can be customized.

So... let's try it! Change the URL to /cheese_listings. Oh, and add the .jsonld on the end. There it is! The collection resource for all cheeses that are owned by this User.

Subresources are kinda cool! But... they're also a bit unnecessary: we already added a way to get the collection of cheese listings for a user via the SearchFilter on CheeseListing. And using subresources means that you have more endpoints to keep track of, and, when we get to security, more endpoints means more access control to think about.

So, use subresources if you want, but I don't recommend adding them everywhere, there is a cost from added complexity. Oh, and by the way, there is a ton of stuff you can customize on subresources, like normalization groups, the URL, etc. It's all in the docs and it's pretty similar to the types of customizations we've seen so far.

For our app, I'm going to remove the subresource to keep things simple.

And... we're done! Well, there is a lot more cool stuff to cover - including security! That's the topic of the next tutorial in this series. But give yourself a jumping high-five! We've already unlocked a huge amount of power! We can expose entities as API resources, customize the operations, take full control of the serializer in a bunch of different ways and a ton more. So start building your gorgeous new API, tell us about it and, as always, if you have questions, you can find us in the comments section.

Alright friends, seeya next time!

Leave a comment!

97
Login or Register to join the conversation
David-G Avatar
David-G Avatar David-G | posted 10 months ago | edited

Hi,
With the Version 3 of Api_platform, Subresources are more complicated than it is explain here.

Here is a code example to apply subresources with the new version
Hope it help

<?php

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Link;
use App\Repository\CheeseListingRepository;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Delete;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;


#[ORM\Entity(repositoryClass: CheeseListingRepository::class)]
#[ApiResource(
    routePrefix: '/',
    operations: [        
        new Get(
            uriTemplate: '/cheese/{id}.{_format}'
        ),
        new GetCollection(
            uriTemplate: '/cheeses.{_format}'
        ),
        new Post(
            uriTemplate: '/cheese'
        ),
        new Delete(
            uriTemplate: '/cheese/{id}.{_format}'
        )
        ],
    normalizationContext: ['groups' => ['cheese_listing:read']],
    denormalizationContext: ['groups' => ['cheese_listing:write']],
    formats: ['json', 'jsonld', 'html', 'jsonhal', 'csv' => ['text/csv']]   
)]
// Here is the subresource configuration
#[ApiResource(
    uriTemplate: '/users/{id}/cheeses.{_format}',
    uriVariables: [
        'id' => new Link(
            fromClass: User::class, 
            fromProperty: 'cheeseListings'           
        )        
    ],
    operations: [new GetCollection()]
)]
class CheeseListing
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    #[Assert\Length(
        min: 3,
        max: 50
    )]
    #[Groups(['cheese_listing:read', 'cheese_listing:write', 'user:read'])]
    private ?string $title = null;

    #[ORM\Column(length: 255)]
    #[Assert\NotBlank]
    #[Assert\Length(
        min: 3,
        max: 255,
        minMessage: 'Votre description doit faire plus de 5 caractĆØres',
        maxMessage: 'Votre description ne doit pas faire plus de 255 caractĆØres'
    )]
    #[Groups(['cheese_listing:read', 'cheese_listing:write'])]
    private ?string $description = null;

    #[ORM\ManyToOne(inversedBy: 'cheeseListings')]
    #[ORM\JoinColumn(nullable: false)]        
    #[Groups(['cheese_listing:read', 'cheese_listing:write'])]
    #[Assert\Valid]
    private ?User $owner = null;

    public function __construct()
    {
        $this->createdAt = new DateTimeImmutable;
    }
    public function getId(): ?int
    {
        return $this->id;
    }

    public function getTitle(): ?string
    {
        return $this->title;
    }

    public function setTitle(string $title): self
    {
        $this->title = $title;

        return $this;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function setDescription(string $description): self
    {
        $this->description = $description;

        return $this;
    }

    public function getOwner(): ?User
    {
        return $this->owner;
    }

    public function setOwner(?User $owner): self
    {
        $this->owner = $owner;

        return $this;
    }
}

And thanks for this course very helpful

1 Reply

Hey David,

Yea, many things have changed in ApiPlatform 3, but they changed for good. We just need to rewire our brains :)
By the way, we're planning in releasing a new series about ApiPlatform :)

Thanks for sharing your solution with others. Cheers!

2 Reply
Paul-L Avatar
Paul-L Avatar Paul-L | posted 1 year ago | edited

In case it's helpful to anyone, I've managed to complete this course with PHP 8.1.7, Mysql 8.0.26 and the following packages. The only real changes I needed to make was converting the course-described docblock annotations to php8 attributes (though I could have converted the generated code back to annotations instead, I think). Now on it part 2!

api-platform/core                   v2.6.8 
doctrine/annotations                1.13.3 
doctrine/cache                      2.2.0  
doctrine/collections                1.6.8  
doctrine/common                     3.3.0  
doctrine/dbal                       3.3.7  
doctrine/deprecations               v1.0.0 
doctrine/doctrine-bundle            2.7.0  
doctrine/doctrine-migrations-bundle 3.2.2  
doctrine/event-manager              1.1.1  
doctrine/inflector                  2.0.4  
doctrine/instantiator               1.4.1  
doctrine/lexer                      1.2.3  
doctrine/migrations                 3.5.1  
doctrine/orm                        2.12.3 
doctrine/persistence                3.0.2  
doctrine/sql-formatter              1.1.3  
fig/link-util                       1.2.0  
friendsofphp/proxy-manager-lts      v1.0.12
laminas/laminas-code                4.5.2  
monolog/monolog                     3.1.0  
nelmio/cors-bundle                  2.2.0  
nesbot/carbon                       2.59.1 
nikic/php-parser                    v4.14.0
phpdocumentor/reflection-common     2.2.0  
phpdocumentor/reflection-docblock   5.3.0  
phpdocumentor/type-resolver         1.6.1  
phpstan/phpdoc-parser               1.6.4  
psr/cache                           3.0.0  
psr/container                       2.0.2  
psr/event-dispatcher                1.0.0  
psr/link                            2.0.1  
psr/log                             3.0.0  
symfony/asset                       v6.1.0 
symfony/cache                       v6.1.2 
symfony/cache-contracts             v3.1.1 
symfony/config                      v6.1.0 
symfony/console                     v6.1.2 
symfony/debug-bundle                v6.1.0 
symfony/dependency-injection        v6.1.2 
symfony/deprecation-contracts       v3.1.1 
symfony/doctrine-bridge             v6.1.2 
symfony/dotenv                      v6.1.0 
symfony/error-handler               v6.1.0 
symfony/event-dispatcher            v6.1.0 
symfony/event-dispatcher-contracts  v3.1.1 
symfony/expression-language         v6.1.2 
symfony/filesystem                  v6.1.0 
symfony/finder                      v6.1.0 
symfony/flex                        v2.2.2 
symfony/framework-bundle            v6.1.2 
symfony/http-foundation             v6.1.2 
symfony/http-kernel                 v6.1.2 
symfony/maker-bundle                v1.43.0
symfony/monolog-bridge              v6.1.2 
symfony/monolog-bundle              v3.8.0 
symfony/password-hasher             v6.1.0 
symfony/polyfill-intl-grapheme      v1.26.0
symfony/polyfill-intl-normalizer    v1.26.0
symfony/polyfill-mbstring           v1.26.0
symfony/property-access             v6.1.0 
symfony/property-info               v6.1.2 
symfony/proxy-manager-bridge        v6.1.0 
symfony/routing                     v6.1.1 
symfony/runtime                     v6.1.1 
symfony/security-bundle             v6.1.0 
symfony/security-core               v6.1.2 
symfony/security-csrf               v6.1.0 
symfony/security-http               v6.1.2 
symfony/serializer                  v6.1.2 
symfony/service-contracts           v3.1.1 
symfony/stopwatch                   v6.1.0 
symfony/string                      v6.1.2 
symfony/translation                 v6.1.0 
symfony/translation-contracts       v3.1.1 
symfony/twig-bridge                 v6.1.2 
symfony/twig-bundle                 v6.1.1 
symfony/validator                   v6.1.1 
symfony/var-dumper                  v6.1.0 
symfony/var-exporter                v6.1.1 
symfony/web-link                    v6.1.0 
symfony/web-profiler-bundle         v6.1.2 
symfony/yaml                        v6.1.2 
twig/twig                           v3.4.1 
webmozart/assert                    1.11.0 
willdurand/negotiation              3.1.0```

1 Reply
Default user avatar

My question is, does there a way to have 2 or more subresource opƩrations for the same Entity .
First one for example : /catƩgories/1/products
Second one : /catƩgories/1/products/export

Reply

Hey @Fabien!

Hmm. The first - /catƩgories/1/products look like a normal sub-resource to me. The second - /catƩgories/1/products/export I think can't be handled as a sub-resource. If you wanted this, I think you would need to have a custom operation (caveat: I don't have a lot of experience with these) that has this exact URL. I believe it would be your responsibility, from inside of the controller for that custom operation, to take your object (the Category with id 1), get its products, then export them in some way.

Let me know if that helps :).

Cheers!

Reply
David B. Avatar
David B. Avatar David B. | posted 1 year ago

I have a specific case in my business logic and I'm not sure if subresources are the answer, before I continue I was hoping you could help! I have two entities: Audience, and AudienceCategory. Audience has a oneToMany relationship with AudienceCategory. Audience->name must be unique in the entire system and its identifier is Audience->slug (slug is generated by a dataPersister using the name). AudienceCategory->name must be unique PER Audience Relationship. For example: There can be multiple AudienceCategory->name's that have the name 'breakfast' but there can only be one 'breakfast' that has a relationship to audience. Because Its unique per relationship i can't set the identifier to "slug".

Where i'm struggling is how to actually pull that data from the API. I suppose that I could setup filters and try to query for Audience->slug and AudienceCategory->slug but that will return a collection with a single entity and I wanted something cleaner. Would it be possible for me to create an endpoint that returns a single entity from the following URL /api/{AudienceSlug}/{AudienceCategorySlug}

Any help would be appreciated!

Reply

Hey David B. !

Yes... this stuff can be complex, can't it! I have two things to say, let me know if either are helpful:

A) subresources are not very flexible. So if you're trying to do something custom, you'll probably hit a wall when them.

B) When you asked for a URL like /api/{AudienceSlug}/{AudienceCategorySlug}, it immediately made me think that the "easy" URL would probably be: /api/audences/{AudienceSlug}?audienceCategory={AudienceCategorySlug}. You might need to create a custom filter for that "audienceCategory" because, iirc, API Platform would want you to do something like ?audienceCategory/api/audience-categories/5 (where 5 if your identifier - so that might actually be the audience category slug for you... but you get the idea). So if you wanted something simpler where AudienceSlug is just that slug, then I'd do that with a custom query.

Overall, there are some times when you have a specific URL in your mind that you'd like to create, but creating that URL might be super difficult in API Platform. But, creating that same thing with a different URL would be quite easy. I think this is one of those cases.

Cheers!

Reply
David B. Avatar

Thanks for the quick reply! I think I may just follow your advice and take the path of least resistance. Out of curiosity if I were to revisit this at a later date what might my approach be? Would I use a custom controller or would this be something that can be done via a data-provider or DTO's?

Edit:: Is it possible run filters on an itemOperations? I tried the following url: /api/audences/{AudienceSlug}?audienceCategory={AudienceCategorySlug} and my filter doesn't run on the itemOperation, but it runs just fine on the collectionOperation.

Reply

Hey David B.!

Would I use a custom controller or would this be something that can be done via a data-provider or DTO's?

Hmmm, probably a custom controller... as the URL looks very custom. You'd need to create a custom operation and customize its URL. I'll be honest: I don't have a lot of experience with custom controllers and API platform, it's the ultimate custom option. But, it's not a bad idea... you just start to be less RESTful with this "odd" endpoints. But ultimately, API Platform mostly just generates routes and then uses the Symfony serializer to serialize data. You could definitely create a custom controller.

Edit:: Is it possible run filters on an itemOperations? I tried the following url: /api/audences/{AudienceSlug}?audienceCategory={AudienceCategorySlug} and my filter doesn't run on the itemOperation, but it runs just fine on the collectionOperation.

I do not believe that's possible, no. So I was wrong about suggesting that URL. I think you might need: /api/audiences?slug={AudienceSlug}&audienceCategory={AudienceCategorySlug}. Because, if I'm understanding things correctly, you basically want to filter the "audiences" resource WHERE "audience slug = {AudienceSlug}" AND "audience category slug = {AudienceCategorySlug}". This would technically return a "collection" one one item (since you said that the audience + category slug combination is guaranteed to be unique.

It's complex, I know :).

Cheers!

Reply
Carl C. Avatar
Carl C. Avatar Carl C. | posted 2 years ago

Hi SymfonyCasts,

I am trying to create an RESTful API endpoint by using the API Platform. We have some data which is not persisted inside a database (coming from ElasticSearch though an ItemDataProviderInterface), and some data from the database.

What we have currently and working fine :
* /transactions/{id} provide data from Elastic Search though ItemDataProviderInterface - ID is a functionnal key inside Elastic Search set up as @ApiProperty(identifier=true) in our Transaction entity

We would like :
* /transactions/{id}/comments provide data from Postgresql database where id is a column inside the database (but not the Primary Key).

We tried with a @ApiSubresource for property Comments inside Entity but it doesn't work.

How can we do that with API Platform ?

Thanks!

Reply

Hey @bcar!

Sorry for the VERY slow reply - some hard questions wait for me, and I just got back from a holiday :).

Hmm. I'm not sure exactly why adding @ApiSubresource didn't work. Obviously, it has something to do with mixing Elastic and pgsql, but I would have thought that this would - in theory - at least make Api Platform attempt to use the "comments" property. Do you get an error? Or does the sub resource simply not show up (including in the documentation)?

But, there is also a more generic answer (specifically in the context of API Platform is).... don't do this :p. What I mean is, based on my knowledge of API Platform, having URLs like "/transactions/{id}/comments" is something API Platform supports... but not super well. And, it's kind of on purpose - it's a vanity URL. What API Platform wants you to do instead is have a URL like: /comments?transaction={id}. It's not nearly as attractive, but that's what they push you towards, which is why sub resources are sometimes a bit of a "second class feature".

Cheers!

Reply
Vladyslav K. Avatar
Vladyslav K. Avatar Vladyslav K. | posted 2 years ago | edited

Hi SymfonyCasts!
I have interesting question.
I have 2 entities: User and UserProfile with One2One relation.
I want to create user in one request, something like:


{
  "email": "user@example.com",
  "username": "string",
  "password": "string",
  "phoneNumber": "string",
  "profile": {
    "name": "John",
    "surname": "Doe"
  }
}

Is it possible?

Reply

Hey Vladyslav K.!

Totally! We talk about it here - https://symfonycasts.com/sc... - in that video, we allow you to make a POST request to create a User... but where you can also create a CheeseListing at the same time. They key to making this possible is just using the correct validation groups so that the embedded data is writable :).

Cheers!

Reply
Vladyslav K. Avatar

thanks bro!

Reply
JuanLuisGarciaBorrego Avatar
JuanLuisGarciaBorrego Avatar JuanLuisGarciaBorrego | posted 2 years ago | edited

Hi team SCast!

I am trying to make a custom entry point that returns the resources without pagination with their subItems that have a condition.

I want to get all provinces that have city.isWorking = true

Entity Province OneToMany City


#[ApiResource(
    collectionOperations: [
    'getIsWorking' => [
        'method' => 'GET',
        'path' => '/provinces-available',
        'controller' => ProvincesAvailableAction::class,
        'normalization_context' => ['groups' => 'province:available:read']
    ]
]
)]
class Province {
     ...
     /**
     * @ORM\OneToMany(targetEntity="App\Entity\City", mappedBy="province")
     * @Groups({"province:available:read"})
     */
    private $cities;
}

class City {
   ....
    /**
     * @ORM\Column(type="string", length=100)
     * @Groups({"province:available:read"})
     */
    private string $name;

    /**
     * @ORM\Column(type="boolean")
     */
    private bool $isWorking;

    /**
     * @ORM\ManyToOne(targetEntity=Province::class, inversedBy="cities")
     * @ORM\JoinColumn(nullable=false)
     */
    private Province $province;
}

My custom action with respository is


//Controller
class ProvincesAvailableAction
{
    public function __invoke(ProvinceRepository $provinceRepository)
    {
        return $provinceRepository->getAllProvincesAvailable();
    }

//Repository
public function getAllProvincesAvailable()
    {
        return $this->createQueryBuilder('province')
            ->leftJoin('province.cities', 'city')
            ->andWhere('city.isWorking = :isWorking')
            ->setParameters(['isWorking' => true])
            ->getQuery()
            ->execute();
    }
}

Note: Raw sql query is ok
<br />select * <br />from province<br />left join city ON province.id= city.province_id<br />where city.is_working = 1<br />
The problem, Province:cities returns all items from Province, ignore the condition query.

I know it is recommended to use subresources /province/slug/cities, but my case returns only less than 10 items..

Do you know any solution?
Thank you very much Scast!

Reply
JuanLuisGarciaBorrego Avatar
JuanLuisGarciaBorrego Avatar JuanLuisGarciaBorrego | JuanLuisGarciaBorrego | posted 2 years ago | edited

Uooo!
I didn't remember the doctrine criteria.... =( .
¿Do you know any other solutions?
I leave the code, in case it helps someone.


/**
     * @return Collection
     * @Groups({"province:available:read"})
     */
    public function getIsWorkingCities(): Collection
    {
        $criteria = Criteria::create()
            ->andWhere(Criteria::expr()->eq('isWorking', true))
            ->orderBy(['name' => 'ASC']);

        return $this->getCities()->matching($criteria);
    }

Thanks!!!

Reply

Hey JuanLuisGarciaBorrego!

Ah, I love it! You answered your question awesomely! Thanks for posting this here for others. And to give them even more context, the solution is to allow your Province entity to be a normal API resource (you don't need a custom controller or anything like that). Then, you expose a isWorkingCities field on that resource, which (instead if reading your cities property directly) uses this getter... which has a custom query to return a sub-set.

I'm sure there are other solutions - but this is absolutely what I would have done. Here is some more info on this approach: https://symfonycasts.com/screencast/api-platform-security/filtered-collection

Cheers!

Reply
Jean-tilapin Avatar
Jean-tilapin Avatar Jean-tilapin | posted 2 years ago

Hi!
I have a weird question again :)
I have an entity "Activity". This entity has a collection of an other entity, "OpeningHours" - (day/openingTime/closingTime). Some activities have very few OpeningHours, others thousands of them. First I treated the OpeningHours as a subresource of Activity (which could be very useful), but it seemed to be very unefficient. So I dropped that idea. But is there a way to get in one query an activity with its openingHours between two dates, and not all of them ? A query that would return something like:
{@id: api/activity/12, openingHours: [...only those between NOW and 30 days later for example...]}, and not their '@id', the actual data ? Is that a CustomFilter on a subresource ? Or a search on OpeningHours and not Activity ? Could you please point the right direction ?
Thank you - again - for your help

Reply

Hey @Xav!

Ah, interesting question! Unless Iā€™m misunderstanding, it sounds like you ALWAYS want to show this openingHours field and ALWAYS have it show the matching entries for the next 30 days (what I mean is, this is not something that the user can control with a query parameter - like to change it to 60 days or something).

Anyways, if Iā€™m right, what you should do is:

1) create a getUpcomingOpeningHours() method and give it an @Group so itā€™s exposed to your API. You can also used @SerializedName to name this ā€œopeningHoursā€ in your api.

2) inside the method, filter the real openingHours relation collection property. You can do this very efficiently by using the doctrine criteria system - https://symfonycasts.com/sc...

Letā€™s me know if this helps... or if I missed some complication ;).

Cheers!

Reply
Jean-tilapin Avatar

Hi Ryan! Sorry for the late answer, I was on vacation :)
Again - i'm soo repeating myself - THANK YOU (you and your SymfonyCasts team). My project couldn't be achieved without your help. Well...it still has a lot of work to do, but Criteria are exactly what I need, and I totally forgot about them! I can't wait to finish my app. You guys will definitely be mentionned on the "thanks section" :)
Now back to work.

Edit one hour later: I was expecting to work a whole week on that. It's done in less than one hour. If someday I get rich enough thanks to that app project, I swear I'll send you bottles of Champagne :)

Reply

Yaaay! I love success stories! Thanks for sharing ā¤ļø

Reply
Jean-tilapin Avatar
Jean-tilapin Avatar Jean-tilapin | weaverryan | posted 2 years ago | edited

Hum...Very sorry to come back on that question. It seems that I totally forgot one case.

For now, my request returns the UpcomingOpeningHours from NOW to 8 days (I've reduced it), but the problem is that if I collect the activites that will happen in next december, the UpcomingOpeningHours are still the ones of the current week, and not those of december.

So, is there a simple way to pass a "start" parameter to the criteria system? For example, a request could be

http://localhost:8000/api/activities?openingHours.day[after]=2021-12-21

And we could intercept that date and pass it to the criteria as a starting point?

Reply

Hey Jean-tilapin!

Hmmm. It's been awhile, so let me repeat the setup to make sure that I remember it correctly :). To have this upcomingOpeningHours property, you added a getUpcomingOpeningHours() method to your Activity.

So, suppose we are inside of this method. If we are, then we could look at the Activity itself to see what its date is? For example, if a single activity's date is Dec 1st, 2021, then could we pass this to the criteria to only return OpeningHours that are after that date? Then, for each Activity that is rendered, we would always be showing opening hours relevant to that activity.

I'm not sure this is what you were asking for, however. Att the start of your question, it seems like it is... but then you asked for this:

http://localhost:8000/api/activities?openingHours.day[after]=2021-12-21

And doing THAT is something totally different :). In this case, if I'm understanding the problem correctly, you are not trying to filter the "opening hours" property on whatever the first 25 (or whatever your pagination is set to) activity results. Instead, you are trying to filter the activity results themselves... using the openingHours as information for that. If THAT is true, then what you want is a custom Doctrine filter. A doctrine filter is basically the combination of (A) a query parameter which is used to (B) change the query that's used when fetching the top-level collection (the Activity objects in this case). We cover that here: https://symfonycasts.com/screencast/api-platform-extending/entity-filter-logic

Let me know if this helps!

Cheers!

Reply
Jean-tilapin Avatar
Jean-tilapin Avatar Jean-tilapin | weaverryan | posted 2 years ago | edited

Sorry if I wasn't clear with my question.

For a better understanding, I have in my DB thousands of records of OpeningHours, which allow me to say: "here is a list of activities (places, for example) that will be opened 2022-05-12 at 4:00pm". I can get that list with codes like above (normally it's between two dates, with "activities?openingHours.day[after]=xxx&openingHours.day[before]=yyy" etc.).

The default behaviour of API Platform was to send all the records of opening hours for each activity, which was a huge waste of time and resources (some activities have literally hundreds of hours for the next months and years). So, thanks to your criteria idea, I've reduced the openinghours Collection to the next 8 days, which allow the user on the homepage to efficiently and quickly filter the activities thanks to JS scripts: "next saturday we can go to that place or that place which will be both opened". So it works fine <i>as long as the user doesn't need to see activities more than 8 days after today.</i>

If the user asks for a later date (single date or range), there's a new ajax call, gathering all activities that have an OpeningHour matching what the user asked. My problem is that it returns the correct activities, but with the wrong collection of openingHours, as they are always between now and 8 days later, and not between the range asked by the user. So what I need is to add some parameters to my API request that I can use to tell API Platform "hey, tell me what activities will be opened at that moment, and join with that list the opening hours of said activities strictly between the dates asked by the user, as the opening hours before or after that won't be useful and will slow down the request and the JS filters".

That was a long explanation, sorry.

Edit: here's the current criteria in Activity Repository. There should be a way to pass parameters for start and end according to the API call?


public static function createUpcomingOpeningHoursCriteria(): Criteria
    {
        $today = new \DateTime();
        $today->format('Y-m-d');
        $nextWeek = new \DateTime();
        $nextWeek->add(new \DateInterval('P8D'));
        $nextWeek->format('Y-m-d');

        return Criteria::create()
            ->andWhere(Criteria::expr()->gt('day', $today))
            ->andWhere(Criteria::expr()->lt('day', $nextWeek))
            ->orderBy(['openingTime' => 'ASC'])
        ;
    }
Reply

Hey Jean-tilapin !

I think (hope!) that I understand now :). So the problem (as you know) is that in your Activity entity (or ActivityRepository), you don't have the "context" you need. You would like to be able to see that "the user is currently requesting activities that are open on Dec 1st" so that you can then filter the OpeningHours to dates that start on Dec 1st.

I can think of a few ways to do this... all of which are basically the same... and none of them is particularly "cool", but I think they would work. So here is probably the simplest:

Create a custom data provider (it would be both a collection data provider and item data provider). Make this leverage the "core", normal entity data provider so that it does all the normal work. After calling the normal system, loop over all of the results (or in the case of the "item" method, you already have just one object). Inside that loop, manually read the query parameters from the Request object to see if the user has requested activities in a future data. If they have, call a method on each Activity - e.g. ->setRequestedStartDate(), which would populate a non-persisted property with, for example, December 1st.

THEN, later, in your Activity.getUpcomingOpeningHours() method (or whatever it is called in your case), you can pass this value to the repository method so that it can filter things.

It's not a super cool solution that hooks into API Platform in some awesome way - but I think this should work just fine.

Cheers!

Reply
Alexandr K. Avatar
Alexandr K. Avatar Alexandr K. | posted 2 years ago

Hi guys! Thanks a lot for the API platform series course.
Is there any way to have a different API endpoints split by subdomains (or scopes)?
For example:
www.apidomain.com/api1/
www.apidomain.com/api2/
etc.
so each scope has its own api documentation, models, dto's etc.
Is that possible with API platform, and what kind of stuff I should handle with to achieve this?

Reply

Yo Alexandr K.!

This is... unfortunately, not currently possible. But there is a good conversation about it and some ways to go about doing that here - https://github.com/api-plat...

Cheers!

Reply

I have completed this courese. Thnks @there for your good company.
Also plant to take other course in the series.
I am looking for some open source projects using api platform.
can anyone suggest ?

Reply

Hey Abusayeed,

Congratulations! We already have 3 episodes of ApiPlatform, you can check it out here: https://symfonycasts.com/tr...

About OS projects that use ApiPlatform? Look at Sylius for example, that's what I know. Actually, you can find the list of popular projects on their home page: https://api-platform.com/ - search for "They use API Platform".

Good luck with learning your next course about ApiPlatform!
Cheers!

Reply
Edgar Avatar

Hello,

I didn't find anything related to API versioning. I want to use something like /api/2020-09/some-endpoint, then /api/2020-10/some-endpoint. Is API-Platform able to do that? or probably something related to versioning? It's really cool to have deprecations, but that doesn't solve the problem of versioning. Any recommendation about it? I'm missing something?

Greetings!

Reply

Hey Edgar

This is a good question! As I know API platform doesn't support versioning yet. There are some issues on github related to this issue, also one pull request to docs describing their position, but it's not merged yet. https://github.com/api-plat...

Cheers!

Reply

Is there a way to add filters and openapi doc to subresource via yaml?

Reply

Hey ahmedbhs!

Do you want to do something like /api/users/8/cheese_listings?title=cheddar? If so, I think that's already possible: the sub-resource will allow any filters that this resource normally has. So, if a CheeseListing can normally be filtered with a ?title=, then you can also apply that when the cheese listing is a sub-resource. And so, you just need to configure the filter like you normally would.

Or maybe I misunderstood your question? Let me know :)

Cheers!

Reply

Hi everybody,
I have a very short question.
Is it possible to get subresource with a custom identifier?
For example i have a column 'name', and it has @ApiProperty(identifier=true)?
The standard id has @ApiProperty(identifier=false).

So, in stead of:
/api/users/4/cheese_listings

I want to use:
/api/users/ryan/cheese_listings

My problem is, that is does work as an embedded relation, but i can't get it to work as subresource.
For subresource i have to give id as identifier, with name it won't find anything.

Thank you very mutch in advance.

Greetings,
Annemieke

Reply

Never mind, other urls do work with different identifier. Only this one does not.
Thank you.

Reply

Hey truuslee

Creating a custom identifier for a subresource does not work? I think it should just work but I'm not sure if it's recommended, I believe it may slow things down because of the extra queries

Cheers!

Reply

Thank you Diego. I think you're right. I will never agree to doing this again.

1 Reply

Hey Diego,

I need some help.

<b>1. The problem</b>

Subresources with 'one-to-many' relation don't work with custom identifier anymore. It won't find anything.
If I want to show it as embedded data it does work. But as a 'subresource' it won't.

This works: /api/relations/1/assortments (this should not work)

This does not work anymore: /api/relations/M133DS44/assortments

<b>2. What i’ve tried</b>

  • With 'normal' id it works.
  • Added an new entity with doctrines 'make:entity' to make sure it is correct. Normally i add a new entity without 'make:entity'.
  • Tested entities with many to many relation.
    They do fine.
  • Searched our git repository to find out if i changed
    anything that could have caused this.

<b>3. Here is some code</b>

`
/**

 * @ApiResource(
 *     normalizationContext={"groups"="Relation:Read"},
 *     collectionOperations={},
 *     itemOperations={
 *         "get"={},
 *     },
 * )
 *
 * @ORM\Table(name="relation")
 * @ORM\Entity()
 */
class Relation
{
    /**
     * @var int
     * @ApiProperty(identifier=false)
     *
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(name="max_id", type="string", length=25, unique=true)
     * @ApiProperty(identifier=true)
     */
    private $maxId;


    /**
     * One relation has many assortment records. 
     *
     * @ORM\OneToMany(targetEntity="App\Entity\Assortment", mappedBy="relation")
     * @ApiSubresource()
     */
    private $assortment;

class Assortment
{

/**
 * @ORM\Column(name="id", type="integer")
 * @ORM\Id
 * @ORM\GeneratedValue(strategy="AUTO")
 * @Groups({"Assortment:Read"})
 */
private $id;

/**
 * @ORM\ManyToOne(targetEntity="App\Entity\Relation", inversedBy="assortment")
 * @ORM\JoinColumn(nullable=false)
 * @Groups({"Assortment:Read"})
 */
private $relation;

`

Thanks in advance !

<b>Update:</b>

When i copy enities relation and assortment to code of this course (the zipfile), it works again.
I really don't understand, but I'm happy i've got it working.
Maybe it has something to do with api platform config. But i cannot think of something.

Reply

Uh, that's weird but I'm glad it's working :)
I believe you had something misconfigured in your old entities

Reply

I found something. When i do a composer update of this course's code, it does not work anymore either.
After the composer update i have to use the generated id e.g. 1 and i cannot use e.g. 'M133DS44' anymore.
???

or in the case of cheeslistings, i have to submit the id of the user, and not the username to get the cheeselistings subresource.

This is what symfony server says:

<br />Matched route "api_users_cheese_listings_get_subresource". method="GET" request_uri="https://localhost:8000/api/users/hatsieflats/cheese_listings" <br />route="api_users_cheese_listings_get_subresource" route_parameters={"_api_resource_class":"App\\Entity\\CheeseListing","_api_subresource_context":{<br />"collection":true,"identifiers":[["id","App\\Entity\\User",true]],<br />"operationId":"api_users_cheese_listings_get_subresource","property":"cheeseListings"},<br />"_api_subresource_operation_name":"api_users_cheese_listings_get_subresource",<br />"_controller":"api_platform.action.get_subresource",<br />"_format":null,"_route":"api_users_cheese_listings_get_subresource","id":"hatsieflats"}<br />

I've been looking for a solution whole weekend. Do you have any ideas? Can you reproduce this problem?
Thank you very much in advance.

Annemieke

<b>Update</b>
I've created a new project with symfony 5. Only added api platform to it, And two entities, namely user and cheeselisting.
No security yet, nothing. And it still does not work.
I'm close to giving up.

1 Reply

Hey truuslee

I'm not sure what's going on here, I couldn't find any related change to "custom identifiers" in the ApiPlatform changelog. Can you double check that your custom identifier is properly wired up and that it's being executed. You could add a dd() call inside the supports() method
Here are the docs in case you need it https://api-platform.com/docs/core/identifiers/

Reply

I think i found it.

It's the vendor\api-platform\core\src\Bridge\Doctrine\Orm\SubresourceDataProvider.php. If i change it back to what it was before the composer update it all works again.

It turns out that api-pack v1.2.2 does not work right with one to many
I downgraded it to api-pack v1.2.0 and everything is oke again.

Greetz,
Annemieke

Reply

Oh really? That's the problem? Wow, it would be nice to spot the BC break change. If you want to go one step further you could open up a new issue on the ApiPlatform project https://github.com/api-plat...

Nice job! Cheers!

Reply

Thank you Diego for helping me.

The real two entities I am using are the entity 'relations' and 'assortments'.
But the situation is the same. One relation has many records in assortments.
It's symfony 5, i only added api platform, nothing else.

I just now created a <b>RelationItemDataProvider</b> for testing.
In there in the function getItem() i put a print_r($identifiers). The variable $identifiers has 'M2M20180621164125617383' as value. So that is oke.

This url works /api/relations/M2M20180621164125617383 . It gives me the correct relation as response.
If I embed the assortment, it works just fine too. It gives me the assortments related to 'relation'.

But as soon as i use it with a subresource, it finds nothing.

And this is the response of /api/relations``

{
"@context": "/api/contexts/Relation",
"@id": "/api/relations",
"@type": "hydra:Collection",
"hydra:member": [

{
  "@id": "/api/relations/M2M20180621164125617383",
  "@type": "Relation",
  "id": 1,
  "maxId": "M2M20180621164125617383",
  "connId": "A28",
  "name": "sdf",
  "debtorId": "s",
  "purchaseId": "s",
  "isActive": true,

<b>

  "assortment": [
    "/api/assortments/1"
  ]

</b>

}

],
"hydra:totalItems": 1
}
`
As you can see it does have the data of the related assorment.

Is it oke if I send you the entities?

`
<b>
Relation entity</b>

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiProperty;
use ApiPlatform\Core\Annotation\ApiResource;
use ApiPlatform\Core\Annotation\ApiSubresource;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

/**

  • @ApiResource()
  • @UniqueEntity(fields={"maxId"})
    *
  • @ORM\Table(name="relation")
  • @ORM\Entity()
    / class Relation { /*

    • @ORM\Id()
    • @ORM\GeneratedValue()
    • @ORM\Column(type="integer")
    • @ApiProperty(identifier=false)
      */
      private $id;

    /**

    • @ORM\Column(name="max_id", type="string", length=25, unique=true)
    • @ApiProperty(identifier=true)
      */
      private $maxId;

    /**

    • @ORM\Column(name="conn_id", type="string", length=15)
      */
      private $connId;

    /**

    • @ORM\Column(name="conn_desc", type="string")
      */
      private $name;

    /**

    • @ORM\Column(name="deb_id", type="string", length=15)
      */
      private $debtorId;

    /**

    • @ORM\Column(name="prch_id", type="string", length=15)
      */
      private $purchaseId;

    /**

    • @ORM\OneToMany(targetEntity="App\Entity\Assortment", mappedBy="relation")
    • @ApiSubresource()
      */
      private $assortment;

    /**

    • @ORM\Column(name="is_active", type="boolean")
      */
      private $isActive;

    /**

    • @return mixed
      */
      public function getId()
      {
      return $this->id;
      }

    /**

    • @return mixed
      */
      public function getMaxId()
      {
      return $this->maxId;
      }

    /**

    • @param mixed $maxId
      */
      public function setMaxId($maxId): void
      {
      $this->maxId = $maxId;
      }

    /**

    • @return mixed
      */
      public function getConnId()
      {
      return $this->connId;
      }

    /**

    • @param mixed $connId
      */
      public function setConnId($connId): void
      {
      $this->connId = $connId;
      }

    /**

    • @return mixed
      */
      public function getName()
      {
      return $this->name;
      }

    /**

    • @param mixed $name
      */
      public function setName($name): void
      {
      $this->name = $name;
      }

    /**

    • @return mixed
      */
      public function getDebtorId()
      {
      return $this->debtorId;
      }

    /**

    • @param mixed $debtorId
      */
      public function setDebtorId($debtorId): void
      {
      $this->debtorId = $debtorId;
      }

    /**

    • @return mixed
      */
      public function getPurchaseId()
      {
      return $this->purchaseId;
      }

    /**

    • @param mixed $purchaseId
      */
      public function setPurchaseId($purchaseId): void
      {
      $this->purchaseId = $purchaseId;
      }

    /**

    • @return mixed
      */
      public function getIsActive()
      {
      return $this->isActive;
      }

    /**

    • @param mixed $isActive
      */
      public function setIsActive($isActive): void
      {
      $this->isActive = $isActive;
      }

    /**

    • @return mixed
      */
      public function getAssortment()
      {
      return $this->assortment;
      }
      }

<b>Assortment entity</b>

namespace App\Entity;

use ApiPlatform\Core\Annotation\ApiResource;
use Doctrine\ORM\Mapping as ORM;

/**

  • @ApiResource()
    *
  • @ORM\Table(name="assortment")
    *
  • @ORM\Entity()
    / class Assortment { /*

    • @ORM\Column(name="id", type="integer")
    • @ORM\Id
    • @ORM\GeneratedValue(strategy="AUTO")
      */
      private $id;

    /**

    • @ORM\ManyToOne(targetEntity="App\Entity\Relation", inversedBy="assortment")
      */
      private $relation;

    /**

    • @ORM\Column(name="m_prio", type="integer", nullable=true)
      */
      private $mPrio;

    /**

    • @ORM\Column(type="integer", nullable=true)
    • @var integer This is in cents, divide by 100
      */
      private $bhp;

    /**

    • @ORM\Column(name="is_active", type="boolean")
      */
      private $isActive;

    /**

    • @return mixed
      */
      public function getId()
      {
      return $this->id;
      }

    /**

    • @return mixed
      */
      public function getMPrio()
      {
      return $this->mPrio;
      }

    /**

    • @return int
      */
      public function getBhp(): int
      {
      return $this->bhp;
      }

    /**

    • @return mixed
      */
      public function getIsActive()
      {
      return $this->isActive;
      }
      }

`

<b>And maybe the composer.json is handy too.</b>

`
{

"type": "project",
"license": "proprietary",
"require": {
    "php": "^7.2.5",
    "ext-ctype": "*",
    "ext-iconv": "*",
    "api-platform/api-pack": "^1.2",
    "symfony/console": "5.0.*",
    "symfony/dotenv": "5.0.*",
    "symfony/flex": "^1.3.1",
    "symfony/framework-bundle": "5.0.*",
    "symfony/yaml": "5.0.*"
},
"require-dev": {
    "roave/security-advisories": "dev-master"
},
"config": {
    "preferred-install": {
        "*": "dist"
    },
    "sort-packages": true
},
"autoload": {
    "psr-4": {
        "App\\": "src/"
    }
},
"autoload-dev": {
    "psr-4": {
        "App\\Tests\\": "tests/"
    }
},
"replace": {
    "paragonie/random_compat": "2.*",
    "symfony/polyfill-ctype": "*",
    "symfony/polyfill-iconv": "*",
    "symfony/polyfill-php72": "*",
    "symfony/polyfill-php71": "*",
    "symfony/polyfill-php70": "*",
    "symfony/polyfill-php56": "*"
},
"scripts": {
    "auto-scripts": {
        "cache:clear": "symfony-cmd",
        "assets:install %PUBLIC_DIR%": "symfony-cmd"
    },
    "post-install-cmd": [
        "@auto-scripts"
    ],
    "post-update-cmd": [
        "@auto-scripts"
    ]
},
"conflict": {
    "symfony/symfony": "*"
},
"extra": {
    "symfony": {
        "allow-contrib": false,
        "require": "5.0.*"
    }
}

}

`

Thank you Diego! I hope we can figure this out. A customer is waiting for this to work....

Just for testing I tried a many to many relation and that works just fine. Problem is only in one to many i think.

Reply
Pedro S. Avatar
Pedro S. Avatar Pedro S. | truuslee | posted 3 years ago | edited

Hi guys!

Same problem here, after performing a "composer update", api subresources with custom identifiers stopped working too, and only for the OneToMany relations (subresources on the ManyToMany relations work fine).

truuslee I downgraded the api-pack to v1.2.0 but it is still not working, are there any additional steps that I need to do to make it work again?

Many thanks in advance!

Reply

Hi Pedro,
I am very sorry you have the same problem, but also glad. This proves I'm not grazy !!!
The 'solution' for now is very very bad but it works:

It's the vendor\api-platform\core\src\Bridge\Doctrine\Orm\SubresourceDataProvider.php file.

Here's the code you have to replace it with and it will work again.

`

/*

  • This file is part of the API Platform project.
    *
  • (c) Kévin Dunglas <dunglas@gmail.com>
    *
  • For the full copyright and license information, please view the LICENSE
  • file that was distributed with this source code.
    */

declare(strict_types=1);

namespace ApiPlatform\Core\Bridge\Doctrine\Orm;

use ApiPlatform\Core\Bridge\Doctrine\Common\Util\IdentifierManagerTrait;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\FilterEagerLoadingExtension;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryResultCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryResultItemExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGenerator;
use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface;
use ApiPlatform\Core\Exception\ResourceClassNotSupportedException;
use ApiPlatform\Core\Exception\RuntimeException;
use ApiPlatform\Core\Identifier\IdentifierConverterInterface;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
use Doctrine\Common\Persistence\ManagerRegistry;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\ClassMetadataInfo;
use Doctrine\ORM\QueryBuilder;

/**

  • Subresource data provider for the Doctrine ORM.
    *
  • @author Antoine Bluchet <soyuka@gmail.com>
    */
    final class SubresourceDataProvider implements SubresourceDataProviderInterface
    {
    use IdentifierManagerTrait;

    private $managerRegistry;
    private $collectionExtensions;
    private $itemExtensions;

    /**

    • @param QueryCollectionExtensionInterface[] $collectionExtensions
    • @param QueryItemExtensionInterface[] $itemExtensions
      */
      public function __construct(ManagerRegistry $managerRegistry, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $collectionExtensions = [], iterable $itemExtensions = [])
      {
      $this->managerRegistry = $managerRegistry;
      $this->propertyNameCollectionFactory = $propertyNameCollectionFactory;
      $this->propertyMetadataFactory = $propertyMetadataFactory;
      $this->collectionExtensions = $collectionExtensions;
      $this->itemExtensions = $itemExtensions;
      }

    /**

    • {@inheritdoc}
      *
    • @throws RuntimeException
      */
      public function getSubresource(string $resourceClass, array $identifiers, array $context, string $operationName = null)
      {
      $manager = $this->managerRegistry->getManagerForClass($resourceClass);
      if (null === $manager) {

       throw new ResourceClassNotSupportedException(sprintf('The object manager associated with the "%s" resource class cannot be retrieved.', $resourceClass));
      

      }

      $repository = $manager->getRepository($resourceClass);
      if (!method_exists($repository, 'createQueryBuilder')) {

       throw new RuntimeException('The repository class must have a "createQueryBuilder" method.');
      

      }

      if (!isset($context['identifiers'], $context['property'])) {

       throw new ResourceClassNotSupportedException('The given resource class is not a subresource.');
      

      }

      $queryNameGenerator = new QueryNameGenerator();

      /*

      • The following recursively translates to this pseudo-dql:
        *
      • SELECT thirdLevel WHERE thirdLevel IN (
      • SELECT thirdLevel FROM relatedDummies WHERE relatedDummies = ? AND relatedDummies IN (
      • SELECT relatedDummies FROM Dummy WHERE Dummy = ?
      • )
      • )
        *
      • By using subqueries, we're forcing the SQL execution plan to go through indexes on doctrine identifiers.
        */
        $queryBuilder = $this->buildQuery($identifiers, $context, $queryNameGenerator, $repository->createQueryBuilder($alias = 'o'), $alias, \count($context['identifiers']));

      if (true === $context['collection']) {

       foreach ($this->collectionExtensions as $extension) {
           // We don't need this anymore because we already made sub queries to ensure correct results
           if ($extension instanceof FilterEagerLoadingExtension) {
               continue;
           }
      
           $extension->applyToCollection($queryBuilder, $queryNameGenerator, $resourceClass, $operationName, $context);
           if ($extension instanceof QueryResultCollectionExtensionInterface && $extension->supportsResult($resourceClass, $operationName, $context)) {
               return $extension->getResult($queryBuilder, $resourceClass, $operationName, $context);
           }
       }
      

      } else {

       foreach ($this->itemExtensions as $extension) {
           $extension->applyToItem($queryBuilder, $queryNameGenerator, $resourceClass, $identifiers, $operationName, $context);
           if ($extension instanceof QueryResultItemExtensionInterface && $extension->supportsResult($resourceClass, $operationName, $context)) {
               return $extension->getResult($queryBuilder, $resourceClass, $operationName, $context);
           }
       }
      

      }

      $query = $queryBuilder->getQuery();

      return $context['collection'] ? $query->getResult() : $query->getOneOrNullResult();
      }

    /**

    • @throws RuntimeException
      */
      private function buildQuery(array $identifiers, array $context, QueryNameGenerator $queryNameGenerator, QueryBuilder $previousQueryBuilder, string $previousAlias, int $remainingIdentifiers, QueryBuilder $topQueryBuilder = null): QueryBuilder
      {
      if ($remainingIdentifiers <= 0) {

       return $previousQueryBuilder;
      

      }

      $topQueryBuilder = $topQueryBuilder ?? $previousQueryBuilder;

      [$identifier, $identifierResourceClass] = $context['identifiers'][$remainingIdentifiers - 1];
      $previousAssociationProperty = $context['identifiers'][$remainingIdentifiers][0] ?? $context['property'];

      $manager = $this->managerRegistry->getManagerForClass($identifierResourceClass);

      if (!$manager instanceof EntityManagerInterface) {

       throw new RuntimeException("The manager for $identifierResourceClass must be an EntityManager.");
      

      }

      $classMetadata = $manager->getClassMetadata($identifierResourceClass);

      if (!$classMetadata instanceof ClassMetadataInfo) {

       throw new RuntimeException("The class metadata for $identifierResourceClass must be an instance of ClassMetadataInfo.");
      

      }

      $qb = $manager->createQueryBuilder();
      $alias = $queryNameGenerator->generateJoinAlias($identifier);
      $normalizedIdentifiers = [];

      if (isset($identifiers[$identifier])) {

       // if it's an array it's already normalized, the IdentifierManagerTrait is deprecated
       if ($context[IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER] ?? false) {
           $normalizedIdentifiers = $identifiers[$identifier];
       } else {
           $normalizedIdentifiers = $this->normalizeIdentifiers($identifiers[$identifier], $manager, $identifierResourceClass);
       }
      

      }

      if ($classMetadata->hasAssociation($previousAssociationProperty)) {

       $relationType = $classMetadata->getAssociationMapping($previousAssociationProperty)['type'];
       switch ($relationType) {
           // MANY_TO_MANY relations need an explicit join so that the identifier part can be retrieved
           case ClassMetadataInfo::MANY_TO_MANY:
               $joinAlias = $queryNameGenerator->generateJoinAlias($previousAssociationProperty);
      
               $qb->select($joinAlias)
                   ->from($identifierResourceClass, $alias)
                   ->innerJoin("$alias.$previousAssociationProperty", $joinAlias);
               break;
           case ClassMetadataInfo::ONE_TO_MANY:
               $mappedBy = $classMetadata->getAssociationMapping($previousAssociationProperty)['mappedBy'];
               $previousAlias = "$previousAlias.$mappedBy";
      
               $qb->select($alias)
                   ->from($identifierResourceClass, $alias);
               break;
           case ClassMetadataInfo::ONE_TO_ONE:
               $association = $classMetadata->getAssociationMapping($previousAssociationProperty);
               if (!isset($association['mappedBy'])) {
                   $qb->select("IDENTITY($alias.$previousAssociationProperty)")
                       ->from($identifierResourceClass, $alias);
                   break;
               }
               $mappedBy = $association['mappedBy'];
               $previousAlias = "$previousAlias.$mappedBy";
      
               $qb->select($alias)
                   ->from($identifierResourceClass, $alias);
               break;
           default:
               $qb->select("IDENTITY($alias.$previousAssociationProperty)")
                   ->from($identifierResourceClass, $alias);
       }
      

      } elseif ($classMetadata->isIdentifier($previousAssociationProperty)) {

       $qb->select($alias)
           ->from($identifierResourceClass, $alias);
      

      }

      // Add where clause for identifiers
      foreach ($normalizedIdentifiers as $key => $value) {

       $placeholder = $queryNameGenerator->generateParameterName($key);
       $qb->andWhere("$alias.$key = :$placeholder");
       $topQueryBuilder->setParameter($placeholder, $value, (string) $classMetadata->getTypeOfField($key));
      

      }
      // Recurse queries
      $qb = $this->buildQuery($identifiers, $context, $queryNameGenerator, $qb, $alias, --$remainingIdentifiers, $topQueryBuilder);

      return $previousQueryBuilder->andWhere($qb->expr()->in($previousAlias, $qb->getDQL()));
      }
      }`

Good luck!!

Reply
Pedro S. Avatar
Pedro S. Avatar Pedro S. | truuslee | posted 3 years ago | edited

Many thanks! I opened an issue on the api-platform github project as truuslee recommended, let's see if someone can take a look at it.

Cheers!

Reply

Thank you Pedro S. for taking the initiative!

Reply
Roland W. Avatar
Roland W. Avatar Roland W. | posted 3 years ago | edited

I tried to change the path for the Subresource to /api/users/{id}/cheeses but failed. Can you please help?

<b>I use these annotations in the</b> User <b>class:</b>

`

/**
 * @ORM\OneToMany(targetEntity="App\Entity\CheeseListing", mappedBy="owner", cascade={"persist"}, orphanRemoval=true)
 * @Groups({"user:read", "user:write"})
 * @Assert\Valid()
 * @ApiSubresource()
 */
private $cheeseListings;

`

<b>I use these annotations in the </b>CheeseListing<b> class:</b>

`
/**

  • @ApiResource(
  • collectionOperations={"get", "post"},
  • itemOperations={
  • "get"={
  • "normalization_context"={
  • "groups"={
  • "cheese_listing:read",
  • "cheese_listing:item:get"
  • }
  • }
  • },
  • "put"
  • },
  • normalizationContext={
  • "groups"={"cheese_listing:read"},
  • "swagger_definition_name"="Read"
  • },
  • denormalizationContext={
  • "groups"={"cheese_listing:write"},
  • "swagger_definition_name"="Write"
  • },
  • shortName="cheeses",
  • attributes={
  • "pagination_items_per_page"=5,
  • "formats"={
  • "jsonld",
  • "json",
  • "html",
  • "jsonhal",
  • "csv"={"text/csv"}
  • }
  • },
  • subresourceOperations={
  • "api_users_cheese_listings_get_subresource"={
  • "method"="GET",
  • "path"="/api/users/{id}/cheeses"
  • },
  • }
  • )
  • @ApiFilter(
  • BooleanFilter::class,
  • properties={
  • "isPublished"
  • }
  • )
  • @ApiFilter(
  • SearchFilter::class,
  • properties={
  • "title": "partial",
  • "description": "partial",
  • "owner": "exact",
  • "owner.username": "partial"
  • }
  • )
  • @ApiFilter(
  • RangeFilter::class,
  • properties={
  • "price"
  • }
  • )
  • @ApiFilter(
  • PropertyFilter::class
  • )
  • @ORM\Entity(repositoryClass="App\Repository\CheeseListingRepository")
    */
    class CheeseListing
    `

<b>Note: I found the operation name</b> api_users_cheese_listings_get_subresource <b>by using</b> bin/console debug:router.

Reply

Hey Roland W.

Did you manage to change the path? If not, here is how you can do it https://github.com/api-plat...

Cheers!

Reply
Cat in space

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

This tutorial works great for Symfony 5 and API Platform 2.5/2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.3
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.10.2
        "doctrine/doctrine-bundle": "^1.6", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "doctrine/orm": "^2.4.5", // v2.7.2
        "nelmio/cors-bundle": "^1.5", // 1.5.5
        "nesbot/carbon": "^2.17", // 2.19.2
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.2.*|4.3.*|4.4.*", // v4.3.11
        "symfony/console": "4.2.*", // v4.2.12
        "symfony/dotenv": "4.2.*", // v4.2.12
        "symfony/expression-language": "4.2.*|4.3.*|4.4.*", // v4.3.11
        "symfony/flex": "^1.1", // v1.17.6
        "symfony/framework-bundle": "4.2.*", // v4.2.12
        "symfony/security-bundle": "4.2.*|4.3.*", // v4.3.3
        "symfony/twig-bundle": "4.2.*|4.3.*", // v4.2.12
        "symfony/validator": "4.2.*|4.3.*", // v4.3.11
        "symfony/yaml": "4.2.*" // v4.2.12
    },
    "require-dev": {
        "symfony/maker-bundle": "^1.11", // v1.11.6
        "symfony/stopwatch": "4.2.*|4.3.*", // v4.2.9
        "symfony/web-profiler-bundle": "4.2.*|4.3.*" // v4.2.9
    }
}
userVoice