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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOh 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:
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.
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?