Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

    View Slide

  2. Objectives
    • Alternative to code generators.
    • API flexibility.
    • Faster delivery.
    2

    View Slide

  3. Anna Filina
    • Developer
    • Problem solver
    • Teacher
    • Advisor
    • FooLab + ConFoo
    3

    View Slide

  4. Plan
    • Basic API in a controller.
    • Refactor & generalize.
    • Validation & tests.
    4

    View Slide

  5. Basic API

    View Slide

  6. List Games
    6

    View Slide

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

    View Slide

  8. Setup
    class GamesController extends AppController
    {
    public function beforeRender(Event $event)
    {
    $this->RequestHandler->renderAs($this, 'json');
    $this->response->type('application/json');
    }
    }
    8

    View Slide

  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

    View Slide

  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

    View Slide

  11. /games?title=battle
    11

    View Slide

  12. Refactor to Reusable
    Components

    View Slide

  13. Flowchart
    13

    View Slide

  14. Flowchart
    14

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  18. Flowchart
    18

    View Slide

  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

    View Slide

  20. Flowchart
    20

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  24. /games?sort=title
    24

    View Slide

  25. Flowchart
    25

    View Slide

  26. ApiQuery
    • Total results: only filters.
    • Limit + joins issue: select distinct ids first.
    • Items query: only filters by ids & sort.
    26

    View Slide

  27. Table
    $apiQuery = new ApiQuery($this, $query, $apiCriteria);
    $results = $apiQuery->getList();
    27

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  32. /games?title=battle
    32

    View Slide

  33. Flowchart
    33

    View Slide

  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

    View Slide

  35. Single Item

    View Slide

  36. /games/2
    36

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  41. Validate & Save

    View Slide

  42. PATCH /games/2
    42

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  47. Upload & Test

    View Slide

  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

    View Slide

  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

    View Slide

  50. PHPUnit
    phpunit --bootstrap vendor/autoload.php tests/TestCase/Api
    50

    View Slide

  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

    View Slide

  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

    View Slide

  53. Anna Filina
    • Development.
    • Fix bugs & performance issues.
    • Workshops on testing, frameworks & APIs.
    • Advisor on testing strategy, legacy code.
    53

    View Slide

  54. @afilina afilina.com
    joind.in

    View Slide