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

Custom Normalizer: Object-by-Object Dynamic Fields

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

We now know how to add dynamic groups: we added admin:read above phoneNumber and then, via our context builder, we're dynamically adding that group to the serialization context if the authenticated user has ROLE_ADMIN.

So... we're pretty cool! We can easily run around and expose input our output fields only to admin users by using these two groups.

But... the context builder - and also the more advanced resource metadata factory - has a tragic flaw! We can only change the context globally. What I mean is, we're deciding which groups should be used for normalizing or denormalizing a specific class... no matter how many different objects we might be working with. It does not allow us to change the groups on an object-by-object basis.

Let me give you a concrete example: in addition to making the $phoneNumber readable by admin users, I now want a user to also be able to read their own phoneNumber: if I make a request and the response will contain data for my own User object, it should include the phoneNumber field.

You might think:

Ok, let's put phoneNumber in some new group, like owner:read... and add that group dynamically in the context builder.

That's great thinking! But... look in the context builder, look at what's passed to the createFromRequest() method... or really, what's not passed: it does not pass us the specific object that's being serialized. Nope, this method is called just once per request.

Creating a Normalizer

Ok, no worries. Context builders are a great way to add or remove groups on a global or class-by-class basis. But they are not the way to dynamically add or remove groups on an object-by-object basis. Nope, for that we need a custom normalizer. Let's convince MakerBundle to create one for us. Run:

php bin/console make:serializer:normalizer

Call this UserNormalizer. When an object is being transformed into JSON, XML or any format, it goes through two steps. First, a "normalizer" transforms the object into an array. And second, an "encoder" transforms that array into whatever format you want - like JSON or XML.

When a User object is serialized, it's already going through a core normalizer that looks at our normalization groups & reads the data via the getter methods. We're now going to hook into that process so that we can change the normalization groups before that core normalizer does its job.

Go check out the new class: src/Serializer/Normalizer/UserNormalizer.php.

... lines 1 - 8
class UserNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface
{
private $normalizer;
public function __construct(ObjectNormalizer $normalizer)
{
$this->normalizer = $normalizer;
}
public function normalize($object, $format = null, array $context = array()): array
{
$data = $this->normalizer->normalize($object, $format, $context);
// Here: add, edit, or delete some data
return $data;
}
public function supportsNormalization($data, $format = null): bool
{
return $data instanceof \App\Entity\BlogPost;
}
public function hasCacheableSupportsMethod(): bool
{
return true;
}
}

This works a bit differently than the context builder - it works more like the voter system. The serializer doesn't have just one normalizer, it has many normalizers. Each time it needs to normalize something, it loops over all the normalizers, calls supportsNormalization() and passes us the data that it needs to normalize. If we return true from supportsNormalization(), it means that we know how to normalize this data. And so, the serializer will call our normalize() method. Our normalizer is then the only normalizer that will be called for this data: we are 100% responsible for transforming the object into an array.

Normalizer Logic

Of course... we don't really want to completely take over the normalization process. What we really want to do is change the normalization groups... and then call the core normalizer so it can do its normal work. That's why the class was generated with a constructor where we're autowiring a class called ObjectNormalizer. This is the main, core, normalizer for objects: it's the one that's responsible for reading the data via our getter methods. So... cool! Our custom normalizer is basically... just offloading all the work to the core normalizer!

Let's start customizing this! For supportsNormalization(), return $data instanceof User. So if the thing that's being normalized is a User object, we handle that.

... lines 1 - 9
class UserNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface
{
... lines 12 - 34
public function supportsNormalization($data, $format = null): bool
{
return $data instanceof User;
}
... lines 39 - 48
}

Now we know that normalize() will only be called if $object is a User. Let's add some PHPDoc above this to help my editor.

... lines 1 - 18
/**
* @param User $object
*/
public function normalize($object, $format = null, array $context = array()): array
... lines 23 - 50

The goal here is to check to see if the User object that's being normalized is the same as the currently-authenticated User. If it is, we'll add that owner:read group. Add that check on top: if $this->userIsOwner($object) - that's a method we'll create in a minute - then, add the group. The $context is passed as the third argument... and we're passing it to the core normalizer below. Let's modify it first! Use $context['groups'][] = 'owner:read.

That's lovely! A normalizer is only used for... um... normalizing an object to an array - it is not used for denormalizing an array back into an object. That's why we're always adding owner:read here. If you wanted to create this same feature for denormalization... and add an owner:write group.. you'll need to create a separate denormalizer class. There's no MakerBundle command to generate it, but the logic will be almost identical to this... and you can even make your one normalizer class implement both NormalizerInterface and DenormalizerInterface.

Oh, also, we don't need to check for the existence of a groups key on the array because, in our system, we are always setting at least one group.

Let's add that missing method: private function userIsOwner(). This will take a User object and return a bool. For now, fake it: return rand(0, 10) > 5.

... lines 1 - 35
private function userIsOwner(User $user): bool
{
return mt_rand(0, 10) > 5;
}
... lines 40 - 46

And... I think that's it! Like with voters, this is a situation where we don't need to add any configuration: as soon as we create a class and make it implement NormalizerInterface, the serializer will see it and start using it.

So... let's take this for a test drive! Back on the docs, I'm currently not logged in. Let's refresh the page... and create a new user. How about email goudadude@example.com, password foo, same username, no cheeseListings, but with a phoneNumber. Execute and... perfect! A 201 status code. Copy that email... go back to the homepage.. and log in: goudadude@example.com, password foo and... go!

Cool! Now that we're authenticated, head back to /api. Yep, the web debug toolbar confirms that I'm a "gouda dude". Let's try the GET operation to fetch a collection of users. Because of our random logic, I'd expect some results to show the phoneNumber and some not. Execute and... hey! The first user has a phoneNumber field! It's null... because apparently we didn't set a phoneNumber for that user, but the field is there. And, thanks to the randomness, there is no phoneNumber for the second and third users.

Tip

If you start a new API Platform project, instead of seeing phoneNumber: null, the field is missing. This is due to a change in API Platform 2.5: if your resource supports the PATCH operation (which is on by default in 2.5), then null fields are "omitted". It's no big deal - just don't let it surprise you!

If you try the operation again... yes! This time the first and second users have that field, but not the third. Hey! We're now dynamically adding the owner:read group on an object-by-object basis! Normalizers rock!

But... wait a second. Something is wrong! We're missing the JSON-LD fields for these users. Well, ok, we have them on the top-level for the collection itself... and even the embedded CheeseListing data has them... but each user is missing @id and @type. Something in our new normalizer killed the JSON-LD stuff!

Next, let's figure out what's going on, find this bug, then crush it!

Leave a comment!

65
Login or Register to join the conversation
Marko Avatar
Marko Avatar Marko | posted 5 months ago | edited

I have created a Custom Normalizer in my API Platform 3 (Symfony 6) application. The goal of this normalizer is to modify and add some data to the response before returning it to the user (GET collection request). This is the code :

namespace App\Serializer;

use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

class EntityCollectionNormalizer implements NormalizerInterface
{
    public function __construct(private readonly NormalizerInterface $normalizer)
    {
    }

    public function normalize($object, $format = null, array $context = array()): array
    {
        $data = $this->normalizer->normalize($object, $format, $context);
        if (isset($data['totalItems'])) {
            $data['total_items'] = $data['totalItems'];
            unset($data['totalItems']);
        }

        if (isset($data['itemsPerPage'])) {
            $data['page_size'] = $data['itemsPerPage'];
            unset($data['itemsPerPage']);
        }


        ...

        return $data;
    }

    public function supportsNormalization($data, $format = null, array $context = []): bool
    {
        return $this->normalizer->supportsNormalization($data, $format, $context);
    }
}

So if I write the code this way, it works, but actually no 3rd argument ($context) should be passed to return $this->normalizer->supportsNormalization(...) call.

And if I write it correctly :

return $this->normalizer->supportsNormalization($data, $format);

It actually returns false (instead of expected true) and the normalize function above is not called, the normalizer is not triggered.

So why ? If, for testing, I write return true in supportsNormalization, then I get an error saying that $context does not contain the resource_class key. So it looks like, in this case, an empty $context array is passed to normalize method, while obviously it should not.

To be complete, here is the excerpt from my services.yaml file :

App\Serializer\EntityCollectionNormalizer:
    arguments:
        $normalizer: "@api_platform.hal.normalizer.collection"

Please help me solve the issue and write the code properly.
Thanks.

Reply

Hey @Marko!

I'm not sure what's going on here - it's super weird :/.

Instead of decorating the hal normalizer collection (which is a nicer-looking solution than I'll suggest, but decoration isn't currently working... and the normalization system is frustratingly weird), I would:

A) Just create your own normalizer without decoration. And implement supports() your own way. You can "mimic" the supports method of the hal collection system by using its same logic https://github.com/api-platform/core/blob/9b4b58ca0113a2645b58a116fe9e4bf200df8aa3/src/Serializer/AbstractCollectionNormalizer.php#L53

B) At this point, your normalizer should be called for hal collections instead of the core one (if it's not, you MAY need to adjust the priority if your normalizer with a tag, but I don't think you'll need to). Then, use the rather-ugly trick we use in the next 2 chapters - e.g. https://symfonycasts.com/screencast/api-platform-security/normalizer-aware#codeblock-326e8f3354 - to call the ENTIRE normalization system.

In effect, instead of decorating just the one normalizer you want, you add a new normalizer, then call the ENTIRE normalization system again (setting a flag to avoid a recursion problem... as calling the entire system again will call YOU again), and then you can finally make whatever changes you want. You're... "kind of" decorating the entire normalizer system instead of just the one you want.

Let me know if that helps

Cheers!

1 Reply
Marko Avatar
Marko Avatar Marko | weaverryan | posted 5 months ago | edited

Hello @weaverryan. Thanks a lot for your answer!
I will try and let you know.
Actually I would be happy with current implementation that works i.e. return $this->normalizer->supportsNormalization($data, $format, $context);with $context as 3rd argument. Unfortunately, PHPStan, our static analysis tool, raises an error there saying that function should be called with 1-2 and not 3 parameters. Which is logical as the 3rd parameter ($context) has been commented out in the NormalizerInterface. So I kind of must implement it otherwise, due to our CI/CD flow when PHPStan gets applied. I don't understand why this $context parameter has been commented out in supportsNormalization, because obviously you don't get the same result if you use it or not.

Reply
Marko Avatar

By the way the implementation suggested here :

https://github.com/symfony/maker-bundle/issues/1252

by mtarld , with calls and setNormalizer does not work for me. I get a 502 error.

Reply
Marko Avatar
Marko Avatar Marko | Marko | posted 5 months ago | edited

So I have finally implemented the suggestion from the link above with setNormalizer (with one change : the custom normalizer does not need to implement NormalizerAwareInterface) but the outcome is the same. If the $context variable is not passed to the supports method, then the custom normalizer is not triggered, and if it is passed, then it works, but I have my problem with PHPStan. So, the question is really : why was this $context parameter commented in NormalizerInterface?

Reply
Marko Avatar
Marko Avatar Marko | Marko | posted 5 months ago | edited

@weaverryan, I have finally solved the issue by partially using your advice. Thank you again.

So the problem here is supports method. I have just replaced my initial implementation by the vendor code that I have found, as you suggested, and yes, $context is used in it (so again why was $context parameter commented in NormalizerInterface supports method ?) :

public const FORMAT = 'jsonhal';

 public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool
{
    return static::FORMAT === $format && is_iterable($data) && isset($context['resource_class']) && !isset($context['api_sub_level']);
}
Reply
Marko Avatar

And apart from that I keep everything the same, including the decoration.

Reply
Marko Avatar
Marko Avatar Marko | Marko | posted 5 months ago | edited

@weaverryan, I have also a more "theoretical" question, hopefully you know the answer. As we can see in the doc :
https://api-platform.com/docs/core/events/#the-event-system

Doctrine Event system is not GraphQL friendly.

So, instead of using Doctrine event system, API Platform advises what they call extension points, in order to stay compatible with both REST and GraphQL:
https://api-platform.com/docs/core/extending/

Question: I have implemented Timestampable functionality in my project, in a 'classic' way :

namespace App\Entity;

use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;

trait Timestampable
{
    #[ORM\Column(type: Types::DATETIME_MUTABLE)]
    #[Groups(groups: ['timestampable'])]
    private ?\DateTimeInterface $created;`

    #[ORM\Column(type: Types::DATETIME_MUTABLE)]
    #[Groups(groups: ['timestampable'])]
    private ?\DateTimeInterface $updated;

    #[ORM\PrePersist]
    public function created()
    {
        $this->created = new DateTime();
        $this->updated = new DateTime();
    }

    #[ORM\PreUpdate]
    public function updated()
    {
        $this->updated = new DateTime();
    }

    /**
     * Get created
     *
     * @return \DateTimeInterface|null
     */
    public function getCreated()
    {
        return $this->created;
    }

    /**
     * Get updated
     *
     * @return \DateTimeInterface|null
     */
    public function getUpdated()
    {
        return $this->updated;
    }
}

and then using this trait and adding the #[ORM\HasLifecycleCallbacks] annotation in the concerned entities.

It works but is it not using the Event system? So is it GraphQL friendly and if not, how could Timestampable be done otherwise in order to be compatible with GraphQL? We have a REST API for the moment but we want it to be extendable to GraphQL.

Reply

Hey Marko!

Nice job solving things! I can, I think, answer one mystery:

(so again why was $context parameter commented in NormalizerInterface supports method ?) :

I missed this when I first answered your question. The reason is that the $context method was ADDED to the supportsNormalization() method in Symfony 6.1 - https://github.com/symfony/symfony/pull/43982 - the reason that the $context argument is commented-out is because adding a new argument to an interface method in a minor version would be a backwards-compatibility break. This is how Symfony adds interface args without breaking things: NOT having that argument in a class that implements this interface would trigger a deprecation warning that you need it. Then, in Symfony 7.0, the argument will be properly added. I admit that my experience with PHPStan is limited enough that I'm not sure how it should understand this intention.

It works but is it not using the Event system?

This IS GraphQL-friendly, so keep it. By "don't use the event system", API Platform means don't try adding event listeners to Symfony's request-related events (e.g. RequestEvent, ViewEvent, etc) as these won't happen in GraphQL - https://api-platform.com/docs/core/events/. In your code above you're using Doctrine events which will 100% fire no matter what system (REST, GraphQL or something else) is saving that data.

Cheers!

1 Reply
Marko Avatar

@weaverryan, thanks a lot for your answers, as usually, very competent and helpful for me.

Reply
Kiuega Avatar

Hello, when I put the 'owner: read' group on phoneNumber, it no longer appears when creating a user, exactly like http://disq.us/p/24ddhmr

I tried, as you ask http://disq.us/p/24e22de to always return 'true' on the 'userIsOwner' function. It hasn't changed anything.

However, if on the 'phoneNumber' property, I put the 'user: read' group back, it works. So I do not understand at all where the problem is coming from. My code seems compliant though:

User entity : https://gist.github.com/bas...
Normalizer : https://gist.github.com/bas...
AutoGroupResourceMetadataFactory : https://gist.github.com/bas...

Do you know where the problem can come from?

When in doubt, I cleared the cache, but nothing changes
I'm using Symfony 5.2.9 and API Platform 2.5

Reply

Hey again Kiuega!

You can see that I'm catching up on the tough questions today ;).

Hmm. Ok, I have some questions!

> when I put the 'owner: read' group on phoneNumber, it no longer appears when creating a user

By "no longer appears", you mean in the API response, correct? You're not talking about whether or not the field shows up in the documentation. I'm 95% sure this is what you meant (it matches the other issue), but I'm just checking :).

And, does the phoneNumber field show up if you simply *fetch* a user (i.e. phoneNumber is not in the response after POST /api/users to create a user, but it DOES show up if you GET /api/users/1 to fetch your user). The answer to this will, I think, help discover the issue.

> I tried, as you ask http://disq.us/p/24e22de to always return 'true' on the 'userIsOwner' function.

Just to make sure, have you verified that userIsOwner() *is* being called? Or, another way to ask this is: have you verified that the normalize() method in your UserNormalizer is being called?

Otherwise, I don't see any obvious problems with your code. But I'm sure we can debug it!

Cheers!

1 Reply
André P. Avatar
André P. Avatar André P. | weaverryan | posted 2 years ago | edited

Hey, weaverryan!

The exact same thing was happening to me and I just spent hours trying to figure it out.

The phoneNumber field was not showing no matter what. Tried every group and endpoint and nothing.
So I tested with another field, username, and it was working as expected, which was sooo confusing.

After a lot of debugging I found out that the phoneNumber was simply not showing because it was NULL in the database. If I set a value, it works as expected.

Is this the expected behaviour? It seems weird to me that a field does not appear in a response if it is NULL.
Or maybe I'm missing something.

I'm using Symfony 5.3 and API Platform 2.6.5.

Thank you!

UPDATE
Upon further investigation, I did a clean install to check if the same thing was happening, and it is.
So, it appears that any nullable field, if NULL, is not included in any response by default, only when it has a value.

Is this a bug?

Reply

Hey André P. !

Thanks for the detailed information! This is pretty interesting. So, I am not aware of anything in the Symfony serializer itself (ignoring API Platform for a moment) that would do this. In fact, you can see in the docs that null values SHOULD be used, unless you tell it to NOT output null values: https://symfony.com/doc/current/components/serializer.html#skipping-null-values - and here is the code that looks for that, in case you want to put some debugging code there to see if it's the cause: https://github.com/symfony/symfony/blob/d679ac56503c54724515044551b61279bf23f0ef/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php#L592-L594

So... I decided to try things for myself - one more than one person has an issue, it's usually legit. But... I can't reproduce it! I went backwards in the code to right where we fill in the userIsOwner() with real logic - so right at this moment - https://symfonycasts.com/screencast/api-platform-security/custom-field#codeblock-f89254b370 - I also upgraded to Symfony 5.3 and 2.6.5 of API Platform. The code is here: https://github.com/SymfonyCasts/api-platform/tree/example-null-phone

And... things worked perfectly! I did NOT set a phoneNumber on my user (so it's null in the database). And, when I request my user data (either via /api/users or /api/users/2 to get the exact record), I DO have phoneNumber: null in the response.

This means that that bad behavior here is a bit of a mystery - I can't explain what you're seeing or why. If you notice anything that looks different in my code vs your code, let me know :).

Cheers!

Reply
André P. Avatar
André P. Avatar André P. | weaverryan | posted 2 years ago | edited

Hey, @weaverryan !

Thank you for you reply!

But... this is so weird.

Just to be clear, when I spoke of a clean install, I meant just creating a new Symfony project and then composer require api and composer require maker.

I created an entity with some fields (including nullable), POSTed some records and then GET them, so the problem can be reproduced.

But I started to dug deeper. The problem seems to lie in here <a href="https://github.com/api-platform/core/blob/6336f6446722cd6728683c36fb70e0074553296d/src/Serializer/SerializerContextBuilder.php#L108-L115&quot;&gt;https://github.com/api-platform/core/blob/6336f6446722cd6728683c36fb70e0074553296d/src/Serializer/SerializerContextBuilder.php#L108-L115&lt;/a&gt;

It appears that if there is a PATCH operation, skip_null_values is set to true.
Since the PATCH operation is now enabled by default (contrary to the time this tutorial was created), this end up being the default behaviour on most recent versions.

If the PATCH operation is disabled, it works as expected.

Can you confirm this?

Thank you!

Reply

Hey André P.!

Ahhhhh! That's really excellent digging! It seems they added that code, being careful not to break backwards compatibility... but they sort of DID change the behavior a bit :). I can confirm that you are 100% correct: as soon as I enable PATCH on the User resource, that phoneNumber: null disappeared.

Sooooo, I'll add a note about this to the tutorial so that it doesn't surprise people. Beyond that, is this a problem for you - or was is it more that you just wanted to figure out why our codes were behaving differently (a worthy pursuit on its own!)

Cheers - and thanks for the follow-up - we really like to add notes when needed to keep the experience smooth for everyone!

Reply
André P. Avatar

Hey!

Thank you for the follow-up as well!

At first, I just wanted to figure out what was happening and really understand the reason behind it, but now that I know what is going on, I'm thinking about it.

So, imagine that I'm developing an API that will also be consumed by me (this is, it is a private API that will be used to "feed" a website and/or an app).

It is important for me, who is going to consume the API, to know how I'll get an endpoint response since it is different to check if a property is null or if a property exists.

Since I wanted to use PATCH operations (because, according to specs, as far as I know, it is the right operation for partial updates) I guess I would have to use the latter way, which is no problem, but what is the approach of API Platform? Will it change the behavior of the PATCH operation and start including null properties? Or all other operations will start not including them? It would be nice to know just to avoid eventual breaks in the code. I know there is always a way to prevent this but the problem, I think, lies in not knowing the consistency of the responses in the next version/future.

I actually went searching for what were the specs regarding null properties in API responses and, guess what, there's a big discussion about it, hahah.

I guess that, ideally, null properties should behave exactly the same across all operations and, maybe, have a global configuration option to skip, or not, null values?

Once again, thank you!

Cheers!

Reply

Hey André P.!

> It is important for me, who is going to consume the API, to know how I'll get an endpoint response since it is different to check if a property is null or if a property exists

Definitely :)

> Will it change the behavior of the PATCH operation and start including null properties? Or all other operations will start not including them?

I think that this change - when PATCH is included as an operation, suddenly null values on non-patch operations are treated differently - was likely a mistake by API Platform. In the future, it seems that they want to go in the direction of "all other operations start not including null values". However, unless they make some accidental change, this would not be done in a minor version - it would wait until API Platform 3.0 (possibly in some 2.x version, they might start forcing you to explicitly set some config to "opt into" not including null values. But the point is, it wouldn't happen suddenly - it would be your choice.

> I guess that, ideally, null properties should behave exactly the same across all operations and, maybe, have a global configuration option to skip, or not, null values?

Totally - and my guess is that this is the direction API Platform will go (using PATCH as the new "correct" behavior at some point).

Cheers!

Reply
André P. Avatar
André P. Avatar André P. | weaverryan | posted 2 years ago | edited

Hey, weaverryan.

Thank you for your reply and insight.

Let's see how things go. It certainly is as you say, if there are going to be breaking changes it will probably be in a major version release.

Keep the amazing work!

Thank you!

Reply
Kiuega Avatar

Hello @weaverryan !

Thank you for answering all my questions, it's great!
So, for this problem, Since then, I have advanced (and finished) in API Platform training, suddenly the code is not the same.

But off the top of my head, I can tell you the following:

>By "no longer appears", you mean in the API response, correct? You're not talking about whether or not the field shows up in the documentation. I'm 95% sure this is what you meant (it matches the other issue), but I'm just checking :).

Well, the phoneNumber field did not even appear in the documentation (just like the password field but you had explained why on another video)

>And, does the phoneNumber field show up if you simply *fetch* a user (i.e. phoneNumber is not in the response after POST /api/users to create a user, but it DOES show up if you GET /api/users/1 to fetch your user). The answer to this will, I think, help discover the issue.

Since my current code matches the one we have at the end of part 3 training, I don't know if that will affect, but since we still have the 'owner: read' group for the phoneNumber, I think it won't change anything, so here's what I can get:

GET '/api/users' : http://image.noelshack.com/... (no phoneNumber field in schema with authenticated user)
On the other hand, in the response, I get the phoneNumber field only on the logged in user, which is correct : http://image.noelshack.com/...

And we have the exact same result when I GET '/api/users/{uuid}'.

In the end, the phoneNumber field will never appear in the documentation, but will appear in the response if it is needed. I no longer know if this was the behavior we expected or if there is indeed a problem.

>Just to make sure, have you verified that userIsOwner() *is* being called? Or, another way to ask this is: have you verified that the normalize() method in your UserNormalizer is being called?

Yes of course! :)

To finish, and here we will only have to trust my memory (very selective), but it seems to me that in the rest of the training (part 3), I thought I saw that the same thing was happening on your side, namely that the phoneNumber field did not appear in the documentation but was present in the response when it was necessary.

In the end it will remain a mystery

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

Hello,

I have a problem with my own project. Everything was working good with User entity, I was using GET operation as expected, but just after creating UserNormalizer with maker with default code, it's throwing an error:


"hydra:description": "No collection route associated with the type \"App\\Entity\\User\"."

,

I'm using symfony 5.1.8 and ApiPlatform 2.5

itemOperation GET is there as always.

Any advice is appreciated!

<b>UPDATE:</b>

User Entity has a collection


    /**
     * @ORM\OneToMany(targetEntity="ClientUserRole", mappedBy="user", fetch="EAGER")
     */
    private $clientUserRoles;

    /**
     * @Groups({"user:read"})
     * @SerializedName("roles")
     */
    public function getClientUserRoles()
    {
        return $this->clientUserRoles;
    }

ClientUserRoles is not an ApiResource, it only has a serialization groups on specific relations


    /**
     * @ORM\ManyToOne(targetEntity="Role", fetch="EAGER")
     * @ORM\JoinColumn(name="role_id", referencedColumnName="role_id")
     * @SerializedName("role")
     * @Groups("user:read")
     */
    private $role;

    /**
     * @ORM\ManyToOne(targetEntity="Client", fetch="EAGER")
     * @ORM\JoinColumn(name="client_id", referencedColumnName="client_id")
     * @SerializedName("client")
     * @Groups("user:read")
     */
    private $client;

Client entity is not an ApiResource, it only has a serialization groups on specific attribute


    /**
     * @ORM\Column(type="string", length=100, nullable=true)
     * @Groups({"user:read"})
     */
    private $name;

Role entity is not an ApiResource, it only has a serialization groups on specific attribute


    /**
     * @ORM\Column(type="string", length=50)
     * @Groups("user:read")
     */
    private $name;

I noticed that the error is related to clientUserRoles collection on User entity. This doesn't cause problems if a remove the new normalizer. I don't have any custom code on normalizer, just what is created with maker:



namespace App\Serializer\Normalizer;

use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;

class UserNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface
{
    private $normalizer;

    public function __construct(ObjectNormalizer $normalizer)
    {
        $this->normalizer = $normalizer;
    }

    public function normalize($object, $format = null, array $context = []): array
    {
        $data = $this->normalizer->normalize($object, $format, $context);

        // Here: add, edit, or delete some data

        return $data;
    }

    public function supportsNormalization($data, $format = null): bool
    {
        return $data instanceof \App\Entity\User;
    }

    public function hasCacheableSupportsMethod(): bool
    {
        return true;
    }
}
Reply

Hey @Edgar!

This is a super weird error! These custom normalizers in api platform are tricky, because you need to make sure (after you do whatever modifications you need) that you call the Serializer system again so that the normal process can take place. The make:serializer:normalizer command in MakerBundle is a bit generic to the Serializer component in general: it’s designed as a starting point to create a normalizer... but api platform is a bit special.

Basically, here’s my guess at what’s happening: in api platform (as I mentioned), you need to do your work in the normalizer and then call the normalization system again so that api platform can do its normal work. The generated normalizer doesn’t do this - it just calls (iirc) the ObjectNormalizer. So, i would modify the code on that normalizer to do what we do in the next couple of chapters. My guess is that there’s something different enough with your entities and relations that using the object normalizer causes this explosion. I could also be totally wrong - it’s a very odd error. But let me know.

Cheers!

Reply
Eric G. Avatar

I'm just trying to see if I can track it down but I just ran into something similar.. at the end of this chapter we refresh and my entire User section/object/api disapeared... I started with the course files and updated a couple things but was still on symfony 4... definitely something strange around the normalizer. If I remove it things appear to work (though it won't get the new user aware permissions.. I'll update here if I can figure it out.

Reply

Hey Eric G.!

Thanks for sharing! It does make me think that, indeed, something weird is going on. For the entire User section in the API to disappear from a normalizer... I can't even imagine how that would happen. Let me know if you find anything :).

Cheers!

Reply
Eric G. Avatar

It was pretty strange. If I removed the normalizer file and cleared the cache the User api would appear again. Of course I managed to break the whole thing trying to figure it out. I was able to ue the "finish" code from the module and got everything working there so it was either something I did by mistake or a quirk of the exact versions I ended up on

Reply

Ha! Yea, that is SUPER weird. Thanks for following up. If you repeat this again later, let me know.

Cheers!

Reply
akincer Avatar
akincer Avatar akincer | posted 2 years ago

I'm getting 401 "Full authentication is required to access this resource." at the end when trying to do a POST. So far I'm unable to figure out what I might have missed. I compared the code for the User resource and the UserNormalizer to the code in the script and can't find any differences except the code for UserNormalizer at the end of the script here differs significantly with the NormalizerAwareInterface and NormalizerAwareTrait stuff and I don't see any discussion of that change. But regardless it fails the same way in both UserNormalizer instances.

Any hints where I might check?

Reply

Hey Aaron,

"Full authentication is required" means that you're not authenticated fully, for example, you might be authenticated via "remember me" feature. Literally, log out first, then log back in, and try again that endpoint. Do you have access to it now? Symfony has "remember me" feature that allow to logging in users via cookie even if session is expired. But to access some resources you may require full authentication. In web interface when you're trying to access such resource - it will redirect you to login form. But with an API endpoint - it will show you the error, and you have to log in yourself.

I hope it's clearer for you now. Does log out and log in again help?

Cheers!

Reply
akincer Avatar

I understand but in the tutorial Ryan explicitly says:

"So... let's take this for a test drive! Back on the docs, I'm currently not logged in. Let's refresh the page... and create a new user. How about email goudadude@example.com, password foo, same username, no cheeseListings, but with a phoneNumber. Execute and... perfect! A 201 status code. Copy that email... go back to the homepage.. and log in: goudadude@example.com, password foo and... go!"

When he refreshes you can see he's still not logged in and is able to perform the operation anonymously as defined in the POST operation under collectionOperations for the ApiResource declaration as both the course script shows and my code reflects yet it's not being honored.

Reply

Hey @Aaron Kincer!

Hmm. So if you're getting a 401 when you try to make a POST to /users then it means that - for *some* reason - something is fully denying access to this operation. What I mean is, I don't think it's related to your custom normalizer... it seems more that something is fully denying access to the entire operation. This can mostly likely happen due to your security rules inside your @ApiResource annotation on User or via access_control inside of security.yaml - it would most likely be the first, since we haven't really touched any access_control stuff in security.yaml in this tutorial.

So make sure an anonymous user can access the POST collection operation, we explicitly set its security - you can see it in this code block - https://symfonycasts.com/sc... - does your operation have this? Let me know - it's the first place I would look :).

Cheers!

Reply
akincer Avatar

Yes it does indeed have that. I copied and pasted the code just to make sure I didn't have a typo. I'm quite confused what's going on here.

Reply

Hey @Aaron!

Ok, here is how we can debug *where* the access denied is coming from:

1) Make the request that is giving you the 401
2) Go to https://localhost:8000/_profiler
3) Find the request that gave you the 401 - it should be the top request or maybe the 2nd to top request. Click the little "token" link on the right
4) You'll now be on the profiler for that request. Click into the "Security" section
5) Somewhere on this page, you'll see an "Access decision log". What do you see here? Can you take a screenshot? What we're looking for is *which* decision was DENIED, which should help us figure out where that is coming from.

Cheers!

Reply
akincer Avatar

Based on the output it's RoleVoter that's denying. I've gone back quite a few chapters to make sure I didn't miss anything copying the entirety of code for any and every file I could find to make sure I had the latest up to this point. I've compared to code in the "finish" portion of the course code. I cannot get past this so I'm not sure what's going on. Any other suggestions?

Reply
Default user avatar
Default user avatar Aaron Kincer | weaverryan | posted 2 years ago | edited

Here's what it says:

# Result Attributes Object<br />1 DENIED ROLE_USER <br />null<br />"Symfony\Component\Security\Core\Authorization\Voter\AuthenticatedVoter"<br />ACCESS ABSTAIN<br />"Symfony\Component\Security\Core\Authorization\Voter\RoleVoter"<br />ACCESS DENIED<br />"Symfony\Component\Security\Core\Authorization\Voter\ExpressionVoter"<br />ACCESS ABSTAIN<br />"App\Security\Voter\CheeseListingVoter"<br />ACCESS ABSTAIN

Reply

Hey @Aaron Kincer!

Sorry for my slow reply - busy week :p.

So yes, it's the RoleVoter, which means that something is denying access based on a "role" - e.g. ROLE_USER or ROLE_ADMIN. But the question is.... where is that coming from? Many things could be checking access for a role - like access_control in security.yaml or even your security expression inside your @ApiPlatform annotations.

Here is a hacky way that we could figure this out:

1) Open the core RoleVoter - https://github.com/symfony/symfony/blob/5.x/src/Symfony/Component/Security/Core/Authorization/Voter/RoleVoter.php - in your project

2) Right before the return statement, add this:


if ($result === VoterInterface::ACCESS_DENIED) {
    throw new \Exception('Access is being denied!');
}

Now try the endpoint again. When you do, use the same trick to go into the profiler for that request, but this time go into the Exception section on the left. You should now be able to see a stacktrace that shows who is calling the RoleVoter. This will hopefully be enough for you to see what code is running this check - but if it's still not clear, take a screenshot and send it over :). I would be looking to see if one of the classes in the stack is ResourceAccessChecker from API Platform (which would point to this coming from some security you have on the @ApiPlatform annotation) or AccessListener (which would mean it is coming from access_control in security.yaml) or something else.

Cheers!

Reply
akincer Avatar

I grabbed the course Finish code and created a new project. I'm not getting the same error so clearly I've done something wrong somewhere so I'll just compare code to figure out what I've done wrong and report back. Thank you for your help.

I never doubted Symfony for a moment. Well, maybe for a moment :)

Reply

Hey @Aaron!

Ah, excellent idea! I also took a look at the deeper exceptions, and I can tell you that the access denied IS coming from API Platform. So, specifically, API Platform is looking at the "security" option of the current "operation" inside of the @ApiResource annotation (this can be specified at the root level of the resource, or overridden on the specific operation) and determining that access should be denied thanks to that expression. Or, you may be using the "access_control" option, which we use in this tutorial because the newer and equivalent "security" wasn't created yet. So, double-check these options in your annotation to help see whaat's going on :).

Cheers!

Reply
akincer Avatar
akincer Avatar akincer | weaverryan | posted 2 years ago | edited

OK, I've figured out the general gist of what's going on here. After extensive examination of project files with a diff tool I simply could not find demonstrable differences between the Finish code and my WIP training code. This left me scratching my head and I spent more time than I care to admit trying to think of a way to isolate the issue.

Finally I decided to do something very basic -- compare package versions. They were quite different so I decided to do a composer update on the Finish code. This required me to change the repository class construct methods from referencing the RegistryInterface to ManagerRegistry class. But, more importantly, it broke the POST operation with the same error! So clearly there's a behavioral change somewhere. Here are the package changes the update process made. The change that breaks the operation is in there somewhere:

`- Removing doctrine/doctrine-cache-bundle (1.3.5)

  • Removing fzaninotto/faker (v1.8.0)
  • Removing jdorn/sql-formatter (v1.2.17)
  • Removing symfony/contracts (v1.1.5)
  • Removing symfony/test-pack (v1.0.6)
  • Removing zendframework/zend-code (3.3.1)
  • Removing zendframework/zend-eventmanager (3.2.1)
  • Upgrading api-platform/api-pack (v1.2.0 => v1.3.0)
  • Upgrading api-platform/core (v2.4.5 => v2.5.7)
  • Upgrading composer/package-versions-deprecated (1.11.99 => 1.11.99.1)
  • Upgrading doctrine/annotations (1.10.2 => 1.11.1)
  • Upgrading doctrine/cache (1.10.0 => 1.10.2)
  • Upgrading doctrine/collections (1.6.4 => 1.6.7)
  • Upgrading doctrine/common (2.12.0 => 2.13.3)
  • Upgrading doctrine/data-fixtures (v1.3.2 => 1.4.4)
  • Upgrading doctrine/dbal (2.10.2 => 2.12.1)
  • Upgrading doctrine/doctrine-bundle (1.11.2 => 2.2.1)
  • Upgrading doctrine/doctrine-migrations-bundle (v2.0.0 => 2.2.1)
  • Upgrading doctrine/event-manager (1.1.0 => 1.1.1)
  • Upgrading doctrine/inflector (1.3.1 => 1.4.3)
  • Upgrading doctrine/instantiator (1.3.0 => 1.4.0)
  • Upgrading doctrine/lexer (1.2.0 => 1.2.1)
  • Upgrading doctrine/migrations (v2.1.0 => 2.3.0)
  • Upgrading doctrine/orm (v2.7.2 => 2.7.4)
  • Upgrading doctrine/persistence (1.3.7 => 1.3.8)
  • Upgrading doctrine/reflection (1.2.1 => 1.2.2)
  • Locking doctrine/sql-formatter (1.1.1)
  • Locking fakerphp/faker (v1.12.0)
  • Upgrading fig/link-util (1.0.0 => 1.1.1)
  • Upgrading hautelook/alice-bundle (v2.5.1 => 2.8.0)
  • Locking laminas/laminas-code (3.5.0)
  • Locking laminas/laminas-eventmanager (3.3.0)
  • Locking laminas/laminas-zendframework-bridge (1.1.1)
  • Upgrading monolog/monolog (1.24.0 => 1.25.5)
  • Upgrading myclabs/deep-copy (1.9.1 => 1.10.2)
  • Upgrading nelmio/alice (v3.5.7 => 3.7.4)
  • Upgrading nelmio/cors-bundle (1.5.6 => 2.1.0)
  • Upgrading nesbot/carbon (2.21.3 => 2.41.5)
  • Upgrading nikic/php-parser (v4.2.2 => v4.10.2)
  • Upgrading ocramius/proxy-manager (2.2.2 => 2.10.0)
  • Upgrading phpdocumentor/reflection-common (1.0.1 => 2.2.0)
  • Upgrading phpdocumentor/reflection-docblock (4.3.1 => 5.2.2)
  • Upgrading phpdocumentor/type-resolver (0.4.0 => 1.4.0)
  • Upgrading sebastian/comparator (3.0.2 => 4.0.6)
  • Upgrading sebastian/diff (3.0.2 => 4.0.4)
  • Upgrading sebastian/exporter (3.1.0 => 4.0.3)
  • Upgrading sebastian/recursion-context (3.0.0 => 4.0.4)
  • Upgrading symfony/asset (v4.3.2 => v4.3.11)
  • Upgrading symfony/browser-kit (v4.3.3 => v4.3.11)
  • Upgrading symfony/cache (v4.3.2 => v4.3.11)
  • Locking symfony/cache-contracts (v1.1.10)
  • Upgrading symfony/config (v4.3.2 => v4.3.11)
  • Upgrading symfony/console (v4.3.2 => v4.3.11)
  • Upgrading symfony/css-selector (v4.3.3 => v4.3.11)
  • Upgrading symfony/debug (v4.3.2 => v4.3.11)
  • Upgrading symfony/dependency-injection (v4.3.2 => v4.3.11)
  • Locking symfony/deprecation-contracts (v2.2.0)
  • Upgrading symfony/doctrine-bridge (v4.3.2 => v4.3.11)
  • Upgrading symfony/dom-crawler (v4.3.3 => v4.3.11)
  • Upgrading symfony/dotenv (v4.3.2 => v4.3.11)
  • Upgrading symfony/event-dispatcher (v4.3.2 => v4.3.11)
  • Locking symfony/event-dispatcher-contracts (v1.1.9)
  • Upgrading symfony/expression-language (v4.3.2 => v4.3.11)
  • Upgrading symfony/filesystem (v4.3.2 => v4.3.11)
  • Upgrading symfony/finder (v4.3.2 => v4.3.11)
  • Upgrading symfony/flex (v1.9.10 => v1.10.0)
  • Upgrading symfony/framework-bundle (v4.3.2 => v4.3.11)
  • Upgrading symfony/http-client (v4.3.3 => v4.3.11)
  • Locking symfony/http-client-contracts (v1.1.10)
  • Upgrading symfony/http-foundation (v4.3.2 => v4.3.11)
  • Upgrading symfony/http-kernel (v4.3.2 => v4.3.11)
  • Upgrading symfony/inflector (v4.3.2 => v4.3.11)
  • Upgrading symfony/maker-bundle (v1.12.0 => v1.24.1)
  • Upgrading symfony/mime (v4.3.2 => v4.3.11)
  • Upgrading symfony/monolog-bridge (v4.3.3 => v4.3.11)
  • Upgrading symfony/monolog-bundle (v3.4.0 => v3.6.0)
  • Locking symfony/orm-pack (v2.0.0)
  • Upgrading symfony/phpunit-bridge (v4.3.3 => v5.1.8)
  • Upgrading symfony/polyfill-intl-idn (v1.11.0 => v1.20.0)
  • Locking symfony/polyfill-intl-normalizer (v1.20.0)
  • Upgrading symfony/polyfill-mbstring (v1.11.0 => v1.20.0)
  • Upgrading symfony/polyfill-php72 (v1.11.0 => v1.20.0)
  • Upgrading symfony/polyfill-php73 (v1.11.0 => v1.20.0)
  • Upgrading symfony/profiler-pack (v1.0.4 => v1.0.5)
  • Upgrading symfony/property-access (v4.3.2 => v4.3.11)
  • Upgrading symfony/property-info (v4.3.2 => v4.3.11)
  • Upgrading symfony/routing (v4.3.2 => v4.3.11)
  • Upgrading symfony/security-bundle (v4.3.2 => v4.3.11)
  • Upgrading symfony/security-core (v4.3.2 => v4.3.11)
  • Upgrading symfony/security-csrf (v4.3.2 => v4.3.11)
  • Upgrading symfony/security-guard (v4.3.2 => v4.3.11)
  • Upgrading symfony/security-http (v4.3.2 => v4.3.11)
  • Upgrading symfony/serializer (v4.3.2 => v4.3.11)
  • Locking symfony/serializer-pack (v1.0.4)
  • Locking symfony/service-contracts (v1.1.9)
  • Upgrading symfony/stopwatch (v4.3.2 => v4.3.11)
  • Upgrading symfony/translation (v4.3.2 => v4.3.11)
  • Locking symfony/translation-contracts (v1.1.10)
  • Upgrading symfony/twig-bridge (v4.3.2 => v4.3.11)
  • Upgrading symfony/twig-bundle (v4.3.2 => v4.3.11)
  • Upgrading symfony/validator (v4.3.2 => v4.3.11)
  • Upgrading symfony/var-dumper (v4.3.2 => v4.3.11)
  • Upgrading symfony/var-exporter (v4.3.2 => v4.3.11)
  • Upgrading symfony/web-link (v4.3.2 => v4.3.11)
  • Upgrading symfony/web-profiler-bundle (v4.3.2 => v4.3.11)
  • Upgrading symfony/webpack-encore-bundle (v1.6.2 => v1.8.0)
  • Upgrading symfony/yaml (v4.3.2 => v4.3.11)
  • Upgrading theofidry/alice-data-fixtures (v1.1.1 => 1.3.0)
  • Upgrading twig/twig (v2.11.3 => v2.14.1)
  • Locking webimpress/safe-writer (2.1.0)
  • Upgrading webmozart/assert (1.4.0 => 1.9.1)
    Writing lock file
    Installing dependencies from lock file (including require-dev)
    Package operations: 15 installs, 89 updates, 7 removals
  • Downloading symfony/maker-bundle (v1.24.1)
  • Removing zendframework/zend-eventmanager (3.2.1)
  • Removing zendframework/zend-code (3.3.1)
  • Removing symfony/test-pack (v1.0.6)
  • Removing symfony/contracts (v1.1.5)
  • Removing jdorn/sql-formatter (v1.2.17)
  • Removing fzaninotto/faker (v1.8.0)
  • Removing doctrine/doctrine-cache-bundle (1.3.5)
  • Upgrading composer/package-versions-deprecated (1.11.99 => 1.11.99.1): Extracting archive
  • Upgrading symfony/flex (v1.9.10 => v1.10.0): Extracting archive
  • Installing symfony/translation-contracts (v1.1.10): Extracting archive
  • Upgrading symfony/polyfill-mbstring (v1.11.0 => v1.20.0): Extracting archive
  • Upgrading symfony/validator (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading twig/twig (v2.11.3 => v2.14.1): Extracting archive
  • Upgrading symfony/twig-bridge (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/polyfill-php73 (v1.11.0 => v1.20.0): Extracting archive
  • Upgrading symfony/polyfill-php72 (v1.11.0 => v1.20.0): Extracting archive
  • Installing symfony/polyfill-intl-normalizer (v1.20.0): Extracting archive
  • Upgrading symfony/polyfill-intl-idn (v1.11.0 => v1.20.0): Extracting archive
  • Upgrading symfony/mime (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/http-foundation (v4.3.2 => v4.3.11): Extracting archive
  • Installing symfony/event-dispatcher-contracts (v1.1.9): Extracting archive
  • Upgrading symfony/event-dispatcher (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/debug (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/http-kernel (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/filesystem (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/config (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/twig-bundle (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/serializer (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/inflector (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/property-info (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/property-access (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading webmozart/assert (1.4.0 => 1.9.1): Extracting archive
  • Upgrading phpdocumentor/reflection-common (1.0.1 => 2.2.0): Extracting archive
  • Upgrading phpdocumentor/type-resolver (0.4.0 => 1.4.0): Extracting archive
  • Upgrading phpdocumentor/reflection-docblock (4.3.1 => 5.2.2): Extracting archive
  • Upgrading doctrine/lexer (1.2.0 => 1.2.1): Extracting archive
  • Upgrading doctrine/annotations (1.10.2 => 1.11.1): Extracting archive
  • Installing symfony/serializer-pack (v1.0.4): Extracting archive
  • Installing symfony/service-contracts (v1.1.9): Extracting archive
  • Upgrading symfony/security-core (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/security-http (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/security-guard (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/security-csrf (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/dependency-injection (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/security-bundle (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/console (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading doctrine/reflection (1.2.1 => 1.2.2): Extracting archive
  • Upgrading doctrine/event-manager (1.1.0 => 1.1.1): Extracting archive
  • Upgrading doctrine/collections (1.6.4 => 1.6.7): Extracting archive
  • Upgrading doctrine/cache (1.10.0 => 1.10.2): Extracting archive
  • Upgrading doctrine/persistence (1.3.7 => 1.3.8): Extracting archive
  • Upgrading doctrine/instantiator (1.3.0 => 1.4.0): Extracting archive
  • Upgrading doctrine/inflector (1.3.1 => 1.4.3): Extracting archive
  • Upgrading doctrine/dbal (2.10.2 => 2.12.1): Extracting archive
  • Upgrading doctrine/common (2.12.0 => 2.13.3): Extracting archive
  • Upgrading doctrine/orm (v2.7.2 => 2.7.4): Extracting archive
  • Upgrading symfony/routing (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/finder (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/var-exporter (v4.3.2 => v4.3.11): Extracting archive
  • Installing symfony/cache-contracts (v1.1.10): Extracting archive
  • Upgrading symfony/cache (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/framework-bundle (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/stopwatch (v4.3.2 => v4.3.11): Extracting archive
  • Installing webimpress/safe-writer (2.1.0): Extracting archive
  • Installing laminas/laminas-zendframework-bridge (1.1.1): Extracting archive
  • Installing laminas/laminas-eventmanager (3.3.0): Extracting archive
  • Installing laminas/laminas-code (3.5.0): Extracting archive
  • Upgrading ocramius/proxy-manager (2.2.2 => 2.10.0): Extracting archive
  • Upgrading doctrine/migrations (v2.1.0 => 2.3.0): Extracting archive
  • Upgrading symfony/doctrine-bridge (v4.3.2 => v4.3.11): Extracting archive
  • Installing doctrine/sql-formatter (1.1.1): Extracting archive
  • Upgrading doctrine/doctrine-bundle (1.11.2 => 2.2.1): Extracting archive
  • Upgrading doctrine/doctrine-migrations-bundle (v2.0.0 => 2.2.1): Extracting archive
  • Installing symfony/orm-pack (v2.0.0): Extracting archive
  • Upgrading symfony/expression-language (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/asset (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading nelmio/cors-bundle (1.5.6 => 2.1.0): Extracting archive
  • Upgrading fig/link-util (1.0.0 => 1.1.1): Extracting archive
  • Upgrading symfony/web-link (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading api-platform/core (v2.4.5 => v2.5.7): Extracting archive
  • Upgrading api-platform/api-pack (v1.2.0 => v1.3.0): Extracting archive
  • Installing fakerphp/faker (v1.12.0): Extracting archive
  • Upgrading symfony/yaml (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading sebastian/recursion-context (3.0.0 => 4.0.4): Extracting archive
  • Upgrading sebastian/exporter (3.1.0 => 4.0.3): Extracting archive
  • Upgrading sebastian/diff (3.0.2 => 4.0.4): Extracting archive
  • Upgrading sebastian/comparator (3.0.2 => 4.0.6): Extracting archive
  • Upgrading myclabs/deep-copy (1.9.1 => 1.10.2): Extracting archive
  • Upgrading nelmio/alice (v3.5.7 => 3.7.4): Extracting archive
  • Upgrading theofidry/alice-data-fixtures (v1.1.1 => 1.3.0): Extracting archive
  • Upgrading doctrine/data-fixtures (v1.3.2 => 1.4.4): Extracting archive
  • Upgrading hautelook/alice-bundle (v2.5.1 => 2.8.0): Extracting archive
  • Upgrading symfony/translation (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading nesbot/carbon (2.21.3 => 2.41.5): Extracting archive
  • Upgrading symfony/dom-crawler (v4.3.3 => v4.3.11): Extracting archive
  • Upgrading symfony/browser-kit (v4.3.3 => v4.3.11): Extracting archive
  • Upgrading symfony/css-selector (v4.3.3 => v4.3.11): Extracting archive
  • Upgrading symfony/dotenv (v4.3.2 => v4.3.11): Extracting archive
  • Installing symfony/http-client-contracts (v1.1.10): Extracting archive
  • Upgrading symfony/http-client (v4.3.3 => v4.3.11): Extracting archive
  • Installing symfony/deprecation-contracts (v2.2.0): Extracting archive
  • Upgrading nikic/php-parser (v4.2.2 => v4.10.2): Extracting archive
  • Upgrading symfony/maker-bundle (v1.12.0 => v1.24.1): Extracting archive
  • Upgrading monolog/monolog (1.24.0 => 1.25.5): Extracting archive
  • Upgrading symfony/monolog-bridge (v4.3.3 => v4.3.11): Extracting archive
  • Upgrading symfony/monolog-bundle (v3.4.0 => v3.6.0): Extracting archive
  • Upgrading symfony/phpunit-bridge (v4.3.3 => v5.1.8): Extracting archive
  • Upgrading symfony/var-dumper (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/web-profiler-bundle (v4.3.2 => v4.3.11): Extracting archive
  • Upgrading symfony/profiler-pack (v1.0.4 => v1.0.5): Extracting archive
  • Upgrading symfony/webpack-encore-bundle (v1.6.2 => v1.8.0): Extracting archive`
Reply

Hey @Aaron!

Ah! Excellent detective work! My immediate guess would be this guy: Upgrading api-platform/core (v2.4.5 => v2.5.7).

You could try running adding (if it's not there already directly) api-platform/core to your composer.json and setting its version explicitly to "2.4.5". Then run a composer update to downgrade to that version. Then you can see if you the error "goes away". I still can't think of why this would happen, but this seems like the likely cause. There is a security difference between API Platform 2.4 and 2.5... but it's just a new feature. What I mean is, in 2.4, you used access_control inside your @ApiResource config to control security. In 2.5, that still exists, but is deprecated. A new security was added (we helped push for that change, the security works a bit better). The point is, if you have access_control, it should work identically in both versions. However, if you have security=, then that would work in 2.5, but do nothing in 2.4.... so... that might explain things? :)

Cheers!

Reply
Default user avatar

It was in fact the 2.5 upgrade. Here's what works:

2.4.5 -> access_control
2.5.x -> security

Having access_control in 2.5 doesn't work.

Once again the problem was in fact me because at some point I obviously ran composer update for reasons. Thanks for your help!

Reply

Hey @Aaron!

Awesome! Good job working through that :). access_control "should" work on 2.5 (it's just deprecated), but it's always possible that some behavior changed between those version. At the very least, there is a small difference between access_control and security: access_control is run AFTER your object is deserialized but security is run BEFORE it's deserialized. access_control is really equivalent to security_post_denormalize in 2.5. It's actually nice in 2.5 because you can choose whether you want to run your security check before or after deserialization.

Cheers!

Reply
akincer Avatar

Also I sent screenshots I put together showing the stack traces and emailed them to the email address on the contact page.

Reply
akincer Avatar

I just appreciate your help and love learning new debug tips along the way. Here are the top entries in the stack traces:

HttpException: ExceptionListener
InsufficientAuthenticationException: ExceptionListener
AccessDeniedException: DenyAccessListener

There's nothing under access_control in security.yaml.

Reply
Titoine Avatar
Titoine Avatar Titoine | posted 2 years ago

Isn't it a bit weird to add group in a normalizer? Or is it a common thing?
There is multiple way to add group (Context builder, meta data factory), but it seems to be the only way to add a group to one specific entity.

Reply

Hey @keuwa!

You nailed it :). The three different ways are used based on 3 different needs:

A) Metadata factory: use this if you need to add groups and the logic for adding the groups isn't dependent on the current request (or authenticated user) or the individual object being serialized. The advantage of a context builder is that the result of this is cached.

B) Context builder: use this if you need to add groups based on info from the current request (or authenticated user), but not based on data for an individual object.

C) Normalizer: use this if you need to add groups for an individual object

3 different ways for 3 different situations :). I know, it's a bit complex - especially because creating normalizes is kind of annoying (with the whole calling the inner normalizer and avoiding recursion).

Cheers!

Reply
hacktic Avatar
hacktic Avatar hacktic | posted 3 years ago

Could you please provide the owner:write code?

Reply

Hiya hacktic !

Absolutely :). It's basically the same process, but you *do* need to implement a few interfaces, and instead of working with objects and turning them into something else, you're working with data and a "type" - so the whole process is literally backwards :).

Here's a finished example to try: https://gist.github.com/wea... - I just hacked this together, so please let me know if something doesn't work. The most interesting part might be the "diff" of how I transformed the finished normalizer from this project and added the denormalizer stuff - https://gist.github.com/wea...

Let me know if it helps! Cheers!

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

Thanks a lot weaverryan ,

i used your example and tried to update the phoneNumber field as owner with owner:write set.
I get Statuscode 200, but the field is not updating.
When changing to user:write everything is working as expected and the field is updating.

Any Ideas?

Reply

Hey Hannah R.!

Hmm. What do the groups look like above the phoneNumnber property? Is owner:write one of the groups above it?

Cheers!

Reply
hacktic Avatar
hacktic Avatar hacktic | weaverryan | posted 3 years ago | edited

Hey weaverryan,

ok -> Updating phoneNumber as expected:
@Groups({"admin:read", "owner:read", "user:write"})

nok -> Status Code 200 but not updating value of phoneNumber:
@Groups({"admin:read", "owner:read", "owner:write"})

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, <8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.5
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.13.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.6
        "nesbot/carbon": "^2.17", // 2.21.3
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.3.*", // v4.3.2
        "symfony/console": "4.3.*", // v4.3.2
        "symfony/dotenv": "4.3.*", // v4.3.2
        "symfony/expression-language": "4.3.*", // v4.3.2
        "symfony/flex": "^1.1", // v1.18.7
        "symfony/framework-bundle": "4.3.*", // v4.3.2
        "symfony/http-client": "4.3.*", // v4.3.3
        "symfony/monolog-bundle": "^3.4", // v3.4.0
        "symfony/security-bundle": "4.3.*", // v4.3.2
        "symfony/twig-bundle": "4.3.*", // v4.3.2
        "symfony/validator": "4.3.*", // v4.3.2
        "symfony/webpack-encore-bundle": "^1.6", // v1.6.2
        "symfony/yaml": "4.3.*" // v4.3.2
    },
    "require-dev": {
        "hautelook/alice-bundle": "^2.5", // 2.7.3
        "symfony/browser-kit": "4.3.*", // v4.3.3
        "symfony/css-selector": "4.3.*", // v4.3.3
        "symfony/maker-bundle": "^1.11", // v1.12.0
        "symfony/phpunit-bridge": "^4.3", // v4.3.3
        "symfony/stopwatch": "4.3.*", // v4.3.2
        "symfony/web-profiler-bundle": "4.3.*" // v4.3.2
    }
}
userVoice