Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Setting the UUID on POST

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

The UUID is now the identifier inside of our User resource:

... lines 1 - 44
class User implements UserInterface
{
... lines 47 - 54
/**
... line 56
* @ApiProperty(identifier=true)
*/
private $uuid;
... lines 60 - 304
}

Awesome! But it still works exactly like the old ID. What I mean is, only the server can set the UUID. If we tried to send UUID as a JSON field when creating a user, it would be ignored.

How can I be so sure? Well, look at the User class: $uuid is not settable anywhere. It's not an argument to the constructor and there's no setUuid() method:

... lines 1 - 44
class User implements UserInterface
{
... lines 47 - 58
private $uuid;
... lines 60 - 121
public function __construct()
{
... line 124
$this->uuid = Uuid::uuid4();
}
... lines 127 - 300
public function getUuid(): UuidInterface
{
return $this->uuid;
}
}

Time to change that!

Setting the UUID in a Test

Let's describe the behavior we want in a test. In UserResourceTest, go up to the top and copy testCreateUser(). Paste that down here and call it testCreateUserWithUuid():

... lines 1 - 9
class UserResourceTest extends CustomApiTestCase
{
... lines 12 - 33
public function testCreateUserWithUuid()
{
... lines 36 - 50
}
... lines 52 - 108
}

The key change we want to make is this: in the JSON, we're going to pass a uuid field. For the value, go up and say $uuid = Uuid - the one from Ramsey - ::uuid4(). Then below, send that as the uuid:

... lines 1 - 9
class UserResourceTest extends CustomApiTestCase
{
... lines 12 - 33
public function testCreateUserWithUuid()
{
... lines 36 - 37
$uuid = Uuid::uuid4();
$client->request('POST', '/api/users', [
'json' => [
'uuid' => $uuid,
... lines 42 - 44
]
]);
... lines 47 - 50
}
... lines 52 - 108
}

I technically could call ->toString()... but since the Uuid object has an __toString() method, we don't need to. Assert that the response is a 201 and... then we can remove the part that fetches the User from the database. Because... we know that the @id should be /api/users/ and then that $uuid. I'll also remove the login part, only because we have that in the other test:

... lines 1 - 9
class UserResourceTest extends CustomApiTestCase
{
... lines 12 - 33
public function testCreateUserWithUuid()
{
... lines 36 - 37
$uuid = Uuid::uuid4();
$client->request('POST', '/api/users', [
'json' => [
'uuid' => $uuid,
... lines 42 - 44
]
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains([
'@id' => '/api/users/'.$uuid
]);
}
... lines 52 - 108
}

So this is the plan: we send the uuid and it uses that uuid. Copy the name of this method and let's make sure it fails:

symfony php bin/phpunit --filter=testCreateUserWithUuid

It does. It completely ignores the UUID that we send and generates its own.

Making the uuid Field Settable

So how can we make the UUID field settable? Well, it's really no different than any other field: we need to put the property in the correct group and make sure it's settable either through the constructor or via a setter method.

Let's think: we only want this field to be settable on create: we don't want to allow anyone to modify it later. So we could add a setUuid() method, but then we would need to be careful to configure and add the correct groups so that it can be set on create but not edit.

But... there's a simpler solution: avoid the setter and instead add $uuid as an argument to the constructor! Then, by the rules of object-oriented coding, it will be settable on create but immutable after.

Let's do that: add a UuidInterface $uuid argument and default it to null. Then $this->uuid = $uuid ?: Uuid::uuid4():

... lines 1 - 13
use Ramsey\Uuid\UuidInterface;
... lines 15 - 44
class User implements UserInterface
{
... lines 47 - 122
public function __construct(UuidInterface $uuid = null)
{
... line 125
$this->uuid = $uuid ?: Uuid::uuid4();
}
... lines 128 - 305
}

So if a $uuid argument is passed, we'll use that. If not, we generate a new one. Oh, and we also need to make sure the UUID is actually writeable in the API. Above the $uuid property, add @Groups() with user:write:

... lines 1 - 44
class User implements UserInterface
{
... lines 47 - 54
/**
... lines 56 - 57
* @Groups({"user:write"})
*/
private $uuid;
... lines 61 - 305
}

Ok, let's try the test again!

symfony php bin/phpunit --filter=testCreateUserWithUuid

This time... woh! It works. That's awesome. And the documentation for this instantly looks perfect. Refresh the API homepage, open up the POST operation for users, hit "Try it out" and... yep! It already shows a UUID example and it understands that it is available for us to set.

UUID String Transformed to an Object?

But wait a second. How did that work? Think about it, if you look at our test, we're sending a string in the JSON:

... lines 1 - 9
class UserResourceTest extends CustomApiTestCase
{
... lines 12 - 33
public function testCreateUserWithUuid()
{
... lines 36 - 37
$uuid = Uuid::uuid4();
$client->request('POST', '/api/users', [
'json' => [
'uuid' => $uuid,
... lines 42 - 44
]
]);
... lines 47 - 50
}
... lines 52 - 108
}

But ultimately, on our User object, the constructor argument accepts a UuidInterface object, not a string:

... lines 1 - 44
class User implements UserInterface
{
... lines 47 - 122
public function __construct(UuidInterface $uuid = null)
{
... lines 125 - 126
}
... lines 128 - 305
}

How did that string become an object?

Remember: API platform - well really, Symfony's serializer - is really good at reading your types. It notices that the type for $uuid is UuidInterface and uses that to try to find a denormalizer that understands this type. And fortunately, API Platform comes with a denormalizer that works with ramsey UUID's out of the box. Yep, that denormalizer takes the string and turns it into a UUID object so that it can then be passed to the constructor.

So... yay UUIDs! But, before we finish, there is one tiny quirk with UUID's. Let's see what it is next and learn how to work around it.

Leave a comment!

8
Login or Register to join the conversation
Nathanael Avatar
Nathanael Avatar Nathanael | posted 9 months ago | edited

Hello!

I am finding that using UuidInterface as a constructor argument type is not "magically" resulting in passed strings being converted into UUIDs. When I attempt to use the endpoint (and pass a UUID) I end up with the following error message. This error message makes perfect sense, but is contrary to the behavior this tutorial describes. Instead, I need to use strings.

Error message: Argument #1 ($uuid) must be of type ?Ramsey\\Uuid\\UuidInterface, string given
I tried creating my own normalizer, but I could not get it to fire the way it's "supposed" to. Is there some undocumented setting or something that I'm missing? My understanding is this should work out of the box with no additional service declarations.

Example code:

#[ApiResource(
    operations: [new Post()],
    normalizationContext: ['groups' => ['message:read']],
    denormalizationContext: ['groups' => ['message:write']]
)]
#[ORM\Entity(repositoryClass: MessageRepository::class)]
class Message
{
	#[ApiProperty(identifier: false)]
	#[ORM\Id]
	#[ORM\GeneratedValue]
	#[ORM\Column(type: 'integer')]
	private int $id;

	#[ApiProperty(identifier: true)]
	#[Groups(['message:read', 'message:write'])]
	#[ORM\Column(type: 'uuid', unique: true)]
	private UuidInterface $uuid;

	public function __construct(?UuidInterface $uuid = null)
	{
		$this->uuid = $uuid ?? Uuid::uuid4();
	}

    // ...
Reply

Hey Nathanael!

That's interesting. Yes, the UuidInterface type should make it so that this denormalizer - https://github.com/api-platform/core/blob/2.7/src/RamseyUuid/Serializer/UuidDenormalizer.php - is called to convert the string into a Uuid. I'd try adding some debug code directly to this method. What I'm curious about is (A) is supportsDenormalization() called when you make the POST request (B) does it return true appropriately and (C) if it does, is denormalize() doing its job correctly?

Let me know what you find out - I'm not sure what could be going wrong.

Cheers!

Reply
Nathanael Avatar

Hey Ryan!

Nope, already tried that. It's not called at all. I also tried to manually (re)declare it in services.yaml to no effect.

A possibly helpful hint: when I create my own denormalizer, it does fire, but only on the entity class. In other words, $data is the request payload (as an array), and $type is App\Entity\Message (per the above example). At no point is the actual constructor argument being passed through the denormalizer.

Reply

Hey Nathanael!

Ok, so I played with the finish code from the repo to see what was going on. First, just to clarify (and I also couldn't remember that this was the case), the denormalization from a UUID string to an object does not happen due to the UuidInterface type-hint in the constructor. Once you have it working (like it is in the finish code, well, once I undid some changes from the next chapter that cloud things), you can remove the UuidInterface type-hint and it'll still work. Instead, denormalization works because the argument is called $uuid, and so the serializer looks at the uuid property and gets the metadata from there (and, in this case, iirc, it's the ORM\Column type that provides the metadata needed for the serializer).

Anyways, the more important detail is that I was wrong about the class that handles the denormalization. It is actually UuidDenormalizer - https://github.com/api-platform/core/blob/2.7/src/RamseyUuid/Serializer/UuidDenormalizer.php - so not the UuidNormalizer that I had linked to earlier - that one is in the Identifier namespace and is used for something different.

So, that is where I would look for debugging. When I run debug:container uuid, the denormalizer's service id is api_platform.serializer.uuid_denormalizer, though it's possible the service id is slightly different in newer versions. I'd check to make sure that service is there.

A possibly helpful hint: when I create my own denormalizer, it does fire, but only on the entity class. In other words, $data is the request payload (as an array), and $type is App\Entity\Message (per the above example). At no point is the actual constructor argument being passed through the denormalizer.

If the UuidDenormalizer service IS present and it's not being called, then this statement becomes interesting. This would tell me that either (A) you're missing some metadata to tell the serializer that the $uuid argument is a UuidInterface OR the serializer thinks that the $uuid shouldn't be allowed to be passed at all. This is pretty simple to test: remove the UuidInterface type-hint from the constructor argument then dd($uuid). If this dumps null, then it points to the idea that the uuid field is not a valid field to send with the request at all. In that case, triple-check your serialization groups. If it dumps a string, then the field is being allowed, but for some reason, the serializer doesn't know it should be a UuidInterface and so it is not triggering the denormalizer.

Let me know what you find out!

Cheers!

Reply
Nathanael Avatar

It looks like we're in "interesting" territory, then!

First, thanks for the additional insight. I'm wondering now what metadata is required—I had assumed (maybe correctly...?) that all denormalizers (probably with some tag) would be checked in sequence, and then the first one for which DenormalizerInterface#supportsDenormalization() returned true would be used to mutate the data passed with the request. This would then either be used to set a property, or in my case, pass an argument to __construct().

To answer your questions quickly:

A) My property is called $uuid and my constructor argument is also called $uuid.
B) The $uuid property is type-hinted as being an instance of UuidInterface.
C) The Doctrine column has the uuid type (though I feel like that shouldn't matter—not all API fields need to be mapped to the database).
D) The $uuid constructor argument is being passed to the constructor as expected. However, it is not being denormalized. When I remove the type-hint, I get no errors, but I receive a string. I then have to manually "denormalize" that string myself, in the constructor, like so:

public function __construct(?string $uuid = null)
{
	$this->uuid = Uuid::isValid($uuid) ? Uuid::fromString($uuid) : Uuid::uuid4();
}

This is obviously wrong, and causes some complications (such being unable to properly validate $uuid during POST with validation constraints).

Your debug:container idea was a good one, though, and here's what I found:

$ bin/console debug:container uuid

 Select one of the following services to display its information:
  [0] doctrine.uuid_generator
  [1] api_platform.serializer.uuid_denormalizer
  [2] api_platform.ramsey_uuid.uri_variables.transformer.uuid
 > 1


Information for Service "api_platform.serializer.uuid_denormalizer"
===================================================================

 ---------------- ----------------------------------------------------
  Option           Value
 ---------------- ----------------------------------------------------
  Service ID       api_platform.serializer.uuid_denormalizer
  Class            ApiPlatform\RamseyUuid\Serializer\UuidDenormalizer
  Tags             serializer.normalizer
  Public           no
  Synthetic        no
  Lazy             no
  Shared           yes
  Abstract         no
  Autowired        no
  Autoconfigured   no
 ---------------- ----------------------------------------------------

 ! [NOTE] The "api_platform.serializer.uuid_denormalizer" service or alias has been removed or inlined when the
 !        container was compiled.

Two things stand out to me here:

A) The service does exist.
B) The "service or alias has been removed" message—I'm not sure what that means, and I couldn't find any explanation online. I've never seen it before.

Again, UuidDenormalizer#supportsDenormalization() is not being called at any point during POST.

For what it's worth, here is a complete entity with which I can reproduce the issue, and here is the stack trace for this simple request:

POST
{
  "uuid": "f383682a-222c-44c5-8cd8-b60fccb2416d"
}

Let me know if you have any insights. I appreciate the help so far.

Reply

Hey Nathanael!

An interesting mystery indeed! Fortunately, you seem like you're quite comfortable doing some deep debugging, so I think we (you) should be able to figure this out :). First:

The "service or alias has been removed" message—I'm not sure what that means, and I couldn't find any explanation online. I've never seen it before

This is ok... or more accurately, it is probably ok, though the message doesn't really tell us for sure. If a service is not referenced by ANYONE else, it is removed (and that WOULD be a hint that something is wrong, though I don't think this is what's happening). If a service is only referenced by ONE other service, then it is "inlined", which is a super not important thing to know, honestly :). It is an internal optimization to how the container is dumped in the cache. It is highly likely that this service is being referenced by just one other service (the normalizer) and thus is being "inlined". That is totally fine.

My instinct is that the service is OK, but some "type" issue is causing the uuid to not be denormalized, though I don't see any issues with your code. Fortunately, you sent me that very nice stack trace, which I think we can use to debug! Specifically, the AbstractItemNormalizer seems to be responsible for creating the constructor arguments - one of the errors in your stacktrace comes from this line - https://github.com/api-platform/core/blob/52a72743028d78255c14870b80eeb195e91740d8/src/Serializer/AbstractItemNormalizer.php#L286 (it's line 283 in your stack trace, due to a slightly different version).

I'd recommend adding some debug code ABOVE this to figure out what's going wrong. Just by reading the code (and it's complex, so I could be wrong), my guess is that, for uuid, the following happens:

A) https://github.com/api-platform/core/blob/52a72743028d78255c14870b80eeb195e91740d8/src/Serializer/AbstractItemNormalizer.php#L272 is called
B) That calls createAttributeValue() - https://github.com/api-platform/core/blob/52a72743028d78255c14870b80eeb195e91740d8/src/Serializer/AbstractItemNormalizer.php#L315
C) Something goes wrong in createAttributeValue(): https://github.com/api-platform/core/blob/52a72743028d78255c14870b80eeb195e91740d8/src/Serializer/AbstractItemNormalizer.php#L673

In createAttributeValue(), we would hope that the UUID value would be sent through the denormalizer system - e.g. this line https://github.com/api-platform/core/blob/52a72743028d78255c14870b80eeb195e91740d8/src/Serializer/AbstractItemNormalizer.php#L743 - but I'm not sure that's happening. But if it IS happening, then it's possible that your uuid IS being denormalized... but then some OTHER denormalizer with a higher priority is returning true from its supports method... which is why the one for the UUID never has a chance to be called. You can get a list of all of the denormalizers by running:

bin/console debug:container --tag=serializer.normalizer

Let me know what you find out!

Cheers!

Reply
Nathanael Avatar

Hello again,

Unfortunately, for all my trying, I've still had no luck. I did discover a mistake in something I said previously—the denormalizer is being called, and an environment issue was preventing me from seeing the result of my kill script—but what the denormalizer is receiving is an array (i.e., the data passed to the endpoint, as strings), not a single value. As a result, the uuid property is never being convered to a UUID object, and when it's finally passed to the constructor (see: the pieces of code you linked), it's still a string. That mistake of mine is probably a big hint, but it's not one I was able to make any headway with.

I took a look at the normalizer list by running the command you recommended and nothing really stood out to me. I noticed that api_platform.serializer.uuid_denormalizer has no priority, but the service is explicitly declared without one in /vendor/api-platform/core/src/Symfony/Bundle/Resources/config/ramsey_uuid.xml so I'm guessing that's not relevant.

Reply

Hey Nathanael!

Well, let me see if I can give a few more hints to help :). I'm playing with the "final" version of the code from this tutorial. So, it is possible that something has changed in newer versions. By comparing my results to your's, perhaps you can find that difference.

As I mentioned earlier, the class that's responsible for getting the constructor arguments to User is AbstractItemNormalizer. For testing, my constructor looks like this:

public function __construct($uuid = null)

And I'm using the testCreateUserWithUuid(), which looks like this (notice I'm still passing uuid :

public function testCreateUserWithUuid()
{
    $client = self::createClient();

    $uuid = Uuid::uuid4();
    $client->request('POST', '/api/users', [
        'json' => [
            'uuid' => $uuid,
            'email' => 'cheeseplease@example.com',
            'username' => 'cheeseplease',
            'password' => 'brie'
        ],
    ]);
    $this->assertResponseStatusCodeSame(201);
    $this->assertJsonContains([
        '@id' => '/api/users/'.$uuid
    ]);
}

Both of these represent tiny differences from the end of the tutorial (this is basically how the code look 5 minutes before the end of the tutorial).

Anyways, AbstractItemNormalizer::instantiateObject() is where we're looking. So let's look at some debugging facts:

A) If I dd($constructorParameters) here - https://github.com/api-platform/core/blob/022fb6a05701fa5a08f6357c82c1b95fbf0fe4b6/src/Serializer/AbstractItemNormalizer.php#L401 - I get:

array:1 [
  0 => ReflectionParameter {#2262
    +name: "uuid"
    position: 0
    default: null
  }
]

No surprise there.

B) If I dd($data) on that same line, again, no surprises:

array:4 [
  "uuid" => "28e67303-49b8-497b-9dd9-dbc51e01ffa1"
  "email" => "cheeseplease@example.com"
  "username" => "cheeseplease"
  "password" => "brie"
]

C) For the one argument - uuid - I get into this if statement: https://github.com/api-platform/core/blob/022fb6a05701fa5a08f6357c82c1b95fbf0fe4b6/src/Serializer/AbstractItemNormalizer.php#L419 - and if I dd($data[$key]), I get (again, no surprise), some string like 76e8daa1-d01b-48b7-bfaf-c7e07664370b

D) So, we follow this into the createConstructorArgument() method. Btw, it doesn't seem to matter, but just an FYI. When I dd($this), the actual instance is ApiPlatform\Core\Serializer\ItemNormalizer. Just keep that in mind, in case you follow some method and, unlike my code, it's overridden in a sub-class. Anyways, we follow createConstructorArgument() ... which leads us to createAttributeValue(): https://github.com/api-platform/core/blob/022fb6a05701fa5a08f6357c82c1b95fbf0fe4b6/src/Serializer/AbstractItemNormalizer.php#L465

E) We're now in AbstractItemNormalizer::createAttributeValue(). So let's dd($propertyMetadata) right at the start - right after this - https://github.com/api-platform/core/blob/022fb6a05701fa5a08f6357c82c1b95fbf0fe4b6/src/Serializer/AbstractItemNormalizer.php#L939 - when I do that, I see:

ApiPlatform\Core\Metadata\Property\PropertyMetadata {#2244
  -type: Symfony\Component\PropertyInfo\Type {#2241
    -builtinType: "object"
    -nullable: false
    -class: "Ramsey\Uuid\UuidInterface"
    -collection: false
    -collectionKeyType: null
    -collectionValueType: null
  }
  -description: null
  -readable: true
  -writable: false
  -readableLink: null
  -writableLink: null
  -required: false
  -iri: null
  -identifier: true
  -childInherited: null
  -attributes: null
  -subresource: null
  -initializable: true
}

This is the first spot where, possibly, you might see something different that matters. The most important thing is the type.

F) Following the logic, my code eventually ends up in this if statement: https://github.com/api-platform/core/blob/022fb6a05701fa5a08f6357c82c1b95fbf0fe4b6/src/Serializer/AbstractItemNormalizer.php#L1000 - which means, not surprisingly, that the string uuid goes through the denormalizer system.

G) The question is, WHICH denormalizer handles the uuid? The answer is, in my project, ApiPlatform\Core\Bridge\RamseyUuid\Serializer\UuidDenormalizer. This is the same class I mentioned earlier, except in 2.7 it has a new namespace - https://github.com/api-platform/core/blob/2.7/src/RamseyUuid/Serializer/UuidDenormalizer.php - if I var_dump($data) (for some reason, in this situation, dump() got swallowed and showed nothing) on the first line of supports() and run the test, it is called TWO times:

First time:

array(4) {
  'uuid' =>
  string(36) "430d9d15-5f92-467b-a80c-94cf6c9ad6ef"
  'email' =>
  string(24) "cheeseplease@example.com"
  'username' =>
  string(12) "cheeseplease"
  'password' =>
  string(4) "brie"
}

Second time:

string(36) "430d9d15-5f92-467b-a80c-94cf6c9ad6ef"

The 2nd time is when the UUID is actually being denormalized. The first time is when the entire object is being denormalized, and then this returns false.

Soooooo, that's the FULL story of how my string UUID becomes a Uuid object. I hope this helps you see why and where your situation is different.

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial also works great with API Platform 2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
        "doctrine/orm": "^2.4.5", // 2.8.2
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.0", // 5.2.2
        "ramsey/uuid-doctrine": "^1.6", // 1.6.0
        "symfony/asset": "5.1.*", // v5.1.5
        "symfony/console": "5.1.*", // v5.1.5
        "symfony/debug-bundle": "5.1.*", // v5.1.5
        "symfony/dotenv": "5.1.*", // v5.1.5
        "symfony/expression-language": "5.1.*", // v5.1.5
        "symfony/flex": "^1.1", // v1.18.7
        "symfony/framework-bundle": "5.1.*", // v5.1.5
        "symfony/http-client": "5.1.*", // v5.1.5
        "symfony/monolog-bundle": "^3.4", // v3.5.0
        "symfony/security-bundle": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/validator": "5.1.*", // v5.1.5
        "symfony/webpack-encore-bundle": "^1.6", // v1.8.0
        "symfony/yaml": "5.1.*" // v5.1.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.3.2
        "symfony/browser-kit": "5.1.*", // v5.1.5
        "symfony/css-selector": "5.1.*", // v5.1.5
        "symfony/maker-bundle": "^1.11", // v1.23.0
        "symfony/phpunit-bridge": "5.1.*", // v5.1.5
        "symfony/stopwatch": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/web-profiler-bundle": "5.1.*", // v5.1.5
        "zenstruck/foundry": "^1.1" // v1.8.0
    }
}
userVoice