Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Publishing a Listing

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

One of the things that we can't do yet is publish a CheeseListing. Boo!

Right now, when we create a CheeseListing through our API, it always gets an isPublished=false value, which is the default:

... lines 1 - 56
class CheeseListing
{
... lines 59 - 98
/**
* @ORM\Column(type="boolean")
*/
private $isPublished = false;
... lines 103 - 214
}

There is no way to change this... because isPublished isn't exposed as a field in our API.

In the imaginary UI of our site, there will be a giant "Publish" button that a user can click. When a user clicks that, we're obviously going to need to change the isPublished field from false to true. But publishing in our app is more than just updating a field in the database. Let's pretend that we also need to run some custom code when a listing is published... like maybe we need to send a request to ElasticSearch to index the new listing... or we want to send some notifications to users who are desperately waiting for our cheese.

Publishing: Custom Endpoint

So... how should we design this in our API? You might think that we need a custom endpoint or "operation" in ApiPlatform language. Something like POST /api/cheeses/{id}/publish.

You can do this. And we'll talk about custom operations in part 4 of this series. But this solution would not be RESTful. In REST, every URL represents the address to a unique resource. So from a REST standpoint, POSTing to /api/cheeses/{id}/publish makes it look like there is a "cheese publish" resource... and that we're trying to create a new one.

Of course, rules are meant to be broken. And ultimately, you should just get your job done however you need to. But in this tutorial, let's see if we can solve this in a RESTful way. How? By making $isPublished changeable in the same way as any other field: by making a PUT request with isPublished: true in the body.

That part will be pretty easy. But running code only when this value changes from false to true? That will be a bit trickier.

Testing the PUT to update isPublished

Let's start with a basic test where we update this field. Open tests/Functional/CheeseListingResourceTest, find testUpdateCheeseListing(), copy the method, paste, and rename it to testPublishCheeseListing():

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 12 - 69
public function testPublishCheeseListing()
{
... lines 72 - 85
}
... lines 87 - 138
}

Ok! I don't need 2 users: I'll just create one... and log in is that user so that we have access to the PUT request:

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 12 - 69
public function testPublishCheeseListing()
{
$client = self::createClient();
$user = UserFactory::new()->create();
$cheeseListing = CheeseListingFactory::new()->create([
'owner' => $user,
]);
$this->logIn($client, $user);
... lines 80 - 85
}
... lines 87 - 138
}

Thanks to the last tutorial, we already have security rules to prevent anyone from editing someone else's listing. Down here, for the JSON body, send isPublished set to true:

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 12 - 69
public function testPublishCheeseListing()
{
$client = self::createClient();
$user = UserFactory::new()->create();
$cheeseListing = CheeseListingFactory::new()->create([
'owner' => $user,
]);
$this->logIn($client, $user);
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['isPublished' => true]
]);
... lines 83 - 85
}
... lines 87 - 138
}

And... the status code we expect is 200:

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 12 - 69
public function testPublishCheeseListing()
{
$client = self::createClient();
$user = UserFactory::new()->create();
$cheeseListing = CheeseListingFactory::new()->create([
'owner' => $user,
]);
$this->logIn($client, $user);
$client->request('PUT', '/api/cheeses/'.$cheeseListing->getId(), [
'json' => ['isPublished' => true]
]);
$this->assertResponseStatusCodeSame(200);
... lines 84 - 85
}
... lines 87 - 138
}

So here's the flow: we create a User - via the Foundry library - and then create a CheeseListing. Oh, but we don't want that published() method: that's a method I made to create a published listing:

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 12 - 69
public function testPublishCheeseListing()
{
... lines 72 - 74
$cheeseListing = CheeseListingFactory::new()->create([
'owner' => $user,
]);
... lines 78 - 85
}
... lines 87 - 138
}

We definitely want to work with an unpublished listing. Anyways, we set the user as the owner of the new cheese listing, log in as that user, and then send a PUT request to update the isPublished field.

To make things more interesting, at the bottom, let's assert that the CheeseListing is in fact published after the request. Do that with $cheeseListing->refresh() - I'll talk about that in a second - and then $this->assertTrue() that $cheeseListing->getIsPublished():

... lines 1 - 9
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 12 - 69
public function testPublishCheeseListing()
{
... lines 72 - 82
$this->assertResponseStatusCodeSame(200);
$cheeseListing->refresh();
$this->assertTrue($cheeseListing->getIsPublished());
}
... lines 87 - 138
}

$cheeseListing->refresh() is another feature of Foundry. Man, that library just keeps on giving! Whenever you create an object with Foundry, it passes you back that object but wrapped inside a Proxy. Hold Command or Ctrl and click refresh(). Yep! A tiny Proxy class from Foundry with several useful methods on it, like refresh()!

Anyways, refresh() will update the entity in my test with the latest data, and then we'll check to make sure $isPublished is true.

Testing time! I mean, time to make sure our test fails! Copy the test method name, spin over to your terminal, and run:

symfony php bin/phpunit --filter=testPublishCheeseListing

We're hoping for failure and... yes!

Failed asserting that false is true

Because... the $isPublished field is simply not writable in our API yet.

Making isPublished Writable in the API

Let's fix that! At the top of the @ApiResource annotation, as a reminder, we have a denormalizationContext that sets the serialization groups to cheese:write:

... lines 1 - 17
/**
* @ApiResource(
... line 20
* denormalizationContext={"groups"={"cheese:write"}},
... lines 22 - 43
* )
... lines 45 - 55
*/
class CheeseListing
{
... lines 59 - 214
}

So if we want a field to be writeable in our API, it needs that group.

Copy that, scroll down to isPublished, add @Groups({}) and paste:

... lines 1 - 17
/**
* @ApiResource(
... line 20
* denormalizationContext={"groups"={"cheese:write"}},
... lines 22 - 43
* )
... lines 45 - 55
*/
class CheeseListing
{
... lines 59 - 98
/**
* @ORM\Column(type="boolean")
* @Groups({"cheese:write"})
*/
private $isPublished = false;
... lines 104 - 215
}

Now, as long as this has a setter - and... yep there is a setIsPublished() method:

... lines 1 - 56
class CheeseListing
{
... lines 59 - 197
public function setIsPublished(bool $isPublished): self
{
$this->isPublished = $isPublished;
return $this;
}
... lines 204 - 215
}

It will be writable in the API.

Let's see if it is! Go back to your terminal and run the test again:

symfony php bin/phpunit --filter=testPublishCheeseListing

And... got it! We can now publish a CheeseListing! But... this was the easy part. The real question is: how can we run custom code only when a CheeseListing is published? So, only when the isPublished field changes from false to true? Let's find out how next.

Leave a comment!

11
Login or Register to join the conversation
gabrielmustiere Avatar
gabrielmustiere Avatar gabrielmustiere | posted 1 year ago

Hi, when I launch the unit test for the testPublishCheeseListing I have a strange error NotFoundHttpException: "NotFound". How can your test succeed on the first assertion to fail on the second one when the field isPublished is not modifiable through the api, the first test returns me a 404 response and not a 200, but it is not the case in the video, did I miss something important.

I am on APIP 2.6.8 and Symfony 5.4.6

Reply
gabrielmustiere Avatar
gabrielmustiere Avatar gabrielmustiere | gabrielmustiere | posted 1 year ago

In the Symfony profiler, I noticed that a first GET request before the PUT request was failing 404, I think it's related to the CheeseListingPublishExtension which only allows to retrieve published items, so the PUT request fails to update an unpublished item because it can't find it and therefore returns a NotFound response.

Is my thinking correct?

Reply

Hey gabrielmustiere

You're right about the CheeseListingPublishExtension, it filters out non-published items unless you're an admin or you're the owner. In this case, we're updating a CheeseListing, so, you need to be the owner. I believe you just forgot to set the owner on the CheeseListing object.

Cheers!

Reply
gabrielmustiere Avatar
gabrielmustiere Avatar gabrielmustiere | MolloKhan | posted 1 year ago

I am the owner of this cheese, even a GET on a cheese I own returns me a NotFound response

Reply
gabrielmustiere Avatar
gabrielmustiere Avatar gabrielmustiere | gabrielmustiere | posted 1 year ago

I solved my problem by looking at this little tip that I had missed: https://symfonycasts.com/sc...

Reply
Riya J. Avatar
Riya J. Avatar Riya J. | MolloKhan | posted 1 year ago | edited

Hey MolloKhan
I have already set the owner on the CheeseListing object, still getting the same 404 error! Any help!?

Reply

Hello,
I would like to know how to do this endpoint:
`POST /api/cheeses/{id}/publish.`
and I'm asking if create a custom controller https://api-platform.com/do... it's the right way?

Thanks for your answer and thank you for your excellent work!

Reply

Hey @paralucia!

Ah, we didn't convince you to do the RESTful approach in this tutorial? Fair enough 😉

We'll talk about this in the next tutorial, but any solution is fine. I would personally probably prefer the Messenger integration first, and the custom controller as the second option, but do what works for you.

Cheers!

Reply

Haha ! Yes, you convinced me, but I was so curious so I asked you!
Thank you for your rapid answer !

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