REST in practice with Symfony2
-
Upload
daniel-londero -
Category
Technology
-
view
3.028 -
download
2
description
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