Buy

As cool as all this HAL JSON stuff is, you need to build your API for whoever is using it - maybe a JavaScript frontend, a mobile app or your customers themselves. And honestly, I don't think that the standardized formats - like Hal - are all that understandable or useful. This _embedded thing? To me it's just ugly.

I also don't love hiding the URL under an object with an href key. So let's suppose that we're building a JavaScript frontend, and it'll work better if the link URL's appeared directly under the _links key - without the href.

Let's do that! The HATEOAS library we installed really just helps you add relations to a class: both link relations and embedded relations. And fortunately, the library let's you control exactly how these are added to your response.

Custom Serializer

In AppBundle, in the Serializer directory, create a new class called CustomHATEOASJsonSerializer:

13 lines src/AppBundle/Serializer/CustomHATEOASJsonSerializer.php
... lines 1 - 2
namespace AppBundle\Serializer;
... lines 4 - 6
use Hateoas\Serializer\JsonHalSerializer;
... lines 8 - 10
class CustomHATEOASJsonSerializer extends JsonHalSerializer
{
}

Make it extend a class called JsonHalSerializer: this is the current class responsible for adding links in the HAL format. In fact: open up the class.

It has two method. serializeLinks() is responsible for reading the Relation annotations and adding them to the JSON with _links. serializeEmbeddeds() adds any embedded relations under the _embedded key.

For now, let's focus on changing how the links render only. Go to the "Code"->"Generate" menu - command+N on a Mac - and hit "Override Methods". Override serializeLinks():

28 lines src/AppBundle/Serializer/CustomHATEOASJsonSerializer.php
... lines 1 - 7
use JMS\Serializer\JsonSerializationVisitor;
use JMS\Serializer\SerializationContext;
class CustomHATEOASJsonSerializer extends JsonHalSerializer
{
... lines 13 - 17
public function serializeLinks(array $links, JsonSerializationVisitor $visitor, SerializationContext $context)
{
... lines 20 - 25
}
}

Re-open the parent method and then the interface: I want to copy all that good PHPDoc so we get auto-complete. Paste it above our method and auto-complete the Link to get its use statement:

28 lines src/AppBundle/Serializer/CustomHATEOASJsonSerializer.php
... lines 1 - 5
use Hateoas\Model\Link;
... lines 7 - 10
class CustomHATEOASJsonSerializer extends JsonHalSerializer
{
/**
* @param Link[] $links
* @param JsonSerializationVisitor $visitor
* @param SerializationContext $context
*/
public function serializeLinks(array $links, JsonSerializationVisitor $visitor, SerializationContext $context)
{
... lines 20 - 25
}
}

Alright: this should be easy.

Create a $serializedLinks array and foreach over the $links variable:

28 lines src/AppBundle/Serializer/CustomHATEOASJsonSerializer.php
... lines 1 - 10
class CustomHATEOASJsonSerializer extends JsonHalSerializer
{
/**
* @param Link[] $links
* @param JsonSerializationVisitor $visitor
* @param SerializationContext $context
*/
public function serializeLinks(array $links, JsonSerializationVisitor $visitor, SerializationContext $context)
{
$serializedLinks = array();
foreach ($links as $link) {
... line 22
}
... lines 24 - 25
}
}

Each of these is a Link object, and contains the configuration for one annotation. Now, just create the format we want: $serializedLinks[], with $link->getRel(). Instead of setting this to an array with an href key, simply set it to $link->getHref():

28 lines src/AppBundle/Serializer/CustomHATEOASJsonSerializer.php
... lines 1 - 10
class CustomHATEOASJsonSerializer extends JsonHalSerializer
{
/**
* @param Link[] $links
* @param JsonSerializationVisitor $visitor
* @param SerializationContext $context
*/
public function serializeLinks(array $links, JsonSerializationVisitor $visitor, SerializationContext $context)
{
$serializedLinks = array();
foreach ($links as $link) {
$serializedLinks[$link->getRel()] = $link->getHref();
}
... lines 24 - 25
}
}

Perfect! Finally, at the bottom, we need to add the _links property. Do that with: $visitor->addData('_links', $serializedLinks):

28 lines src/AppBundle/Serializer/CustomHATEOASJsonSerializer.php
... lines 1 - 10
class CustomHATEOASJsonSerializer extends JsonHalSerializer
{
/**
* @param Link[] $links
* @param JsonSerializationVisitor $visitor
* @param SerializationContext $context
*/
public function serializeLinks(array $links, JsonSerializationVisitor $visitor, SerializationContext $context)
{
$serializedLinks = array();
foreach ($links as $link) {
$serializedLinks[$link->getRel()] = $link->getHref();
}
$visitor->addData('_links', $serializedLinks);
}
}

With any luck, that should give us a simpler format without that href.

Registering the Serializer

To hook this up. You guys can probably guess step 1: in app/config/services.yml, register this as a service. How about: custom_hateoas_json_serializer. Set its class to that same thing:

38 lines app/config/services.yml
... lines 1 - 5
services:
... lines 7 - 36
custom_hateoas_json_serializer:
class: AppBundle\Serializer\CustomHATEOASJsonSerializer

And we don't have any constructor args yet.

Finally, copy the service name. To tell the bundle to use our class instead of the existing one, open up config.yml. Now, without even looking at its docs, we can get a list of the configuration for this bundle by going to the terminal and running:

./bin/console debug:config

Thanks to the bundle, there's a new valid config key called bazinga_hateoas. Pass that to the same command:

./bin/console debug:config bazinga_hateoas

Ah, that serializer.json key looks like our target.

Back in config.yml, add bazinga_hateoas, serializer, json and then paste our service name:

82 lines app/config/config.yml
... lines 1 - 78
bazinga_hateoas:
serializer:
json: custom_hateoas_json_serializer

That should do it!

Changing our Tests Back

But don't run the tests quite yet: we know some things will be broken. In BattleControllerTest, take off the href we just added: it should be _links.programmer:

81 lines tests/AppBundle/Controller/Api/BattleControllerTest.php
... lines 1 - 6
class BattleControllerTest extends ApiTestCase
{
... lines 9 - 15
public function testPOSTCreateBattle()
{
... lines 18 - 40
$this->asserter()->assertResponsePropertyEquals(
$response,
'_links.programmer',
$this->adjustUri('/api/programmers/Fred')
);
... lines 46 - 53
}
... lines 55 - 79
}

And in ProgrammerControllerTest, under testGetProgrammer, do the same:

291 lines tests/AppBundle/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 6
class ProgrammerControllerTest extends ApiTestCase
{
... lines 9 - 38
public function testGETProgrammer()
{
... lines 41 - 56
$this->asserter()->assertResponsePropertyEquals(
$response,
'_links.self',
$this->adjustUri('/api/programmers/UnitTester')
);
}
... lines 63 - 289
}

Phew! That's a lot of changes, so let's re-run the entire test suite:

./vendor/bin/phpunit

Hey, it passes! I must've left a debugResponse() in there somewhere: but that's nothing to worry about - we're green!

Leave a comment!