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

Relations and IRIs

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

I just tried to create a CheeseListing by setting the owner property to 1: the id of a real user in the database. But... it didn't like it! Why? Because in API Platform and, commonly, in modern API development in general, we do not use ids to refer to resources: we use IRIs. For me, this was strange at first... but I quickly fell in love with this. Why pass around integer ids when URLs are so much more useful?

Check out the response of the user we just created: like every JSON-LD response, it contains an @id property... that isn't an id, it's an IRI! And this is what you'll use whenever you need to refer to this resource.

Head back up to the CheeseListing POST operation and set owner to /api/users/1. Execute that. This time... it works!

And check it out, when it transforms the new CheeseListing into JSON, the owner property is that same IRI. That is why Swagger documents this as a "string"... which isn't totally accurate. Sure, on the surface, owner is a string... and that's what Swagger is showing in the cheeses-Write model.

But we know... with our human brains, that this string is special: it actually represents a "link" to a related resource. And... even though Swagger doesn't quite understand this, check out the JSON-LD documentation: at /api/docs.jsonld. Let's see, search for owner. Ha! This is a bit smarter: JSON-LD knows that this is a Link... with some fancy metadata to basically say that the link is to a User resource.

The big takeaway is this: a relation is just a normal property, except that it's represented in your API with its IRI. Pretty cool.

Adding cheesesListings to User

What about the other side of the relationship? Use the docs to go fetch the CheeseListing with id = 1. Yep, here's all the info, including the owner as an IRI. But what if we want to go the other direction?

Let's refresh to close everything up. Go fetch the User resource with id 1. Pretty boring: email and username. What if you also want to see what cheeses this user has posted?

That's just as easy. Inside User find the $username property, copy the @Groups annotation, then paste above the $cheeseListings property. But... for now, let's only make this readable: just user:read. We're going to talk about how you can modify collection relationships later.

... lines 1 - 22
class User implements UserInterface
{
... lines 25 - 58
/**
... line 60
* @Groups("user:read")
*/
private $cheeseListings;
... lines 64 - 184
}

Ok, refresh and open the GET item operation for User. Before even trying this, it's already advertising that it will now return a cheeseListings property, which, interesting, will be an array of strings. Let's see what User id 1 looks like. Execute!

Ah.. it is an array! An array of IRI strings - of course. By default, when you relate two resources, API Platform will output the related resource as an IRI or an array of IRIs, which is beautifully simple. If the API client needs more info, they can make another request to that URL.

Or... if you want to avoid that extra request, you could choose instead to embed the cheese listing data right into the user resource's JSON. Let's chat about that next.

Leave a comment!

28
Login or Register to join the conversation
Cecile Avatar
Cecile Avatar Cecile | posted 2 years ago | edited

Hi!

I can't limit the results get from a relationship.

I Explain my problem, I have a relationship like this:


 /**
 * @ApiResource(
 * itemOperations={
 * "get"={"normalization_context"={"groups"={ "user:item:get"}}}
 * }
 */
class User
{

 // .... others code

 /**
 * @ORM\OneToMany(targetEntity=Comment::class, mappedBy="user", orphanRemoval=true)
 * @Groups({"user:item:get"})
 */
 private $comments;

 // .... others code
}

Let's say that the user whose Id 199 has 50 comments.

I want when I call a this uri : http://localhost/api/user/199
I get a maximum 3 comments and no more per request,
I don't know how to do that someone has an idea?

Thanks

Reply

Hi @sidi!

Excellent question! When make a request for the user data, to get the comments data, API Platform simply calls $user->getComments(), which returns all 50 results. To limit things, you can use this trick: https://symfonycasts.com/screencast/api-platform-security/filtered-collection#adding-getpublishedcheeselistings

Except that for performance (so you don't query for all 50 comments... only then to reutrn 3), you should use the Criteria system explained here - https://symfonycasts.com/screencast/doctrine-relations/collection-criteria

Let me know if that helps!

Cheers!

1 Reply
Cecile Avatar

Thanks Ryan, But where should I place the Criteria? in getComments function?

Reply

Hey @sidi!

Yes, exactly! Or, sometimes, to keep the getComments() method pure (and returning all comments) in case I need to call it somewhere else in my code, I will create another method - like getMostRecentComments() - and then use the Groups and SerializedName annotations to expose this method as the “comments” field in your api.

Cheers!

1 Reply
Cecile Avatar
Cecile Avatar Cecile | weaverryan | posted 2 years ago | edited

Great! Thank you very much weaverryan it's very clear ;)

Reply
David R. Avatar
David R. Avatar David R. | posted 3 years ago

Hi. One question.

How do you force return an IRI in a relation if the relation target is the same class of the current object(a recursive relation).

Like a "parent" or "child" if you have for example a "Folder" entity that have a parent "Folder" and several child "Folders" how do you tell for example that for the parent return the IRI and for the childs, return only the "name".

Reply

Hey David R.!

Wow, that's an excellent question, and not one that I've thought of before! It should be simple, but unless I'm completely missing something, it is not simple. To accomplish this, I needed to create a custom normalizer.

To test this, I created a parent->child relationship with the CheeseListing from this tutorial - CheeseListing.parentCheese is ManyToOne to CheeseListing.childCheeses. Here is the final CheeseNormalizer:


<?php

namespace App\Serializer\Normalizer;

use App\Entity\CheeseListing;
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;

class CheeseNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
{
    use NormalizerAwareTrait;

    private const ALREADY_CALLED = 'USER_NORMALIZER_ALREADY_CALLED';

    public function normalize($object, $format = null, array $context = array())
    {
        $context[self::ALREADY_CALLED] = true;

        // set the groups to nothing so that no data is serialized from
        // the child object
        $context['groups'] = [];
        // not sure why this is needed. This is normally added
        // by the JSON-LD ObjectNormalizer, but that doesn't seem to
        // be used... and the logic here is quite hard to follow.
        // so, I added it myself - it's the flag that converting to an
        // IRI is ok
        $context['api_empty_resource_as_iri'] = true;
        $data = $this->normalizer->normalize($object, $format, $context);

        $context[self::ALREADY_CALLED] = false;

        return $data;
    }

    public function supportsNormalization($data, $format = null, array $context = [])
    {
        // avoid recursion: only call once per object
        if (isset($context[self::ALREADY_CALLED])) {
            return false;
        }

        // api_attribute is a context key set to the property being normalized
        return $data instanceof CheeseListing
            && isset($context['api_attribute'])
            && in_array($context['api_attribute'], ['parentCheese', 'childCheeses']);
    }

    public function hasCacheableSupportsMethod(): bool
    {
        return false;
    }
}

With this, parentCheese is an IRI and childCheeses is an array of IRI's. This really should be simpler (and maybe it is somehow?) but this is the only way I could sort it out.

Let me know if that helps!

Cheers!

Reply
David R. Avatar

Hi Ryan, I think that there is there some code missing in your answer :S

Reply

Hey David R.

Yeah, looks like Ryan's copy-paste function is eating some bytes :p

I believe this is the piece of code he's missing


class CheeseNormalizer implements ContextAwareNormalizerInterface, NormalizerAwareInterface 
{
    public function normalize($object, $format = null, array $context = [])
    {
        $data = $this->normalizer->normalize($object, $format, $context);

        $context[self::ALREADY_CALLED] = false;

        return $data;
    }

    ...
}

You can read more about Serializers/Normilizers here: https://api-platform.com/docs/core/serialization/#changing-the-serialization-context-dynamically

Cheers!

Reply
David R. Avatar

Hi, I am trying to use this code, but it doesn't work, the result is the same, and also I don't understand it, for me as I read it this code "does nothing", it returns true only when the fields to normalize are parentCheese and childCheeses, but does nothing on normailze. Am I missing something?

Reply

Bah! Sorry about that, let me try posting the code again, but on a gist this time: https://gist.github.com/wea...

The key thing is that supports() returns true ONLY when are normalizing a CheeseListing under a parentCheese or childCheeses property (supports() will return false when normalizing the main CheeseListing object). Then, when normalize() is called, we change the serialization groups to an empty array so that *nothing* on those children CheeseListing objects is serialized.

Hopefully now that the full code is showing, it'll make more sense. Sorry about missing the code for you - I think Disqus may have swallowed it :/

Cheers!

Reply
David R. Avatar

Hi Ryan, Tried your solution, now with the full code and now I get your point, but still no luck.

Now I receive the parent as an empty array, and the childs as an array of empty arrays. Emptying the context groups is working, as nothing gets normalized when these properties are being normalized, but the "api_empty_resource_as_iri" seems not to be working.

I've added some dumps, and I see that ApiPlatform AbstractItemNormalized (where the api_empty_resource_as_iri logic is located) is being called before my custom normalizer, so the context in AbstractItemNormalized doesn't have this property.

Reply

Hey David R.!

Well... darn it! Let's see :). I just tried the code again in my app, and it's working perfectly - you can see screenshots of the correct behavior for parent and children here: https://imgur.com/a/KiWfTAu

So, I'm not sure what's different in your case. I also upgraded to the latest api-platform/core version (2.5.) and it still worked. You mentioned:

I've added some dumps, and I see that ApiPlatform AbstractItemNormalized (where the api_empty_resource_as_iri logic is located)
is being called before my custom normalizer, so the context in AbstractItemNormalized doesn't have this property

I would double-check this. I don't doubt what you're saying, but there is are multiple levels of recursion - iirc, AbstractItemNormalizer will be called once for the top level CheeseListing, then again for the user property (for my example) and THEN for the parentCheese or childCheeses properties.

In general, because we're decorating the normalizer, our normalizer should be an "outer" normalizer (with the rest of the normalization system inside the $this->normalizer property. That means that our normalizer (assuming supports returns true) should always be called first and that WE are in fact calling (indirectly) the AbstractItemNormalizer via the $data = $this->normalizer->normalize($object, $format, $context);

To verify that things are working as expected, I might comment-out that line and replace it with $data = ['testing' => true];. If everything is working correctly, then YOUR normalizer should be called for parentCheese and childCheese, and you should see data that looks like this:


  "childCheeses": [
    {
      "testing": true
    }
  ],

The recursive & decorated nature of the normalizers is, honestly, one of my least favorite features of API Platform - it's confusing. I'd prefer if I could "hook into" the normalizing process to "tweak" something, but not be responsible for calling the "inner" normalizer and managing the self::ALREADY_CALLED flag to avoid infinite recursion. Hopefully that's something we can clean up in the serializer component at some point.

Let me know if that helps!

Cheers!

Reply
David R. Avatar
David R. Avatar David R. | weaverryan | posted 3 years ago | edited

Finally I've found the issue, after checking that adding $data = ['testing' => true]; was working, I've copied all your code from gist, and just replaced, the class name and it worked. After that, comparing line by line, the error was that I hadn't added the NormalizerAwareInterface to my Normalizer, and as I had the constructor with the ObjectNormalizer, it wasn't working properly.

Now it works, I removed the constructor and used the NormalizerAwareTrait with the NormalizerAwareInterface and the api is returning the IRIs properly.

Also in my function i had public function normalize(...): array, I had to remove the :array part that was added on creating the normalizer with make:serlializer:normalizer, so PHP doesn't complaint when returning a string.

Thank you very much!! :)

1 Reply
Volodymyr T. Avatar
Volodymyr T. Avatar Volodymyr T. | weaverryan | posted 2 years ago | edited

Dear Ryan!
I dare say that the solution offered <a href="https://gist.github.com/weaverryan/3b2da11198e3bb012c7c9698ef9248ef&quot;&gt;in the mentioned gist</a> is now not working (at least for me). For children and the parent the $this->normalizer is null.
When I introduced this code at the very beginning of the normalize method, all parents and children were marked with the "no normalizer" string instead of the IRI.


if (is_null($this->normalizer)) {  

     return 'no normalizer';
}

On the side note, I understand that entity is generally expected to have one Serializer per entity. To confirm this my guess, I cannot have several `@SerializedName` annotations for one entity property. I use Serializer not only for API Platform, so I'd like to have a more granulated control for a serialized property name for different serialization groups. Is there a case to expect this?

Could you please confirm that the example in the gist doesn't need a correction now?

PS. I was given a piece of advice (actually 2 pieces): to use service decoration for my custom normalizer <a href="#https://api-platform.com/docs/core/serialization/#decorating-a-serializer-and-adding-extra-data&quot;&gt;as described here</a> and to introduce some annotations for the "children" and "parent" properties, i.e. [ApiProperty(readableLink: false, writableLink: false)] or @ApiProperty(readableLink = false, writableLink = false). After experimenting, I factored out some methods in a separate trait. Here is <a href="#https://gist.github.com/voltel/ac2820fc7f97892999162774452a97fa&quot;&gt;the gist that you might find helpful</a>.

Reply

Hey Volodymyr T.!

Sorry for the slow reply - busy week :).

Decoration is probably a good idea - I can't remember exactly why I chose or didn't choose to do that with the gist that you linked to above. I'm effectively using decoration, because by using the NormalizerAwareTrait to "do some work then call the normalizer system", but at this moment, the decoration looks simpler. In the 2nd part of the series we use decoration for context builders, but not for normalizers. I may have missed a "simpler solution" for normalizers.

> I dare say that the solution offered in the mentioned gist is now not working (at least for me). For children and the parent the
> $this->normalizer is null.
> When I introduced this code at the very beginning of the normalize method, all parents and children were marked with the "no
> normalizer" string instead of the IRI.

I'm not sure about this part. The NormalizerAwareTrait should cause the serializer system to "set" the normalizer before it's executed. I don't know why that wouldn't happen. But the decoration strategy doesn't need to rely on this - so it seems better to me.

Anyways, it sounds like you've got it working now? I've just posted a link to your gist from my gist. As I'm replying late, let me know if you still have any problems or questions :).

Cheers!

Reply
Ben B. Avatar

I'm using a postgres database and I have a table called `stations` with a column called `markets`. This column type is an array and contains a collection of market ID's. I also have a market table. I've created an entity for the station and market tables. One station can be associated with many markets. How do I create a relationship in the station entity to markets? I'm assuming this can be achieved using some annotation magic but I haven't been able to figure it out. I can't change the structure of the DB as it was created before using the API Platform.

Reply
Ben B. Avatar
Ben B. Avatar Ben B. | Ben B. | posted 3 years ago | edited

I should also include that the returned values from the station.markets column looks something like this...
{12,14,...}

I want to be able to get a markets object as well as post/put/patch market values to the stations endpoint.

Reply

Hey Ben B.!

Hmm, so it sounds like you have a bit of a messy database structure. I don't mean that to sound bad - you mentioned that you can't change the DB structure - that's a reality that we often need to work in :). In a more perfect world, the "stations" table would have a true relation to the "markets" table through a join table. That would then all be mapped correctly on Doctrine (as a ManyToMany relationship) and API Platform would be happy.

> I want to be able to get a markets object

Because you have a Markets entity, this part should already be ok. I'm guessing this is not a problem ;)

> as well as post/put/patch market values to the stations endpoint.

This IS a problem... probably. Questions:

A) For your Station resource, do you want a markets JSON field to be returned? If so, do you want it to be the array of ids? Array of IRIs? Embedded Market objects?

B) For post/put/patch of the Station resource... if your markets property is an array of ids... then you should be able to simply send an array of ids on the markets property and it will work. There is no referential integrity in the database or anything... but I think it would be that simple. But... I think I may be missing something - let me know if I am ;).

Cheers!

Reply

And here is another question from me on a sunday. I hope you don't mind. I'm pretty exited about api platform and can't wait to dazzle people with it.
Case:
A user has many cheeselistings.
Api platform gives me the user with all the data of all the cheelistings
url : /api/user/2

`
"cheeseListings": [

    {
      "@id": "/api/cheeses/9",
      "@type": "cheeses",
      "title": "nice cheddar cheese",
      "price": 1000
    },

...

  ]

`

But what url can i use to get a specific cheeselist of this user. With a filter? /api/user/2?cheeselist_id =....
And will i still get all the data of this cheese list?
Oke, that's all folks! Have a good sunday Cheers !

Reply

Hey truuslee

If you know the CheeseListing id, I think you can just do a get request to "/api/cheeselist/{id}".

Cheers!

Reply

Hey Diego, thanks for the reply. 👌
What I mean is: when you do /api/user/2 the response contains the user data and if you want, all the data of all the related 'cheeselistings'. My question is if i can filter on the cheeselistings while using the /api/user/2 call
Thanks for your help in advance.

Reply

Ah, I get it now and yes, you can add a filter to your User resource based on its CheeseListing field. Try something like this:


// User.php

/**
 * @ApiFilter(SearchFilter::class, properties={
 *     "cheeseListing": "exact",
 *     ...
 * })
*/
class User
Reply

Hi guys, great work as usual !
I have a product owner that wants the api to have urls like this:
/api/v1/user/{cust_id}/orders/{order_id}
/api/v1/user/{cust_id}/products/{product_id}/specs

There are relations between all data.

My gut feeling says it's way better to do it like this:
/api/v1/orders/{order_id}?customer_id={customer_id}
/api/v1/products/{product_id}?customer_id={customer_id}

So the api is useful for many other projects.
What is your opinion about this? I look forward to your answer.

Thank you and please keep up the good work !!!

Reply

Hey truuslee

Could you tell me why this structure is better /api/v1/orders/{order_id}?customer_id={customer_id} than the other one? Other projects could just follow that structure.

What I know about the first structure is that that's the standard structure of RESTful APIs, that might be the reason of your boss but you may want to ask him, so you know the real reason behind it and act accordingly.

Cheers!

Reply

Hi Diego, my feeling was right. I just watched the chapter about subresources. And it is recommended to keep things simple and not use subresources. You can easily do the same with the filter options.

Reply
Isaac E. Avatar
Isaac E. Avatar Isaac E. | posted 3 years ago

I purchased a sub to symfonycasts to learn about api-platform... because we are considering using api-platform for an upcoming project we have. Unforunately we won't be able to use IRIs for relations on this project, so I'm hoping this is configurable so I can use regular plain ids. Is this a configurable option for api-platform yet? I found some issues related to this on github but was confused about whether a solution was ever found. Also I would like a supported solution and not a workaround where I have to do something hacky/fragile.

Reply

Hey Isaac E.

Welcome to SymfonyCasts! About your question, you made me dug and looks like it's a topic that have been active for quite long. The latest info I could find is this comment: https://github.com/api-plat...
Seems like that guy find a solution. Give it a try and let us know if it worked for you

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