Rock-Solid, Consistent Collection Endpoints
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 SubscribeGo 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
:
// ... 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:
// ... 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:
// ... 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)
:
// ... 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()
:
// ... 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()
:
// ... 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:
// ... 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:
// ... 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)
:
// ... 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()
:
// ... 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:
// ... 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()
:
// ... 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...