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!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.2.5",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.7
        "doctrine/annotations": "^1.0", // 1.10.4
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.1
        "doctrine/orm": "^2.4.5", // v2.7.3
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.4
        "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.9.3
        "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.7.3
        "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.21.1
        "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.1.2
    }
}