Buy

Rock-Solid, Consistent Collection Endpoints

Go back to the test we're working on right now. First, every collection should have an items key for consistency. Assert that with $this->asserter()->assertResponsePropertyExists() for items:

318 lines tests/AppBundle/Controller/Api/ProgrammerControllerTest.php
... lines 1 - 7
class ProgrammerControllerTest extends ApiTestCase
{
... lines 10 - 64
public function testFollowProgrammerBattlesLink()
{
... lines 67 - 86
$this->asserter()->assertResponsePropertyExists($response, 'items');
$this->debugResponse($response);
}
... lines 90 - 316
}

Pagination in the Past

Next, open ProgrammerController. The whole reason the other endpoint had an items key was because - in listAction() - we went through our fancy pagination system. Click into the pagination_factory. The key part is that this method eventually creates a PaginatedCollection object:

58 lines src/AppBundle/Pagination/PaginationFactory.php
... lines 1 - 10
class PaginationFactory
{
... lines 13 - 19
public function createCollection(QueryBuilder $qb, Request $request, $route, array $routeParams = array())
{
... lines 22 - 33
$paginatedCollection = new PaginatedCollection($programmers, $pagerfanta->getNbResults());
... lines 35 - 55
return $paginatedCollection;
}
}

This is what we feed to the serializer.

The PaginatedCollection object is something we created. And hey! It has an $items property! So this isn't rocket science. It also has a few other properties: total, count and the pagination links:

27 lines src/AppBundle/Pagination/PaginatedCollection.php
... lines 1 - 4
class PaginatedCollection
{
private $items;
private $total;
private $count;
private $_links = array();
... lines 14 - 25
}

So if we want every collection endpoint to be identical, every endpoint should return a PaginatedCollection.

Creating a PaginatedCollection

We could do this the simple way: $collection = new PaginatedCollection() and pass it $battles and the total items - which right now is count($battles):

165 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 23
class ProgrammerController extends BaseController
{
... lines 26 - 154
public function battlesListAction(Programmer $programmer)
{
$battles = $this->getDoctrine()->getRepository('AppBundle:Battle')
->findBy(['programmer' => $programmer]);
$collection = new PaginatedCollection($battles, count($battles));
... lines 161 - 162
}
}

There's not actually any pagination going on.

At the bottom, pass that $collection to createApiResponse():

165 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 23
class ProgrammerController extends BaseController
{
... lines 26 - 154
public function battlesListAction(Programmer $programmer)
{
$battles = $this->getDoctrine()->getRepository('AppBundle:Battle')
->findBy(['programmer' => $programmer]);
$collection = new PaginatedCollection($battles, count($battles));
return $this->createApiResponse($collection);
}
}

Done! Run that test:

./vendor/bin/phpunit --filter testFollowProgrammerBattlesLink

Yes! Now we have an items key, and total, count and _links... which is empty.

Adding Real Pagination

And really: if we're going to all of this trouble to use the PaginatedCollection, shouldn't we go one extra half-step and actually add pagination? After all, it'll make this endpoint even more consistent by having those pagination links.

Change the $collection = line to $this->get('pagination_factory')->createCollection():

170 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 23
class ProgrammerController extends BaseController
{
... lines 26 - 154
public function battlesListAction(Programmer $programmer, Request $request)
{
... lines 157 - 159
$collection = $this->get('pagination_factory')->createCollection(
... lines 161 - 164
);
... lines 166 - 167
}
}

This needs a few arguments. The first is a query builder. So instead of making this full query for battles, we need to just return the query builder. Rename this to a new method called - createQueryBuilderForProgrammer() and pass it the $programmer object:

170 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 23
class ProgrammerController extends BaseController
{
... lines 26 - 154
public function battlesListAction(Programmer $programmer, Request $request)
{
$battlesQb = $this->getDoctrine()->getRepository('AppBundle:Battle')
->createQueryBuilderForProgrammer($programmer);
... lines 159 - 167
}
}

I'll hold command and I'll click Battle to jump into BattleRepository. Add that method: public function createQueryBuilderForProgrammer() with a Programmer $programmer argument:

17 lines src/AppBundle/Repository/BattleRepository.php
... lines 1 - 4
use AppBundle\Entity\Programmer;
... lines 6 - 7
class BattleRepository extends EntityRepository
{
public function createQueryBuilderForProgrammer(Programmer $programmer)
{
... lines 12 - 14
}
}

Fortunately, the query is easy: return $this->createQueryBuilder('battle'), then ->andWhere('battle.programmer = :programmer') with setParameter('programmer', $programmer):

17 lines src/AppBundle/Repository/BattleRepository.php
... lines 1 - 7
class BattleRepository extends EntityRepository
{
public function createQueryBuilderForProgrammer(Programmer $programmer)
{
return $this->createQueryBuilder('battle')
->andWhere('battle.programmer = :programmer')
->setParameter('programmer', $programmer);
}
}

Perfect! Back in ProgrammerController, rename the variable to $battlesQb and pass it to createCollection():

170 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 23
class ProgrammerController extends BaseController
{
... lines 26 - 154
public function battlesListAction(Programmer $programmer, Request $request)
{
$battlesQb = $this->getDoctrine()->getRepository('AppBundle:Battle')
->createQueryBuilderForProgrammer($programmer);
$collection = $this->get('pagination_factory')->createCollection(
$battlesQb,
... lines 162 - 164
);
... lines 166 - 167
}
}

The second argument is the request object. You guys know what to do: type-hint a new argument with Request and pass that in:

170 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 16
use Symfony\Component\HttpFoundation\Request;
... lines 18 - 23
class ProgrammerController extends BaseController
{
... lines 26 - 154
public function battlesListAction(Programmer $programmer, Request $request)
{
$battlesQb = $this->getDoctrine()->getRepository('AppBundle:Battle')
->createQueryBuilderForProgrammer($programmer);
$collection = $this->get('pagination_factory')->createCollection(
$battlesQb,
$request,
... lines 163 - 164
);
... lines 166 - 167
}
}

The third argument is the name of the route the pagination links should point to. That's this route: api_programmers_battles_list. Finally, the last argument is any route parameters that need to be passed to the route. This route has a nickname, so pass nickname => $programmer->getNickname():

170 lines src/AppBundle/Controller/Api/ProgrammerController.php
... lines 1 - 23
class ProgrammerController extends BaseController
{
... lines 26 - 154
public function battlesListAction(Programmer $programmer, Request $request)
{
... lines 157 - 159
$collection = $this->get('pagination_factory')->createCollection(
$battlesQb,
$request,
'api_programmers_battles_list',
['nickname' => $programmer->getNickname()]
);
... lines 166 - 167
}
}

Done. We basically changed one line to create a real paginated collection. And now, we celebrate. Run the test:

./vendor/bin/phpunit --filter testFollowProgrammerBattlesLink

That is real pagination pretty much out of the box. Yea, this only has three results and only one page: but if this programmer keeps having battles, we're covered.

We've really perfected a lot of traditional REST endpoints. Now, let's talk about what happens when endpoints get weird...

Leave a comment!