Resolving IRIs to Doctrine Entities with the Object Mapper
In api-platform/core#8587 a user wanted to POST an Activity with an IRI for its user and ended up parsing the IRI with a regex. Their $user was a string, so API Platform had no relation to resolve, and typing it with the entity doesn’t work either because an IRI resolves to the resource the API exposes, not to the entity.
Let’s build the POST on /activities the way I’d do it. We want to send this:
POST /api/activities
Content-Type: application/ld+json
{"name": "Morning ride", "user": "/api/users/0199a1c2-7d3e-7f00-9b1a-3c5d2e8f4a10"}
The User resource maps from the entity for reads, and to the entity through a transform for writes:
#[Get(uriTemplate: 'users/{uuid}', shortName: 'user', stateOptions: new Options(entityClass: UserEntity::class))]
// both directions: `target` turns the resource back into the managed entity on writes,
// `source` lets the entity become this resource when it's nested in an Activity on reads
#[Map(target: UserEntity::class, transform: UserResourceToEntity::class)]
#[Map(source: UserEntity::class)]
final class User
{
#[ApiProperty(identifier: true)]
public Uuid $uuid;
public string $email;
public string $firstName;
public string $lastName;
}
The transformer finds the entity the IRI points to:
final readonly class UserResourceToEntity implements TransformCallableInterface
{
public function __construct(private UserRepository $userRepository) {}
public function __invoke(mixed $value, object $source, ?object $target): UserEntity
{
return $this->userRepository->findOneBy(['uuid' => $value->uuid])
?? throw new MappingTransformException(sprintf('User "%s" not found.', $value->uuid));
}
}
The Activity resource references the User resource, not the entity:
#[Get(uriTemplate: 'activities/{uuid}', shortName: 'activity', stateOptions: new Options(entityClass: ActivityEntity::class))]
#[Post(uriTemplate: 'activities', shortName: 'activity', stateOptions: new Options(entityClass: ActivityEntity::class))]
#[Map(source: ActivityEntity::class, target: ActivityEntity::class)]
final class Activity
{
#[ApiProperty(writable: false, identifier: true)]
public ?Uuid $uuid = null;
public string $name;
#[ApiProperty(readableLink: true)]
public User $user;
}
There is no #[ApiResource] and no input:/output: DTO here. If you want a collection where the user is only an IRI, add another class with readableLink: false on $user.
On the write, the serializer sees a User property and resolves the IRI to the User resource, an unknown IRI gives a 400. Then the Object Mapper maps Activity to ActivityEntity. When it reaches $user, the User class has a transform on its #[Map], so the Object Mapper uses what the transform returns instead of mapping the properties, and ActivityEntity::$user is the managed UserEntity from the repository. We need the lookup because the IRI carries the uuid while the Doctrine identifier is an int, so a getReference() wouldn’t find it. Doctrine’s PersistProcessor skips relations the entity manager already knows, and the activity gets persisted without any custom processor.
The response embeds the user:
{
"@id": "/api/activities/0199a1c3-02b4-7a11-8e6f-5b7c9d0e1f23",
"@type": "Activity",
"uuid": "0199a1c3-02b4-7a11-8e6f-5b7c9d0e1f23",
"name": "Morning ride",
"user": {
"@id": "/api/users/0199a1c2-7d3e-7f00-9b1a-3c5d2e8f4a10",
"@type": "User",
"uuid": "0199a1c2-7d3e-7f00-9b1a-3c5d2e8f4a10",
"email": "[email protected]",
"firstName": "Jane",
"lastName": "Doe"
}
}
Reading the user back from the entity needs Symfony 8.1. The full example is in arthurGrinjo/PostRelation#1.