Simple API Development with Laravel

Simple API Development with Laravel

Presentation from Laracon 2013 - Washington, D.C.

2c91f8dbf323072a564dbc4d97a0b627?s=128

Aaron Kuzemchak

February 23, 2013
Tweet

Transcript

  1. Simple API Development with Laravel Aaron Kuzemchak Laracon 2013 –

    Washington, D.C. kuzemchak.net • @akuzemchak • github.com/akuzemchak
  2. Who am I?

  3. Where to find me online Blog: http://kuzemchak.net/ Twitter: @akuzemchak Github:

    akuzemchak
  4. Why am I here today?

  5. I’m in love with Laravel Have been since v2

  6. I’m here to hang out with you... the Laravel community

    AKA: The “I used to be with CodeIgniter and met Laravel at the funeral” community
  7. I’m here to talk to you about building REST APIs

  8. What we’ll be going over today

  9. 1. Foundation & best practices

  10. 2. Authentication

  11. 3. Rate limits

  12. Why Laravel?

  13. “The perfect merger of simplicity and power” — me

  14. REST is part of the foundation

  15. Filters & events keep things clean

  16. And also...

  17. Laravel is perhaps Arkansas’ greatest export since Walmart

  18. Well, except for my wife ... and my dog ...

    and my college degree No, I’m not from there!
  19. Anywho...

  20. Let’s talk about REST

  21. What is it?

  22. “Representational State Transfer (REST) is a style of so!ware architecture

    for distributed systems such as the World Wide Web …” “REST has emerged as a predominant Web service design model. The REST architectural style was developed in parallel with HTTP/1.1, based on the existing design of HTTP/1.0 ...” “REST exemplifies how the Web's architecture emerged by characterizing and constraining the macro-interactions of the four components of the Web, namely origin servers, gateways, proxies and clients, without imposing limitations on the individual participants.” — Wikipedia
  23. None
  24. “A RESTful web service (also called a RESTful web API)

    is a web service implemented using HTTP and the principles of REST. It is a collection of resources ...” — Wikipedia
  25. REST uses standard HTTP methods to perform specific actions

  26. GET ‛ Read POST ‛ Create PUT ‛ Update DELETE

    ‛ Duh
  27. Also, it’s much cooler that other Web service protocol I’m

    the king of the WSDL!
  28. Let’s build a sample REST API

  29. A TODO list API I know... boring but effective

  30. A few things to note before we start:

  31. 1. I assume you have basic knowledge of Laravel

  32. 2. All of the code is available on Github akuzemchak/laracon-todo-api

  33. 3. Keeping things very simple No content negotiation, HATEOAS, ETags,

    or other assorted fanciness
  34. 4. These are my solutions to these problems 4.1. They

    are not applicable to every developer or project
  35. 5. There is no one right way to build an

    API
  36. There are, however, a lot of wrong ways

  37. When building a REST API, you should follow the established

    guidelines/principles
  38. KEEP REFRIGERATED

  39. But they aren’t enforced, so it still technically works if

    you don’t follow them
  40. None
  41. This means there are a lot of bad APIs out

    there
  42. Some awful stuff I’ve seen:

  43. This was considered a valid 404 response HTTP/1.1 200 OK

    Content-Type: text/html Not found
  44. HTTP/1.1 200 OK Content-Type: text/html "1|My first task|false|2012-02-11 22:34:00|2012-02-11 22:34:00\n2|My

    second task|false|2012-02-11 22:36:00|2012-02-11 22:36:00" This was considered a valid JSON response
  45. So was this HTTP/1.1 200 OK Content-Type: text/html MXxNeSBmaXJzdCB0YXNrfGZhbHNlfDIwMTItMDItMTEgMjI6MzQ6MDB8MjAxMi0w Mi0xMSAyMjozNDowMAoyfE15IHNlY29uZCB0YXNrfGZhbHNlfDIwMTItMDItMTEg

    MjI6MzY6MDB8MjAxMi0wMi0xMSAyMjozNjowMA==
  46. I know...

  47. None
  48. Which brings us to:

  49. Foundation & best practices

  50. 0. Find a good testing tool

  51. Testing directly in the browser is frustrating

  52. Postman for Chrome

  53. RESTed for Mac $2.99 in App Store

  54. HTTPie for terminal junkies

  55. 1. Session driver

  56. Every API request will spawn a new session

  57. Lots of junk rows/keys/files

  58. None
  59. Unnecessary cookies

  60. None
  61. Solution: Use the “array” driver

  62. // app/config/session.php /* |-------------------------------------------------------------------------- | Default Session Driver |-------------------------------------------------------------------------- |

    | This option controls the default session "driver" that will be used on | requests. By default we will use the light-weight cookie driver but | you may specify any of the other wonderful drivers provided here. | | Supported: "cookie", file", "database", "apc", | "memcached", "redis", "array" | */ 'driver' => 'array',
  63. You can still use session features

  64. No persistence, no cookies

  65. 2. Error handling

  66. Setup our errors to return JSON

  67. // app/errors.php // General HttpException handler App::error(function(Symfony\Component\HttpKernel\Exception\HttpException $e, $code) {

    $headers = $e->getHeaders(); switch ($code) { case 401: $default_message = 'Invalid API key'; $headers['WWW-Authenticate'] = 'Basic realm="REST API"'; break; case 403: $default_message = 'Insufficient privileges to perform this action'; break; case 404: $default_message = 'The requested resource was not found'; break; default: $default_message = 'An error was encountered'; } return Response::json(array( 'error' => $e->getMessage() ?: $default_message, ), $code, $headers); });
  68. // app/errors.php // ErrorMessageException handler App::error(function(ErrorMessageException $e) { $messages =

    $e->getMessages()->all(); return Response::json(array( 'error' => $messages[0], ), 400); }); // NotFoundException handler App::error(function(NotFoundException $e) { $default_message = 'The requested resource was not found'; return Response::json(array( 'error' => $e->getMessage() ?: $default_message, ), 404); }); // PermissionException handler App::error(function(PermissionException $e) { $default_message = 'Insufficient privileges to perform this action'; return Response::json(array( 'error' => $e->getMessage() ?: $default_message, ), 403); });
  69. 3. URI design & routes

  70. /{type}/{id} /{type}/{id}/{sub-type}/{id}

  71. /lists/1 /lists/1/tasks/1

  72. Method + URI = Action

  73. GET /lists Get all lists GET /lists/1 Get list w/

    id == 1 GET /lists/1/tasks Get all tasks for list w/ id == 1 GET /lists/1/tasks/1 Get task w/ id == 1 for list w/ id == 1
  74. POST /lists Create new list PUT /lists/1 Update list w/

    id == 1 DELETE /lists/1 Delete list w/ id == 1
  75. POST /lists/1/tasks Create new task for list w/ id ==

    1 PUT /lists/1/tasks/1 Update task w/ id == 1 DELETE /lists/1/tasks/1 Delete task w/ id == 1
  76. While we’re on the topic of URIs...

  77. Versioning is a very good idea

  78. /v1/lists/1 /v1/lists/1/tasks/1

  79. // app/routes.php Route::group(array('prefix' => 'v1', 'before' => 'api.auth|api.limit'), function() {

    // Routes go here... }); Easy with route groups and prefixes
  80. Completely re-architect later without breaking anything

  81. So you won’t catch a beatdown for breaking your users

    apps
  82. 4. Send good responses That includes headers!

  83. Response codes tell your users what happened

  84. {"success": false} The old way

  85. Common response codes

  86. 200: Success! 201: Resource created 204: Success, but no content

    to return 400: Request not fulfilled 401: Not authenticated 403: Refusal to respond 404: Not found 500: Other error
  87. An example use case

  88. // Request setup stuff up here... $response = curl_exec($ch); $code

    = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($code !== 200) { // Handle the error... if ($code === 404) { // Do some additional logging... } } // Continue on with code...
  89. Let’s look at how our API responds

  90. HTTP/1.1 200 OK Content-Type: application/json { "id": 1, "name": "Things

    to do at Laracon", "created_at": "2013-02-04 18:18:52", "updated_at": "2013-02-04 18:18:52" } GET /v1/lists/1
  91. // app/routes.php Route::get('lists/{id}', function($id) { $list = TaskList::findByOwnerAndId(Auth::user(), $id); return

    Response::json($list->toArray()); })->where('id', '\d+');
  92. // app/models/TaskList.php public static function findByOwnerAndId($owner, $id) { if (!is_numeric($owner)

    && !($owner instanceof UserInterface)) { throw new InvalidArgumentException('Owner must be either a numeric ID or an instance of UserInterface'); } $list = static::find($id); if (!$list) { throw new NotFoundException('List was not found'); } $owner_id = ($owner instanceof UserInterface) ? (int) $owner->id : (int) $owner; if ((int) $list->user_id !== $owner_id) { throw new PermissionException('Insufficient access privileges for this list'); } return $list; }
  93. // app/models/TaskList.php public function toArray() { $data = parent::toArray(); $data['id']

    = (int) $data['id']; $data['created_at'] = $this->fromDateTime($this->created_at); $data['updated_at'] = $this->fromDateTime($this->updated_at); return $data; }
  94. GET /v1/lists/1/tasks HTTP/1.1 200 OK Content-Type: application/json []

  95. // app/routes.php Route::get('lists/{id}/tasks', function($id) { $list = TaskList::findByOwnerAndId(Auth::user(), $id); return

    Response::json($list->tasks->toArray()); })->where('id', '\d+');
  96. POST /v1/lists/1/tasks REQUEST: description=Make+CodeIgniter+joke+during+presentation RESPONSE: HTTP/1.1 201 Created Content-Type: application/json

    { "description": "Make CodeIgniter joke during presentation", "updated_at": "2013-02-04 18:23:08", "created_at": "2013-02-04 18:23:08", "id": 1, "completed": false }
  97. // app/routes.php Route::post('lists/{id}/tasks', function($id) { $list = TaskList::findByOwnerAndId(Auth::user(), $id); $task

    = new Task(Input::get()); $task->validate(); $task->list_id = $id; if (!$task->save()) { App::abort(500, 'Task was not saved'); } return Response::json($task->toArray(), 201); })->where('id', '\d+');
  98. HTTP/1.1 200 OK Content-Type: application/json [ { "id": 1, "description":

    "Make CodeIgniter joke during presentation", "completed": false, "created_at": "2013-02-04 18:23:08", "updated_at": "2013-02-04 18:23:08" } ] GET /v1/lists/1/tasks
  99. GET /v1/lists/1/tasks/1 HTTP/1.1 200 OK Content-Type: application/json { "id": 1,

    "description": "Make CodeIgniter joke during presentation", "completed": false, "created_at": "2013-02-04 18:23:08", "updated_at": "2013-02-04 18:23:08" }
  100. // app/routes.php Route::get('lists/{list_id}/tasks/{id}', function($list_id, $id) { $list = TaskList::findByOwnerAndId(Auth::user(), $list_id);

    $task = $list->tasks()->find($id); if (!$task) { App::abort(404); } return Response::json($task->toArray()); })->where('list_id', '\d+')->where('id', '\d+');
  101. PUT /v1/lists/1/tasks/1 REQUEST: completed=true RESPONSE: HTTP/1.1 200 OK Content-Type: application/json

    { "id": 1, "description": "Make CodeIgniter joke during presentation", "completed": true, "created_at": "2013-02-04 18:23:08", "updated_at": "2013-02-04 18:33:19" }
  102. // app/routes.php Route::put('lists/{list_id}/tasks/{id}', function($list_id, $id) { $list = TaskList::findByOwnerAndId(Auth::user(), $list_id);

    $task = $list->tasks()->find($id); if (!$task) { App::abort(404); } $task->fill(Input::get()); $task->validate(); if (!$task->save()) { App::abort(500, 'Task was not updated'); } return Response::json($task->toArray()); })->where('list_id', '\d+')->where('id', '\d+');
  103. DELETE /v1/lists/1/tasks/1 HTTP/1.1 204 No Content

  104. // app/routes.php Route::delete('lists/{list_id}/tasks/{id}', function($list_id, $id) { $list = TaskList::findByOwnerAndId(Auth::user(), $list_id);

    $task = $list->tasks()->find($id); if (!$task) { App::abort(404); } $task->delete(); return Response::make(null, 204); })->where('list_id', '\d+')->where('id', '\d+');
  105. What about errors?

  106. GET /v1/bazinga HTTP/1.1 404 Not Found Content-Type: application/json { "error":

    "The requested resource was not found" }
  107. POST /v1/lists/1/tasks REQUEST: *This space intentionally left blank* RESPONSE: HTTP/1.1

    400 Bad Request Content-Type: application/json { "error": "Description is required" }
  108. To sum it up: Don’t test in the browser Use

    the “array” session driver Handle errors properly Pay attention to URI design Send good responses
  109. Authentication

  110. Serves two primary purposes

  111. 1. Identify the user

  112. 2. Keep that user from causing trouble

  113. Don’t reinvent the wheel

  114. See what other APIs do

  115. You’re probably going to see these options:

  116. 1. API key via query string Old school APIs... don’t

    do this
  117. 2. HTTP authentication Secret API key or user/pass for credentials

  118. 3. OAuth Tokens, tokens, tokens!

  119. Since we’re talking about simple, we’ll go with option #2

  120. Specifically: API key as username over HTTP Basic auth Password

    can be anything... we don’t care
  121. We need to generate API keys for our users

  122. // app/models/User.php public static function createApiKey() { return Str::random(32); }

    // app/events.php User::creating(function($user) { $user->api_key = User::createApiKey(); });
  123. Now we create a filter

  124. // app/filters.php Route::filter('api.auth', function() { if (!Request::getUser()) { App::abort(401, 'A

    valid API key is required'); } $user = User::where('api_key', '=', Request::getUser())->first(); if (!$user) { App::abort(401); } Auth::login($user); });
  125. Auth w/ cURL in PHP $ch = curl_init('http://api.laracon.app/v1/lists'); curl_setopt($ch, CURLOPT_RETURNTRANSFER,

    true); curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); curl_setopt($ch, CURLOPT_USERPWD, 'aa284e44ba20b63f880fb68157736829:x'); $response = curl_exec($ch); curl_close($ch);
  126. Auth w/ Postman

  127. Auth w/ RESTed

  128. http --auth aa284e44ba20b63f880fb68157736829:x api.laracon.app/v1/lists Auth w/ HTTPie

  129. HTTP/1.1 401 Unauthorized WWW-Authenticate: Basic realm="REST API" Content-Type: application/json {

    "error": "A valid API key is required" } If we fail authentication
  130. If we pass, request succeeds

  131. Rate limits

  132. Second line of defense against abuse Authentication is the first

  133. Determine how and what to limit

  134. In our case: Overall hourly limit per user

  135. Pick a key/value storage engine Preferably an in-memory solution

  136. •APC •Memcached •Redis

  137. Very lightweight and fast

  138. Automatically expire keys at specified intervals

  139. Let’s use APC, since you should already be running it

  140. None
  141. Because there’s no way to update a value without changing

    the TTL
  142. A simple filter to do this

  143. // app/filters.php Route::filter('api.limit', function() { $key = sprintf('api:%s', Auth::user()->api_key); //

    Create the key if it doesn't exist apc_add($key, 0, 60*60); // Increment by 1 $count = apc_inc($key); // Fail if hourly requests exceeded if ($count > Config::get('api.requests_per_hour')) { App::abort(403, 'Hourly request limit exceeded'); } });
  144. Counts will reset every hour automatically

  145. Closing thoughts

  146. API’s don’t need to be complicated

  147. Please try to stick to REST principles

  148. Utilize your resources Copy from well-designed APIs Have other developers

    use it Ask if you’re not sure about something
  149. None
  150. That’s it for me! Thanks for listening kuzemchak.net • @akuzemchak

    • github.com/akuzemchak