📜 ⬆️ ⬇️

Implementing REST API on symfony2: the right way

REST
Creating a REST API is not an easy task. No seriously! If you want to write the API correctly, you will have to think a lot, decide whether to be a pragmatist, or an API maniac. REST is not only GET, POST, PUT and Delete. In practice, you may have interactions between resources, you need to move resources somewhere else (for example, inside a tree), or you want to get a specific resource value.

In this article, I collected everything that I learned by implementing various API services using Symfony2 , FOSRestBundle , NelmioApiDocBundle and Propel for this purpose. For example, let's make an API for working with users.

Do you speak...?


APIs use client applications. They should know how to access your API service, and quality documentation is a good way to achieve this, but this is the end of the article.
')
In addition, you also need to know how to communicate with customers, and the HTTP protocol, namely the Accept header, will help us with this. In essence, customers will send a header with the type of data format they want to receive.

However, in FOSRestBundle everything has been done for you. It takes care of the need to keep track of this part, but you must determine in the settings which format you want to support. Most likely, you usually use JSON, but if you are puzzled by the problem of semantics, you will send XML. This part will also be covered later.

GET what?


The HTTP GET method is idempotent. This means that no matter how many times you request data using this method, you should receive the same data. There should be no change in them. Use GET to get resources: a collection or a separate resource. In Symfony2, the routing rule descriptions will look something like this:
# src/Acme/DemoBundle/Resources/config/routing.yml acme_demo_user_all: pattern: /users defaults: { _controller: AcmeDemoBundle:User:all, _format: ~ } requirements: _method: GET acme_demo_user_get: pattern: /users/{id} defaults: { _controller: AcmeDemoBundle:User:get, _format: ~ } requirements: _method: GET id: "\d+" 


The UserController class will contain the following code:
 <?php namespace Acme\DemoBundle\Controller; use Acme\DemoBundle\Model\User; use Acme\DemoBundle\Model\UserQuery; use FOS\RestBundle\Controller\Annotations as Rest; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class UserController { /** * @Rest\View */ public function allAction() { $users = UserQuery::create()->find(); return array('users' => $users); } /** * @Rest\View */ public function getAction($id) { $user = UserQuery::create()->findPk($id); if (!$user instanceof User) { throw new NotFoundHttpException('User not found'); } return array('user' => $user); } } 


Instead of using a parameter converter in get * () methods, I always have to get objects myself. Later I will explain why, but for now just trust me, it's really better.

The status code is important for clients, so if the user does not exist, use a NotFoundHttpException exception, which will return a response with status code 404.

Using the View annotation, you will display the user object in the desired format that the user specified in the Accept header. Using the alias (Rest) for annotations is a trick that will help avoid conflicts with the View object, which we will discuss later. Simply put, annotations refer to this class. It is only a matter of taste, whether to use annotations or not.

And lastly, the allAction () method. It has the same behavior as getAction: you just get a sample of users and return it.

The user object has 4 properties: id, email, username and password. Probably common sense will not allow you to give passwords to users in free access through the API. The easiest way to eliminate this property when serializing an object is by setting up the serializer. Configuration example in YAML format:
 # In Propel, the most part of the code is located in base classes # src/Acme/DemoBundle/Resources/config/serializer/Model.om.BaseUser.yml Acme\DemoBundle\Model\om\BaseUser: exclusion_policy: ALL properties: id: expose: true username: expose: true email: expose: true 


I advise by default to exclude all properties of the object, and add the necessary ones explicitly. This gives more flexibility on large objects. This does not really make sense with 4 properties, but still try to stick to this strategy, this is how to set up a firewall in the end.

As a result, we get the following JSON response:
 { "user": { "id": 999, "username": "xxxx", "email": "xxxx@example.org" } } 


Simple, isn't it? But you will probably need to create, modify or delete users, and this will be the topic for the next chapter.

POST 'it


Creating a user involves using the HTTP POST method. But how will you get the data? How will you check them? And how will you create a new object? There are more than one answer or strategy to these three questions.

You can use the deserialization mechanism to create a form object from the serialized entered data. A guy named Benjamin works on a form-based desialyzer . This method is only slightly different from using the Serializer component , but it seems simpler.

I use the cool form component from symfony to do everything at once. Let's write a form class to create a new user. Using the PropelBundle you can use the propel command : form: generate command :
 php app/console propel:form:generate @AcmeDemoBundle User 


This command will create the following form class:
 <?php namespace Acme\DemoBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolverInterface; class UserType extends AbstractType { /** * {@inheritdoc} */ public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('username'); $builder->add('email', 'email'); $builder->add('password', 'password'); } /** * {@inheritdoc} */ public function setDefaultOptions(OptionsResolverInterface $resolver) { $resolver->setDefaults(array( 'data_class' => 'Acme\DemoBundle\Model\User', 'csrf_protection' => false, )); } /** * {@inheritdoc} */ public function getName() { return 'user'; } } 


Something I had to tweak with my hands: email and password types, and I also turned off CSRF protection. In the REST API, you are most likely using a security layer, such as OAuth. Having CSRF protection in a REST context makes no sense.

Now we need to add validation rules, and thanks to the appropriate component it will be easy. I really like this component, as it makes it quite easy to check all incoming data in a safe way.

Returning to our case, I used to describe the validation rules in YAML, but no one restricts you in choosing. Here is an example:
 # src/Acme/DemoBundle/Resources/config/validation.yml Acme\DemoBundle\Model\User: getters: username: - NotBlank: email: - NotBlank: - Email: password: - NotBlank: 


Let's now write a method in the controller:
 <?php // ... public function newAction() { return $this->processForm(new User()); } 


Another tip. Always use a separate method for processing your forms. Then you thank yourself. The processForm () method looks like this:
 // ... private function processForm(User $user) { $statusCode = $user->isNew() ? 201 : 204; $form = $this->createForm(new UserType(), $user); $form->bind($this->getRequest()); if ($form->isValid()) { $user->save(); $response = new Response(); $response->setStatusCode($statusCode); $response->headers->set('Location', $this->generateUrl( 'acme_demo_user_get', array('id' => $user->getId()), true // absolute ) ); return $response; } return View::create($form, 400); } 


In short, you create a form, bind incoming data to it, and, if all data is valid, you save your user and return the answer. If something goes wrong, you can return code 400 along with the form. An instance of the form class will be serialized to display error messages. For example, you can get the following error response:
 { "children": { "username": { "errors": [ "This value should not be blank." ] } } } 


Note: The View class that we see here is not the same as what we use in the annotations, which is why I used a nickname for them. Read more about this class in the “The View Layer” chapter in the documentation for the FOSRestBundle.

It is also important to pass the name of the form. Typically, customers will send you some data like this:
 { "user": { "username": "foo", "email": "foo@example.org", "password": "hahaha" } } 


You can try calling this method with curl:
 curl -v -H "Accept: application/json" -H "Content-type: application/json" -X POST -d '{"user":{"username":"foo", "email": "foo@example.org", "password": "hahaha"}}' http://example.com/users 


Make sure that you set the value of the body_listener parameter to true in the FOSRestBundle settings. This option allows you to receive data in JSON, XML and others. Again, everything works out of the box.

As I said earlier, if everything goes well, you save your user ($ user-> save to Propel) and then return the answer.

You must send the status code 201, which says that the resource has been created. Please note that I do not use the View annotation for this method.

But if you look closely at the code, you may notice that I did a strange thing. In fact, when creating a resource, you must return certain information: how to access this resource. In other words, you must return a URI. The HTTP specification says that you should use the Location header, which I did. But you hardly want to make another request to get such information as the user id (you already have the rest of the information anyway). And here comes my main concept: a pragmatist or a maniac?

You know what? I prefer the manic approach, and I follow the specification, returning only the Location header:
 Location: http://example.com/users/999 


If, as a client, I use the JavaScript framework ala Backbone.js, since I do not want to rewrite parts of it, because it does not support the correct APIsh, I return in addition to everything and Id. Being a pragmatist is not so bad.

Remember to add a routing rule for this action. Creating a resource is a POST request to the collection, so add a new rule:
 acme_demo_user_new: pattern: /users defaults: { _controller: AcmeDemoBundle:User:new, _format: ~ } requirements: _method: POST 


Knowing how to create a new resource, it will be quite easy to change it.

PUT vs PATCH, fight!


Changing a resource means replacing it, especially if you use the HTTP PUT method. There is also a PATCH method that takes the difference between resources and applies a patch to the source resource, in other words, it makes a partial update .

Thanks to the work done earlier, changing the resource will be quite easy to implement. You must write a new method in the controller and add a new routing rule. Here I can rely on the parameter converter to select our user object. If such user does not exist, the parameter converter will throw an exception and this exception will be converted to a response with status code 404.

 <?php // ... public function editAction(User $user) { return $this->processForm($user); } 


Changing a resource means that you already know everything about it, so you can return in the PUT request only the URI for this resource:
 acme_demo_user_edit: pattern: /users/{id} defaults: { _controller: AcmeDemoBundle:User:edit, _format: ~ } requirements: _method: PUT 


And it's all! And what about the deletion of the resource?

DELETE


Deleting a resource is very easy. Add a routing rule:
 acme_demo_user_delete: pattern: /users/{id} defaults: { _controller: AcmeDemoBundle:User:remove, _format: ~ } requirements: _method: DELETE 


And write a short method:
 <?php // ... /** * @Rest\View(statusCode=204) */ public function removeAction(User $user) { $user->delete(); } 


Having written only a few dozen lines of code, you have implemented a fully working API that safely implements CRUD operations. And now, what about adding interactions between users? Like friendship?

How to get a list of friends for this user through REST? We just have to treat friends as a collection of users owned by a particular user. Let's implement this interaction.

Friendship Algorithm


First we need to create a new routing rule. Since we consider friends as a collection of users, we will get it directly from the resource:
 acme_demo_user_get_friends: pattern: /users/{id}/friends defaults: { _controller: AcmeDemoBundle:User:getFriends, _format: ~ } requirements: _method: GET 


This action will look like this in the controller:
 <?php // ... public function getFriendsAction(User $user) { return array('friends' => $user->getFriends()); } 


That's all. Now let's think about how to describe the process of becoming a friend to another user. How would you manage this through REST? You cannot use POST for a collection of friends because you are not going to create anything. Both users already exist. You cannot use the PUT method because you really do not want to replace the entire collection. It can really put us in a dead end ...

But, the HTTP protocol specification describes the LINK method, which solves our problem. It says:
The LINK method establishes one or more links between an existing resource specified in the Request-URI and other existing resources.


This is exactly what we need. We want to link two resources, we should not forget about resources while we write API services. So how to do this in symfony2?

My method is based on using the query listener. The client sends a LINK request for the resource and sends at least one Link header.
 LINK /users/1 Link: <http://example.com/users/2>; rel="friend" Link: <http://example.com/users/3>; rel="friend" 


Using the query listener is a pretty nice option, as it allows you to get clean input in your methods. In the end, the purpose of this method is to link objects, but we do not want to play with the URI in the controller. All conversions must be performed before this.

The request listener will take all of these Link headers and use them for the Symfony2 component RouterMatcher to get the controller and method names. He will also prepare the parameters.

In other words, it has all the necessary information to create a controller and call the correct method in it with the necessary parameters. In our example, for each Link header, the getUser () method will be called in the UserController controller. That is why I did not use the parameter converter, this allowed me to accept the value of the id argument so that I could get a resource from it. I made a couple of suggestions:


When I get my resource objects, I will place them as request attributes, and for our listener this will complete the work. Here is the code:
 <?php namespace Acme\DemoBundle\EventListener; use Symfony\Component\HttpKernel\Event\GetResponseEvent; use Symfony\Component\HttpKernel\Controller\ControllerResolverInterface; use Symfony\Component\Routing\Matcher\UrlMatcherInterface; use Symfony\Component\HttpFoundation\Request; class LinkRequestListener { /** * @var ControllerResolverInterface */ private $resolver; private $urlMatcher; /** * @param ControllerResolverInterface $controllerResolver The 'controller_resolver' service * @param UrlMatcherInterface $urlMatcher The 'router' service */ public function __construct(ControllerResolverInterface $controllerResolver, UrlMatcherInterface $urlMatcher) { $this->resolver = $controllerResolver; $this->urlMatcher = $urlMatcher; } public function onKernelRequest(GetResponseEvent $event) { if (!$event->getRequest()->headers->has('link')) { return; } $links = array(); $header = $event->getRequest()->headers->get('link'); /* *   ,     *     . * *         Link   * http://tools.ietf.org/html/rfc2068#section-19.6.2.4 */ while (preg_match('/^((?:[^"]|"[^"]*")*?),/', $header, $matches)) { $header = trim(substr($header, strlen($matches[0]))); $links[] = $matches[1]; } if ($header) { $links[] = $header; } $requestMethod = $this->urlMatcher->getContext()->getMethod(); //    GET    //     ,      (LINK/UNLINK) $this->urlMatcher->getContext()->setMethod('GET'); //           $stubRequest = new Request(); foreach ($links as $idx => $link) { $linkParams = explode(';', trim($link)); $resource = array_shift($linkParams); $resource = preg_replace('/<|>/', '', $resource); try { $route = $this->urlMatcher->match($resource); } catch (\Exception $e) { //        //     Link continue; } $stubRequest->attributes->replace($route); if (false === $controller = $this->resolver->getController($stubRequest)) { continue; } $arguments = $this->resolver->getArguments($stubRequest, $controller); try { $result = call_user_func_array($controller, $arguments); //       if (!is_array($result)) { continue; } //     $links[$idx] = current($result); } catch (\Exception $e) { continue; } } $event->getRequest()->attributes->set('link', $links); $this->urlMatcher->getContext()->setMethod($requestMethod); } } 


Now we can create a routing rule:
 acme_demo_user_link: pattern: /users/{id} defaults: { _controller: AcmeDemoBundle:User:link, _format: ~ } requirements: _method: LINK 


And the code of our action will look like this:
 <?php // ... /** * @Rest\View(statusCode=204) */ public function linkAction(User $user, Request $request) { if (!$request->attributes->has('link')) { throw new HttpException(400); } foreach ($request->headers->get('Link') as $u) { if (!$u instanceof User) { throw new NotFoundHttpException('Invalid resource'); } if ($user->hasFriend($u)) { throw new HttpException(409, 'Users are already friends'); } $user->addFriend($u); } $user->save(); } 


If users are already friends, we will receive a response with the status code 409, which means that a conflict has occurred. If the request does not have a Link header, then this is a bad request (400).

The same goes for deletion from friends. Only here we will use the UNLINK method.

And finally. I have not explained the PATCH method. I mean, what's the script for this method? The answer will be a partial update or any method that is unsafe, unreliable, or not idempotent. If you have a non-standard method, and you do not know which method to use for it, then most likely PATCH will suit you.

Suppose your users are allowed to change their email through third-party clients. This client uses a two-step process. The user requests permission to change his email address, he receives an email with a link and, after clicking on it, gets permission for the changes. Skip the first stage and focus on the second. The user sends a new email to the client, and the client must call your API method. Either the client will receive the resource and its replacement, or, you are smart and provided him with the PATCH method.

Impose the PATCH world


First, we define a new routing rule:
 acme_demo_user_patch: pattern: /users/{id} defaults: { _controller: AcmeDemoBundle:User:patch, _format: ~ } requirements: _method: PATCH 


And now is the time to turn on the imagination to write the secure patchAction () method in your controller. Let's consider the usage scenario. The client can send one or more value for your resource. It would be nice to rely on the white list to prevent mass assignment, as all good rubists do ...

Let's filter the incoming parameters:
 <?php $parameters = array(); foreach ($request->request->all() as $k => $v) { //   if (in_array($k, array('email'))) { $parameters[$k] = $v; } } 


As soon as we filtered the incoming parameters, we received exactly the parameters we wanted. If we have not received anything, then this is a bad request and we must return a response with the status code 400.

If all is well, then we can assign new values ​​to the resource. Oh wait a minute ... No! It is necessary to check the entity first and only if all the data is valid, save it.

The code for this action will be something like this:
 <?php // .... public function patchAction(User $user, Request $request) { $parameters = array(); foreach ($request->request->all() as $k => $v) { // whitelist if (in_array($k, array('email'))) { $parameters[$k] = $v; } } if (0 === count($parameters)) { return View::create( array('errors' => array('Invalid parameters.')), 400 ); } $user->fromArray($parameters); $errors = $this->get('validator')->validate($user); if (0 < count($errors)) { return View::create(array('errors' => $errors), 400); } $user->save(); $response = new Response(); $response->setStatusCode(204); $response->headers->set('Location', $this->generateUrl( 'acme_demo_user_get', array('id' => $user->getId()), true // absolute ) ); return $response; } 


It turned out pretty simple code, right? As always, when you create or update a resource, you must send a response with the status code 2xx and the Location header. Here we send code 204 because there is no content here and we do not create anything.

And now, what's the plan? We have already used the methods GET, POST, PUT, DELETE, PATCH, LINK , and UNLINK . We can create, receive, modify, delete and even partially update users. We can get a list of all users and establish a friendly relationship with them. We know that if we need to change user data, we can safely use the PATCH method.

In fact, with respect to the Rijadsson Maturity model , we covered only the second level. So let's look at HATEOAS and unlock the third level!



Who to hate?


HATEOAS has nothing to do with hate, but you may hate this approach if you consider yourself a pragmatic programmer. This abbreviation stands for “Hypermedia as the basis of application states” (Hypermedia As The Engine Of Application State). For me, this is seen as adding semantics to your API services.

Earlier in this article I talked about the formats used to exchange information between the client and your API. JSON is not the best option if you decide to follow the principles of HATEOAS, despite the fact that some people offer solutions to this problem .

Transform our user’s representation into XML:
 <user> <id>999</id> <username>xxxx</username> <email>xxxx@example.org</email> </user> 


This is the output of your get method, if the client requests XML. There is nothing from HATEOAS. The first step is to add links:
 <user> <id>999</id> <username>xxxx</username> <email>xxxx@example.org</email> <link href="http://example.com/users/999" rel="self" /> </user> 


It was simple, we just added a link that relates to the user whose data we received. But if you have a collection of users paginated, you can get the following:
 <users> <user> <id>999</id> <username>xxxx</username> <email>xxxx@example.org</email> <link href="http://example.com/users/999" rel="self" /> <link href="http://example.com/users/999/friends" rel="friends" /> </user> <user> <id>123</id> <username>foobar</username> <email>foobar@example.org</email> <link href="http://example.com/users/123" rel="self" /> <link href="http://example.com/users/123/friends" rel="friends" /> </user> <link href="http://example.com/users?page=1" rel="prev" /> <link href="http://example.com/users?page=2" rel="self" /> <link href="http://example.com/users?page=3" rel="next" /> </users> 


, / .

: ? ? ?

:
 Content-Type: application/vnd.yourname.something+xml 


: application/vnd.acme.user+xml.
 <user> <id>999</id> <username>xxxx</username> <email>xxxx@example.org</email> <link href="http://example.com/users/999" rel="self" /> <link rel="friends" type="application/vnd.acme.user+xml" href="http://example.com/users/999/friends" /> </user> 


, , API- . URI:
 /api/v1/users 

:
 application/vnd.acme.user-v1+xml 


Accept , :
 application/vnd.acme.user+xml;v=1 


. , RESTful . .

Testing


, API- , . , REST , API- , .

API- , . Symfony2 , API :
 $client = static::createClient(); $crawler = $client->request('GET', '/users'); $response = $this->client->getResponse(); $this->assertJsonResponse($response, 200); 


WebTestCase assertJsonResponse():
 <?php // ... protected function assertJsonResponse($response, $statusCode = 200) { $this->assertEquals( $statusCode, $response->getStatusCode(), $response->getContent() ); $this->assertTrue( $response->headers->contains('Content-Type', 'application/json'), $response->headers ); } 


, API. , , .

API-, , . bad- - . 500 400.

Documentation


API , , . ?

HATEOAS, API , , .

, HATEOAS API , . API- , , !

NelmioApiDocBundle. Nelmio API-. , , - .




, API-.

useful links




: . , , , . , .

Source: https://habr.com/ru/post/148951/


All Articles