If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeThe 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!
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.
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.
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.
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!
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.
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!
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.
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!
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.
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!
// 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
}
}
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: