Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Input DTO Class

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

If you liked the output DTO, we can do the same thing for handling the input data for a resource. Basically, we create a class that looks just like the input fields that are sent when creating or updating a CheeseListing and then API Platform will start deserializing the JSON to create that object. Then, our job - via a data transformer - will be to convert that input object into the final CheeseListing so that API Platform can save it.

Creating the CheeseListingInput

So... it's the exact same idea as the output, just... the other direction. Though, there will be a few interesting and tricky pieces.

Let's get started! In the src/Dto/ directory, create a new class called CheeseListingInput:

... lines 1 - 2
namespace App\Dto;
... lines 4 - 7
class CheeseListingInput
{
... lines 10 - 43
}

This time, let's move all of the fields that we can currently send to create or update a CheeseListing - like title and price, into here.

Start with public $title and put some PHPDoc on it. Then in CheeseListing, steal @Groups, delete it - we won't need any groups here anymore - and paste the @Groups on top of the new title property:

... lines 1 - 4
use Symfony\Component\Serializer\Annotation\Groups;
... lines 6 - 7
class CheeseListingInput
{
/**
* @Groups({"cheese:write", "user:write"})
*/
public $title;
... lines 14 - 43
}

Oh, but, I'll re-type the end of this and hit tab so that PhpStorm adds the use statement for me:

... lines 1 - 4
use Symfony\Component\Serializer\Annotation\Groups;
... lines 6 - 45

The other fields we need are public $price, owner and isPublished. Let's go steal their groups: find price, move its @Groups over, then for owner, do the same... and finally, grab the @Groups for isPublished:

... lines 1 - 7
class CheeseListingInput
{
... lines 10 - 14
/**
* @Groups({"cheese:write", "user:write"})
*/
public $price;
/**
* @Groups({"cheese:collection:post"})
*/
public $owner;
/**
* @Groups({"cheese:write"})
*/
public $isPublished = false;
... lines 29 - 43
}

There is one other field: search for groups. Yep, setTextDescription():

... lines 1 - 62
class CheeseListing
{
... lines 65 - 145
/**
* The description of the cheese as raw text.
*
* @Groups({"cheese:write", "user:write"})
* @SerializedName("description")
*/
public function setTextDescription(string $description): self
{
$this->description = nl2br($description);
return $this;
}
... lines 158 - 198
}

This allows the user to send a description field... but ultimately the deserialization process calls setTextDescription() and then we call nl2br on it. We want to do the exact same thing in the input class. So, copy this method, delete it, and paste it at the bottom of CheeseListingInput. Re-type the end of @SerializedName and auto-complete it to get the use statement:

... lines 1 - 7
class CheeseListingInput
{
... lines 10 - 31
/**
* The description of the cheese as raw text.
*
* @Groups({"cheese:write", "user:write"})
* @SerializedName("description")
*/
public function setTextDescription(string $description): self
{
$this->description = nl2br($description);
return $this;
}
}

Of course, when the deserializer calls this method, we're storing the end result on a description property... which doesn't exist yet. Let's add it: public $description:

... lines 1 - 7
class CheeseListingInput
{
... lines 10 - 29
public $description;
... lines 31 - 43
}

But we're not going to put this in any groups because we don't want this field to be writable directly: it's just there to store data.

Ok! Back in CheeseListing, if we search for "Groups", cool! The gray means that both the Groups and SerializedName use statements are not needed anymore because we have moved all of this stuff. Delete both:

... lines 1 - 16
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
... lines 19 - 200

There is now nothing inside of CheeseListing about serializing or deserializing.

Ok! Our CheeseListingInput is ready! To tell API Platform to use it, it's the same as output. On CheeseListing, add input=, remove the quotes, and say CheeseListingInput::class. Don't forget to add the use statement manually: use CheeseListingInput:

... lines 1 - 11
use App\Dto\CheeseListingInput;
... lines 13 - 20
/**
* @ApiResource(
... line 23
* input=CheeseListingInput::CLASS,
... lines 25 - 48
* )
... lines 50 - 63
class CheeseListing
{
... lines 66 - 182
}

How Deserializing Works

We don't have a data transformer yet, but this should be enough to get this to show up in our docs. Find your browser and refresh the docs homepage. Go down to the POST endpoint for cheeses and hit "Try it". And... Oh! Interesting. It only shows the description field here, which is odd... but let's ignore that for now.

Try the endpoint with title, description and a valid owner: /api/users/1. Double-check your database to make sure that's a real user.

Testing time! Hit Execute and... 400 error! We didn't expect it to work yet, but the way it doesn't work is the cool part: we get a bunch of "this value should not be blank" errors on title, description and price... even though we did send some of these fields!

Here's what's going on: thanks to the input= we added, when we send JSON to a CheeseListing operation, the serializer is now taking that JSON and deserializing it into a CheeseListingInput object - not a CheeseListing object.

But... because we haven't created a data transformer yet, nothing ever takes that CheeseListingInput and converts it into a CheeseListing. So... API Platform just creates an empty CheeseListing... then runs validation on that empty object.

To fix this, we know the answer: we need a data transformer!

Creating the DataTransformer

Inside of the DataTransformer directory, create a new PHP class called CheeseListingInputDataTransformer. Make this implement, of course, DataTransformerInterface:

... lines 1 - 2
namespace App\DataTransformer;
use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 9 - 15
}

And then go to "Code"->"Generate" - or Command + N on a Mac - to generate the two methods we need:

... lines 1 - 6
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
public function transform($object, string $to, array $context = [])
{
}
public function supportsTransformation($data, string $to, array $context = []): bool
{
}
}

This time, the supportsTransformation() method will look a little bit different. Dump all three arguments... and actually use dump() instead of dd() because we're going to test this inside the interactive docs... and reading HTML inside the response is ugly there. Using dump() will save the HTML to the profiler so we can easily look at it.

Anyways, dump $data, $to and $context. And at the bottom return false so this method doesn't cause an error: it needs to return a boolean:

... lines 1 - 6
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 9 - 12
public function supportsTransformation($data, string $to, array $context = []): bool
{
dump($data, $to, $context);
return false;
}
}

Ok: move over, hit "Execute" and... huh? It... actually did dump the variables right in the response... which is what I was trying to avoid! Normally, if you use dump(), it doesn't dump in the response, it instead saves it to the profiler.

The reason this isn't happening... is that I'm missing a bundle that adds the integration between the dump() function and the profiler. To install it, find your terminal and run:

composer require symfony/debug-bundle

Once this finishes... we should be able to go back to the browser, hit Execute again and... perfect! A 400 error JSON response, but no HTML.

To see the dumped variables, go down to the web debug toolbar and open the last request's profiler in a new tab. Nice! It automatically took me to the Debug section: here are the $data, $to and $context variables.

Next: let's use this information to finish our data transformer and get this thing working!

Leave a comment!

8
Login or Register to join the conversation
donwilson Avatar
donwilson Avatar donwilson | posted 6 months ago

Hello guys, I'm reaching out because I'm in need of some help here. Im dealing with this project on which I have two resources, ResourceA lives in my DB and I have full access but then I have ResourceB that lives in another DB and I can only access it through another API the tricky part is that A and B are related so when I create A I must store an ID related to B.
So far I managed to handle B as is a part of the native API using a DataProvider and a DataPersister, but now when I try to POST A, A should have an IRI pointing to B and if I define a ManyToOne in A pointing to B I'm having the error that B "is not a valid entity or mapped super class"..
To sum up, I must have a relation between A and B beeing B a complete custom resource because it lives in another API (and DB).
Hope someone understand what I tried to explain, and can shed some light on wich is the best way to achieve this or if an impossible task.
Thanks in advance.

Reply

Hi Don!

Yea... that does some complex. I don't have any direct experience with non-entity classes and relating them. However, I "think" that, on an API Platform level, all API Platform cares about is that class A contains an instance of class B on a property: it does not need to be (and shouldn't be) using a ManyToOne attribute from Doctrine. So, for example:

#[ApiResource]
class A
{
    public string $name;

    public B $b;
}

And, of course, B is also an #[ApiResource]. With just this setup, I believe you should be able to do something like this:

POST /api/a

{
    "name": "foo",
    "b": "/api/b/5"
}

I "think" (but I am doing some guessing), that API Plaform will see that the b field should be an instance of B and that B is an #[ApiResource] and so it will then use the IRI (/api/b/5) to "find" the B object with id 5 using your DataProvider for B. It would then call ->setB($b) on class A. I'm guessing you also have some sort of private int $bId property on A where you store the id. So, to handle setting that, you could do something like:

// A.php

public function setB(B $b)
{
    $this->b = $b;
    $this->bId = $b->getId();
}

Additionally, IF you want to get fancy, you could use a post-hydration (I think this is the hook you want) listener on Doctrine that could read the bId field and use the API to find the B object and set it onto the A class every time you query for A. The downside is that you'll be hitting the API every time you query for an A - so it's up to you :).

Let me know if that helps!

1 Reply
donwilson Avatar

Ryan! Hi there!,
Thank you so much for your thorough reply!. Yes! :) for the POSTing part I was half way on doing your proposal, but very uncertain if I was on the right path, the best or the only one. (like most of the time on API Platform :P).
So, POST worked perfect and as expected.

Now for the GET part, post-hydration was the exact word that I was looking for that. I used a Doctrine Listener on PostLoad and worked!, of course I endup duplicating part of the code that lives in BDataProvider::getItem() so next step would be re organize (try) the code to not repeat myself.
I'd like to ask you couple of followup questions:

  1. Is Doctrine Listener the only solution or is another way of doing the post-hydration?
  2. Is there a way of calling the DataProvider::getItem() to the job of post hydration?
  3. Thinking only about consistency, is it ok if GETing the resource for A only shows B_id instead of B IRI?.

Thanks again, and keep up the great job you are doing with these tutorials, I'm very happy that you are addressing more advanced topics, that really helps on getting us to the next level.

Reply

Hi Don!

Yay! I'm really happy to hear about all your success - and thanks for following up to tell me!

  1. Is Doctrine Listener the only solution or is another way of doing the post-hydration?

You could also do it manually in your app code. For API Platform, that would mean doing it in the "data provider". For example, you could decorate the "Doctrine" data provider - https://symfonycasts.com/screencast/api-platform-extending/decorate-provider - you would call the Doctrine data provider so it can do its normal logic for fetching the A entity. Then you would manually grab the A object, read the bId off of it, fetch over the API for B, then set it onto A. That is, I think a perfectly acceptable alternative to the Doctrine Listener.

  1. Is there a way of calling the DataProvider::getItem() to the job of post hydration?

I think I answered this above - but let me know :). Yes, the idea is that YOU need to have a custom data provider for A. But of course, you don't want to do ALL of the work - so you decorate the Doctrine data provider so it can do most of the work... then you do the little tweak with B after.

  1. Thinking only about consistency, is it ok if GETing the resource for A only shows B_id instead of B IRI?.

It depends on who is using your API. If only YOU are using it... then who cares! :) But, it IS a bit weird that all of your other relations would use an IRI, and it would be different here.

However, you did give me an idea. In theory, if you KNOW that you will only return the IRI - and not the actual data - in the custom data provider for A that we're discussing, instead of fetching B from the external API, you could create a fake, empty B object that ONLY has its id set (which you already have). In theory, the serializer would use that to create the IRI... never caring that the object is otherwise empty.

Cheers!

1 Reply
donwilson Avatar
donwilson Avatar donwilson | weaverryan | posted 6 months ago | edited

Hi Ryan!.. awesome!..
I have learned a lot in this couple of posts.. Thanks a lot for taking the time and give such a complete answers, they were very clarifying.
So, these are the results:

  • Begining with your last paragraph, don't konw why I did not think about that!, I'm doing exactly the same when a B resource is created so Api Platform will return the IRI for the newly created resource. (that works!)

  • Using that idea, placed in the DoctrineListener and worked beatifully.
    Then moved on creating ADataProvider and test the same thing and also worked, of course.
    Lastly, in case B details needed to be shown, inject BDataProvider in ADataProvider::constructor() and instead of doing a new B(), simple call to BDataProvider::getItem() and worked extremely well.

Now, let's go celebrate (as you would say).

Cheers!

Reply

Woohoo! This is a really cool situation - it's awesome to hear that it's now all working nicely! Yup, celebration time :D

Reply
Default user avatar
Default user avatar Gianluca | posted 1 year ago

Hi
I have a question. Let I have a Resource, Book Entity, a standard ApiResource, with a REST operation POST for adding new Entity.
Let I have a new partner that want to add an Entity using API, BUT ... this partner can send a JSON that is totally different from standard POST Api.
Which is the best option to handle this situation? I think that DTO could be useful , I can declare a PartnerXBookDTO, but should I create a new custom operation? If I create a custom operation, should I create the entire ControllerAction or I can use some kind of Listener?

Reply

Hey Gianluca!

Sorry for the slow reply - the team left this tougher question for me :). Hmm... I'm not really sure what the "best" way to handle this would be. So yes, I'd probably try this with a DTO. In theory, if you create a DTO that looks like the JSON, then the JSON will deserializer into the DTO, then you can transform it back into a Book. Should you create a custom operation? That depends on whether you need to keep the "original" POST action or if having this new POST action is the only you need. If it's the only you need, then skip the custom operation and make the original POST operation use your DTO. If you DO need a custom operation... I'm not sure whether you would need a custom controller or not... it might be enough to create the custom operation and configure it with the DTO (and no controller is needed).

I'm going some guessing here about the way to handle things, but let me know if this helps or if you found a nice solution :).

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