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

Relaciones e 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

Acabo de intentar crear un CheeseListing poniendo la propiedad owner a 1: el id de un usuario real en la base de datos. Pero... ¡no le ha gustado! ¿Por qué? Porque en la Plataforma API y, comúnmente, en el desarrollo moderno de APIs en general, no utilizamos ids para referirnos a los recursos: utilizamos IRIs. Para mí, esto fue extraño al principio... pero rápidamente me enamoré de esto. ¿Por qué pasar ids enteros cuando las URLs son mucho más útiles?

Mira la respuesta del usuario que acabamos de crear: como toda respuesta JSON-LD, contiene una propiedad @id... que no es un id, ¡es un IRI! Y esto es lo que utilizarás siempre que necesites referirte a este recurso.

Vuelve a la operación POST CheeseListing y establece owner como/api/users/1. Ejecuta eso. Esta vez... ¡funciona!

Y fíjate, cuando transforma el nuevo CheeseListing en JSON, la propiedad owneres ese mismo IRI. Por eso Swagger lo documenta como una "cadena"... lo cual no es del todo exacto. Claro, en la superficie, owner es una cadena... y eso es lo que muestra Swagger en el modelo cheeses-Write.

Pero sabemos... con nuestro cerebro humano, que esta cadena es especial: en realidad representa un "enlace" a un recurso relacionado. Y... aunque Swagger no lo entienda del todo, echa un vistazo a la documentación de JSON-LD: en /api/docs.jsonld. Veamos, busca propietario. ¡Ja! Esto es un poco más inteligente: JSON-LD sabe que se trata de un Enlace... con algunos metadatos extravagantes para decir básicamente que el enlace es a un recurso deUser.

La gran conclusión es ésta: una relación es sólo una propiedad normal, excepto que se representa en tu API con su IRI. Muy bueno.

Añadir quesosListados a Usuario

¿Qué pasa con el otro lado de la relación? Utiliza los documentos para ir a buscar elCheeseListing con id = 1. Sí, aquí está toda la información, incluido el owner como IRI. ¿Pero qué pasa si queremos ir en la otra dirección?

Actualicemos para cerrar todo. Ve a buscar el recurso User con id 1. Bastante aburrido: email y username. ¿Y si también quieres ver qué quesos ha publicado este usuario?

Eso es igual de fácil. Dentro de User encuentra la propiedad $username, copia la anotación@Groups y pégala encima de la propiedad $cheeseListings. Pero... por ahora, sólo vamos a hacer esto legible: sólo user:read. Más adelante hablaremos de cómo puedes modificar las relaciones de colección.

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

Bien, actualiza y abre la operación de elemento GET para Usuario. Antes de intentarlo, ya anuncia que ahora devolverá una propiedad cheeseListings que, curiosamente, será un array de cadenas. Veamos qué aspecto tiene User id 1. ¡Ejecuta!

Ah... ¡es una matriz! Una matriz de cadenas IRI, por supuesto. Por defecto, cuando relacionas dos recursos, la Plataforma API mostrará el recurso relacionado como un IRI o una matriz de IRIs, lo cual es maravillosamente sencillo. Si el cliente de la API necesita más información, puede hacer otra petición a esa URL.

O... si quieres evitar esa petición adicional, puedes optar por incrustar los datos del listado de quesos directamente en el JSON del recurso del usuario. Hablemos de eso a continuación.

Leave a comment!

28
Login or Register to join the conversation
Cecile Avatar
Cecile Avatar Cecile | posted hace 2 años | 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 hace 2 años | edited

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

Reply
David R. Avatar
David R. Avatar David R. | posted hace 3 años

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 hace 3 años | 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 hace 2 años | 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 hace 3 años | 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 hace 3 años

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!

Este tutorial funciona muy bien para Symfony 5 y la Plataforma API 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