REST in practice with Symfony2

5,113 views

Published on

Translated version of slides used for my talk about creating RESTful APIs with Symfony2 at Italian SymfonyDay (Rome, October 18th 2013)

Published in: Technology, Design
0 Comments
16 Likes
Statistics
Notes
  • Be the first to comment

No Downloads
Views
Total views
5,113
On SlideShare
0
From Embeds
0
Number of Embeds
15
Actions
Shares
0
Downloads
103
Comments
0
Likes
16
Embeds 0
No embeds

No notes for slide

REST in practice with Symfony2

  1. 1. REST in practice with Symfony2
  2. 2. @dlondero
  3. 3. OFTEN...
  4. 4. Richardson Maturity Model
  5. 5. NOT TALKING ABOUT...
  6. 6. Level 0 POX - RPC
  7. 7. Level 1 RESOURCES
  8. 8. Level 2 HTTP VERBS
  9. 9. Level 3 HYPERMEDIA
  10. 10. TALKING ABOUT HOW TO DO
  11. 11. WHAT WE NEED ! •symfony/framework-standard-edition ! •friendsofsymfony/rest-bundle ! •jms/serializer-bundle ! •nelmio/api-doc-bundle
  12. 12. //src/Acme/ApiBundle/Entity/Product.php;! ! use SymfonyComponentValidatorConstraints as Assert;! use DoctrineORMMapping as ORM;! ! /**! * @ORMEntity! * @ORMTable(name="product")! */! class Product! {! /**! * @ORMColumn(type="integer")! * @ORMId! * @ORMGeneratedValue(strategy="AUTO")! */! protected $id;! ! ! ! /**! * @ORMColumn(type="string", length=100)! * @AssertNotBlank()! */! protected $name;! /**! * @ORMColumn(type="decimal", scale=2)! */! protected $price;! /**! * @ORMColumn(type="text")! */! protected $description;!
  13. 13. CRUD
  14. 14. Create HTTP POST
  15. 15. Request POST /products HTTP/1.1! Host: acme.com! Content-Type: application/json! ! {! "name": "Product #1",! "price": 19.90,! "description": "Awesome product"! }!
  16. 16. Response HTTP/1.1 201 Created! Location: http://acme.com/products/1! Content-Type: application/json! ! {! "product": {! "id": 1,! "name": "Product #1",! "price": 19.9,! "description": "Awesome product"! }!
  17. 17. //src/Acme/ApiBundle/Resources/config/routing.yml! ! acme_api_product_post:! pattern: /products! defaults: { _controller: AcmeApiBundle:ApiProduct:post, _format: json }! requirements:! _method: POST
  18. 18. //src/Acme/ApiBundle/Controller/ApiProductController.php! ! use FOSRestBundleViewView;! ! public function postAction(Request $request)! {! $product = $this->deserialize(! 'AcmeApiBundleEntityProduct',! $request! );! ! ! ! ! ! if ($product instanceof Product === false) {! return View::create(array('errors' => $product), 400);! }! $em = $this->getEM();! $em->persist($product);! $em->flush();! $url = $this->generateUrl(! 'acme_api_product_get_single',! array('id' => $product->getId()),! true! );! $response = new Response();! $response->setStatusCode(201);! $response->headers->set('Location', $url);! return $response;! }
  19. 19. Read HTTP GET
  20. 20. Request GET /products/1 HTTP/1.1! Host: acme.com
  21. 21. Response HTTP/1.1 200 OK! Content-Type: application/json! ! {! "product": {! "id": 1,! "name": "Product #1",! "price": 19.9,! "description": "Awesome product"! }!
  22. 22. public function getSingleAction(Product $product)! {! return array('product' => $product);! }
  23. 23. Update HTTP PUT
  24. 24. Request PUT /products/1 HTTP/1.1! Host: acme.com! Content-Type: application/json! ! {! "name": "Product #1",! "price": 29.90,! "description": "Awesome product"! }!
  25. 25. Response HTTP/1.1 204 No Content! ! HTTP/1.1 200 OK! Content-Type: application/json! ! {! "product": {! "id": 1,! "name": "Product #1",! "price": 29.90,! "description": "Awesome product"! }!
  26. 26. //src/Acme/ApiBundle/Controller/ApiProductController.php! ! use FOSRestBundleControllerAnnotationsView as RestView;! ! /**! * @RestView(statusCode=204)! */! public function putAction(Product $product, Request $request)! {! $newProduct = $this->deserialize(! 'AcmeApiBundleEntityProduct',! $request! );! ! if ($newProduct instanceof Product === false) {! return View::create(array('errors' => $newProduct), 400);! }! ! $product->merge($newProduct);! ! $this->getEM()->flush();! }
  27. 27. Partial Update HTTP PATCH
  28. 28. Request PATCH /products/1 HTTP/1.1! Host: acme.com! Content-Type: application/json! ! {! "price": 39.90,! }!
  29. 29. Response HTTP/1.1 204 No Content! ! HTTP/1.1 200 OK! Content-Type: application/json! ! {! "product": {! "id": 1,! "name": "Product #1",! "price": 39.90,! "description": "Awesome product"! }!
  30. 30. //src/Acme/ApiBundle/Controller/ApiProductController.php! ! use FOSRestBundleControllerAnnotationsView as RestView;! ! /**! * @RestView(statusCode=204)! */! public function patchAction(Product $product, Request $request)! {! $validator = $this->get('validator');! ! $raw = json_decode($request->getContent(), true);! ! $product->patch($raw);! ! if (count($errors = $validator->validate($product))) {! return $errors;! }! ! $this->getEM()->flush();! }
  31. 31. Delete HTTP DELETE
  32. 32. Request DELETE /products/1 HTTP/1.1! Host: acme.com
  33. 33. Response HTTP/1.1 204 No Content
  34. 34. //src/Acme/ApiBundle/Controller/ApiProductController.php! ! use FOSRestBundleControllerAnnotationsView as RestView;! ! /**! * @RestView(statusCode=204)! */! public function deleteAction(Product $product)! {! $em = $this->getEM();! $em->remove($product);! $em->flush();! }
  35. 35. Serialization
  36. 36. use JMSSerializerAnnotation as Serializer;! ! /**! * @SerializerExclusionPolicy("all")! */! class Product! {! /**! * @SerializerExpose! * @SerializerType("integer")! */! protected $id;! ! ! ! /**! * @SerializerExpose! * @SerializerType("string")! */! protected $name;! /**! * @SerializerExpose! * @SerializerType("double")! */! protected $price;! /**! * @SerializerExpose! * @SerializerType("string")! */! protected $description;!
  37. 37. Deserialization
  38. 38. //src/Acme/ApiBundle/Controller/ApiController.php! ! protected function deserialize($class, Request $request, $format = 'json')! {! $serializer = $this->get('serializer');! $validator = $this->get('validator');! ! try {! $entity = $serializer->deserialize(! $request->getContent(),! $class,! $format! );! } catch (RuntimeException $e) {! throw new HttpException(400, $e->getMessage());! }! ! if (count($errors = $validator->validate($entity))) {! return $errors;! }! ! return $entity;! }!
  39. 39. Testing
  40. 40. //src/Acme/ApiBundle/Tests/Controller/ApiProductControllerTest.php! ! use LiipFunctionalTestBundleTestWebTestCase;! ! class ApiProductControllerTest extends WebTestCase! {! public function testPost()! {! $this->loadFixtures(array());! ! $product = array(! 'name' => 'Product #1',! 'price' => 19.90,! 'description' => 'Awesome product',! );! ! $client = static::createClient();! $client->request(! 'POST', ! '/products', ! array(), array(), array(), ! json_encode($product)! );! ! $this->assertEquals(201, $client->getResponse()->getStatusCode());! $this->assertTrue($client->getResponse()->headers->has('Location'));! $this->assertContains(! "/products/1", ! $client->getResponse()->headers->get('Location')! );! }!
  41. 41. //src/Acme/ApiBundle/Tests/Controller/ApiProductControllerTest.php! ! public function testPostValidation()! {! $this->loadFixtures(array());! ! $product = array(! 'name' => '',! 'price' => 19.90,! 'description' => 'Awesome product',! );! ! $client = static::createClient();! $client->request(! 'POST', ! '/products', ! array(), array(), array(), ! json_encode($product)! );! ! $this->assertEquals(400, $client->getResponse()->getStatusCode());! }!
  42. 42. //src/Acme/ApiBundle/Tests/Controller/ApiProductControllerTest.php! ! public function testGetAction()! {! $this->loadFixtures(array(! 'AcmeApiBundleTestsFixturesProduct',! ));! ! $client = static::createClient();! $client->request('GET', '/products');! ! $this->isSuccessful($client->getResponse());! $response = json_decode($client->getResponse()->getContent());! ! $this->assertTrue(isset($response->products));! $this->assertCount(1, $response->products);! ! $product = $response->products[0];! $this->assertSame('Product #1', $product->name);! $this->assertSame(19.90, $product->price);! $this->assertSame('Awesome product!', $product->description);! }
  43. 43. //src/Acme/ApiBundle/Tests/Fixtures/Product.php! ! use AcmeApiBundleEntityProduct as ProductEntity;! ! use DoctrineCommonPersistenceObjectManager;! use DoctrineCommonDataFixturesFixtureInterface;! ! class Product implements FixtureInterface! {! public function load(ObjectManager $em)! {! $product = new ProductEntity();! $product->setName('Product #1');! $product->setPrice(19.90);! $product->setDescription('Awesome product!');! ! $em->persist($product);! $em->flush();! }! }!
  44. 44. //src/Acme/ApiBundle/Tests/Controller/ApiProductControllerTest.php! ! public function testGetSingleAction()! {! $this->loadFixtures(array(! 'AcmeApiBundleTestsFixturesProduct',! ));! ! $client = static::createClient();! $client->request('GET', '/products/1');! ! $this->isSuccessful($client->getResponse());! $response = json_decode($client->getResponse()->getContent());! ! $this->assertTrue(isset($response->product));! $this->assertEquals(1, $response->product->id);! $this->assertSame('Product #1', $response->product->name);! $this->assertSame(19.90, $response->product->price);! $this->assertSame(! 'Awesome product!', ! $response->product->description! );! }
  45. 45. //src/Acme/ApiBundle/Tests/Controller/ApiProductControllerTest.php! ! public function testPutAction()! {! $this->loadFixtures(array(! 'AcmeApiBundleTestsFixturesProduct',! ));! ! $product = array(! 'name' => 'New name',! 'price' => 39.90,! 'description' => 'Awesome new description'! );! ! $client = static::createClient();! $client->request(! 'PUT', ! '/products/1', ! array(), array(), array(), ! json_encode($product)! );! ! $this->isSuccessful($client->getResponse());! $this->assertEquals(204, $client->getResponse()->getStatusCode());! }
  46. 46. //src/Acme/ApiBundle/Tests/Controller/ApiProductControllerTest.php! ! /**! * @depends testPutAction! */! public function testPutActionWithVerification()! {! $client = static::createClient();! $client->request('GET', '/products/1');! $this->isSuccessful($client->getResponse());! $response = json_decode($client->getResponse()->getContent());! ! $this->assertTrue(isset($response->product));! $this->assertEquals(1, $response->product->id);! $this->assertSame('New name', $response->product->name);! $this->assertSame(39.90, $response->product->price);! $this->assertSame(! 'Awesome new description', ! $response->product->description! );! }
  47. 47. //src/Acme/ApiBundle/Tests/Controller/ApiProductControllerTest.php! ! public function testPatchAction()! {! $this->loadFixtures(array(! 'AcmeApiBundleTestsFixturesProduct',! ));! ! $patch = array(! 'price' => 29.90! );! ! $client = static::createClient();! $client->request(! 'PATCH', ! '/products/1', ! array(), array(), array(), ! json_encode($patch)! );! ! $this->isSuccessful($client->getResponse());! $this->assertEquals(204, $client->getResponse()->getStatusCode());! }
  48. 48. //src/Acme/ApiBundle/Tests/Controller/ApiProductControllerTest.php! ! public function testDeleteAction()! {! $this->loadFixtures(array(! 'AcmeApiBundleTestsFixturesProduct',! ));! ! $client = static::createClient();! $client->request('DELETE', '/products/1');! $this->assertEquals(204, $client->getResponse()->getStatusCode());! }
  49. 49. Documentation
  50. 50. //src/Acme/ApiBundle/Controller/ApiProductController.php! ! use NelmioApiDocBundleAnnotationApiDoc;! ! /**! * Returns representation of a given product! *! * **Response Format**! *! * {! * "product": {! * "id": 1,! * "name": "Product #1",! * "price": 19.9,! * "description": "Awesome product"! * }! * }! *! * @ApiDoc(! * section="Products",! * statusCodes={! * 200="OK",! * 404="Not Found"! * }! * )! */! public function getSingleAction(Product $product)! {! return array('product' => $product);! }!
  51. 51. Hypermedia?
  52. 52. There’s a bundle for that™
  53. 53. willdurand/hateoas-bundle
  54. 54. fsc/hateoas-bundle
  55. 55. //src/Acme/ApiBundle/Entity/Product.php;! ! use JMSSerializerAnnotation as Serializer;! use FSCHateoasBundleAnnotation as Rest;! use DoctrineORMMapping as ORM;! ! /**! * @ORMEntity! * @ORMTable(name="product")! * @SerializerExclusionPolicy("all")! * @RestRelation(! * "self", ! * href = @RestRoute("acme_api_product_get_single", ! * parameters = { "id" = ".id" })! * )! * @RestRelation(! * "products", ! * href = @RestRoute("acme_api_product_get")! * )! */! class Product! {! ...! }
  56. 56. application/hal+json
  57. 57. GET /orders/523 HTTP/1.1! Host: example.org! Accept: application/hal+json! ! HTTP/1.1 200 OK! Content-Type: application/hal+json! ! {! "_links": {! "self": { "href": "/orders/523" },! "invoice": { "href": "/invoices/873" }! },! "currency": "USD",! "total": 10.20! }
  58. 58. “What needs to be done to make the REST architectural style clear on the notion that hypertext is a constraint? In other words, if the engine of application state (and hence the API) is not being driven by hypertext, then it cannot be RESTful and cannot be a REST API. Period. Is there some broken manual somewhere that needs to be fixed?” Roy Fielding
  59. 59. “Anyway, being pragmatic, sometimes a level 2 well done guarantees a good API…” Daniel Londero
  60. 60. “But don’t call it RESTful. Period.” Roy Fielding
  61. 61. “Ok.” Daniel Londero
  62. 62. THANKS
  63. 63. @dlondero

×