API Platform 3 Part 3: Custom Resources
Become an API power user with our new tutorial on Symfony. Learn state providers, custom fields, data transformation, and more.
About this course
Thanks to part 1 & part 2, we've already built a seriously powerful API, complete with security, custom fields and many more goodies. In this course, we'll take things even further:
- State Providers & "proper" custom fields
- Run custom code on a "state" change (e.g. publishing)
- Custom (non-entity) DTO #[ApiResource] classes
- The new
stateOptions
shortcut for DTO's - DTO's & state providers (
make:state-provider
) - State processors with DTO's
- Data transformation with
symfonycasts/micro-mapper
IGNORED_ATTRIBUTES
,security
& other tricks to avoid serialization groups- Embedded objects (including non-ApiResource objects)
- Pagination for DTO resources
Woh. If you thought you were dangerous before with API Platform, just wait...
Next courses in the APIs: API Platform 3 section of the APIs Track!
25 Comments
Thank you for your suggestion @Evgeny we love to know what other topics you'd like to learn, and we consider them when choosing our next tutorial.
Cheers!
Counted! Thanks for your interest in SymfonyCasts tutorials
Cheers!
HI, could you add an extra chapter about filters? I don't see how they work with custom resources. Is there an easy way to make it work?
Hey @Peter-P!
I'm hoping to do an episode 4 at some point, which will include filters. No timeline however! Until then, we do talk about custom filters in the API Platform v2 version of this tutorial - https://symfonycasts.com/screencast/api-platform2-extending - as far as I know, not so much has changed in this area.
I don't see how they work with custom resources. Is there an easy way to make it work?
But to answer this, yes, when you have a custom resource, you probably need to create custom filters. If you're using our stateOptions + entityClass trick, then you can reuse the normal "entity" filters, which we showed.
If you have any questions along the way, I'd be happy to do my best to answer them.
Cheers!
Yes!!
This is the course I've been waiting.
Looking forward to it! :)
This course leads to an incomplete setup when using a custom resource with a different input and output format (either distinct DTO classes or when using serialization groups). The 'output' property and the 'normalizationContext' on both the resource and operation are ignored, so POST requests will return the input DTO.
I solved this by mapping to the output DTO and returning this object at the end of the process method in the state processor.
...
class DtoToEntityStateProcessor implements ProcessorInterface
{
...
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
...
return $this->microMapper->map($entity, OutputDto::class, [
MicroMapperInterface::MAX_DEPTH => 1
]);
}
I am posting this here in case anyone runs into the same issue, as this took me multiple days to track down.
Otherwise, I really appreciated this course. I think having a distinct input and output format are a common use case, so updating the course to include this gotcha could be very helpful for folks.
Hey @SJ
Thank you for sharing your solution. We'll check on it. Cheers!
This was a really nice course!
There is just one Issue i'm facing with the DTO and related Objects.
Lets take the DragonTreasureApi
file. In that DTO we have the property: $owner
which is of type: UserApi
so far so good.
Calling the Endpoint (Get / GetCollection) to fetch the DragonTreasures
we get a result like this:
{
"@context": "/api/contexts/Treasure",
"@id": "/api/treasures/1",
"@type": "Treasure",
"name": "collection of novelty mugs with silly sayings",
"description": "xxxx",
"value": 847274,
"coolFactor": 8,
"owner": "/api/users/3",
"shortDescription": "Laboriosam officia dolorem aut rerum....",
"plunderedAtAgo": "1 day ago",
"isMine": false
}
As we can see, the property owner
is currently the IRI, still, so far so good.
Now the Issue. I want to have the property populated with the UserApi Object, instead of only the IRI.
Therefore i've changed the MicroMapperInterface::MAX_DEPTH => 0
to 1
(tried with multiple values, like 10, null etc.) in the DragonTreasureEntityToApiMapper.php
File.
In my opinion, the property owner
should now be populated with the UserApi Object instead of the IRI.
Dumping the $dto inside the DragonTreasureEntityToApiMapper.php
file also shows, that the DTO contains the populated UserApi Object
What am i doing wrong here?
Hey @Kai-G!
Sorry for the slow reply! Here's what's going on: when a field is an #[ApiResource]
object, the field will be represented either as an IRI or an embedded object. Which is chosen by the serializer 100% comes down to serialization groups and nothing to do with how your object looks in the end. To do what you want, you'd need to:
A) Serializer DragonTreasureApi
in some group - like treasure:read
.
B) Add the group treasure:read
to at least one property in UserApi
(these will become the fields in the embedded object).
The annoying thing is that one of the main benefits of using DTOs is to not need to use groups. But now you suddenly need them again just for this one embedded object. That's why I show a different approach here: https://symfonycasts.com/screencast/api-platform-extending/embedded-object
Let me know if this clarifies!
Cheers!
Thank you for your Answer!
Yes that helped me to understand the Problem.
Just playing along with your Example, i tried the same stuff with GraphQL.
Only thing i did, was adding the dependency "webonyx/graphql-php" ( composer require webonyx/graphql-php )
When i do the following GraphQL Query:
{
users {
edges {
node {
email
username
flameThrowingDistance
dragonTreasures {
edges {
node {
name
}
}
}
}
}
}
}
I get the following error:
"extensions": {
"debugMessage": "The class \"App\\Entity\\DragonTreasure\" cannot be retrieved from \"App\\ApiResource\\UserApi\".",
"file": "/var/www/html/vendor/api-platform/core/src/Doctrine/Common/State/LinksHandlerTrait.php",
"line": 85,
"trace": [
{
"file": "/var/www/html/vendor/api-platform/core/src/Doctrine/Orm/State/LinksHandlerTrait.php",
"line": 37,
"call": "ApiPlatform\\Doctrine\\Orm\\State\\CollectionProvider::getLinks('App\\Entity\\DragonTreasure', instance of ApiPlatform\\Metadata\\GraphQl\\QueryCollection, array(8))"
},
{
"file": "/var/www/html/vendor/api-platform/core/src/Doctrine/Orm/State/CollectionProvider.php",
"line": 62,
"call": "ApiPlatform\\Doctrine\\Orm\\State\\CollectionProvider::handleLinks(instance of Doctrine\\ORM\\QueryBuilder, array(1), instance of ApiPlatform\\Doctrine\\Orm\\Util\\QueryNameGenerator, array(8), 'App\\Entity\\DragonTreasure', instance of ApiPlatform\\Metadata\\GraphQl\\QueryCollection)"
},
Is this a bug in the core? I don't really understand, why it tries to access the DragonTreasure Entity instead of the DragonTreasureApi.
I know this is a bit off topic, but maybe its an easy fix :)
Thanks for your help anyway.
Cheers !
Hey @Kai-G!
Hmm. I don't know anything about the GraphQL implementation in API Platform, so unfortunately, I can't suggest any possible fixes.
Sorry I can't be more helpful!
Hello,
Thanks for the Course.
I think there might be a bug in the chapter 36 of this part 3.
In the src/Validator/TreasuresAllowedOwnerChangeValidator.php
file, there is a return
statement in the loop :
foreach ($value->dragonTreasures as $dragonTreasureApi) {
assert($dragonTreasureApi instanceof DragonTreasureApi);
$originalOwnerId = $dragonTreasureApi->owner?->id;
$newOwnerId = $value->id;
if (!$originalOwnerId || $originalOwnerId === $newOwnerId) {
return; // <-- HERE !
}
// the owner is being changed
$this->context->buildViolation($constraint->message)
->addViolation();
}
I think this will be problematic when configuring multiple IRIs. Would't that check only the first DragonTreasureApi instance ? then if the first is owned by the user, the others won't be checked ?
As it is far in the course, the problem might come in earlier chapters.
In a completely other subject, I saw you talked about releasing a new episode. It might be good to see how to handle file upload, I followed the official documentation and I had a really difficult time to set it up ;)
Hey @fGuix
That's a good observation. The validator will stop if the DragonTreasureApi
object has no owner (a new object has been created), or if the owner changed. But, as you said, there's a tiny issue with this code, it won't check all objects. What I can say is Ryan assumed that all the modified objects belong to the same owner. It can be seen as a bug or not but it can be easily fixed by changing the return
statement by continue
Cheers!
Hello @fGuix,
Nice catch, the case looks legit. The best thing to bypass this issue I think will be to change return;
to continue;
. We will discuss internally how to fix the course properly.
Cheers!
This is a really nice way to design APIs, I really love it! This look so much clearer than having tons of serialization groups everywhere, and having calculated field in dedicated classes is so nicer than having them in entities.
But whoa, if you're going to deal with several entities, you better have a nice AI companion to code with you or it'll be a real pain to write all you Api, EntityToApiMapper and *ApiToEntityMapper.
I'd probably gave up while creating these 3 files manually for each Entity, as it is so time consuming and off-putting.
Hey @Jeremy
Yea, I agree, it feels like a lot to do if you want to rely on DTOs. I hope ApiPlatform will add better support to DTOs in the future
Cheers!
Hi,
as response from POST and PATCH requests we get the serialized resource,
like when i do a GET request. i noticed that changes to the resource that not coming from my POST/PATCH requests are not updated and reflected in the response. an example is an automatically changed property like "updatedAt". instead there is the old value or nothing at all when the value was not set before.
In the follow up requests i see the changes.
Is there an elegant way to ensure that i get the actual state of the resource?
What i tried so far is:
a) in the Dto2Entity Mapper also setting the Dto properties. It works, but at this point i dont have all infos, for example updatedAt coming from an doctrine event.
b) in the processor where you set the id of the generated entity back to the dto. That works but there have to be some sort of callback/hook mechanism for a generic processor.
c) similar to b) not really tested, but is it a good idea to simply call the micromapper and overwrite the dto by mapping the entity back to it?
Thanks for your great tutorials.
Peter
Hey @Peter-Hoehne!
Yea, I think understand the issue and I've had this conversation in the comments on this tutorial a few times (I wish I could find those!).
The answer is (c). Others have suggested it, and I agree. It also removes the need to manually call $data->id = $entity->getId();
to set the entity id back onto the DTO. The micro mapper will take care of that.
So yea, go for C! There could be a small performance impact, but I bet it's small, and it will only happen on those write operations anway.
Cheers!
Hello, a great course :)
I just have one question. Maybe, I have missed it but we saw how to persist embedded objects with Groups but what about the same case with DTOs?
Is it possible to create nested objects via DTOs? For instance I am trying to persist a new User object with liked Profile object (1:1 in the particular case). I am using the microMapper but now I got an error "400 Nested documents for attribute \"profile\" are not allowed. Use IRIs instead.". If I use serialization Groups I do get to save both objects but it does not retrieve any fields, besides the LD+JSON ones @id, @type.
Everything in all the mappers seems OK.
Example Post Request body:
{
"email": "test-email@example.com",
"password": "123456",
"profile": {
"firstName": "FirstName",
"middleName": "MiddleName",
"lastName": "LastName"
}
}
I have found the culprit. I use the suggested EntityClassDtoStateProcessor but it does not populate the ID field for all the embedded objects, thus generateIriFromResource throws an exception. In the code bellow the commented line actually makes it work for the discussed case.
EntityClassDtoStateProcessor#process
...
$this->persistProcessor->process($entity, $operation, $uriVariables, $context);
$data->id = $entity->getId();
// $data->profile->id = $entity->getProfile()->getId();
return $data;
Hey @MMilev!
Ah yea, that makes sense. Some people, which I think makes sense, have been using the micromapper after calling $this->persistProcessor->process()
to map the $entity
back into the DTO. Because the $entity
(and in your case the Profile) now have ids, this maps the ids back to the DTO's.
Cheers!
Hello, thank you for your answer :) I will try that :)
Best regards :)
What about API Platform 3 Part 4: Using graphql to the fullest?