Ce diaporama a bien été signalé.
Nous utilisons votre profil LinkedIn et vos données d’activité pour vous proposer des publicités personnalisées et pertinentes. Vous pouvez changer vos préférences de publicités à tout moment.

REST in practice with Symfony2

5 334 vues

Publié le

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

Publié dans : Technologie, Design
  • Soyez le premier à commenter

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

×