Приложение в твоем смартфоне
Приложение в твоем смартфоне
Приложение в твоем смартфоне
Приложение в твоем смартфоне
3 августа 2012 в 16:24

Реализация REST API на Symfony2: правильный путь перевод

REST
Создание REST API это нелегкая задача. Нет, серьезно! Если вы хотите написать API правильно, вам придется о многом подумать, решить, быть прагматиком, или API маньяком. REST это не только GET, POST, PUT и Delete. На практике, у вас могут быть взаимодействия между ресурсами, нужно перемещать ресурсы куда-то еще (к примеру внутри дерева), или вы захотите получить конкретное значение ресурса.

В данной статье собрано все, чему я научился реализуя различные API сервисы, используя для этих целей Symfony2, FOSRestBundle, NelmioApiDocBundle и Propel. К примеру сделаем API для работы с пользователями.

Вы говорите по...?


API используют клиентские приложения. Они должны знать как обращаться к вашему API сервису, и качественная документация это хороший способ добиться этого, но о об этом в конце статьи.

Помимо этого, вы тоже должны знать как общаться с клиентами, и в этом нам поможет HTTP протокол, а именно заголовок Accept. По сути, клиенты будут слать заголовок с типом того формата данных, который они хотят получить.

Однако в FOSRestBundle все уже сделано за вас. Он берет на себя необходимость отслеживать эту часть, но вы должны определить в настройках какой формат вы хотите поддерживать. Скорее всего, обычно вы используете JSON, но если вы озадачитесь проблемой семантики, вы будете отправлять XML. Эта часть так же будет освещена позже.

GET What?


Метод HTTP GET является идемпотентным. Это означает, что сколько бы раз вы не запрашивали данные этим методом, вам должны приходить те же данные. В них не должно происходить каких-либо изменений. Используйте GET для того что бы получить ресурсы: коллекцию или же отдальный ресурс. В Symfony2 описания правил маршрутизации будут примерно такими:
# 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+"


Класс UserController у нас будет содержать следующий код:
<?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);
    }
}


Вместо того, что бы использовать в get*() методах конвертер параметров, мне всегда приходится получать объекты самостоятельно. Позже я объясню почему, а пока просто доверьтесь мне, так действительно лучше.

Статус-код важен для клиентов, так что если пользователя не существует, используйте исключение NotFoundHttpException, которое вернет ответ со статус-кодом 404.

Используя аннотацию View, вы отобразите объект пользователя в нужном формате, который пользователь указал в заголовке Accept. Использование псевдонима (Rest) для аннотаций это трюк, который поможет избежать конфликтов с объектом View, который мы рассмотрим позже. Проще говоря, аннотации ссылаются на этот класс. Это лишь вопрос вкуса, использовать аннотации или нет.

И на последок, метод allAction(). Он имеет такое же поведение как и getAction: вы просто получаете выборку пользователей и возвращаете ее.

Объект пользователя имеет 4 свойства: id, email, username и password. Наверное здравый смысл не позволит вам отдавать пароли пользователей в свободный доступ через API. Проще всего исключить это свойство при сериализации объекта, настроив сериализатор. Пример настройки в формате YAML:
# 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


Я советую по умолчанию исключить все свойства объекта, а нужные уже добавлять явно. Это дает больше гибкости на больших объектах. В этом нету особо смысла при 4-ех свойствах, но все же старайтесь придерживаться этой стратегии, это как настраивать фаервол в конце концов.

В итоге мы получим следующий JSON ответ:
{
  "user": {
    "id": 999,
    "username": "xxxx",
    "email": "xxxx@example.org"
  }
}


Просто, не так ли? Но наверное вам понадобится создавать, изменять или удалять пользователей, и это уже будет темой для следующей главы.

POST ‘it


Создание пользователя подразумевает использование метода POST HTTP. Но как вы будете получать данные? Как будете их проверять? И как вы будете создавать новый объект? На эти три вопроса имеется больше одного ответа или стратегии.

Вы можете использовать механизм десериализации что бы создать объект формы из сериализованных введенных данных. Парень по имени Бенджамин работает надо десиализатором форм. Этот способ лишь немного отличается от использования компонента Serializer, но кажется более простым.

Я использую клевый компонент форм из Symfony что бы делать все сразу. Давайте напишем класс формы для создания нового пользователя. Используя PropelBundle вы можете использовать команду propel:form:generate command:
php app/console propel:form:generate @AcmeDemoBundle User


Это команда создаст следующий класс формы:
<?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';
    }
}


Кое что мне пришлось подправить руками: типы email и password, а так же я отключил CSRF защиту. В REST API вы скорее всего используете прослойку безопасности, например OAuth. Наличие CSRF защиты в контексте REST не имеет никакого смысла.

Теперь нам нужно добавить правила валидации, и благодаря соответствующему компоненту это будет легко. Мне по настоящему нравится этот компонент, так как он позволяет довольно легко проверить все входящие данные причем безопасными способами.

Возвращаясь к нашему случаю, я привык описывать правила валидации в YAML, но вас никто не ограничивает в выборе. Вот пример:
# src/Acme/DemoBundle/Resources/config/validation.yml
Acme\DemoBundle\Model\User:
    getters:
        username:
            - NotBlank:
        email:
            - NotBlank:
            - Email:
        password:
            - NotBlank:


Давайте теперь напишем метод в контроллере:
<?php

// ...

    public function newAction()
    {
        return $this->processForm(new User());
    }



Еще один совет. Всегда используйте отдельный метод для обработки ваших форм. Потом вы себя поблагодарите. Метод processForm() выглядит примерно так:

// ...

    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);
    }


Коротко говоря, вы создаете форму, привязываете к ней входящие данные, и, если все данные валидны, вы сохраняете вашего пользователя и возвращаете ответ. Если что-то пойдет не так, вы можете вернуть код 400 вместе с формой. Экземпляр класса формы будет сериализован для отображения сообщений об ошибках. Например, вы можете получить вот такой вот ответ с ошибками:
{
  "children": {
    "username": {
      "errors": [
        "This value should not be blank."
      ]
    }
  }
}


Примечание: класс View который мы тут видим, не тот же, что мы используем в аннотациях, именно поэтому я использовал псевдоним для них. Почитайте подробнее об этом классе в главе «The View Layer» в документации к FOSRestBundle.

Так же здесь важно передать имя формы. Обычно клиенты будут посылать вам примерно такие данные:
{
  "user": {
    "username": "foo",
    "email": "foo@example.org",
    "password": "hahaha"
  }
}


Вы можете попробовать вызвать этот метод при помощи 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


Убедитесь что в том, что вы выставили значение true у параметра body_listener parameter в настройках FOSRestBundle. Этот параметр позволяет получать данные в формате JSON, XML и других. Опять же, все работает из коробки.

Как я говорил ранее, если все пойдет хорошо, вы сохраните вашего пользователя ($user->save в Propel) и потом вернете ответ.

Вы должны отправить статус-код 201, который говорит что ресурс был создан. Прошу заметить, что я не использую аннотацию View для этого метода.

Но если вы внимательно посмотрите на код, вы можете заметить, что я сделал странную вещь. Вообще-то, при создании ресурса, вы должны вернуть определенную информацию: как получить доступ к этому ресурсу. Другими словами вы должны вернуть URI. В спецификации HTTP написано, что вы должны использовать заголовок Location, что я и сделал. Но вам вряд-ли захочется делать еще один запрос для получения такой информации, как id пользователя (у вас уже в любом случае есть остальная информация). И тут появляется моя главная концепция: прагматик или маньяк?

Знаете что? Мне больше по душе маниакальный подход, и я следую спецификации, возвращая только заголовок Location:
Location: http://example.com/users/999


Если как клиент я испоьзую JavaScript фреймворк аля Backbone.js, так как я не хочу переписывать его части, потому что он не поддерживает правильные APIшки, я возвращаю в дополнение ко всему и Id. Быть прагматиком все же не так плохо.

Не забудьте добавить правило маршрутизации для этого действия. Создание ресурса это POST запрос к коллекции, так что добавим новое правило:
acme_demo_user_new:
    pattern:  /users
    defaults: { _controller: AcmeDemoBundle:User:new, _format: ~ }
    requirements:
        _method: POST


Зная как создавать новый ресурс, довольно легко будет его изменять.

PUT vs PATCH, fight!


Изменение ресурса подразумевает его замену, особенно в случае, если вы используете метод HTTP PUT. Так же существует метод PATCH, который берет разницу между ресурсами и применяет патч к исходному ресурсу, другими словами производит частичное обновление.

Благодаря проделанной ранее работе, изменение ресурса будет довольно легко реализовать. Вы должны написать новый метод в контроллере и добавить новое правило маршрутизации. Здесь я могу положиться на конвертер параметров для выборки объекта нашего пользователя. Если такой пользователь не существует, конвертер параметров выбросит исключение и это исключение будет преобразовано в ответ со статус-кодом 404.

<?php

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


Изменение ресурса означает что вы уже знаете о нем все, так что вы можете вернуть в PUT запросе только URI для этого ресурса:
acme_demo_user_edit:
    pattern:  /users/{id}
    defaults: { _controller: AcmeDemoBundle:User:edit, _format: ~ }
    requirements:
        _method: PUT


И это все! А что на счет удаление ресурса?

DELETE


Удаление ресурса это очень просто. Добавьте правило маршрутизации:
acme_demo_user_delete:
    pattern:  /users/{id}
    defaults: { _controller: AcmeDemoBundle:User:remove, _format: ~ }
    requirements:
        _method: DELETE


И напишите коротенький метод:
<?php

// ...

    /**
     * @Rest\View(statusCode=204)
     */
    public function removeAction(User $user)
    {
        $user->delete();
    }


Написав всего несколько десятков строк кода, вы реализовали полностью рабочую APIшку, которая безопасно реализует CRUD операции. А теперь, что на счет добавление взаимодействий между пользователями? Например дружба?

Как получить список друзей для данного пользователя через REST? Мы просто должны рассматривать друзей как коллекцию пользователей, принадлежащих конкретному пользователю. Давайте реализуем это взаимодействие.

Алгоритм дружбы


Для начала, мы должны создать новое правило маршрутизации. Так как мы рассматриваем друзей как коллекцию пользователей, мы получим ее прямо из ресурса:
acme_demo_user_get_friends:
    pattern:  /users/{id}/friends
    defaults: { _controller: AcmeDemoBundle:User:getFriends, _format: ~ }
    requirements:
        _method: GET


Это действие будет выглядеть в контроллере так:
<?php

// ...

    public function getFriendsAction(User $user)
    {
        return array('friends' => $user->getFriends());
    }


Вот и все. Теперь давайте подумаем над тем, как описать процесс становления другом другому пользователю. Как бы вы управляли этим через REST? Вы не можете использовать POST для коллекции друзей так как вы ничего не собираетесь создавать. Оба пользователя уже существуют. Вы не можете использовать метод PUT так как вы на самом деле не хотите заменять коллекцию целиком. Это действительно может поставить нас в тупик…

Но, спецификации протокола HTTP описан метод LINK, который решает нашу проблему. Там говорится:
Метод LINK устанавливает одну или более связей между существующим ресурсом, указанным в Request-URI, и другими существующими ресурсами.


Это именно то, что нам нужно. Мы хотим связать два ресурса, мы не должны забывать о ресурсах пока мы пишем API-сервисы. Так как это сделать в Symfony2?

Мой метод основывается на использовании слушателя запросов. Клиент посылает LINK запрос для ресурса и пошлет по крайней мере один заголовок Link.
LINK /users/1
Link: <http://example.com/users/2>; rel="friend"
Link: <http://example.com/users/3>; rel="friend"


Использование слушателя запросов это довольно симпатичный вариант, так ка он позволяет вам получать чистые входные данные в ваших методах. В конце концов, цель данного метода слинковать объекты, мы же не хотим играть с URI в контроллере. Все преобразование должны быть выполнены до этого.

Слушатель запросов возьмет все эти заголовки Link и использует их для компонента Symfony2 RouterMatcher для того, что бы получить контроллер и названия методов. Он так же подготовит параметры.

Другими словами, он имеет всю необходимую информацию что бы создать контроллер и вызвать правильный метод в нем с нужными параметрами. В нашем примере на каждый заголовок Link будет вызван метод getUser() в контроллере UserController. Именно поэтому я не использовал конвертер параметров, это позволило мне принимать как значение аргумента id, что бы я мог по нему получить ресурс. Я сделал пару предположений:
  • Если пользователь не существует, мне вернется исключение;
  • Я получу массив как возвращаемое значение, так как я использовал аннотацию View.


Когда я заполучу мои объекты ресурсов, я помещу их как атрибуты запроса, и для нашего слушателя на этом работа завершится. Вот код:
<?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);
    }
}


Теперь мы можем создать правило маршрутизации:
acme_demo_user_link:
    pattern:  /users/{id}
    defaults: { _controller: AcmeDemoBundle:User:link, _format: ~ }
    requirements:
        _method: LINK


И код нашего действия будет выглядеть так:
<?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();
    }


Если пользователи уже являются друзьями, мы получим ответ со статус кодом 409, который означает, что произошел конфликт. Если в запросе будет отсутствовать заголовок Link, то это bad request (400).

Тоже самое касается и удаления из друзей. Только тут мы будем использовать метод UNLINK.

И напоследок. Я не объяснил метод PATCH. В смысле, каким будет сценарий для этого метода? Ответом будет частичное обновление или любой метод, который является небезопасным, ненадежным или не идемпотентным. Если у вас есть нестандартный метод, и вы не знаете какой метод использовать для него, то скорее всего вам подойдет PATCH.

Предположим что вашим пользователям позволено менять свой email посредством сторонних клиентов. Этот клиент использует процесс, состоящий из двух этапов. Пользователь запрашивает разрешение для изменения адреса его электронной почты, он получает email со ссылкой и перейдя по ней, получает разрешение для изменений. Пропустим первый этап и сосредоточимся на втором. Пользователь отправляет новый email на клиент, и клиент должен вызвать метод вашего API. Либо клиент получит ресурс и замент его, или, вы умный и предоставили ему метод PATCH.

Наложим на мир PATCH


Сперва определим новое правило маршрутизации:
acme_demo_user_patch:
    pattern:  /users/{id}
    defaults: { _controller: AcmeDemoBundle:User:patch, _format: ~ }
    requirements:
        _method: PATCH


И теперь самое время включить воображение для того, что бы написать безопасный метод patchAction() в вашем контроллере. Давайте рассмотрим сценарий использования. Клиент может отправить одно и более значение для вашего ресурса. Будет неплохо положиться на белый список для предотвращения массового присваивания, как это делают все хорошие рубисты…

Давайте отфильтруем входящие параметры:
<?php

$parameters = array();
foreach ($request->request->all() as $k => $v) {
    // белый список
    if (in_array($k, array('email'))) {
        $parameters[$k] = $v;
    }
}


Как только мы отфильтровали входящие параметры, мы получили именно те параметры, которые хотели. Если мы ничего не получили, то это нехороший запрос и мы должны вернуть ответ со статус-кодом 400.

Если же все хорошо, то мы можем присвоить новые значения ресурсу. Ой, погодите… Нет! Надо же сперва проверить сущность и только если все данные валидны, сохранить ее.

Код этого действия будет примерно таким:
<?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;
    }


Получился довольно простой код, верно? Как всегда, когда вы создаете или обновляете ресурс, вы должны отправить ответ со статус-кодом 2xx и заголовок Location. Здесь мы отправляем код 204 так как здесь нету контента и мы ничего не создаем.

И теперь, какой план? Мы уже использовали методы GET, POST, PUT, DELETE, PATCH, LINK, и UNLINK. Мы можем создавать, получать, изменять, удалять и даже частично обновлять пользователей. Мы можем получить список всех пользователей и устанавливать меду ними дружеские отношения. Мы знаем что если нам нужно изменить данные пользователя то мы можем смело использовать метод PATCH.

Вообще-то, относительно модели Ричадсона Матурити, мы покрыли только второй уровень. Так давайте посмотрим на HATEOAS и разблокируем третий уровень!



Кого ненавидеть?


HATEOAS не имеет ничего общего с ненавистью, но вы можете возненавидеть этот подход если вы считаете себя прагматичным программистом. Эта аббревиатура расшифровывается как «Гипермедиа как основа состояний приложения» (Hypermedia As The Engine Of Application State). Для меня это видится как добавление вашим API-сервисам семантики.

Ранее в этой статье я говорил о форматах используемых для обмена информацией между клиентом и вашим API. JSON не лучший вариант если вы решили следовать принципам HATEOAS, несмотря на то что некоторые люди предлагают решения этой проблемы.

Преобразуем репрезентацию нашего пользователя в XML:
<user>
    <id>999</id>
    <username>xxxx</username>
    <email>xxxx@example.org</email>
</user>


Это вывод вашего метода get, в случае если клиент запросит XML. Тут нету ничего от HATEOAS. Первым шагом будет добавление ссылок:
<user>
    <id>999</id>
    <username>xxxx</username>
    <email>xxxx@example.org</email>

    <link href="http://example.com/users/999" rel="self" />
</user>


Это было просто, мы просто добавили ссылку, которая относится к пользователю, данные которого мы получили. Но если у вас коллекция пользователей разбита на страницы, вы можете получить следующее:
<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 чем остальные два. Правда для остальных двух нужны более умные клиенты.

Тестирование


Если честно, если вы решили отдать ваш 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.

Документация


Наличие хорошей документации к API очень важный параметр, потому что это единственное, к чему будут иметь доступ клиенты. Вы ведь не будете предоставлять им весь код?

Если вы используете подход HATEOAS, ваш API уже содержит документацию, так что вам нет нужны писать ее самостоятельно, так как клиенты сами узнают обо всех имеющихся возможностях.

Но опять же, реализовать HATEOAS API достаточно сложно, и они сейчас они не очень то распространены. Если ваш API-сервис следует второму уровню модели Ричардсона, это уже хорошо, но вам придется писать всю документацию самостоятельно!

Но тут на помощь приходит NelmioApiDocBundle. Я написал этот бандл в Nelmio для автоматической генерации документации к нашим API-сервисам. На основе самоанализа кода, бандл получает много различной информации, и отображает ее на приятно-оформленных страничках.




Теперь у вас есть все, что бы построить замечательные API-сервисы.

Полезные ссылки




От автора перевода: Автор оригинальной статьи планирует ее дальнейшее обновление. Так же, если вы нашли ошибки, неточности или просто некрасиво оформленные фразы в моем переводе, сообщите об этом посредством личных сообщений. Учитывая объемы статьи и недосыпания, неточности наверняка есть.
+26
12084
212
Fesor 23,4

комментарии (25)

+1
Inori, #
На эту тему David Zuelke еще давал замечательную презентацию на Symfony Live в этом году.

Видео, к сожалению, еще пока нет, нашел только с другой конференции.
0
Zaboday, #
Появилось это видео c «Symfony Live Paris 2012». А так же другие интересные доклады.

А пост отличный. Спасибо!
+1
nikita2206, #
Кстати стоит заметить, в комментах говорят о том, что 2616 рфц убирает LINK/UNLINK методы. Вообще для линковки как-то логичней PUT/PATCH использовать по-моему. К тому же, можно коллекцию линкованных объектов тоже представлять как ресурсы, так что можно будет делать например DELETE /user/1/friends/5 для разлинковки связи 1-5 между юзерами.
И еще, мне вот не понравилось, что автор при линковке опять же работает с сущностью, а не с коллекцией. Хотя в заголовке Link он указывает в rel саму коллекцию, это все же выглядит как-то странно. Если не уходить от метода LINK, заголовки выглядели бы так:
LINK /user/1/friends
Link: </user/2>
Link: </user/5>

Но пост отличный, и перевод хороший, спасибо)
0
nikita2206, #
Предпоследнее предложение — это конечно мой имхо
0
chEbba, #
Узнал про NelmioApiDocBundle — за это отдельное спасибо.
0
Davert, #
«Кого ненавидеть»?
гугл транслейт не смог перевести игру слов :(

«Хейтеочто???» — имхо, было бы лучше.
0
Fesor, #
Я бы вообще оригинал оставил, да и вариация Haters gona HATEOAS мне больше по душе. Хейтеочто точно не лучше.
0
Davert, #
Ну это ж кривой дословный перевод… Из контекста вообще непонятно почему вдруг разговор идет о ненависти.
+1
Inori, #
Такую игру слов не переведешь, чтобы её оценили тогда уж надо и HATEOAS заодно переводить.

«Хейтечто» уж точно не лучше.

Я бы просто поставил «HATEOAS» заголовком.
0
Davert, #
Смотря что важнее, сохранить смысл или иронческий стиль. Вцелом, так как это блогпост, я бы сохранил стиль.
0
Inori, #
Тогда уж лучше «Натечто?».
0
Davert, #
Вариантов масса :)
0
TigranAM, #
Спасибо!
0
SamDark, #
Интересно, зачем такая страшная проверка на null?

$user = UserQuery::create()->findPk($id);

if (!$user instanceof User) {
  throw new NotFoundHttpException('User not found');
}
0
Fesor, #
А где вы тут проверку на null Нашли? Если убрать первую строку, вы же не будете на 100% уверены что вам вернется User или Null. Логично проверить объект на то, является ли он экземпляром нужного нам класса. Всего на десяток символов больше чем классическая проверка на нул, да только надежнее.
0
SamDark, #
Без рефакторинга у нас есть уверенность, что вернётся либо `User`, либо `null`. Не выполнится это только если товарищи, пишущие Propel сойдут вдруг с ума.

Если дело дойдёт до рефакторинга, можно будет вынести `User` в тип входного параметра и быть уверенным, что у нас либо `User`, либо `null`.

Хотя, конечно, это всё вкусовщина… просто интересно было.
0
Fesor, #
Ну просто разве это не логично? Если нужен объект определенного типа, почему нужно проверять на null когда можно проверить является ли этот объект нужного нам типа. Ну в общем вы поняли. Издержки то минимальны.

Да, пожалуй это дело вкуса, хотя как по мне это логично и правильно.
0
zim32, #
А правила сериализации повлияют на все методы которые извлекают юзера?
0
Fesor, #
Правила сериализации задаются для объекта, который вы возвращаете. А сколько методов возвращают этот объект, возвращают они коллекцию или что-то еще роли не играет. Объект в коллекции или просто в составе какого-то объекта сериализуется одинаково.
0
sunsey, #
вот прочитал еще одну статью по restful api
и никак не могу понять зачем заголовки, post, delete put и более мудреные штуки?
что они дают?
чем плох подход, например, задать единый формат вывода
$api_res  = array(
	"result_int" => 0/1,
	"err_msg" => "тут текст ошибки, если нет - пусто",
	"result_details" => array(/* с любым возвращаемым результатом */)
);


потом этот массив сериализовать в json/xml и отдавать юзеру например
{"result_int":0,"err_msg":"тут текст ошибки, если нет - пусто","result_details":{"user_id_created":23}}

или
<api_result>
    <result_int>0</result_int>
    <err_msg>тут текст ошибки, если нет - пусто</err_msg>
    <result_details>
        <user_id_created>23</user_id_created>
    </result_details>
</api_result>


был бы рад, если бы «на пальцах» кратко объяснили изъяны этого подхода и что дают именно http статусы?
0
Fesor, #
Использование HTTP статусов делает структуру вашей апишки более обобщенной. Ну тобиш HTTP статусы тут выступают в роли уровня абстракции для ваших методов. Вы лишь имплементите интерфейс. Поидее, это должно сокращать время разработки API сервисов, так как и на клиенте, и на сервере, большая часть компонентов системы может быть использована снова и снова. Коды ошибок позволяют не изобретать свои коды, ну и все в этом духе.
0
Strate, #
Интересно, а если написать полностью restful-контроллер, его можно будет без подводных камней использовать для рендеринга в браузере? Ну т.е. сделать браузер Resftul API клиентом чтоли :)
0
Fesor, #
Ну как бы многие так делают. Допустим если использовать Backbone то это самое то. Но есть нюансы.
0
Strate, #
Какие нюансы?
0
Fesor, #
При создании записи апишка должна отдавать ID записи, это можно обойти что бы все было действительно по феншую но обычно в этом нету смысла. Так же Backbone по умолчанию не умеет работать с XML, только с JSON. Можно конечно заставить его работать и с XML но опять же смысла в этом особо нету. Так же элементы управления гипермедиа тоже не будут работать ну и т.д. По сути у вас будет доступен только 2-ой уровень REST по приведенной в посте диаграмме, и то не полностью. Если вы хотите конечно, вы можете все допилить до такого состояния, что выйдет хороший универсальный RESTFull клиент, который можно будет использовать везде и всюду как основу, но реального профита мало.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.