If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeGo directly to /api/users/5.jsonld
. This user owns one CheeseListing
... and we've decided to embed the title
and price
fields instead of just showing the IRI. Great!
Earlier, we talked about a really cool filter called PropertyFilter
, which allows us to, for example, add ?properties[]=username
to the URL if we only want to get back that one field. We added that to CheeseListing
, but not User
. Let's fix that!
Above User
, add @ApiFilter(PropertyFilter::class)
. And remember, we need to manually add the use
statement for filter classes: use PropertyFilter
.
... lines 1 - 6 | |
use ApiPlatform\Core\Serializer\Filter\PropertyFilter; | |
... lines 8 - 15 | |
/** | |
... lines 17 - 20 | |
* @ApiFilter(PropertyFilter::class) | |
... lines 22 - 24 | |
*/ | |
class User implements UserInterface | |
... lines 27 - 190 |
And... we're done! When we refresh, it works! Other than the standard JSON-LD properties, we only see username
.
But wait there's more! Remove the ?properties[]=
part for a second so we can see the full response. What if we wanted to fetch only the username
property and the title
property of the embedded cheeseListings
? Is that possible? Totally! You just need to know the syntax. Put back the ?properties[]=username
. Now add &properties[
, but inside of the square brackets, put cheeseListings
. Then []=
and the property name: title
. Hit it! Nice! Well, the title
is empty on this CheeseListing
, but you get the idea. The point is this: PropertyFilter
kicks butt and can be used to filter embedded data without any extra work.
Speaking of filters, we gave CheeseListing
a bunch of them, including the ability to search by title
or description
and filter by price
. Let's add another one.
Scroll to the top of CheeseListing
to find SearchFilter
. Let's break this onto multiple lines.
... lines 1 - 16 | |
/** | |
... lines 18 - 34 | |
* @ApiFilter(SearchFilter::class, properties={ | |
* "title": "partial", | |
* "description": "partial" | |
* }) | |
... lines 39 - 41 | |
*/ | |
class CheeseListing | |
... lines 44 - 202 |
Searching by title
and description
is great. But what if I want to search by owner: find all the CheeseListings
owned by a specific User
? Well, we can already do this a different way: fetch that user's data and look at its cheeseListings
property. But having it as a filter might be super useful. Heck, then we could search for all cheese listings owned by a specific user and that match some title! And... if users start to have many cheeseListings
, we might decide not to expose that property on User
at all: the list might be too long. The advantage of a filter is that we can get all the cheese listings for a user in a paginated collection.
To do this... add owner
set to exact
.
... lines 1 - 16 | |
/** | |
... lines 18 - 34 | |
* @ApiFilter(SearchFilter::class, properties={ | |
... lines 36 - 37 | |
* "owner": "exact" | |
* }) | |
... lines 40 - 42 | |
*/ | |
class CheeseListing | |
... lines 45 - 203 |
Go refresh the docs and try the GET endpoint. Hey! We've got a new filter box! We can even find by multiple owners. Inside the box, add the IRI - /api/users/4
. You can also filter by id
, but the IRI is recommended.
Execute and... yes! We get the one CheeseListing
for that User
. And the syntax on the URL is beautifully simple: ?owner=
and the IRI... which only looks ugly because it's URL-encoded.
But we can get even crazier! Add one more filter: owner.username
set to partial
.
... lines 1 - 16 | |
/** | |
... lines 18 - 34 | |
* @ApiFilter(SearchFilter::class, properties={ | |
... lines 36 - 38 | |
* "owner.username": "partial" | |
* }) | |
... lines 41 - 43 | |
*/ | |
class CheeseListing | |
... lines 46 - 204 |
This is pretty sweet. Refresh the docs again and open up the collection operation. Here's our new filter box, for owner.username
. Check this out: Search for "head" because we have a bunch of cheesehead usernames. Execute! This finds two cheese listings owned by users 4 and 5.
Let's fetch all the users... just to be sure and... yep! Users 4 and 5 match that username search. Let's try searching for this cheesehead3
exactly. Put that in the box and... Execute! Got it! The exact search works too. And, even though we're filtering across a relationship, the URL is pretty clean: owner.username=cheesehead3
.
Ok just one more short topic for this part of our tutorial: subresources.
Heyyy back Omar! :)
First, sorry for the slow reply - holidays started a bit early for me, and these harder API Platform questions usually wait for me.
Hmm, what you're describing sounds an awful lot like GraphQL. And, though I haven't used it personally, API Platform does also expose a GraphQL API - https://api-platform.com/docs/core/graphql/ - perhaps that's the direction you should check into (unless you've already checked into it).
Cheers!
Hey, many many thanks for this helpful tutorial. it's really helpful.
but now i got a error. and that is when i try to fetch user data then i saw this error. single user and all user both.
<blockquote> "@context": "/api/contexts/Error",
"@type": "hydra:Error",
"hydra:title": "An error occurred",
"hydra:description": "The total number of joined relations has exceeded the specified maximum. Raise the limit if necessary with the \"api_platform.eager_loading.max_joins\" configuration key (https://api-platform.com/docs/core/performance/#eager-loading), or limit the maximum serialization depth using the \"enable_max_depth\" option of the Symfony serializer (https://symfony.com/doc/current/components/serializer.html#handling-serialization-depth).",
"trace": [
{
"namespace": "",
"short_class": "",
"class": "",
"type": "",
"function": "",
"file": "/home/mono/Projects/Goodness/vendor/api-platform/core/src/Bridge/Doctrine/Orm/Extension/EagerLoadingExtension.php",
"line": 137,
"args": []
},
</blockquote>
i try to find the solution from api platform. but still i can't solve it. could you please tell me about eager-loading. and how can i solve it.
here is my user.php code.
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiFilter;
use ApiPlatform\Core\Annotation\ApiResource;
use ApiPlatform\Core\Serializer\Filter\PropertyFilter;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity(repositoryClass=UserRepository::class)
* @ApiResource(
* normalizationContext={"groups"={"user:read"}},
* denormalizationContext={"groups"={"user:write"}},
* )
* @ApiFilter(PropertyFilter::class)
* @UniqueEntity(fields={"username"})
* @UniqueEntity(fields={"email"})
*/
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* @ORM\Column(type="string", length=180, unique=true)
* @Groups({"user:read","user:write"})
* @Assert\NotBlank()
* @Assert\Email()
*/
private $email;
/**
* @ORM\Column(type="json")
*/
private $roles = [];
/**
* @var string The hashed password
* @ORM\Column(type="string")
* @Groups({"user:write"})
*/
private $password;
/**
* @ORM\Column(type="string", length=255, unique=true)
* @Groups({"user:read","user:write","products:item:get","products:write"})
* @Assert\NotBlank()
*/
private $username;
/**
* @ORM\OneToMany(targetEntity=Product::class, mappedBy="owner", cascade={"persist"}, orphanRemoval=true)
* @Groups({"user:read","user:write"})
*/
private $products;
public function __construct()
{
$this->products = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(string $email): self
{
$this->email = $email;
return $this;
}
/**
* A visual identifier that represents this user.
*
* @see UserInterface
*/
public function getUserIdentifier(): string
{
return (string) $this->email;
}
/**
* @see UserInterface
*/
public function getRoles(): array
{
$roles = $this->roles;
// guarantee every user at least has ROLE_USER
$roles[] = 'ROLE_USER';
return array_unique($roles);
}
public function setRoles(array $roles): self
{
$this->roles = $roles;
return $this;
}
/**
* @see PasswordAuthenticatedUserInterface
*/
public function getPassword(): string
{
return $this->password;
}
public function setPassword(string $password): self
{
$this->password = $password;
return $this;
}
/**
* Returning a salt is only needed, if you are not using a modern
* hashing algorithm (e.g. bcrypt or sodium) in your security.yaml.
*
* @see UserInterface
*/
public function getSalt(): ?string
{
return null;
}
/**
* @see UserInterface
*/
public function eraseCredentials()
{
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
}
public function getUsername(): ?string
{
return $this->username;
}
public function setUsername(string $username): self
{
$this->username = $username;
return $this;
}
/**
* @return Collection|Product[]
*/
public function getProducts(): Collection
{
return $this->products;
}
public function addProduct(Product $product): self
{
if (!$this->products->contains($product)) {
$this->products[] = $product;
$product->setOwner($this);
}
return $this;
}
public function removeProduct(Product $product): self
{
if ($this->products->removeElement($product)) {
// set the owning side to null (unless already changed)
if ($product->getOwner() === $this) {
$product->setOwner(null);
}
}
return $this;
}
and here is product.php (like CheeseListing.php)
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiFilter;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\ProductRepository;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Core\Serializer\Filter\PropertyFilter;
use Carbon\Carbon;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ApiResource(
* collectionOperations={ "get","post"},
* itemOperations={
"get"={
* "normalization_context"={"groups"={"products:read","products:item:get"}}
* },
* "put",
* "delete"
* },
* attributes={
"pagination_items_per_page"=10,
* "formats"={"json","jsonld","html","jsonhal","csv"={"text/csv"}},
* },
* normalizationContext={"groups"={"products:read"},"swagger_defination_name"="Read"},
* denormalizationContext={"groups"={"products:write"},"swagger_defination_name"="Write"}
* )
* @ORM\Entity(repositoryClass=ProductRepository::class)
* @ApiFilter(BooleanFilter::class,properties={"isPublished"})
* @ApiFilter(SearchFilter::class, properties={
* "title": "partial",
* "description": "partial",
* "owner": "exact"
* })
* @ApiFilter(PropertyFilter::class)
*/
class Product
{
/**
* @ORM\Id
* @ORM\GeneratedValue
* @ORM\Column(type="integer")
*/
private $id;
/**
* it is title column
* @ORM\Column(type="string", length=255, unique=true)
* @Groups({"products:read","products:write","user:read","user:write"})
* @Assert\NotBlank()
* @Assert\Length(
* min=5,
* max=40,
* maxMessage="write your title in less then 20 chars"
* )
*/
private $title;
/**
* @ORM\Column(type="integer", nullable=true)
* @Groups({"products:read","products:write","user:read","user:write"})
* @Assert\NotBlank()
*/
private $price;
/**
* @ORM\Column(type="text", nullable=true)
* @Groups({"products:read","products:write","user:read","user:write"})
* @Assert\NotBlank()
*/
private $description;
/**
* @ORM\Column(type="boolean", nullable=true)
* @Groups({"products:read","products:write","user:write","user:read"})
*/
private $isPublished;
public function __construct(string $title)
{
$this->createdAt = new \DateTimeImmutable();
$this->title = $title;
}
/**
* @ORM\Column(type="datetime", nullable=true)
*/
private $createdAt;
/**
* @ORM\ManyToOne(targetEntity=User::class, inversedBy="products",fetch="EAGER")
* @ORM\JoinColumn(nullable=false)
* @Groups({"products:read","products:write","user:read","user:write"})
*/
private $owner;
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 getPrice(): ?int
{
return $this->price;
}
public function setPrice(?int $price): self
{
$this->price = $price;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
/**
* @Groups({"products:read"})
*/
public function getShortDescription(): ?string
{
if(strlen($this->getDescription()) < 20){
return $this->description;
}
return substr($this->getDescription(),0,20).'...';
}
/**
* @SerializedName("details")
*/
public function setDescription(?string $description): self
{
$this->description = $description;
return $this;
}
// public function setTextDescription(?string $description): self
// {
// $this->description = nl2br($description);
//
// return $this;
// }
public function getIsPublished(): ?bool
{
return $this->isPublished;
}
public function setIsPublished(?bool $isPublished): self
{
$this->isPublished = $isPublished;
return $this;
}
public function getCreatedAt(): ?\DateTimeInterface
{
return $this->createdAt;
}
/**
* @Groups({"products:read"})
*/
public function getCreatedAtAgo(): string
{
return Carbon::instance($this->getCreatedAt())->diffForHumans();
}
// public function setCreatedAt(?\DateTimeInterface $createdAt): self
// {
// $this->createdAt = $createdAt;
//
// return $this;
// }
public function getOwner(): ?User
{
return $this->owner;
}
public function setOwner(?User $owner): self
{
$this->owner = $owner;
return $this;
}
i am using symfony version 5.3.0
please help me. sorry the comment so long.
Hey Covi A.!
I'm really sorry for my very delayed reply - you had a tough question and I've been working on a new library this past week!
So, I believe the problem is basically one of recursion. When you serialize a user, you're using the group user:read
. That means the User.product
property is serialized. But then, in Product, on the owner property, you also have user:read
. This means that it then tries to serialize the "owner" property, which is a User. So, it serializes that User.... which then serializes its User.product property... and so on.. forever. I believe the "too many joins" is basically another way of saying "too much recursion".
The easiest solution is to remove user:read
from the Product.owner
property. If you fetch a Product directly, the owner would still be included (since it's in the product:read
group), but it wouldn't try to serialize recursively anymore.
Let me know if that helps!
Cheers!
Hello there!
I'm still learning a lot of stuff lately, and that's great. Thanks SymfonyCast. But could I have some advice on a search problem ?
I have an Article entity on one hand, and a Category (=tag) entity on the other. An article can have many categories. In my search function, I can generate something like :
/api/articles?categories[]=api/categories/1&categories[]=api/categories/2&categories[]=&page=1
which returns all the articles having tag1 and all the articles having tag2. But what I really want, is the articles having both, to narrow the list.
Is there a simple way to do that ? Have I missed some documentation ? Or should I learned how to make Custom Research Filters...? finger crossed
Hey Jean-tilapin!
Really happy we've been useful - keep up the hard work!
> But what I really want, is the articles having both, to narrow the list.
Hmm. Good question! It's a little bit tough to read due to the dynamic nature of the class, but here is the logic behind the search filter: https://github.com/api-plat...
If I'm guessing and reading correctly, you are using the "exact" strategy, which means you are falling into this case - https://github.com/api-plat... - which is a "WHERE IN". That means you're getting something like "WHERE category in (api/categories/1, api/categories/2, api/categories/3)". So.. exactly what you're saying - it will return all articles that are in *any* of these categories.
Looking through the rest of this class, unless I'm missing something, you will not be able to use the SearchFilter out of the box for this. So... you'll need a custom filter. Fortunately, this isn't too hard and we did cover it in a recent tutorial :). Check out https://symfonycasts.com/sc... and the next chapter after.
Let me know if that helps!
Cheers!
Hello,
Unless I have missed something, there is a fairly basic operation that I need to do but can't see how. It would be similar to having something like this in your example.
Request a user (item get), with embedded cheese-listings filtered to isPublished=true.
In other words, get user 1 with his published cheese-listings.
Or /api/users/1?cheese-listings.isPublished=true
In the interface, filters only show up in the get collection pane... which on the face of it seems obvious, but the case I'm describing doesn't seem so far-fetched and I really want to avoid multiple requests to achieve what seems rather trivial.
Thanks!
Hey Ian!
Yea, it's a pretty good question :). So, the way that you're "supposed" to do this is probably by fetching the User and then fetching the exact cheese listings you need - like GET /api/cheeses?user=/api/users/1&published=1
(or something like that, you get the idea). I know that you've already thought about this (and are trying to avoid the extra HTTP request), but this is basically what ApiPlatform wants you to do.
Check out this video for some rationale about how the embedded resources are loaded - https://symfonycasts.com/screencast/api-platform-security/filtered-collection - I think it will help highlight what's going on.
I believe the only way for you to accomplish this would be to:
A) Add a publishedCheeseListings field (like I did) by adding a getter method
B) Use the PropertyFilter - https://symfonycasts.com/screencast/api-platform/property-filter - as a way to be able to sometimes request this field and sometimes not request it. The only thing I'm not sure about is if there is a way to NOT include the field by default, and only include it IF it's requested via the PropertyFilter (I know it's possible to return it by default and then avoid returning it via the PropertyFilter, just not sure if you can do add fields via PropertyFilter).
Anyways, let me know if that helps :).
Cheers!
hi, is there a way to filter the subresources while fetching the main resource
For example:
All Items:
{
[
brand: 'audi',
colors: [
{name: blue},
{name: green}
]
}
Is there a way to filter the results of a subresource, so the result would be following?
{
[
brand: 'audi',
colors: [
{name: blue}
]
}
Hey Stefan L.
Have you tried configuring the filter like this?
@ApiFilter(SearchFilter::class, properties={"brand.colors": "exact"})
Cheers!
Isn't this only for filtering the main Resource, so for example it will return all brands where a specific color is included, but it will filter the brands, not the subresource colors?
Hey Stefan L.!
Yea, I believe also that this would filter the main resource, not the colors property. So, the overall issue is how API Platform loads the colors property. The logic looks like this:
A) API Platform makes an initial query for the main resource. In this example, let's pretend the main resource is for "cars". So, when you GET /api/cars, it queries for all the car resources. If you have any filters applied (e.g. ?brand=audi) then those are used to modify the query.
B) To return the "colors" property for each car, API Platform simple calls $car->getColors()
on each Car object. And so, you can see why you would always get all the colors returned.
So if you want to filter that sub-collection, you probably need to do it (more or less) as a custom field. You could still expose your "colors" field as a normal field. However, you would then probably need a custom normalizer for the Car resource so that you could modify this field dynamically. The process would be similar to when we add a completely custom field in the next tutorial - https://symfonycasts.com/screencast/api-platform-security/custom-field#adding-the-custom-isme-field - the difference would be that you aren't really adding a new field. Instead, you would read the Request query parameter and, if it exists, you would change the "colors" property to a different value.
Let me know if that makes sense! Often, the more natural way to do something like this is to make a request to /api/colors?car=/api/cars/5&color=blue
. In other words, make a direct query to the resource you want to filter. However, I realize that under certain situations, this isn't ideal - so what you're trying to do isn't wrong - just showing how it might work more easily in some situations.
Cheers!
Yes, it makes sense to me, thanks :)
Unfortunately I have to use this query on a list, so /api/colors?car=/api/cars/5&color=blue would only work for 1 item and there would also be cars with no color but they have to be visible too.
I am trying to solve it with a custom field, thank you very much :)
Hi, i have this config in my Entities
* @ApiResource(
* collectionOperations={
* "get",
* },
* itemOperations={
* "get",
* "delete"={
* "controller"=NotFoundAction::class,
* "read"=false,
* "output"=false,
* },
* "enhancement"={
* "method"="GET",
* "normalization_context"={"groups"={"enhancement:read"}},
* },
* }
* )
and i have 'enhancement:read' in almost all fields and in relation also, and i thought that API platform will query DB with joins, but i see in my symfony debugger that to get 5 entities and corresponding related entities api platform is making 9 queries, maybe i'm doing something wrong
Hi Daniel K.!
Interesting. I don't know the answer to this, but I know where to look. API Platform (I'm guessing you know this part, but just in case - it's not something we talked about in the tutorial) automatically fetches relationships eagerly. The class that does that is this one: https://github.com/api-plat...
I would add some debug code to this class and figure out *where* & why the eager loading is not happening. I can't see anything with your code or that class that would make me expect this behavior.
Let me know what you find out - I'd really be interested!
Cheers!
Hey there
I didn't try this but looks like you have to declare your filter service first like so:
services:
#...
someEntity.search_filter:
parent: 'api_platform.doctrine.orm.search_filter'
arguments: [ { someProperty: 'strategy' }, {...} ]
...
Then you have to bind that service to your ApiResource (entity), and then it should work. You can find more info about configuring filters here: https://api-platform.com/docs/core/filters/#doctrine-orm-and-mongodb-odm-filters
I hope this helps. Cheers!
Thanks Diego! Sorry, I removed by comment because I didn't see your answer! :/ I managed to find a solution before seeing your comment and it seems that you were right. I posted the whole solution here: https://stackoverflow.com/q... Maybe it will help others. ;)
// 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
}
}
Heyyy, thanks a lot for the great courses!
I have a small question regarding 2 things, "Filtering" and "Relations".
I'v been working for quite some time with a framework based on symfony (Shopware), and they have a very powerful searching system. When using the "List" api to get a list of the entitties, we can pass a "Criteria" Json object. Thanks to this "Criteria" object, I can pass any filter on any field I want. I can also inside this object pass "Associations" which basically says, 'Load the fields in this relation object' or 'Just the ID is enough'.
All of this functionality exists even when adding any new entities without any more configurations!
An example of the json object that would be sent with the api is:
Here is another example with "Associations", imagin we are fetching an order, and we want to get all transactions made for this customer, beside the current state of these transactions (Order -> Transaction -> TransactionState):
Do anyone knows how can I implement something like this!! Is there some more config in the background that I can add or some other library.
Thanks a lot in advanced