Share
Embed
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
May 28, 2016
Transcript
54 slides
-
foolab.ca | @foolabca Implementing an API in CakePHP CakeFest,
Amsterdam - May 28, 2016 -
Objectives • Alternative to code generators. • API flexibility. •
Faster delivery. 2 -
Anna Filina • Developer • Problem solver • Teacher •
Advisor • FooLab + ConFoo 3 -
Plan • Basic API in a controller. • Refactor &
generalize. • Validation & tests. 4 -
Setup class GamesController extends AppController { public function beforeRender(Event $event)
{ $this->RequestHandler->renderAs($this, 'json'); $this->response->type('application/json'); } } 8 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
ApiQuery • Total results: only filters. • Limit + joins
issue: select distinct ids first. • Items query: only filters by ids & sort. 26 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
Routing $routes->connect('/games/:id', [ 'controller' => 'Games', 'action' => 'editItem', '[method]'
=> 'PATCH' ], [ 'pass' => ['id'] ] ); 43 -
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 -
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 -
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 -
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 -
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 -
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 -
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 -
Anna Filina • Development. • Fix bugs & performance issues.
• Workshops on testing, frameworks & APIs. • Advisor on testing strategy, legacy code. 53