Buy

Adding Links via Annotations

Oh man, this chapter will be one of my favorite ever to record, because we're going to do some sweet stuff with annotations.

In ProgrammerControllerTest, we called this key uri:

233 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 35
public function testGETProgrammer()
{
... lines 38 - 51
$this->asserter()->assertResponsePropertyEquals(
$response,
'uri',
$this->adjustUri('/api/programmers/UnitTester')
);
}
... lines 58 - 231
}

Because, well... why not?

But when we added pagination, we included its links inside a property called _links:

233 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 76
public function testGETProgrammersCollectionPagination()
{
... lines 79 - 101
$this->asserter()->assertResponsePropertyExists($response, '_links.next');
... lines 103 - 125
}
... lines 127 - 231
}

That kept links separate from data. I think we should do the same thing with uri: change it to _links.self. The key self is a name used when linking to, your, "self":

233 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 35
public function testGETProgrammer()
{
... lines 38 - 51
$this->asserter()->assertResponsePropertyEquals(
$response,
'_links.self',
$this->adjustUri('/api/programmers/UnitTester')
);
}
... lines 58 - 231
}

Renaming this is easy, but we have a bigger problem. Adding links is too much work. Most importantly, the subscriber only works for Programmer objects - so we'll need more event listeners in the future for other classes.

I have a different idea. Imagine we could link via annotations, like this: add @Link with "self" inside, route = "api_programmers_show" params = { }. This route has a nickname wildcard, so add "nickname": and then use the expression object.getNickname():

196 lines src/AppBundle/Entity/Programmer.php
... lines 1 - 9
/**
* Programmer
... lines 12 - 15
* @Link(
* "self",
* route = "api_programmers_show",
* params = { "nickname": "object.getNickname()" }
* )
*/
class Programmer
{
... lines 24 - 194
}

That last part is an expression, from Symfony's expression language. You and I are going to build the system that makes this work, so I'm going to assume that we'll pass a variable called object to the expression language that is this Programmer object being serialized. Then, we just call .getNickname().

Of course, this won't work yet - in fact it'll totally bomb if you try it. But it will in a few minutes!

Creating an Annotation

To create this cool system, we need to understand a bit about annotations. Every annotation - like Table or Entity from Doctrine - has a class behind it. That means we need a Link class. Create a new directory called Annotation. Inside add a new Link class in the AppBundle\Annotation namespace:

27 lines src/AppBundle/Annotation/Link.php
<?php
namespace AppBundle\Annotation;
... lines 4 - 8
class Link
{
... lines 11 - 25
}

To hook this annotation into the annotations system, we need a few annotations: the first being, um, well, @Annotation. Yep, I'm being serious. The second is @Target, which will be "CLASS". This means that this annotation is expected to live above class declarations:

27 lines src/AppBundle/Annotation/Link.php
... lines 1 - 4
/**
* @Annotation
* @Target("CLASS")
*/
class Link
{
... lines 11 - 25
}

Inside the Link class, we need to add a public property for each option that can be passed to the annotation, like route and params. Add public $name;, public $route; and public $params = array();:

27 lines src/AppBundle/Annotation/Link.php
... lines 1 - 8
class Link
{
... lines 11 - 15
public $name;
... lines 17 - 22
public $route;
public $params = array();
}

The first property becomes the default property, which is why we don't need to have name = "self" when using it.

The name and route options are required, so add an extra @Required above them:

27 lines src/AppBundle/Annotation/Link.php
... lines 1 - 8
class Link
{
/**
* @Required
*
* @var string
*/
public $name;
/**
* @Required
*
* @var string
*/
public $route;
... lines 24 - 25
}

And... that's it!

Inside of Programmer, every annotation - except for the special @Annotation and @Target guys, they're core to that system - needs a use statement - we already have some for @Serializer, @Assert and @ORM. Add a use statement directly to the class itself for @Link:

196 lines src/AppBundle/Entity/Programmer.php
... lines 1 - 7
use AppBundle\Annotation\Link;
/**
* Programmer
*
* @ORM\Table(name="battle_programmer")
* @ORM\Entity(repositoryClass="AppBundle\Repository\ProgrammerRepository")
* @Serializer\ExclusionPolicy("all")
* @Link(
* "self",
* route = "api_programmers_show",
* params = { "nickname": "object.getNickname()" }
* )
*/
class Programmer
{
... lines 24 - 194
}

This hooks the annotation up with the class we just created.

Reading the Annotation

Ok... so how do we read annotations? Great question, I have no idea. Ah, it's easy, thanks to the Doctrine annotations library that comes standard with Symfony. In fact, we already have a service available called @annotation_reader.

Inside LinkSerializationSubscriber, inject that as the second argument. It's an instance of the Reader interface from Doctrine\Common\Annotations. Call it $annotationsReader:

74 lines src/AppBundle/Serializer/LinkSerializationSubscriber.php
... lines 1 - 5
use Doctrine\Common\Annotations\Reader;
... lines 7 - 12
class LinkSerializationSubscriber implements EventSubscriberInterface
{
private $router;
private $annotationReader;
... lines 18 - 20
public function __construct(RouterInterface $router, Reader $annotationReader)
{
$this->router = $router;
$this->annotationReader = $annotationReader;
... line 25
}
... lines 27 - 72
}

I'll hit option+enter and select initialize fields to get that set on property.

And before I forget, in services.yml, inject that by adding @annotation_reader as the second argument:

35 lines app/config/services.yml
... lines 1 - 5
services:
... lines 7 - 29
link_serialization_subscriber:
class: AppBundle\Serializer\LinkSerializationSubscriber
arguments: ['@router', '@annotation_reader']
tags:
- { name: jms_serializer.event_subscriber }

Super easy.

Too easy, back to work! Delete all of this junk in onPostSerialize() and start with $object = $event->getObject(). To read the annotations off of that object, add $annotations = $this->annotationReader->getClassAnnotations(). Pass that a new \ReflectionObject() for $object:

74 lines src/AppBundle/Serializer/LinkSerializationSubscriber.php
... lines 1 - 12
class LinkSerializationSubscriber implements EventSubscriberInterface
{
... lines 15 - 27
public function onPostSerialize(ObjectEvent $event)
{
/** @var JsonSerializationVisitor $visitor */
$visitor = $event->getVisitor();
$object = $event->getObject();
$annotations = $this->annotationReader
->getClassAnnotations(new \ReflectionObject($object));
... lines 36 - 50
}
... lines 52 - 72
}

That's it!

Now, the class could have a lot of annotations above it, but we're only interested in the @Link annotation. We'll add an if statement to look for that in a second. But first, create $links = array(): that'll be our holder for any links we find:

74 lines src/AppBundle/Serializer/LinkSerializationSubscriber.php
... lines 1 - 32
$object = $event->getObject();
$annotations = $this->annotationReader
->getClassAnnotations(new \ReflectionObject($object));
$links = array();
... lines 38 - 74

Now, foreach ($annotations as $annotations). Immediately, see if this is something we care about with if ($annotation instanceof Link). At this point, the annotation options are populated on the public properties of the Link object. To get the URI, we can just say $this->router->generate() and pass it $annotation->route and $annotation->params:

74 lines src/AppBundle/Serializer/LinkSerializationSubscriber.php
... lines 1 - 36
$links = array();
foreach ($annotations as $annotation) {
if ($annotation instanceof Link) {
$uri = $this->router->generate(
$annotation->route,
$this->resolveParams($annotation->params, $object)
);
... line 44
}
}
... lines 47 - 74

How sweet is that? Well, we're not done yet: the params contain an expression string... which we're not parsing yet. We'll get back to that in a second.

Finish this off with $links[$annotation->name] = $uri;. At the bottom, finish with the familiar $visitor->addData() with _links set to $links;. Other than evaluating the expression, that's all the code you need:

74 lines src/AppBundle/Serializer/LinkSerializationSubscriber.php
... lines 1 - 12
class LinkSerializationSubscriber implements EventSubscriberInterface
{
... lines 15 - 27
public function onPostSerialize(ObjectEvent $event)
{
... lines 30 - 36
$links = array();
foreach ($annotations as $annotation) {
if ($annotation instanceof Link) {
$uri = $this->router->generate(
$annotation->route,
$this->resolveParams($annotation->params, $object)
);
$links[$annotation->name] = $uri;
}
}
... line 48
$visitor->addData('_links', $links);
... line 50
}
... lines 52 - 72
}

Check this out by going to /api/programmers in the browser. Look at that! The embedded programmer entities actually have a link called self. It worked!

Of course, the link is totally wrong because we're not evaluating the expression yet. But, we're really close.

Leave a comment!

  • 2016-07-29 weaverryan

    Hi Vlad!

    Wow, that *is* weird! I don't initially see any php.ini setting for this (but I have never really looked into this too far), but it could still be the cause. Or, something more subtle is happening!

    And btw, at the end of this video when I load the JSON in the browser, I DO also have the escaped slashes. My JsonView Chrome extension is cleaning this up for me - if you look at the actual dumped source, the slashes are escaped.

    If you end up figuring it out - let me know, I'm also curious :).

    Cheers!

  • 2016-07-28 Vlad

    Hi Ryan,
    Yes, it works when I run unit tests, but not when I load it in the browser.
    I'll follow your suggestion to set breakpoints or dump() to see what's going on.
    Do you think there might be a PHP ini setting that does that? I noticed, you don't get slashes escaped.
    Nothing wrong in the slash escaping, it is just not required by JSON. But if I can't figure it, I'll leave it as is. No big deal.
    Thank you!

  • 2016-07-27 weaverryan

    Hey Vlad!

    Setting these options works when you run it in your terminal (do you mean, like running unit tests?) but not in the browser? That by itself looks weird to me! Tell me more about that. From what I can see, you're doing the right types of things - the JsonSerializationVisitor uses those options in json_encode(). I'd add some temporary breakpoints or dump code in that class to see if your options are making it there.

    Btw - what's wrong with the slash escaping? Just looking kinda ugly (it is a bit ugly)?

    Cheers!

  • 2016-07-26 Vlad

    Well, it works when I run it in Terminal, but not in the browser.
    Also tried (to no avail):

    $visitor->setOptions([JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE]);
  • 2016-07-26 Vlad

    Found a solution: https://github.com/schmittjoh/...
    added the following options to config.yml:

    jms_serializer:
    visitors:
    json:
    options: [JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE]
  • 2016-07-26 Vlad

    Hi Ryan,
    In my case, forward slashes in `_links` are escaped with a backslash:

    "_links": {
    "self": "\/app_test.php\/api\/programmers\/CowboyCoder"
    }

    .
    Any idea why this might be happening?
    Thank you!