Buy

Pagination Design and Test

Hey, Guys! Welcome to Episode 3 of our REST in Symfony Series. In this episode, we're going to cover some really important details we haven't talked about yet, like pagination, filtering, and taking the serializer and doing really cool and custom things with it.

If you're following along with me, use the same project we've been building. If you're just joining, where have you been? Ah, it's fine: download the code from this page and move into the start/ directory. Start up the built-in PHP web server to get things running:

./app/console server:run

Designing how Pagination should Work

Let's talk about pagination first, because the /api/programmers endpoint doesn't have it. Eventually, once someone talks about our cool app on Reddit, we're going to have a lot of programmers here: too many to return all at once. First, think about pagination on the web. How does it work? Usually, it's done with query parameters: something like ?page=1, ?page=2, and so on. Sometimes, it's done in the URL - like /products/1 and /products/2. For API's, query parameters is better.

Second, on the web, we don't make the user guess those URLs: we give them links, like "next" and "previous", and maybe even "first" and "last".

So why would building an API be any different? Let's use query parameters and include links to help the API client get around.

Adding a Test

Like always, we're gonna start with a test because it's the easiest way to try things out and it helps us think about the API's design. In ProgrammerControllerTest, find the testProgrammersCollection() method and copy this to make a new test for pagination:

196 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 71
public function testGETProgrammersCollectionPaginated()
{
... lines 74 - 88
}
... lines 90 - 194
}

To make this interesting, we need more programmers - like 25. Add a for loop to do this: for i=0; i<25; i++. In each loop, create a programmer with the super creative name of Programmer plus the $i value. This means that we'll have programmers zero through 24. The avatarNumber is required, but we don't care about its value:

196 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 71
public function testGETProgrammersCollectionPaginated()
{
for ($i = 0; $i < 25; $i++) {
$this->createProgrammer(array(
'nickname' => 'Programmer'.$i,
'avatarNumber' => 3,
));
}
// page 1
... lines 82 - 88
}
... lines 90 - 194
}

Keep the same URL and the 200 status code assertion. Below, start basic with a sanity check for page 1: assert that the programmer with index 5 is equal to Programmer5:

196 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 71
public function testGETProgrammersCollectionPaginated()
{
... lines 74 - 80
// page 1
$response = $this->client->get('/api/programmers');
$this->assertEquals(200, $response->getStatusCode());
$this->asserter()->assertResponsePropertyEquals(
$response,
'programmers[5].nickname',
'Programmer5'
);
}
... lines 90 - 194
}

I'll use multiple lines to keep things clear. Index 5 is actually the 6th programmer, but since we start with Programmer0, this should definitely be Programmer5.

Adding count and total

It might also be useful to tell the API client how many results are on this page and how many results there are in total. I want to show 10 results per page in the API so add a line that looks for a new property called count that's set to 10. Let's also have another property called total. That'll be the total number of results. In this case, that should be 25:

200 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 71
public function testGETProgrammersCollectionPaginated()
{
... lines 74 - 89
$this->asserter()->assertResponsePropertyEquals($response, 'count', 10);
$this->asserter()->assertResponsePropertyEquals($response, 'total', 25);
... line 92
}
... lines 94 - 198
}

Finally, the API response needs to have those links! And by "links", I mean that I want to add a new field - maybe called "next" - whose value will be the URL to get the next page of results. Use the asserter again and change this to assertResponsePropertyExists(). Let's assert that there is an _links.next key, which means the JSON will have an _links key and a next key under that:

200 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 71
public function testGETProgrammersCollectionPaginated()
{
... lines 74 - 91
$this->asserter()->assertResponsePropertyExists($response, '_links.next');
}
... lines 94 - 198
}

By moving things under _links, it makes it a little more obvious that next isn't a property of a programmer, but something different: a link.

Oh, and you probably saw my mistake above: change the line above to total, not count.

And here's where things get really cool. In our test, we need to make a request to page 2 and make sure we see the next 10 programmers. Instead of hardcoding the URL, we can read the next link and use that for the next request. It's like the API version of clicking links!

Use $this->asserter() and then a method called readResponseProperty() to read the _links.next property. Now, add $response = $this->client->get($nextUrl) to go to the next page:

211 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 71
public function testGETProgrammersCollectionPaginated()
{
... lines 74 - 93
// page 2
$nextLink = $this->asserter()->readResponseProperty($response, '_links.next');
$response = $this->client->get($nextLink);
... lines 97 - 103
}
... lines 105 - 209
}

Ok, let's test page 2! Copy some of the asserts that we just wrote. This time, the programmer with index 5 should be Programmer15 because we're looking at results 11 through 20. Next, the count should still be 10, and the total still 25 - but let's save a little code and remove that line:

211 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 71
public function testGETProgrammersCollectionPaginated()
{
... lines 74 - 96
$this->assertEquals(200, $response->getStatusCode());
$this->asserter()->assertResponsePropertyEquals(
$response,
'programmers[5].nickname',
'Programmer15'
);
$this->asserter()->assertResponsePropertyEquals($response, 'count', 10);
}
... lines 105 - 209
}

The next link is nice. But we can do even more by also having a first link, a last link and a prev link unless we're on page 1. Copy the code from earlier that clicked the next link. Ooh, and let me fixing my formatting!

This time, use the _links.last key and update the variable to be $lastUrl. When we make a request to the final page, programmers[4] will be the last programmer because we started with index 0. The name should be Programmer24. And on this last page, count should be just 5:

223 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 71
public function testGETProgrammersCollectionPaginated()
{
... lines 74 - 104
$lastLink = $this->asserter()->readResponseProperty($response, '_links.last');
$response = $this->client->get($lastLink);
$this->assertEquals(200, $response->getStatusCode());
$this->asserter()->assertResponsePropertyEquals(
$response,
'programmers[4].nickname',
'Programmer24'
);
... line 114
$this->asserter()->assertResponsePropertyEquals($response, 'count', 5);
}
... lines 117 - 221
}

I'm also going to use the asserter with assertResponsePropertyDoesNotExist() to make sure that there is no programmer here with index 5. Specifically, check for no programmers[5].nickname path:

223 lines src/AppBundle/Tests/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 5
class ProgrammerControllerTest extends ApiTestCase
{
... lines 8 - 71
public function testGETProgrammersCollectionPaginated()
{
... lines 74 - 113
$this->asserter()->assertResponsePropertyDoesNotExist($response, 'programmers[5].name');
$this->asserter()->assertResponsePropertyEquals($response, 'count', 5);
}
... lines 117 - 221
}

There's a small bug in my asserter code: if I just check for programmers[5], it thinks it exists but is set to null. That's why I'm checking for the nickname key.

That's it! Our pagination system is now really well-defined. Next, we'll bring this all to life.

Leave a comment!