Upload
daniel-londero
View
3.034
Download
2
Tags:
Embed Size (px)
DESCRIPTION
Translated version of slides used for my talk about creating RESTful APIs with Symfony2 at Italian SymfonyDay (Rome, October 18th 2013)
Citation preview
REST in practice with Symfony2
@dlondero
OFTEN...
Richardson Maturity Model
NOT TALKING ABOUT...
Level 0
POX - RPC
Level 1
RESOURCES
Level 2
HTTP VERBS
Level 3
HYPERMEDIA
TALKING ABOUT HOW
TO DO
WHAT WE NEED !
•symfony/framework-standard-edition !
•friendsofsymfony/rest-bundle !
•jms/serializer-bundle !
•nelmio/api-doc-bundle
//src/Acme/ApiBundle/Entity/Product.php;!!use Symfony\Component\Validator\Constraints as Assert;!use Doctrine\ORM\Mapping as ORM;!!/**! * @ORM\Entity! * @ORM\Table(name="product")! */!class Product!{! /**! * @ORM\Column(type="integer")! * @ORM\Id! * @ORM\GeneratedValue(strategy="AUTO")! */! protected $id;!! /**! * @ORM\Column(type="string", length=100)! * @Assert\NotBlank()! */! protected $name;!! /**! * @ORM\Column(type="decimal", scale=2)! */! protected $price;!! /**! * @ORM\Column(type="text")! */! protected $description;!
CRUD
Create HTTP POST
POST /products HTTP/1.1!Host: acme.com!Content-Type: application/json!!{! "name": "Product #1",! "price": 19.90,! "description": "Awesome product"!}!
Request
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"!}!
Response
//src/Acme/ApiBundle/Resources/config/routing.yml!!acme_api_product_post:! pattern: /products! defaults: { _controller: AcmeApiBundle:ApiProduct:post, _format: json }! requirements:! _method: POST
//src/Acme/ApiBundle/Controller/ApiProductController.php!!use FOS\RestBundle\View\View;!!public function postAction(Request $request)!{! $product = $this->deserialize(! 'Acme\ApiBundle\Entity\Product',! $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;!}
Read HTTP GET
GET /products/1 HTTP/1.1!Host: acme.com
Request
HTTP/1.1 200 OK!Content-Type: application/json!!{! "product": {! "id": 1,! "name": "Product #1",! "price": 19.9,! "description": "Awesome product"!}!
Response
public function getSingleAction(Product $product)!{! return array('product' => $product);!}
Update HTTP PUT
PUT /products/1 HTTP/1.1!Host: acme.com!Content-Type: application/json!!{! "name": "Product #1",! "price": 29.90,! "description": "Awesome product"!}!
Request
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"!}!
Response
//src/Acme/ApiBundle/Controller/ApiProductController.php!!use FOS\RestBundle\Controller\Annotations\View as RestView;!!/**! * @RestView(statusCode=204)! */!public function putAction(Product $product, Request $request)!{! $newProduct = $this->deserialize(! 'Acme\ApiBundle\Entity\Product',! $request! );!! if ($newProduct instanceof Product === false) {! return View::create(array('errors' => $newProduct), 400);! }!! $product->merge($newProduct);!! $this->getEM()->flush();!}
Partial Update HTTP PATCH
PATCH /products/1 HTTP/1.1!Host: acme.com!Content-Type: application/json!!{! "price": 39.90,!}!
Request
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"!}!
Response
//src/Acme/ApiBundle/Controller/ApiProductController.php!!use FOS\RestBundle\Controller\Annotations\View 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();!}
Delete HTTP DELETE
DELETE /products/1 HTTP/1.1!Host: acme.com
Request
HTTP/1.1 204 No Content
Response
//src/Acme/ApiBundle/Controller/ApiProductController.php!!use FOS\RestBundle\Controller\Annotations\View as RestView;!!/**! * @RestView(statusCode=204)! */!public function deleteAction(Product $product)!{! $em = $this->getEM();! $em->remove($product);! $em->flush();!}
Serialization
use JMS\Serializer\Annotation as Serializer;!!/**! * @Serializer\ExclusionPolicy("all")! */!class Product!{! /**! * @Serializer\Expose! * @Serializer\Type("integer")! */! protected $id;!! /**! * @Serializer\Expose! * @Serializer\Type("string")! */! protected $name;!! /**! * @Serializer\Expose! * @Serializer\Type("double")! */! protected $price;!! /**! * @Serializer\Expose! * @Serializer\Type("string")! */! protected $description;!
Deserialization
//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;!}!
Testing
//src/Acme/ApiBundle/Tests/Controller/ApiProductControllerTest.php!!use Liip\FunctionalTestBundle\Test\WebTestCase;!!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')! );! }!
//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());!}!
//src/Acme/ApiBundle/Tests/Controller/ApiProductControllerTest.php!!public function testGetAction()!{! $this->loadFixtures(array(! 'Acme\ApiBundle\Tests\Fixtures\Product',! ));!! $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);!}
//src/Acme/ApiBundle/Tests/Fixtures/Product.php!!use Acme\ApiBundle\Entity\Product as ProductEntity;!!use Doctrine\Common\Persistence\ObjectManager;!use Doctrine\Common\DataFixtures\FixtureInterface;!!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();! }!}!
//src/Acme/ApiBundle/Tests/Controller/ApiProductControllerTest.php!!public function testGetSingleAction()!{! $this->loadFixtures(array(! 'Acme\ApiBundle\Tests\Fixtures\Product',! ));!! $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! );!}
//src/Acme/ApiBundle/Tests/Controller/ApiProductControllerTest.php!!public function testPutAction()!{! $this->loadFixtures(array(! 'Acme\ApiBundle\Tests\Fixtures\Product',! ));!! $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());!}
//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! );!}
//src/Acme/ApiBundle/Tests/Controller/ApiProductControllerTest.php!!public function testPatchAction()!{! $this->loadFixtures(array(! 'Acme\ApiBundle\Tests\Fixtures\Product',! ));!! $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());!}
//src/Acme/ApiBundle/Tests/Controller/ApiProductControllerTest.php!!public function testDeleteAction()!{! $this->loadFixtures(array(! 'Acme\ApiBundle\Tests\Fixtures\Product',! ));!! $client = static::createClient();! $client->request('DELETE', '/products/1');! $this->assertEquals(204, $client->getResponse()->getStatusCode());!}
Documentation
//src/Acme/ApiBundle/Controller/ApiProductController.php!!use Nelmio\ApiDocBundle\Annotation\ApiDoc;!!/**! * 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);!}!
Hypermedia?
There’s a bundle for that™
willdurand/hateoas-bundle
fsc/hateoas-bundle
//src/Acme/ApiBundle/Entity/Product.php;!!use JMS\Serializer\Annotation as Serializer;!use FSC\HateoasBundle\Annotation as Rest;!use Doctrine\ORM\Mapping as ORM;!!/**! * @ORM\Entity! * @ORM\Table(name="product")! * @Serializer\ExclusionPolicy("all")! * @Rest\Relation(! * "self", ! * href = @Rest\Route("acme_api_product_get_single", ! * parameters = { "id" = ".id" })! * )! * @Rest\Relation(! * "products", ! * href = @Rest\Route("acme_api_product_get")! * )! */!class Product!{! ...!}
application/hal+json
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!}
“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
“Anyway, being pragmatic, sometimes a level 2 well done guarantees a good API…”
Daniel Londero
“But don’t call it RESTful. Period.”
Roy Fielding
“Ok.”
Daniel Londero
THANKS
@dlondero