REST in practice with Symfony2

Post on 10-May-2015

3.028 views 2 download

Tags:

description

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

Transcript of REST in practice with Symfony2

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