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 SubscribeOnce upon a time, I worked with a client that had a really interesting API requirement. In fact, one that totally violate REST... but it's kinda cool. They said:
When we have one object that relates to another object - like how our programmer relates to a user - sometimes we want to embed the user in the response and sometimes we don't. In fact, we want the API client to tell us via - a query parameter - whether or not they want embedded objects in the response.
Sounds cool...but it totally violates REST because you now have two different URLs that return the same resource... each just returns a different representation. Rules are great - but come on... if this is useful to you, make it happen.
Let's start with a quick test: copy part of testGETProgramer()
and name the new method testGETProgrammerDeep()
. Now, add a query parameter called ?deep
:
... lines 1 - 5 | |
class ProgrammerControllerTest extends ApiTestCase | |
{ | |
... lines 8 - 58 | |
public function testGETProgrammerDeep() | |
{ | |
$this->createProgrammer(array( | |
'nickname' => 'UnitTester', | |
'avatarNumber' => 3, | |
)); | |
$response = $this->client->get('/api/programmers/UnitTester?deep=1'); | |
$this->assertEquals(200, $response->getStatusCode()); | |
... lines 68 - 70 | |
} | |
... lines 72 - 245 | |
} |
The idea is simple: if the client adds ?deep=1
, then the API should expose more embedded objects. Use the asserter to say assertResponsePropertyExists()
, pass that the $response
and the property we'll expect, which is user
. Since this will be an entire user object, check specifically for user.username
:
... lines 1 - 5 | |
class ProgrammerControllerTest extends ApiTestCase | |
{ | |
... lines 8 - 58 | |
public function testGETProgrammerDeep() | |
{ | |
... lines 61 - 67 | |
$this->asserter()->assertResponsePropertiesExist($response, array( | |
'user.username' | |
)); | |
} | |
... lines 72 - 245 | |
} |
Very nice!
If you look at this response in the browser, we definitely do not have a user
field. But there are only two little things we need to do to add it.
First, expose the user
property with @Serializer\Expose()
:
... lines 1 - 21 | |
class Programmer | |
{ | |
... lines 24 - 65 | |
/** | |
... lines 67 - 69 | |
* @Serializer\Expose() | |
*/ | |
private $user; | |
... lines 73 - 196 | |
} |
Of course, it can't be that simple: now the user
property would always be included. To avoid that, add @Serializer\Groups()
and use a new group called deep
:
... lines 1 - 21 | |
class Programmer | |
{ | |
... lines 24 - 65 | |
/** | |
... lines 67 - 68 | |
* @Serializer\Groups({"deep"}) | |
* @Serializer\Expose() | |
*/ | |
private $user; | |
... lines 73 - 196 | |
} |
Here's the idea: when you serialize, each property belongs to one or more "groups". If you don't include the @Serializer\Groups
annotation above a property, then it will live in a group called Default
- with a capital D
. Normally, the serializer serializes all properties, regardless of their group. But you can also tell it to serialize only the properties in a different group, or even in a set of groups. We can use groups to serialize the user
property under only certain conditions.
But before we get there - I just noticed that the password
field is being exposed on my User
. That's definitely lame. Fix it by adding the Expose
use statement, removing that last part and writing as Serializer
instead. That's a nice trick to get that use
statement:
... lines 1 - 7 | |
use JMS\Serializer\Annotation as Serializer; | |
... lines 9 - 14 | |
class User implements UserInterface | |
{ | |
... lines 17 - 84 | |
} |
Now set @Serializer\ExclusionPolicy()
above the class with all
and add @Expose
above username
:
... lines 1 - 9 | |
/** | |
* @Serializer\ExclusionPolicy("all") | |
* @ORM\Table(name="battle_user") | |
* @ORM\Entity(repositoryClass="AppBundle\Repository\UserRepository") | |
*/ | |
class User implements UserInterface | |
{ | |
... lines 17 - 23 | |
/** | |
* @Serializer\Expose() | |
* @ORM\Column(type="string", unique=true) | |
*/ | |
private $username; | |
... lines 29 - 84 | |
} |
Back in Programmer.php
, remove the "groups" code temporarily and refresh. OK good, only the username
is showing. Put that "groups" code back.
Ok... so now, how can we serialize a specific set of groups? To answer that, open ProgrammerController
and find showAction()
. Follow createApiResponse()
into the BaseController
and find serialize()
:
... lines 1 - 16 | |
abstract class BaseController extends Controller | |
{ | |
... lines 19 - 123 | |
protected function serialize($data, $format = 'json') | |
{ | |
$context = new SerializationContext(); | |
$context->setSerializeNull(true); | |
return $this->container->get('jms_serializer') | |
->serialize($data, $format, $context); | |
} | |
} |
When we serialize, we create this SerializationContext
, which holds a few options for serialization. Honestly, there's not much you can control with this, but you can set which groups you want to serialize.
First, get the $request
object by fetching the request_stack
service and adding getCurrentRequest()
. Next, create a new $groups
variable and set it to only Default
: we always want to serialize the properties in this group:
... lines 1 - 16 | |
abstract class BaseController extends Controller | |
{ | |
... lines 19 - 123 | |
protected function serialize($data, $format = 'json') | |
{ | |
$context = new SerializationContext(); | |
$context->setSerializeNull(true); | |
$request = $this->get('request_stack')->getCurrentRequest(); | |
$groups = array('Default'); | |
... lines 131 - 137 | |
} | |
} |
Now say if ($request->query->get('deep'))
is true then add deep
to $groups
. Finish this up with $context->setGroups($groups)
:
... lines 1 - 16 | |
abstract class BaseController extends Controller | |
{ | |
... lines 19 - 123 | |
protected function serialize($data, $format = 'json') | |
{ | |
... lines 126 - 129 | |
$groups = array('Default'); | |
if ($request->query->get('deep')) { | |
$groups[] = 'deep'; | |
} | |
$context->setGroups($groups); | |
... lines 135 - 137 | |
} | |
} |
Go Deeper!
You could also use $request->query->getBoolean('deep')
instead of get()
to convert
the deep
query parameter into a boolean
. See accessing request data for other
useful methods.
And just like that, we're able to conditionally show fields. Sweet!
Re-run our test for testGETProgrammerDeep()
:
./bin/phpunit -c app --filter testGETProgrammer
It passes! To really prove it, refresh the browser. Nope, no user
property. Now add ?deep=1
to the URL. That's a cool way to leverage groups.
Wow, nice work guys! We've just taken another huge chunk out of our API with pagination, filtering and a whole lot of cool serialization magic. Ok, now keep going with the next episode!
Hey Anthony R.!
Sorry for my late reply! Symfony 4 comes out today (woo!) - it's been a bit busier around here than normal :).
So as far as "it violates REST" is concerned, the real "issue" is that normally, if you want to return different *representations* of a resource (e.g. a battle), then you would do it in some way that didn't involve the URL (e.g. by reading headers, etc). This is all a little bit fuzzy, honestly. But, the most common example is if you want to be able to return both the JSON or XML of a resource. The WRONG way to do it (via the rules of REST) is to change the URL somehow - e.g. /battles.json vs /battles.xml. That is because each URL should represent a unique resource... and these are 2 different URLs that are the *same* resource! Instead, you're suppose to tell your client to send an "Accept" header that tells the server what format they want. Then you have one URL, but that one URL can return different representations.
The same is basically true in this situation: returning different "amount" of embedded data is really just a different "representation" of the same resource. We're *always* returning a Battle resource, just with different "depth" of data (that's a different representation). REST would want us to do this in some way that didn't change the URL.
In other words, what is the most RESTful? Well, first, you should choose whether or not you want to embed your OneToMany data or not. And once you've decided this, do it consistently. Then, if you DO want to get fancy, you should make your client send a header (you can invent a header - X-API-DEPTH for example). And also, since REST should contain links, you would actually embed links to the OneToMany resources somewhere.
But in practice, headers are a bit more difficult (or at least, less obvious) for clients of your API to work with. That's why I kinda like the query parameter option. Rules be damned! :)
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
}
}
@weaverryan First, I'd love to say thank you because your tutorial is outstanding. The quality is just superb..
Quick question, you mention this violates REST.
If you have an entity Battle like in your example, would you return the id of the programmer in it only?
For example:
From what I understand OPTION 1 is not RESTful
OPTION 1:
{
"id": 12,
"programmer": {
"id": 1,
"nickname": "UnitTester",
"avatar": 3
}
}
But, OPTION 2 would be?
OPTION 2:
{
"id": 12,
"programmer": {
"id": 1
}
}
Or maybe OPTION 3?
OPTION 3:
{
"id": 12,
"programmer_id": 1
}
What would the best RESTful way to handle OneToMany(s) like this?
Thank you!