17 октября 2012 в 17:28

Удобное встраивание RESTful API в проект tutorial

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

Я хочу представить сообществу нашу реализацию RESTful архитектуры, без дублирования кода и с минимальными изменениями существующей бизнес-логики. Или Как добавить в проект API за пять минут?

Для реализации данного подхода, нами было написано расширение для Yii Framework, но сам подход может быть использован в любой MVC архитектуре.

Давайте представим, что у нас есть контроллер RestUserController с методами:
  • actionIndex — список пользователей
  • actionView — просмотр пользователя
  • actionCreate — создание пользователя
  • actionUpdate — обновление пользователя
  • actionDelete — удаление пользователя

Также у нас есть модель RestUser, которая представляет из себя ActvieRecord таблицы rest_users.

Рассмотрим метод actionCreate, задача которого, создание нового пользователя RestUser,
class RestUserController extends Controller
{
    ...
    public function actionCreate()
    {
        $model = new RestUser();

        if (isset($_POST) && ($data = $_POST)) { // проверяем отправлен ли POST запрос 
            $model->attributes = $data; // пишем в модель новые атрибуты
            if ($model->save()) { // проверяем атрибуты, если валидны - то сохраняем
                $this->redirect(array('view', 'id' => $model->id));
            }
        }
        $this->render('create', array('model' => $model)); // отображаем html-форму добавления
    }
    ...
}

Тут все понятно — еcли просто запрашиваем /restUser/create — отображается html-форма добавления нового пользователя, если отправляем POST-запрос на этот адрес, то отрабатывает логика валидации и добавления, затем либо перенаправляет нас на просмотр пользователя, либо отображает html-форму c ошибками.

Теперь, допустим, мы хотим сделать мобильное приложение которое будет иметь возможность из своего интерфейса создавать новых пользователей. Правильный путь — создать серверное API.
Т.к. мы говорим о RESTful стиле, то взаимодействие серверного и мобильного приложения, на примере запроса через curl, будет выглядеть примерно так:

Запрос

curl http://test.local/api/users \
   -u demo:demo \
   -d email="user@test.local" \
   -d password="passwd"

Ответ

< HTTP/1.1 201 Created
< Content-Type: application/json
< WWW-Authenticate: Basic realm="App"
< Location: http://test.local/api/users/TEST_ID
{
    "object":"rest_user",
    "id":"TEST_ID",
    "email":"user@test.local",
    "name":"Test REST User"
}

Здесь происходит авторизация через HTTP basic auth логина demo с паролем demo, и передаются обязательные параметры email и password, в ответ, если все правильно, получаем JSON-объект нового пользователя.

Вся идея нашего подхода заключается в том, чтобы добавить action-ам возможность правильно отвечать на API-запросы, только изменением методов redirect и render, а также добавлением правил рендеринга моделей.
Конечно, необходима также реализовать перехват ошибок и эксепшенов приложения, а также ошибок при создании самой модели, для корректного ответа API-клиенту, но для этого не потребуется изменения самой бизнес-логики action-ов контроллера.

В нашем расширении мы реализовали предложенный подход перехватом событий onException и onError, а также добавлением дополнительной функциональности к базовой модели CActiveRecord и контроллеру CController при помощи поведений.
В результате, код, возвращающий нужный ответ, при запросе через API, и html-форму при обычном запросе, будет выглядеть так:
class RestUserController extends Controller
{
    ...
    public function actionCreate()
    {
        $model = new RestUser();

        if ($this->isPost() && ($data = $_POST)) { // добавился метод isPost наряду с isPut и isDelete
            $model->attributes = $data;
            if ($model->save()) {
                $this->redirect(array('view', 'id' => $model), true, 201); // возвращаем объект
            }
        }
        $this->render('create', array('model' => $model), false, array('model')); // в ответе только model
    }
    ...
}

Важное отличие нового кода от предидущего — это передача в метод redirect в качестве параметра id не $model->id, а объекта $model, для того чтобы созданный объект был возвращен клиенту. Также, третьим параметром добавлен код ответа 201 — это необходимо для соответсвия стандарту, т.к. вместе с ответом передается заголовок Location, содержащий адрес созданного объекта. HTTP-коды 3xx в ответе не позволяются.
Ещё одним отличием является добавленный четвертый параметр в методе render, в нем содержится перечисление полей из массива $data, передаваемых в ответе клиенту. Если праметр null то возвращается весь массив $data.

Теперь при неверном запросе, данные, которые в обычном режиме отобразились бы в html-форме, вернутся в следующем формате:

Запрос

curl http://test.local/api/users \
   -u demo:demo \
   -d email="user@test.local" 

Ответ

< HTTP/1.1 400 Bad Request
< Content-Type: application/json
< WWW-Authenticate: Basic realm="App"
{
	"error":{
		"params":[
			{
				"code":"required",
				"message":"Password cannot be blank.",
				"name":"password"
			}
		],
		"type":"invalid_param_error",
		"message":"Invalid data parameters"
	}
}

Отлично, теперь нужно как-то защитить чувствительные данные модели — у нашего RestUser это поле password. Для этого определим в правиле список возвращаемых полей.
Правило отображения для модели будет находится в методе rules
class RestUser extends CModel
{
    public function rules()
    {
        return array(
            ...
            array('id, email, name', 'safe', 'on' => 'render'),
        );
    }
}

Это правило затем будет учтено в методе getRenderAttributes, добавленном в модель, который будет возвращать массивом все доступные для отображения атрибуты, рекурсивно проходя по связям объекта, если они указаны в правиле.

В заключении хочу рассказать немного о возможностях аутентификации и отображения.
Ядро расширения построено вокруг компонента (сервиса) \rest\Service, который занимается основной обработкой событий и правильным отображением данных. У данного сервиса есть две группы адаптеров auth и renderer.
В auth находятся адаптеры, осуществляющие аутентификацию — по умолчанию доступен адаптер HTTP basic auth.
В renderer находятся адаптеры, осуществляющие отображение данных — по умолчанию доступны два адаптера JSON и XML.

Расширение


Коротко о настройках

Пример конфигурационного файла main.php
YiiBase::setPathOfAlias('rest', realpath(__DIR__ . '/../extensions/yii-rest-api/library/rest'));

return array(
	'basePath' => dirname(__FILE__) . DIRECTORY_SEPARATOR . '..',
	'name' => 'My Web Application',

	'preload' => array('restService'),

	'import' => array(
		'application.models.*',
		'application.components.*',
	),

	'components' => array(
        'restService' => array(
            'class'  => '\rest\Service',
            'enable' =>strpos($_SERVER['REQUEST_URI'], '/api/') !== false, // для примера
        ),

		'urlManager' => array(
			'urlFormat'      => 'path',
			'showScriptName' => false,
            'baseUrl'        => '',
            'rules'          => array(
                array('restUser/index',  'pattern' => 'api/v1/users', 
                      'verb' => 'GET',   'parsingOnly' => true),
                array('restUser/create', 'pattern' => 'api/v1/users', 
                      'verb' => 'POST', 'parsingOnly' => true),
                array('restUser/view',   'pattern' => 'api/v1/users/<id>', 
                      'verb' => 'GET', 'parsingOnly' => true),
                array('restUser/update', 'pattern' => 'api/v1/users/<id>',
                       'verb' => 'PUT', 'parsingOnly' => true),
                array('restUser/delete', 'pattern' => 'api/v1/users/<id>',
                       'verb' => 'DELETE', 'parsingOnly' => true),

                array('restUser/index2',  'pattern' => 'api/v2/users', 
                      'verb' => 'GET', 'parsingOnly' => true), // к примеру, если нужно будет сменить версию API
            )
		),
	),
);

Добавим в контроллер поведение и переопределим методы
/**
 * @method bool isPost()
 * @method bool isPut()
 * @method bool isDelete()
 * @method string renderRest(string $view, array $data = null, bool $return = false, array $fields = array())
 * @method void redirectRest(string $url, bool $terminate = true, int $statusCode = 302)
 * @method bool isRestService()
 * @method \rest\Service getRestService()
 */
class RestUserController extends Controller
{
    public function behaviors()
    {
        return array(
            'restAPI' => array('class' => '\rest\controller\Behavior')
        );
    }
    // если поле  $fields не определено, есть возвращаемые поля по умолчанию
	public function render($view, $data = null, $return = false, array $fields = array('count', 'model', 'data'))
	{
        if (($behavior = $this->asa('restAPI')) && $behavior->getEnabled()) {
            if (isset($data['model']) && $this->isRestService() && 
                count(array_intersect(array_keys($data), $fields)) == 1) {
                $data = $data['model']; // по логике нашего API, возвращаемый объект мы никак не оборачиваем, но детали конкретной реализации - на ваше усмотрение
                $fields = null;
            }
            return $this->renderRest($view, $data, $return, $fields);
        } else {
            return parent::render($view, $data, $return);
        }
    }

    public function redirect($url, $terminate = true, $statusCode = 302)
    {
        if (($behavior = $this->asa('restAPI')) && $behavior->getEnabled()) {
            $this->redirectRest($url, $terminate, $statusCode);
        } else {
            parent::redirect($url, $terminate, $statusCode);
        }
    }
}

Все эти методы можно и нужно добавить в родительский контроллер, чтобы не имплементировать в каждом контроллере по отдельности.

Добавим поведение в модель для того, чтобы заработали правила рендеринга
/**
 * @method array getRenderAttributes(bool $recursive = true)
 * @method string getObjectId()
 */
class RestUser extends CActiveRecord
{
    /**
     * @return array
     */
    public function behaviors()
    {
        return array(
            'renderModel' => array('class' => '\rest\model\Behavior')
        );
    }
}


Ссылки


GitHub репозиторий — github.com/paysio/yii-rest-api
Описание установки и настройки — github.com/paysio/yii-rest-api#installation
Весь код, приведенный выше — github.com/paysio/yii-rest-api/tree/master/demo
+28
19343
217
volovikov 8,0

Комментарии (21)

+1
someoneisusingmyusualnick, #
Не зря на yiiframework.ru/forum/ я просил у вас больше документации. Так гораздо лучше, спасибо, пригодится.
0
volovikov, #
Всё — для общего дела! =)
+2
sparkle, #
Хорошая наработка. Возьму на заметку.
Вот версионность API конечно спорный вопрос.
Вообще советовал бы еще по сабжу посмотреть вот этот доклад. Много интересных мыслей.

Кстати, чем не понравилось вот это решение http://www.yiiframework.com/wiki/175/how-to-create-a-rest-api?

0
volovikov, #
По поводу версионности — спорный вопрос сама версионность или её реализация?
Я считаю вполне приемлемым то, что версионность поддерживается на уровне роутов, т.к. изменение нескольких параметров не должно приводить к смене версии, а новый код естественно будет писаться в новых модулях/контроллерах/экшенах, которые внутри можно удобно именовать, а снаружи просто прилинковать с соответствующей версией.

В приведенной ссылке на How-To по REST API, не идет речь о том чтобы добавить API к существующей бизнес-логике, там просто приведен пример построения ответов. Ошибки приложения (эксепшены, ворнинги) там также никак не обрабатываются, ошибки валидации форм отдаются в html и без возможности определения типа ошибки. Да и просто много оверхеда по приведенному коду.
Но как тема для размышлений — вполне нормальный пример.
0
KeepYourMind, #
Плюсую за видео, тоже рекомендую.
0
Fesor, #
Интересный доклад, как-то его раньше не видел. Жаль что эллементы гипермедиа не так уж часто можно встретить в апишках.
0
Fesor, #
if ($this->isPost() && ($data = $_POST)) {

Почему так, а не проверять на уровне роутов, или же более обычным способом?:
if (Yii::app()->request->isPostRequest) {


И что за ужасный && ($data = $_POST)?
0
volovikov, #
А что Вы собираетесь проверять на уровне роутов? Я в тексте достаточно чётко объяснил, что экшен по GET запросу отдает форму, по POST добавляет запись.

Метод isPost добавлен для семантики, в месте с методами isPut и isDelete которые обдадают расширенной функциональностью по ср. с базовыми — github.com/paysio/yii-rest-api/blob/master/library/rest/controller/Behavior.php. Да и просто так писать меньше =)

($data = $_POST) в таком виде действительно особого смысла не имеет, но присутствует здесь т.к. работать с переменной $_POST напрямую — плохая практика, которая, между тем, в Yii используется. Хорошим примером является предоставление единой точки доступа, как, например, тут — github.com/zendframework/zf2/blob/master/library/Zend/Http/Request.php
+1
SamDark, #
В Yii тоже есть единая точка доступа Yii::app()->request->getPost('name'), но почему-то исторически повелось обращаться напрямую…
–1
volovikov, #
Не совсем так, в Yii метод getPost оперирует с переменной $_POST
    public function getPost($name,$defaultValue=null)
    {
        return isset($_POST[$name]) ? $_POST[$name] : $defaultValue;
    }

когда как в Zend-е данные хранятся в $this->postParams
    public function getPost($name = null, $default = null)
    {
        if ($this->postParams === null) {
            $this->postParams = new Parameters();
        }

        if ($name === null) {
            return $this->postParams;
        }

        return $this->postParams->get($name, $default);
    }
0
Fesor, #
А в чем профит то? Ну работаете через $_POST, ну и что с того? В Yii есть обертка которая удобнее, в плане того что там есть возможность указать значение по умолчанию. В Zend по сути тоже самое, просто данные представлены в ОО виде, ну мол так как бы более по феншую. Есть ли там обработка данных перед записью в массив, это уже другой вопрос.

А так делать $data = $_POST это… просто переименовать переменную, содержащую массив неблагонадежных данных. Смысла в этом нету.
0
volovikov, #
Так это, действительно, если нужно расшарить какой-то объект между классами, зачем парится и использовать паттерн Registry martinfowler.com/eaaCatalog/registry.html, лучше его наверно в переменную $GLOBALS записать.

Переменная $_POST содержит исходные данные запроса, и не предназначена для того, чтобы в неё писали что-то, в то время как с лёгкой руки программистов Yii она активно меняется, в том числе и самим фреймворком.
Почему это плохо — один из примеров, представим что у нас есть система которая логирует обращения к сервису, а затем выводит эти логи пользователям данного сервиса, чтобы они могли проконтролировать насколько верно их приложение работает с API. Но при такой модели работы с переменной $_POST в ней может оказаться всё что угодно, ещё до того как она будет записана в базу. Естественно можно извернуться и записывать всегда нескомпроментированные данные, но я повторяю — это плохой дизайн.

Теперь последний раз по поводу $data = $_POST на этом месте должно быть что-то вроде $data = $this->getPost(), но даже в простом присваивании переменной $_POST к $data есть смысл, если в дальнейшем будет написано что-то вроде $data['user_id'] = Yii::app()->user->getId();
    if (isset($_POST) && ($data = $_POST)) { // проверяем отправлен ли POST запрос 
        $data['user_id'] = Yii::app()->user->getId();
        $model->attributes = $data; // пишем в модель новые атрибуты
        if ($model->save()) { // проверяем атрибуты, если валидны - то сохраняем
            $this->redirect(array('view', 'id' => $model->id));
        }
    }

или что-то в этом духе, в общем, я не вижу смысла это больше обсуждать.

Если есть желание по коду подискутировать, давайте обсудим лучше какие-то сложные/спорые моменты в самой библиотеке.
+1
jMas, #
Можете подсказать, в каких местах Yii Framework изменяет переменную $_POST?
Мое мнение по этому поводу: лучше работать через обертку Yii::app()->request->..., т.к. в таком случае у нас появляется возможность переопределить request, наследовать методы в каких либо целях. Например, вызывать определенную функциональность при попытке получения данных из запроса.
Использовать $_POST конечно же никто не запрещает. Просто использовать API фреймверка — «удобней», т.к. гипотетически дает больше «свободы» в будущем.
0
Fesor, #
Смешивать REST контроллеры и обычные контроллеры — плохая практика. Дублирование кода в контроллерах нормальное явление, а бизнес логика по умолчанию должна быть удалена из контроллера и перенесена в модели/сервисы.
0
volovikov, #
Никто не спорит, что тонкие контроллеры — это хорошо, но чем по вашему «обычные контроллеры» отличаются от обрабатывающих REST-запросы, если они выполняют одну и туже функцию — получают данные и добавляют запись. В то время, как вся логика добавления записи может и должна быть зашита внутри модели, а сложная логика обработки в сервис.
0
Elkan, #
Хм, а куда тогда деть логику REST контроллеров? Т.е. в какой сервисный слой вынести ту логику которая там была?
0
Fesor, #
В идеале я бы хотел видеть REST API в виде какого-то сервиса, который можно использовать в контроллере, но так редко выходит. Да и редко выходит красиво, когда у тебя REST контроллер и обычный контроллер — это один и тот же класс. Обычно приходится разносить по разным контроллерам.
0
Actor, #
А как дела с авторизацией?
0
Actor, #
Сделал как в статье. В результате:
{
	"object":"list"
}

GET api/controller
0
copist, #
Всё просто, ошибка тут:

class RestUserController extends Controller
{
    ....

    public function render($view, $data = null, $return = false, array $fields = null /* значение по умолчанию было - пустой массив, что означает - НИЧЕГО наружу не отдавать */)
    {
0
copist, #
А зачем понадобилась обязательно переформатировать JSON?

class Json implements AdapterInterface
{
   ...


     /**
     * Pretty-print JSON string from ZendFramework
     *
     * Use 'indent' option to select indentation string - by default it's a tab
     *
     * @param string $json Original JSON string
     * @param array $options Encoding options
     * @return string
     */
    public static function prettyPrint($json, $options = array())
    {


1. Это излишнее украшательство — реализовать user friendly просмотр JSON лучше на стороне получателя
2. Это непроизводительно — делать разбор посредством интерпретируемого языка

Как минимум можно поставить проверку на if (defined(YII_DEBUG) && YII_DEBUG))

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