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!

  • 2020-07-01 David Rojo

    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!! :)

  • 2020-07-01 weaverryan

    Hey David Rojo!

    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!

  • 2020-07-01 David Rojo

    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.

  • 2020-06-30 weaverryan

    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!

  • 2020-06-29 David Rojo

    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?

  • 2020-06-25 Diego Aguiar

    Hey David Rojo

    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/do...

    Cheers!

  • 2020-06-25 David Rojo

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

  • 2020-06-24 weaverryan

    Hey David Rojo!

    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:


    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!

  • 2020-06-22 David Rojo

    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".

  • 2019-12-09 weaverryan

    Hey Ben Bonora!

    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!

  • 2019-12-05 Ben Bonora

    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.

  • 2019-12-05 Ben Bonora

    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.

  • 2019-11-05 Annemieke Buijs

    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.

  • 2019-10-30 Diego Aguiar

    Hey Annemieke Buijs

    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!

  • 2019-10-29 Diego Aguiar

    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
  • 2019-10-29 Annemieke Buijs

    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.

  • 2019-10-29 Diego Aguiar

    Hey Annemieke Buijs

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

    Cheers!

  • 2019-10-27 Annemieke Buijs

    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 !

  • 2019-10-27 Annemieke Buijs

    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 !!!

  • 2019-09-09 Diego Aguiar

    Hey Isaac Earl

    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!

  • 2019-09-07 Isaac Earl

    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.