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.

B3b2139e4f2c0eca4efe2379fcebc1c5?s=128

Anna Filina

May 28, 2016
Tweet

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