Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

UUID as a API Identifier

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 have a $uuid property on User and it is being set:

... lines 1 - 43
class User implements UserInterface
{
... lines 46 - 52
/**
* @ORM\Column(type="uuid", unique=true)
*/
private $uuid;
... lines 57 - 296
}

But it's completely not part of our API yet.

Testing for the UUID

Before we change that, let's write a test that describes the behavior we expect. Open up tests/Functional/UserResourceTest.php and find testCreateUser():

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
public function testCreateUser()
{
$client = self::createClient();
$client->request('POST', '/api/users', [
'json' => [
'email' => 'cheeseplease@example.com',
'username' => 'cheeseplease',
'password' => 'brie'
]
]);
$this->assertResponseStatusCodeSame(201);
$this->logIn($client, 'cheeseplease@example.com', 'brie');
}
... lines 26 - 82
}

After creating a User, our API serializes that User and returns it in the response. Once we've changed to use the UUID, we'll expect the @id property on that response to be /api/user/{uuid} instead of the auto-increment ID.

Let's check for that! Start by querying for the User object that was just created. We can do that with $user = UserFactory::repository()->findOneBy() and pass it the email that we used up here. Below that, do a sanity check that the user does exist: $this->assertNotNull($user):

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
public function testCreateUser()
{
... lines 13 - 21
$this->assertResponseStatusCodeSame(201);
$user = UserFactory::repository()->findOneBy(['email' => 'cheeseplease@example.com']);
$this->assertNotNull($user);
... lines 26 - 27
}
... lines 29 - 85
}

In order to check that the @id is correct, we need to know what random UUID the user was just assigned. Now that we have the User object from the database, we can say $this->assertJsonContains(), pass an array and assert that @id should be /api/users/ and then $user->getUuid():

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
public function testCreateUser()
{
... lines 13 - 23
$user = UserFactory::repository()->findOneBy(['email' => 'cheeseplease@example.com']);
$this->assertNotNull($user);
$this->assertJsonContains([
'@id' => '/api/users/'.$user->getUuid()->toString()
]);
... lines 29 - 30
}
... lines 32 - 88
}

Oh, except we don't have a getUuid() method yet!

The UUID Object and UuidInterface

No problem - let's add it! Over in User, down at the bottom, go to "Code"->"Generate" - or Command+N on a Mac - and generate the getter:

... lines 1 - 13
use Ramsey\Uuid\UuidInterface;
... lines 15 - 44
class User implements UserInterface
{
... lines 47 - 298
public function getUuid(): UuidInterface
{
return $this->uuid;
}
}

We don't need a setter.

Oh! Apparently this will return a UuidInterface.... though I'm not sure why it used the long version here. I'll shorten that, re-type the end and auto-complete it so that PhpStorm adds the use statement on top:

... lines 1 - 13
use Ramsey\Uuid\UuidInterface;
... lines 15 - 304

The uuid property will store in the database as a string in MySQL. But in PHP, this property holds a Uuid object. That's not too important... just be aware of it. Over in the test, to get the string, we can say ->toString():

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
public function testCreateUser()
{
... lines 13 - 25
$this->assertJsonContains([
'@id' => '/api/users/'.$user->getUuid()->toString()
]);
... lines 29 - 30
}
... lines 32 - 88
}

Though, really, that's not needed because the Uuid object has an __toString() method.

Let's try this! Copy the method name, find your terminal and run:

symfony php bin/phpunit --filter=testCreateUser

And... excellent. We're looking for the UUID, but it still uses the id.

Using the UUID as the API Identifier

So how do we tell API Platform to use the uuid property as the "identifier"? It's actually pretty simple! And we talked about it before.

Go to the top of User. Every API Resource needs an identifier. And when you use Doctrine, API Platform assumes that you want the database id as the identifier. To tell it to use something different, add @ApiProperty() with identifier=false:

... lines 1 - 44
class User implements UserInterface
{
/**
... lines 48 - 50
* @ApiProperty(identifier=false)
*/
private $id;
... lines 54 - 304
}

That says:

Hey! Please don't use this as the identifier.

Then, above uuid, add @ApiProperty() with identifier=true:

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

That's it! Try the test again:

symfony php bin/phpunit --filter=testCreateUser

And... got it! But if we run all of the tests:

symfony php bin/phpunit

Fixing all the Places we Relied in the Id

Ah... things are not so good. It turns out that we were relying on the id as the identifier in a lot of places. Let's see an example. In testCreateCheeseListing(), we send the owner field set to /api/users/1. But... that is not the IRI of that user anymore! The IRI of every user just changed!

Let's fix some tests! Start inside UserResourceTest and search for /api/users/. Yep! To update a user, we won't use the id anymore, we'll use the uuid. In testGetUser(), it's the same to fetch a user. Change one more spot at the bottom:

... lines 1 - 8
class UserResourceTest extends CustomApiTestCase
{
... lines 11 - 32
public function testUpdateUser()
{
... lines 35 - 38
$client->request('PUT', '/api/users/'.$user->getUuid(), [
... lines 40 - 43
]);
... lines 45 - 51
}
public function testGetUser()
{
... lines 56 - 63
$client->request('GET', '/api/users/'.$user->getUuid());
... lines 65 - 82
$client->request('GET', '/api/users/'.$user->getUuid());
... lines 84 - 87
}
}

Over in CheeseListingResourceTest, search for the same thing: /api/users/. Then we'll change a few more spots. Like, when we're setting the owner property, this needs to use the UUID. I'll keep searching and fix a few more spots:

... lines 1 - 10
class CheeseListingResourceTest extends CustomApiTestCase
{
public function testCreateCheeseListing()
{
... lines 15 - 35
$client->request('POST', '/api/cheeses', [
'json' => $cheesyData + ['owner' => '/api/users/'.$otherUser->getUuid()],
]);
... lines 39 - 40
$client->request('POST', '/api/cheeses', [
'json' => $cheesyData + ['owner' => '/api/users/'.$authenticatedUser->getUuid()],
]);
... line 44
}
public function testUpdateCheeseListing()
{
... lines 49 - 57
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
... line 59
'json' => ['title' => 'updated', 'owner' => '/api/users/'.$user2->getUuid()]
]);
... lines 62 - 68
}
... lines 70 - 146
public function testGetCheeseListingCollection()
{
... lines 149 - 167
$this->assertJsonContains(['hydra:member' => [
0 => [
... lines 170 - 173
'owner' => '/api/users/' . $user->getUuid(),
... lines 175 - 176
]
]]);
}
public function testGetCheeseListingItem()
{
... lines 183 - 192
$response = $client->request('GET', '/api/users/'.$otherUser->getUuid());
... lines 194 - 195
}
}

Let's see if we found everything! Run the tests now:

symfony php bin/phpunit

And... green! We just switched to UUID's! Woo!

But... part of the point of changing to a UUID was that it would be nice to allow our API clients - like JavaScript - to set the UUID themselves. Let's make that possible next.

Leave a comment!

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

Hi all! I have created an API using Symfony 6 and API Platform 3. For the first entities, everything went fine but I have an error now when I am doing a GET collection request for User entity (api/users) :

Unable to generate an IRI for the item of type \"App\Entity\User\"

So why this happens and how to fix it ?

Here is an excerpt from my User entity :

#[ApiResource(
    operations: [
        new Delete(),
        new Get(),
        new Put(),
        new Patch(),
        new GetCollection(),
        new Post()
    ],
    normalizationContext: ['groups' => ['read:User']]
)]
#[ApiFilter(OrderFilter::class)]
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User
{
    #[ORM\Id]
    #[ORM\Column(unique: true, nullable: false, type: 'string')]
    #[Groups(groups: ['read:User'])]
    #[ApiProperty(identifier: true)]
    private $username;
        
    #[ORM\OneToOne(mappedBy: 'user', cascade: ['all'])]
    #[Groups(groups: ['read:User'])]
    private ?Employee $employee = null;

    #[ORM\Column(name: 'first_name', length: 255, nullable: true)]
    #[Groups(groups: ['read:User'])]
    private ?string $firstName = null;

    #[ORM\Column(name: 'last_name', length: 255, nullable: true)]
    #[Groups(groups: ['read:User'])]
    private ?string $lastName = null;

    #[ORM\OneToMany(mappedBy: 'username', targetEntity: UserRole::class, cascade: ['all'], orphanRemoval: true)]
    #[Groups(groups: ['read:User'])]
    private Collection $userRoles;

    #[ORM\Column(length: 2000, nullable: true)]
    private ?string $password = null;

    public function __construct()
    {
        $this->userRoles = new ArrayCollection();
    }

    public function getUsername(): string
    {
        return $this->username;
    }

    public function setUsername(string $username): self
    {
        $this->username = $username;

        return $this;
    }

Thanks in advance!

Reply

Hey Marko!

Weird! The error comes from this class - https://github.com/api-platform/core/blob/main/src/Symfony/Routing/IriConverter.php - unfortunately that exact error wording is used 3 times in that file, so it's not clear exactly which "bad path" you're hitting. But it's likely you're hitting one of these 2 errors - https://github.com/api-platform/core/blob/9b4b58ca0113a2645b58a116fe9e4bf200df8aa3/src/Symfony/Routing/IriConverter.php#L171-L191

In both cases, there is a DIFFERENT error that occurs, and then it throws the error you're seeing. However, Symfony DOES display the original error as well. If you open the profiler for this failed request and go to the "Exception" tab, you should see something near the top that says something like "Exceptions 1/2". If you scroll down, eventually you'll see the OTHER exception. It may give you a better clue about what is going wrong. For example, if you left username blank, the system would blow up because it would call ->getUsername() during this process, but the username property would be uninitialized. That's just one possible problem :)

Cheers!

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

Hi @weaverryan. Thanks a lot for your answer. I managed to fixed this bug with the following code:

    operations: [
        new Delete(uriTemplate: 'users/{username}'),
        new Get(uriTemplate: 'users/{username}'),
        new Put(uriTemplate: 'users/{username}'),
        new Patch(uriTemplate: 'users/{username}'),
        new GetCollection(uriTemplate: 'users'),
        new Post(uriTemplate: 'users')
    ]
Reply

Niiice! Thanks for sharing that :)

Reply
Paul-L Avatar

Thank you for your excellent videos!

I'm trying to set up a table ("users") with a composite key - a (Ramsey) uuid_binary and an integer - and then use that as a foreign key in another table ("companies"). I think I have it mostly working, but I have a few issues / questions, please.

1) Is there a meaningful way for me to define the item "get" end point - something like /users/{userUuid}/something_else/{anotherId} ?
I don't think it's strictly a sub-resource, but possibly composite keys don't really fit for this kind of end point in REST? I can't work out how (if possible) to get the API website to display an entry box for "anotherId" in addition to the "userUuid" one.

2) I've got the "companies" table get request displaying the "userUuid" string and "anotherId" integer value on its jsonld output. I guess, partially based on question 1), that there's no reasonable way to output an IRI instead - since it would be combining 2 fields into 1, potentially?

3) Related to 2), I guess there's also no reasonable way I could return a "users" record, rather than just the key values, since that would, again, be associated with 2 fields on "companies"?

PHP 8.1, Symfony 6.1, API Platform 2.8, doctrine-bundle 2.7, MySQL 8.0.26
I'm using uuid_binary in this case, because it's a Type 4 UUID. I'm aware of the uuid_binary_ordered_time version for Type 1 UUID - some of which I will also be using in my project.

(I have an example I could include, but it's pretty big, even after I minimised most of functionality... I'm not sure it's beneficial?)

Reply
Paul-L Avatar
Paul-L Avatar Paul-L | Paul-L | posted 1 year ago | edited

Oh, I guess the attributions are useful, in case I'm doing something obviously foolish with those. When I "get" /companies/1 I have an IRI for "address", but values for "insertedBy" and "another".

Entity/Users.php


#[ORM\Entity(repositoryClass: UsersRepository::class)]
#[ApiResource(
	normalizationContext: ["groups" => ["users:read", "companies:read"]],
)]
class Users
{
	#[ORM\Id]
	#[ORM\Column(type: 'uuid_binary')]
	#[ApiProperty(identifier: true)]
	#[Groups(['users:read','companies:read'])]
	private $userUuid;

	#[ORM\Id]
	#[ORM\Column(type: 'integer')]
	#[ApiProperty(identifier: false)]
	#[Groups(['users:read'])]
	private $anotherId;

	#[ORM\Column(type: 'string', length: 128, nullable: true)]
	#[Groups(['users:read'])]
	private $jobTitle;

	#[ORM\Column(type: 'integer')]
	#[Groups(['users:read'])]
	private $userPermissionLevel;

Entity/Companies.php


#[ORM\Entity(repositoryClass: CompaniesRepository::class)]
#[ApiResource(normalizationContext: ['groups' => ['companies:read']])]

class Companies
{
	#[ORM\Id]
	#[ORM\GeneratedValue]
	#[ORM\Column(type: 'integer')]
	#[Groups(['companies:read'])]
	private $id;

	#[ORM\ManyToOne(targetEntity: Users::class)]
	#[ORM\JoinColumn(nullable: false, referencedColumnName: "user_uuid", name: "inserted_by")]
	#[ORM\Column(type: 'uuid_binary')]
	#[Groups(['companies:read'])]
	private $insertedBy;

	#[ORM\ManyToOne(targetEntity: Users::class)]
	#[ORM\JoinColumn(nullable: false, referencedColumnName: "another_id", name: "another_id")]
	#[ORM\Column(type: 'integer', name: 'another_id')]
	#[Groups(['companies:read'])]
	private $another;

	#[ORM\OneToOne(targetEntity: Addresses::class)]
	#[Groups(['companies:read'])]
	private $address;

	#[ORM\Column(type: 'datetime_immutable', options: ["default" => "CURRENT_TIMESTAMP"])]
	#[Groups(['companies:read'])]
	private $insertedAt;

Entity/Addresses.php


#[ORM\Entity(repositoryClass: AddressesRepository::class)]
#[ApiResource]
class Addresses
{
	#[ORM\Id]
	#[ORM\GeneratedValue]
	#[ORM\Column(type: 'integer')]
	private $id;

	#[ORM\Column(type: 'string', length: 255)]
	private $companyAddress;
1 Reply
Paul-L Avatar
Paul-L Avatar Paul-L | Paul-L | posted 1 year ago | edited

Debugging into the code, I've got a bit further, I think. If I do this, I think it mostly solves question 1 - I get valid IRIs with two parameters for "users". I can curl for the entries in jsonld format, but a web browser gives me the full HMTL interface when I use the /users/uuid/{userUuid}/another/{anotherId} end point with a .jsonld or .json extension.

EDIT: If I remove the 'path' => ... line, the default endpoint, /users/{userUuid}/{anotherId}.jsonld DOES work correctly in a web browser. Weird.

Entity/Users.php


#[ORM\Entity(repositoryClass: UsersRepository::class)]
#[ApiResource(
	itemOperations: [
    'put','patch','delete',
    'get' => [
      'path' => '/users/uuid/{userUuid}/another/{anotherId}',
      'openapi_context' => [
        'parameters' => [
          ['name' => 'userUuid', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']],
          ['name' => 'anotherId', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]
        ]
      ],
    ]
  ],
  normalizationContext: ["groups" => ["users:read", "companies:read"]],
  compositeIdentifier: false
)]
class Users
{
	#[ORM\Id]
	#[ORM\Column(type: 'uuid_binary')]
	#[Groups(['users:read','companies:read'])]
	private $userUuid;

	#[ORM\Id]
	#[ORM\Column(type: 'integer')]
	#[Groups(['users:read'])]
	private $anotherId;

	#[ORM\Column(type: 'string', length: 128, nullable: true)]
	#[Groups(['users:read'])]
	private $jobTitle;

	#[ORM\Column(type: 'integer')]
	#[Groups(['users:read'])]
	private $userPermissionLevel;

Reply

Hey Paul-L!

WOOOH. Ok, so you're doing some seriously complex stuff - and it looks like you've made some good headway since your original question (the team was waiting for me to read and reply to your comment).

In general, back to (1), I've actually never created a URL like this - I typically tell people that life is much easier if you let the URL be a filter - e.g. /users/{userUuuid}?another={anotherId}. However, if you've got it working - nice job!

After all of your progress, are you still struggling with any part of this?

Cheers!

1 Reply
Paul-L Avatar
Paul-L Avatar Paul-L | weaverryan | posted 1 year ago | edited

Thanks weaverryan.

I think I understand 1) sufficiently now - at least enough to make it work for me - though fighting with this has also helped me find a way to refactor out the composite key in this case anyway.

And I'm pretty sure that the answers to 2) and 3) are that there isn't really a meaningful way to replace two lines on the results from GET /companies/1 (userUuid and anotherId) with a single IRI or a record from the user table - where would it live in the hierarchy?

I suspect the problem I encountered with paths using slugs might be an API platform bug (or user misuse). It doesn't work as I expect with non-composite keys either. If I add 'path'=>'/companies/id/{id}' to the itemOperations "get" entry on Companies, http://127.0.0.1:8000/companies/id/1.jsonld in my web browser takes me to the API Platform OpenAPI web interface but this curl command returns the ld+json entry I'd expect:


curl -X 'GET' 'http://127.0.0.1:8000/companies/id/1' -H 'accept: application/ld+json'
Reply

Hey Paul-L!

Sorry for the slow reply - just back from some vacation :).

If I add 'path'=>'/companies/id/{id}' to the itemOperations "get" entry on Companies, http://127.0.0.1:8000/companies/id/1.jsonld in my web browser takes me to the API Platform OpenAPI web interface but this curl command returns the ld+json entry I'd expect:

The ability to add the .jsonld extension and see that in your browser is kind of a "bonus" feature just to make life easier when debugging (because using a browser is nice). This is "powered" by the fact that the routes built by API Platform look like this: /api/cheeses/{id}.{_format}. In your case, for your custom "path", I bet if you added .{_format} to the end, you would get the result you expect. So, it's not a misuse on your part - though you do need a little bit more if you want that nice browser behavior.

Cheers!

Reply

Hey guys! Loved this tutorial.

But I sill miss some hardcore examples. I have a legacy application and I had to create an API to talk to it. So no Doctrine at all. Not even for user authentication!

Then I really got in trouble when I have to deal with sub resources. Mainly many to many relationships. For instance: A user can be in multiple locations. So there's an endpoint to user, another for locations and I had to create a third one to add the user to a location.

I hacked my way with custom `collectionOperations` + controllers and `OpenApiFactory` decorator to fix the documentation. But I would love some best practices on that.

Thanks!

Reply
Cameron Avatar
Cameron Avatar Cameron | posted 1 year ago

for reference: There's a stackoverflow question about this:
https://stackoverflow.com/a...

cool tutorial!

Reply

Hey Fox C.

Good work on StackOverflow! And thanks for mentioning us!

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