Provider: Transforming Entities to DTOs
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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeLet's keep track of the goal. When we first used stateOptions, it triggered the core Doctrine collection provider to be used. That's great... except that it returns User entities, meaning that those became the central objects for the UserApi endpoints. That causes a serious limitation when serializing: our UserApi properties need to match our User properties... otherwise the serializer explodes.
To fix that and give us full control, we've created our own state provider that calls the core collection provider. But instead of returning these User entity objects, we're going to return UserApi objects so that they become the central objects and serialize normally.
Mapping to the DTO
Create a $dtos array and foreach over $entities as $entity. Then add to the $dtos array by calling a new method: mapEntityToDto($entity).
| // ... lines 1 - 9 | |
| class EntityToDtoStateProvider implements ProviderInterface | |
| { | |
| // ... lines 12 - 18 | |
| public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null | |
| { | |
| $entities = $this->collectionProvider->provide($operation, $uriVariables, $context); | |
| dd(iterator_to_array($entities)); | |
| } | |
| } |
Hit "alt" + "enter" to add that method at the bottom. This will return an object. Well... it will be a UserApi object... but we're trying to keep this class generic. I'll paste in some logic - you can copy this from the code block on this page - then hit "alt" + "enter" to add the missing use statement. This code is user-specific... but we'll make it more generic later, so we can reuse this class for dragon treasures.
| // ... lines 1 - 7 | |
| use App\ApiResource\UserApi; | |
| // ... lines 9 - 10 | |
| class EntityToDtoStateProvider implements ProviderInterface | |
| { | |
| // ... lines 13 - 19 | |
| public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null | |
| { | |
| // ... lines 22 - 23 | |
| $dtos = []; | |
| foreach ($entities as $entity) { | |
| $dtos[] = $this->mapEntityToDto($entity); | |
| } | |
| // ... lines 28 - 29 | |
| } | |
| private function mapEntityToDto(object $entity): object | |
| { | |
| $dto = new UserApi(); | |
| $dto->id = $entity->getId(); | |
| $dto->email = $entity->getEmail(); | |
| $dto->username = $entity->getUsername(); | |
| $dto->dragonTreasures = $entity->getDragonTreasures()->toArray(); | |
| return $dto; | |
| } | |
| } |
But isn't this refreshingly boring and understandable code? Just transferring properties from the User $entity... onto the DTO. The only thing that's kind of fancy is where we change this collection to an array... because this property is an array on UserApi.
Finally, at the bottom of provide(), return $dtos.
| // ... lines 1 - 7 | |
| use App\ApiResource\UserApi; | |
| // ... lines 9 - 10 | |
| class EntityToDtoStateProvider implements ProviderInterface | |
| { | |
| // ... lines 13 - 19 | |
| public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null | |
| { | |
| // ... lines 22 - 23 | |
| $dtos = []; | |
| foreach ($entities as $entity) { | |
| $dtos[] = $this->mapEntityToDto($entity); | |
| } | |
| return $dtos; | |
| } | |
| private function mapEntityToDto(object $entity): object | |
| { | |
| $dto = new UserApi(); | |
| $dto->id = $entity->getId(); | |
| $dto->email = $entity->getEmail(); | |
| $dto->username = $entity->getUsername(); | |
| $dto->dragonTreasures = $entity->getDragonTreasures()->toArray(); | |
| return $dto; | |
| } | |
| } |
Thanks to this, the central objects will be UserApi objects... and these will be serialized normally: no fanciness where the serializer tries to go from a User entity into a UserApi.
Drumoll please! Tada! It works... with the same result as before! But now we have the power to add custom properties.
Adding Custom Properties
Add back the public int $flameThrowingDistance.
| // ... lines 1 - 21 | |
| class UserApi | |
| { | |
| // ... lines 24 - 34 | |
| public int $flameThrowingDistance = 0; | |
| } |
Then, in the provider, this is where we have an opportunity to set those custom properties, like $dto->flameThrowingDistance = rand(1, 10).
| // ... lines 1 - 10 | |
| class EntityToDtoStateProvider implements ProviderInterface | |
| { | |
| // ... lines 13 - 31 | |
| private function mapEntityToDto(object $entity): object | |
| { | |
| // ... lines 34 - 38 | |
| $dto->flameThrowingDistance = rand(1, 10); | |
| return $dto; | |
| } | |
| } |
And... voilà ! We are so freakin' dangerous right now! We're reusing the core Doctrine CollectionProvider, but with the ability to add custom fields. Oh! And I forgot to mention: the JSON-LD fields @id and @type are back. We did it!
Fixing Pagination
Though, it looks like we're now missing pagination. The filter is documented... but the hydra:view field that documents the pagination is gone! Ok, really, pagination does still work. Watch: if I go to ?page=2, the first "user 1" user... becomes "user 6". Yup, internally, the core CollectionProvider from Doctrine is still reading the current page and querying for the correct set of objects for that page. We're missing the hdra:view field at the bottom that describes the pagination simply because we're no longer returning an object that implements PaginationInterface.
Remember, this $entities variable is actually a Pagination object. Now that we're just returning an array, it makes API Platform think that we don't support pagination.
The solution is dead-simple. Instead of returning $dtos, return new TraversablePaginator() with a new \ArrayIterator() of $dtos. For the other arguments, we can grab those from the original paginator. To help, assert($entities instanceof Paginator) (the one from Doctrine ORM). Then, down here, use $entities->getCurrentPage(), $entities->getItemsPerPage(), and $entities->getTotalItems().
| // ... lines 1 - 4 | |
| use ApiPlatform\Doctrine\Orm\Paginator; | |
| // ... lines 6 - 7 | |
| use ApiPlatform\State\Pagination\TraversablePaginator; | |
| // ... lines 9 - 12 | |
| class EntityToDtoStateProvider implements ProviderInterface | |
| { | |
| // ... lines 15 - 21 | |
| public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null | |
| { | |
| $entities = $this->collectionProvider->provide($operation, $uriVariables, $context); | |
| assert($entities instanceof Paginator); | |
| // ... lines 26 - 31 | |
| return new TraversablePaginator( | |
| new \ArrayIterator($dtos), | |
| $entities->getCurrentPage(), | |
| $entities->getItemsPerPage(), | |
| $entities->getTotalItems() | |
| ); | |
| } | |
| // ... lines 39 - 50 | |
| } |
The core collection provider already did all that hard work for us. What a pal. Refresh now. The results don't change... but down here, hydra:view is back!
Next: Let's get this working for our item operations, like GET one or PATCH. We'll also leverage our new system to add something to UserApi that we previously had.... but this time, we're going to do it in a much cooler way.
14 Comments
I tried to write some code in which I have one standard provider, one standard processor and one standard mapper which all rely on ApiPlatform functionality. It works and I'm happy with it. There is just one annoying thing. To each DTO I add the ApiResource annotation to help ApiPlatform generate correct IRI's for each resource, eg:
#[ApiResource(shortName: 'users',operations: [],stateOptions: new Options(entityClass: UserEntity::class),)]The goal is twofold; it's used to help ApiPlatform to generate IRI's and in the provider I use this stateOption to validate the correct entity.
However it comes with some issues because it does actually create a path (console debug:router) which is ofcourse necessary to generate IRI's:
_api_/users/{uuid}{._format}_get GET ANY ANY /api/users/{uuid}.{_format}And in some cases this route is above the actual GET path (probably because of the order of code):
_api_/users/{uuid}{._format}_get GET ANY ANY /api/users/{uuid}.{_format}_api_/users/{uuid}_get GET ANY ANY /api/users/{uuid}I have a solution, which is putting files which contains the actual operation on a higher folder level (parent folder
/Dto/User.phpinstead of/Dto/User/User.phpwhere this responseDTO is in/Dto/User/Response/UserResponseDto.php) in my code. I could also add priority to the operation but that's not an solution because there is no way to de-prioritize the path in my DTO while there is no GET operation. I tried priority -1 to 8 but because the other side does not have a priority it does not know how to relate these two.Any creative ideas on this?
Hey Arthur,
Ah, I think I see the problem. You want your DTOs to carry just enough
#[ApiResource]metadata so that ApiPlatform knows how to generate proper IRIs, but you don't actually want them to expose their own operations or generate their own routes, right?Yeah,
#[ApiResource]always implies tp expose that as a resource, which by default means routes get registered.I personally didn't try it but IIRC since ApiPlatform 3.3 you can set
uriTemplate: nullin#[ApiResource()]PHP attr. Then it should exist purely for metadata and no routes should be generated. Probably worth to try.Also, another soft option might be to use
extraPropertiesinstead ofstateOptions. Then in your provider, you can read it with$operation->getExtraProperties()['entityClass'] ?? null. That way you don’t need to rely on theOptionsstate option at all.As possible more advanced option - try to override the
ResourceMetadataCollectionFactoryif you want full control. You can decorate that and inject thestateOptionsfor your DTOs programmatically without having to sprinkle#[ApiResource]everywhere. That way, you can tell API Platform: "Here’s the DTO, link it to this entity,", but don’t create routes for it.That’s a bit more work, but it should keep your DTOs clean and avoid conflicts I think. But haven't tried it myself either.
I hope this helps!
Cheers!
Thanks @Victor . Some nice creative ideas!!
You're welcome! I hope that helps, not sure what else you can do in this case.
Cheers!
I am doing everything according to the course, but after returning $dtos in EntityToDtoStateProvider I get an error:
Unable to generate an IRI for the item of type \"App\\ApiResource\\UserApi\"Before returning $dtos I used die and dump, a valid array with
App\ApiResource\UserApiobjects was returned. Could I ask for an explanation of why this is happening?Oh, I noticed that when using
/api/users.json, everything generates correctly, even more so I don't know what the problem is. :(Hey @Albert-K!
This error:
This almost always means that you have a
UserApiobject with anullid property... but not always :p. You mentioned going to/api/users.jsonworks. So is the problem only when you go to/api/users/5.jsonto fetch a single User? If so, in this case, are you returning an array ofUserApiobjects or only a singleUserApiobject (or null)?Cheers!
Oh, I didn't add this in the first message, the error:
occurs with .jsonld, I tried the following endpoints in .jsonld and .json format:
/api/users.jsonld/api/users/5.jsonld/api/users.json/api/users/5.jsonThose in
.jsonldformat generate an errorUnable to generate an IRI for the item of type \"App\\ApiResource\\UserApi\", while those in.jsonformat work fine.As far as I can see the error is caused by the
IriConverterin thegenerateSymfonyRoutemethod in this code:I set
in the DTO class over
$idand/api/users.jsonldworks fine, just without IRI for an individual user.I had the same issue, identifier fixed it, thanks!
After that I got the same error when querying items, updating the api package fixed it
Hey @Albert-K!
Very strange issue - but good debugging. Everything about this feels simply like the id is missing on the DTO - I’m not sure why that would be the case. But I’m happy you have a workaround.
Cheers!
"Houston: no signs of life"
Start the conversation!