$30 off During Our Annual Pro Sale. View Details »

Implementing an API 
in CakePHP

Implementing an API 
in CakePHP

You know the theory of RESTful APIs. But how does it translate to code? Join me as I walk you through the fundamentals of designing and implementing an API in CakePHP. I'll cover input validation, data filtering, internationalization, output and automated testing.

Anna Filina
PRO

May 28, 2016
Tweet

More Decks by Anna Filina

Other Decks in Programming

Transcript

  1. foolab.ca | @foolabca Implementing an API 
 in CakePHP CakeFest,

    Amsterdam - May 28, 2016
  2. Objectives • Alternative to code generators. • API flexibility. •

    Faster delivery. 2
  3. Anna Filina • Developer • Problem solver • Teacher •

    Advisor • FooLab + ConFoo 3
  4. Plan • Basic API in a controller. • Refactor &

    generalize. • Validation & tests. 4
  5. Basic API

  6. List Games 6

  7. Routing $routes->connect('/games', [ 'controller' => 'Games', 'action' => 'getList' ]);

    7
  8. Setup class GamesController extends AppController { public function beforeRender(Event $event)

    { $this->RequestHandler->renderAs($this, 'json'); $this->response->type('application/json'); } } 8
  9. Controller public function getList() { $gamesTable = TableRegistry::get('Games'); $games =

    $gamesTable ->find() ->hydrate(false) ->toList(); $response = [ 'data' => $games, ]; $this->set('response', $response); $this->set('_serialize', ['response']); } 9
  10. Search By Title $gamesTable = TableRegistry::get('Games'); $query = $gamesTable ->find()

    ->hydrate(false); $title = $this->request->query('title'); if ($title != null) { $query->where(['title LIKE' => '%'.$title.'%']); } $games = $query->toList(); 10
  11. /games?title=battle 11

  12. Refactor to Reusable Components

  13. Flowchart 13

  14. Flowchart 14

  15. Simplify Controller public function getList() { $apiCriteria = new ApiCriteria($this->request);

    $gamesTable = TableRegistry::get('Games'); $response = $gamesTable->getApiList($apiCriteria); $this->set('response', $response); $this->set('_serialize', ['response']); } 15
  16. Criteria class ApiCriteria { use AccessorTrait; private $userFilters = [];

    private $systemFilters = []; private $sort = []; private $pageSize = 10; private $pageNumber = 1; public function getFilters() { return $this->userFilters + $this->systemFilters; } } 16
  17. ApiCriteria public function __construct(Request $request) { foreach ($request->query as $param

    => $value) { switch ($param) { case 'pageSize': $this->pageSize = $value; break; case 'pageNumber': $this->pageNumber = $value; break; case 'sort': $this->sort = $value; break; default: $this->userFilters[$param] = $value; } } } 17
  18. Flowchart 18

  19. Table class GamesTable extends Table { public function getApiList(ApiCriteria 


    $apiCriteria, $hydrate = false) { $this->alias('root'); $query = $this ->find('all') ->hydrate($hydrate) ->select(['root.id', 'root.title') ; $apiQuery = new ApiQuery($this, $query, $apiCriteria); $results = $apiQuery->getList(); return $results; } } 19
  20. Flowchart 20

  21. ApiQuery public function addFilterCriteria(Query $query) { foreach ($this->apiCriteria->filters as $name

    => $value) { if ($value === '') { continue; } $this->table->{'add'.ucfirst($name).'Filter'}
 ($query, $value); } } public function addPageCriteria(Query $query) { $query->limit($this->apiCriteria->pageSize); $query->page($this->apiCriteria->pageNumber); } 21
  22. Table class GamesTable extends Table { public function addTitleFilter(Query $query,

    $value) { $query->andWhere(['root.title LIKE' => '%'.$value.'%']); } public function addTitleSort(Query $query, $order) { $query->order('root.title', $order=='-' ? 'DESC':'ASC'); } } 22
  23. Sorting // sort=title,-publisher case 'sort': $parts = explode(',', $value); foreach

    ($parts as $part) { preg_match_all('/^(\-)?([\w]+)$/', $part, $matches); if (empty($matches[0])) { throw new \Exception('Invalid sort format.', 1); } $name = $matches[2][0]; $order = $matches[1][0]; $this->sort[$name] = $order; } break; 23
  24. /games?sort=title 24

  25. Flowchart 25

  26. ApiQuery • Total results: only filters. • Limit + joins

    issue: select distinct ids first. • Items query: only filters by ids & sort. 26
  27. Table $apiQuery = new ApiQuery($this, $query, $apiCriteria); $results = $apiQuery->getList();

    27
  28. Count Query public function getList() { $this->table->alias('root'); $countQuery = $this->table

    ->query() ->hydrate(false) ->distinct(true) ->select(['count' => $this->query ->func()->count('root.id')]); $this->addFilterCriteria($countQuery); $count = $countQuery->toList()[0]['count']; if ($count == 0) { return [ 'data' => [], 'meta' => ['count' => 0, 'pages' => 0], ]; } } 28
  29. Ids Query $idsQuery = $this->table ->query() ->hydrate(false) ->distinct(true) ->select(['root.id']) ;

    $this->addFilterCriteria($idsQuery); $this->addSortCriteria($idsQuery); $this->addPageCriteria($idsQuery); $ids = array_map(function($item) { return $item['id']; }, $idsQuery->toList()); 29
  30. Item Query $itemQuery = $this->query ->where(['root.id' => $ids], ['root.id' =>

    'integer[]']) ; $this->addSortCriteria($itemQuery); $results = $itemQuery->toList(); return [ 'data' => $results, 'meta' => [ 'count' => $count, 'pages' => ceil($count/$this->apiCriteria->pageSize), ], ]; 30
  31. Output public function getList() { $apiCriteria = new ApiCriteria($this->request); $gamesTable

    = TableRegistry::get('Games'); $response = $gamesTable->getApiList($apiCriteria); $this->set('response', $response); $this->set('_serialize', ['response']); } 31
  32. /games?title=battle 32

  33. Flowchart 33

  34. How Much Code? • ApiCriteria: 22 lines, 2 methods •

    ApiQuery: 30 lines, 5 methods • GamesTable: 11 lines, 3 methods • GamesController: 26 lines, 2 methods • Total: 77 lines, 1.93 cyclomatic complexity 34
  35. Single Item

  36. /games/2 36

  37. Routing $routes->connect('/games/:id', ['controller' => 'Games', 'action' => 'getItem'], [ 'pass'

    => ['id'] ] ); 37
  38. Controller public function getItem($id) { $apiCriteria = new ApiCriteria($this->request); $apiCriteria->addUserFilter('id',

    $id); $gamesTable = TableRegistry::get('Games'); $response = $gamesTable->getApiItem($apiCriteria); $this->set('response', $response); $this->set('_serialize', ['response']); } 38
  39. Table public function getApiItem(ApiCriteria $apiCriteria, $hydrate = false) { $this->alias('root');

    $query = $this ->find('all') ->hydrate($hydrate) ->select(['root.id', 'root.title', 'root.image_path']) ; $apiQuery = new ApiQuery($this, $query, $apiCriteria); $results = $apiQuery->getItem(); return $results; } public function addIdFilter(Query $query, $value) { $query->andWhere(['root.id' => $value]); } 39
  40. ApiQuery public function getItem() { $itemQuery = $this->query; $this->addFilterCriteria($itemQuery); $results

    = $itemQuery->toList(); $data = null; if (count($results) == 1) { $data = $results[0]; } return [ 'data' => $data, 'meta' => [], ]; } 40
  41. Validate & Save

  42. PATCH /games/2 42

  43. Routing $routes->connect('/games/:id', [ 'controller' => 'Games', 'action' => 'editItem', '[method]'

    => 'PATCH' ], [ 'pass' => ['id'] ] ); 43
  44. Controller public function editItem($id) { $apiCriteria = new ApiCriteria($this->request); $apiCriteria->addUserFilter('id',

    $id); $apiInput = new ApiInput($this->request); $gamesTable = TableRegistry::get('Games'); $game = $gamesTable->getApiItem($apiCriteria, true)['data']; $response = $gamesTable->saveApiItem($apiInput, $game, false); if (isset($response['errors'])) { $this->response->statusCode(400); } $this->set('response', $response); $this->set('_serialize', ['response']); } 44
  45. Table public function saveApiItem(ApiInput $apiInput, Entity $item = null, $newRecord

    = true) { if ($newRecord) { $item = $this->newEntity($apiInput->data); } else { $item = $this->patchEntity($item, $apiInput->data); } $errors = $item->errors(); if (count($errors) > 0) { return [ 'errors' => $errors, ]; } $this->save($item); return [ 'data' => $item->toArray(), ]; } 45
  46. Table public function validationDefault(Validator $validator) { $validator ->requirePresence('title') ->notEmpty('title', 'Cannot

    be empty') ->add('title', [ 'length' => [ 'rule' => ['minLength', 2], 'message' => 'Min 2 characters', ] ]) ; return $validator; } 46
  47. Upload & Test

  48. Test class GamesControllerTest extends \PHPUnit_Framework_TestCase { private $client; public function

    setUp() { $this->client = new Client([ 'base_uri' => 'http://cakeapi.dev', 'timeout' => 2.0, ]); } } 48
  49. Test public function testEdit_WithCover_ReturnsSuccess() { $file = file_get_contents(__DIR__.'/bf4.png'); $response =

    $this->client->request('PATCH', '/games/2', [ 'http_errors' => false, 'json' => ['cover' => base64_encode($file)] ]); $this->assertEquals(200, $response->getStatusCode()); } 49
  50. PHPUnit phpunit --bootstrap vendor/autoload.php tests/TestCase/Api 50

  51. Test $this->client->request('GET', '/games?title=bf'); $this->assertEquals(200, $response->getStatusCode()); $body = $this->client->getResponse()->getContent(); $this->assertJsonStringEqualsJsonString(' {

    "data": [ { "id": 2, "name": "BF4" } ] }', $body); 51
  52. Takeaways • No need for views, just serialize to JSON.

    • Input JSON data in the request body. • Keep all the filter and sort logic in the table. • Keep controllers lean, offload work to reusable classes. • Simple acceptance tests for API endpoints. • Unit tests for domain logic. 52
  53. Anna Filina • Development. • Fix bugs & performance issues.

    • Workshops on testing, frameworks & APIs. • Advisor on testing strategy, legacy code. 53
  54. @afilina afilina.com joind.in