Publishing a Listing
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
istrue
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.
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