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!
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.
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 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!
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?
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!
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!
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]
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]);
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!
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!
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!
// 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
}
}
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?