Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

Adding Links via Annotations

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

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:

... 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:

... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 76
public function testGETProgrammersCollectionPaginated()
{
... 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":

... 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():

... 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:

<?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:

... 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();:

... 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:

... 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:

... 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:

... 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:

... 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:

... 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:

... 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:

... 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:

... 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!

10
Login or Register to join the conversation
Shaun T. Avatar
Shaun T. Avatar Shaun T. | posted 5 years ago

Hey guys,

Great content as always, this course has helped me immensely!

In my app I have 2 directories for controllers:

/web - for all routes for the web app i.e. app/posts retrieves the html and javascript templates for the post page template
/api - all api routes - for examples api/posts returns the JSON data for all posts

So with regards to links, I have a problem. If I want to link to a post then the web link (/app/posts/123) works great. However, if I want to edit a post then this is done via an AJAX call, and the route is different (PATCH: /api/posts/123).

Is it possible that the links annotation can be updated to have a web and an api link? Is this good practice, or is there a better way to do this?

Reply

Hey Shaun T.!

You can make your controller's action to serve 2 different routes by specifying another "@Route" annotation, but I don't think is a good idea for your case. What you can do is to use the "edit" API endpoint in your web AJAX calls

Cheers!

Reply
Default user avatar

This tutorial really opened up the world of annotations for me. But, should I worry about caching? Does the doctrine annotation reader automatically cache the annotations for me?

Reply

Yo bblue

Great question! The answer is, yes! Well, actually, the answer is: "it depends", but in this case yes. So, the Doctrine library exposes some classes that help read annotations. If you use them directly, it's possible to read annotations and *not* cache them. But, when you're in Symfony, Symfony has pre-configured the annotation_reader service to cache things, and all systems that read annotations use this service. So, in Symfony, yep, it's all cached. It may depend on your Symfony version, but in our app, these are cached via the filesystem to one of the directories in var/cache/dev/pools.

Cheers!

1 Reply
Vladimir Z. Avatar
Vladimir Z. Avatar Vladimir Z. | posted 5 years ago | edited

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!

Reply
Vladimir Z. Avatar
Vladimir Z. Avatar Vladimir Z. | Vladimir Z. | posted 5 years ago | edited

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


jms_serializer:
    visitors:
        json:
            options: [JSON_UNESCAPED_SLASHES, JSON_UNESCAPED_UNICODE]
Reply
Vladimir Z. Avatar
Vladimir Z. Avatar Vladimir Z. | Vladimir Z. | posted 5 years ago | edited

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]);
Reply

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!

Reply
Vladimir Z. Avatar

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!

Reply

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!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses an older version of Symfony. The concepts of REST and serialization are still valid, but I recommend using API Platform in new Symfony apps.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=5.3.3",
        "symfony/symfony": "2.6.*", // v2.6.11
        "doctrine/orm": "~2.2,>=2.2.3,<2.5", // v2.4.7
        "doctrine/dbal": "<2.5", // v2.4.4
        "doctrine/doctrine-bundle": "~1.2", // v1.4.0
        "twig/extensions": "~1.0", // v1.2.0
        "symfony/assetic-bundle": "~2.3", // v2.6.1
        "symfony/swiftmailer-bundle": "~2.3", // v2.3.8
        "symfony/monolog-bundle": "~2.4", // v2.7.1
        "sensio/distribution-bundle": "~3.0,>=3.0.12", // v3.0.21
        "sensio/framework-extra-bundle": "~3.0,>=3.0.2", // v3.0.7
        "incenteev/composer-parameter-handler": "~2.0", // v2.1.0
        "hautelook/alice-bundle": "0.2.*", // 0.2
        "jms/serializer-bundle": "0.13.*", // 0.13.0
        "white-october/pagerfanta-bundle": "^1.0" // v1.2.4
    },
    "require-dev": {
        "sensio/generator-bundle": "~2.3", // v2.5.3
        "behat/behat": "~3.0", // v3.0.15
        "behat/mink-extension": "~2.0.1", // v2.0.1
        "behat/mink-goutte-driver": "~1.1.0", // v1.1.0
        "behat/mink-selenium2-driver": "~1.2.0", // v1.2.0
        "phpunit/phpunit": "~4.6.0" // 4.6.4
    }
}
userVoice