Personal maps: REST интерфейс. Часть 8.

Владимир | | Ajax, AngularJS, PHP, Web разработка, Yii.

personal maps rest api

Приветствую, это очередная статья о разработке web приложения с использованием под названием Personal maps. В прошлый раз мы закончили разработку клиентской части приложения, а сегодня займемся созданием REST интерфейса. Ссылки на все предыдущие части вы найдёте внизу этой страницы.

Форматы запросов клиентской части к серверу и его ответов у нас уже определены. Фактически мы это сделали когда тестировали сервис Places. Теперь нам нужно реализовать поддержку этих запросов серверной частью приложения.

Создание REST сервисов с помощью Yii фреймворка

Примечание. Если вы не знакомы с общими принципами создания REST сервисов, то рекомендую прочитать статью Create a REST API with PHP.

Также напоминаю, что исходный код приложения размещён на GitHub и доступна демоверсия приложения.

Source

Demo

Обычные web приложения работают с двумя типами HTTP запросов – GET и POST, т.к. вы не можете отправить другие типы запросов с помощью стандартной HTML формы. Но при использовании JavaScript ситуация изменяется. AngularJS, как и большинство других фреймворков, позволяет использовать дополнительные типы запросов, в нашем случае это PUT и DELETE.

Таким образом, для создания REST сервиса нам нужно:

  • написать контроллер, который будет обрабатывать запросы;
  • создать правила для роутера.

Контроллер REST сервиса

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

  • GET api/places – в ответ мы должны вернуть массив, содержащий все объекты данного пользователя;
  • GET api/places/id_объекта – ищем объект с данным id и возвращаем только его;
  • POST api/places – этот запрос указывает, что необходимо создать новый объект, информация о нём передаётся в параметрах запроса;
  • PUT api/places/id_объекта – изменение объекта с указанным id, как и в случае с POST информация об объекте будет отправлена в параметрах запроса;
  • DELETE api/places/id_объекта – этот запрос говорит о том, что необходимо удалить объект с указанным id.

Т.е. мы можем создать обычный контроллер с методами, которые будут соответствовать этим запросам. Но, т.к. наш REST сервис должен отправлять ответы в json формате с правильными заголовками, то будет удобнее создать абстрактный базовый класс RestController и на его основе – контроллер сервиса.

В документации Yii есть хорошая статья о создании REST сервисов. Я использовал приведённый в ней пример в качестве основы для RestController, но немного его упростил с учётом особенностей данного приложения.

abstract class RestController extends Controller
{
    // Members
    /**
     * Default response format
     * either 'json' or 'xml'
     */
    private $format = 'json';
    /**
     * @return array action filters
     */
    public function filters()
    {
        return array();
    }

    // Actions
    abstract public function actionList();

    abstract public function actionView($id);

    abstract public function actionCreate();

    abstract public function actionUpdate($id);

    abstract public function actionDelete($id);

    protected function _sendResponse($status = 200, $body = '', $content_type = 'text/html')
    {
        // set the status
        $status_header = 'HTTP/1.1 ' . $status . ' ' . $this->_getStatusCodeMessage($status);
        header($status_header);
        // and the content type
        header('Content-type: ' . $content_type);

        // pages with body are easy
        if($body != '')
        {
            // send the body
            echo $body;
        }
        // we need to create the body if none is passed
        else
        {
            // create some body messages
            $message = '';

            // this is purely optional, but makes the pages a little nicer to read
            // for your users.  Since you won't likely send a lot of different status codes,
            // this also shouldn't be too ponderous to maintain
            switch($status)
            {
                case 401:
                    $message = 'You must be authorized to view this page.';
                    break;
                case 404:
                    $message = 'The requested URL ' . $_SERVER['REQUEST_URI'] . ' was not found.';
                    break;
                case 500:
                    $message = 'The server encountered an error processing your request.';
                    break;
                case 501:
                    $message = 'The requested method is not implemented.';
                    break;
            }

            // servers don't always have a signature turned on
            // (this is an apache directive "ServerSignature On")
            $signature = ($_SERVER['SERVER_SIGNATURE'] == '') ? $_SERVER['SERVER_SOFTWARE'] . ' Server at ' . $_SERVER['SERVER_NAME'] . ' Port ' . $_SERVER['SERVER_PORT'] : $_SERVER['SERVER_SIGNATURE'];

            // this should be templated in a real-world solution
            $body = '
<!doctype html>
<html lang="en-US">
<head>
    <meta charset="UTF-8">
    <title>' . $status . ' ' . $this->_getStatusCodeMessage($status) . '</title>
</head>
<body>
    <h1>' . $this->_getStatusCodeMessage($status) . '</h1>
    <p>' . $message . '</p>
    <hr />
    <address>' . $signature . '</address>
</body>
</html>';

            echo $body;
        }
        Yii::app()->end();
    }

    protected function _getStatusCodeMessage($status)
    {
        // these could be stored in a .ini file and loaded
        // via parse_ini_file()... however, this will suffice
        // for an example
        $codes = Array(
            200 => 'OK',
            400 => 'Bad Request',
            401 => 'Unauthorized',
            402 => 'Payment Required',
            403 => 'Forbidden',
            404 => 'Not Found',
            500 => 'Internal Server Error',
            501 => 'Not Implemented',
        );
        return (isset($codes[$status])) ? $codes[$status] : '';
    }
}

В строках 18-26 мы объявляем пять абстрактных методов, которые соответствуют всем нашим запросам. Т.е. класс, который будет наследовать RestController должен будет определить эти методы.

Также у нас есть два вспомогательных метода.

_getStatusCodeMessage – в нём просто определены описания HTTP статусов, которые будут отправляться в заголовках ответов.

_sendResponse – формирует ответ сервера, т.е. устанавливает HTTP заголовки и отправляет данные.

Теперь создадим PlacesController

class PlacesController extends RestController
{
    public function actionIndex() {
        if (Yii::app()->user->checkAccess('user')) {
            $this->render('index');
        }
        else {
            $this->redirect(array('site/login'));
        }
    }

    public function actionList()
    {
        if (!Yii::app()->user->checkAccess('user')) {
            $this->_sendResponse(403);
            return;
        }
        //searching only for current users places (defaultScope returns appropriate condition)
        $places = Places::model()->findAll();
        echo CJSON::encode($places);
    }

    public function actionView($id)
    {
    }

    public function actionCreate()
    {
        if (!Yii::app()->user->checkAccess('user')) {
            $this->_sendResponse(403);
            return;
        }
        $data = CJSON::decode(file_get_contents('php://input'));
        $place = new Places();
        $place->attributes = $data;
        if ($place->save()) {
            $this->_sendResponse(200, CJSON::encode($place));
        }
        else {
            $this->_sendResponse(500, CJSON::encode(array(
                'message'=>'Could not save place',
                'errors'=>$place->getErrors(),
            )));
        }
    }

    public function actionUpdate($id)
    {
        $data = CJSON::decode(file_get_contents('php://input'));
        $place = Places::model()->findByPk($id);
        if (!Yii::app()->user->checkAccess('user', array('place'=>$place))) {
            $this->_sendResponse(403);
            return;
        }
        if (null === $place) {
            $this->_sendResponse(404, CJSON::encode(array('message'=>'Could not find place with id = '.$id)));
        }
        $place->attributes = $data;
        if ($place->save()) {
            $this->_sendResponse(200, CJSON::encode($place));
        }
        else {
            $this->_sendResponse(500, CJSON::encode(array(
                'message'=>'Could not save place',
                'errors'=>$place->getErrors(),
            )));
        }
    }

    public function actionDelete($id)
    {
        $place = Places::model()->findByPk($id);
        if (null === $place) {
            $this->_sendResponse(404, CJSON::encode(array('message'=>'Could not find place with id = '.$id)));
            return;
        }
        if (!Yii::app()->user->checkAccess('user', array('place'=>$place))) {
            $this->_sendResponse(403);
            return;
        }
        if ($place->delete()) {
            $this->_sendResponse(200, CJSON::encode($place));
        }
        else {
            $this->_sendResponse(500, CJSON::encode(array(
                'message'=>'Could not delete place',
                'errors'=>$place->getErrors(),
            )));
        }
    }
}

Этот класс наследует RestController, т.е. в нём мы должны определить все абстрактные методы (actionList, actionView и т.д.). Во всех этих методах мы в первую очередь с помощью Yii::app()->user->checkAccess проверяем права текущего пользователя и если проверка не пройдена, отправляем 403 ошибку.

Примечание. Подробно систему разграничения доступа мы рассмотрим в следующий раз.

После этого, мы выполняем соответствующую операцию. Т.е. либо возвращаем список объектов (actionList), либо создаём, изменяем или удаляем указанную модель. Во всех методах id модели передаётся в первом параметре, т.к. его определяет роутер при разборе URL. Для получения остальных данных запроса используется функция file_get_contents.

$data = CJSON::decode(file_get_contents('php://input'));

Т.к. данные приходят в JSON формате, мы их декодируем с помощью класса CJSON.

Ещё одни момент касается обработки ошибок. Если у пользователя не достаточно прав для выполнения операции – отправляем 403-ю со стандартным сообщением (задано в методе _getStatusCodeMessage класса RestController).

Если модель не найдена (методы actionUpdate и actionDelete) – возвращаем 404-ю с собственным сообщением, т.к. по стандартному сложно понять, что именно не было найдено.

Наконец, если операцию выполнить не удалось, возвращаем 500-ую. В описание этих ошибок входят два параметра:
message – содержит название операции, которую не удалось выполнить;
errors – содержит массив с ошибками.

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

Правила для роутера

Формат запросов к API отличается от стандартного формата запросов Yii (имя_контроллера/имя_метода), поэтому добавим в файл в файл config/main.php правила для каждого из наших запросов.

Обратите внимание, что для каждого правила, мы указываем параметр verb. Это необходимо, т.к. шаблоны для запросов на просмотр, удаление и обновление объекта не отличаются ничем кроме типа (GET, PUT, DELETE).

'components'=>array(
	...
	'urlManager'=>array(
		'urlFormat'=>'path',
		'rules'=>array(
			// REST patterns
			array('places/list', 'pattern'=>'api/places', 'verb'=>'GET'),
			array('places/view', 'pattern'=>'api/places/<id:\d+>', 'verb'=>'GET'),
			array('places/update', 'pattern'=>'api/places/<id:\d+>', 'verb'=>'PUT'),
			array('places/delete', 'pattern'=>'api/places/<id:\d+>', 'verb'=>'DELETE'),
			array('places/create', 'pattern'=>'api/places', 'verb'=>'POST'),
			// regular patterns
			'<controller:\w+>/<id:\d+>'=>'<controller>/view',
			'<controller:\w+>/<action:\w+>/<id:\d+>'=>'<controller>/<action>',
			'<controller:\w+>/<action:\w+>'=>'<controller>/<action>',
		),
		'showScriptName'=>false,
	),
	...
),

Важно помнить, что роутер разбирает правила сверху вниз и использует первое же правило, которое соответствует данному запросу. В данном случае если мы добавим новые правила в конец списка, то запросы вроде api/list будут обрабатываться стандартными правилами и Yii попытается найти класс ApiController и вызвать его метод actionList.

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

В следующей части мы рассмотрим разграничение прав пользователей с помощью RBAC.

Если есть вопросы или замечания, пишите. Успехов!

Содержание

  • Игорь Хайлов

    Здравствуйте. Подскажите, пожалуйста, как реализовать поддержку кириллицы? Весь русский текст приходит в юникоде.

    • Я не понял вопрос. Кириллические символы входят в юникод. Именно по этому рекомендуется использовать кодировку UTF-8.

      • Игорь Хайлов

        Да, неправильно выразился.
        Вообщем, русский текст (заголовки, ошибки и тд) приходят такими: «u041fu0440u0438u0432u0435u0442»
        Как это исправить?
        ЗЫ я так понял проблема в CJSON::encode

        • Всё правильно, стандартная библиотека PHP (json_encode) всегда преобразовывает UTF-8 в эскейп-последовательности «uXXXX…». Сделано это для того, чтобы строка была ascii-совместимой.

          Но я всё-таки не пойму где именно у Вас возникла проблема. Эскейп-последовательности автоматически преобразовываются браузером. Например, если Вы вставите строку из Вашего комментария в консоль инструментов Google Chrome, то увидите слово «Привет».
          Я сделал 2 скриншота из приложения к этой статье, как видите, текст передаются в виде эскейп-последовательностей (также как и у Вас), но при преобразовании в JS объект, он автоматически преобразуется в UTF-8.

        • Игорь Хайлов

          Все понял, спасибо.