Как я заставил работать API в Yiinitializr Advanced

  • Tutorial


В продолжение моего предыдущего поста о таком интересном инструменте как Yiinitializr, я решил ответить на вопрос о возможностях работы API, предоставляемых шаблоном Advanced. В рамках комментария или дополнительного пункта к прошлой статье материал уместить не удалось, поэтому всех, кого интересует данная тема, приглашаю под кат. В ней мы не будем касаться принципов проектирования правильной архитектуры API, а разберёмся как воспользоваться трудами ребят из 2amigos, которые дали нам возможность быстро (после прочтения статьи — точно быстро) развернуть API для наших проектов на Yii.

Способ реализации работы с API в Yiinitializr


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

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

Наша система генерирует, выдаёт и сохраняет в базе данных публичный ключ (идентификатор внешнего приложения), приватный ключ, а также пользователя, за которым эти ключи резервируются. Регистрация на этом закончена. Взаимодействие пользователя API с нашей системой производится на основе принципов REST. Приложение пользователя отправляет нашей системе запрос определённым HTTP-методом, включающий в себя HTTP-заголовок с публичным ключом, а также сообщение в JSON-формате, в котором обязательно содержатся подпись и срок её годности, а также различные дополнительные параметры. Обработав запрос и убедившись в его корректности, система выдаёт ответ.

Отличия шаблона Advanced


Если раньше речь шла о шаблоне Intermediate, то теперь давайте взглянем, что же добавилось в шаблон Advanced. Как вы уже поняли — все дополнительные возможности, которые он нам даёт, связаны с API. Скачиваем, распаковываем и заходим в директорию ./api смотреть, что же у нас теперь имеется. А имеются у нас:

./api/extensions/filters/EApiAccessControlFilter.php — класс-фильтр для выполнения проверки правил доступа к API.
./api/extensions/components/EApiAccessRule.php — класс, представляющий правило доступа к API.
./api/extensions/components/EApiActiveRecord.php — класс для вспомогательных методов работы AR-моделей с API.
./api/extensions/components/EApiController.php — класс-контроллер для обработки запросов к API.
./api/extensions/components/EApiError.php — класс ошибок API, существующий для удобства чтения логов.
./api/extensions/components/EApiErrorHandler.php — класс для обработки ошибок API. Если мы решим логировать ошибки в базу данных, то воспользуемся именно этим классом.
./api/models/ApiUser.php — пример модели, которой мы будем управлять внешними пользователями нашего API.
./common/lib/YiiRestTools/ — вспомогательные классы для функционирования REST API.

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

Конфигурирование и исправление недоработок


Разработчики Yiinitializr по всей видимости придерживались принципа Парето, и выполнив 80% работы, решили не тратить 80% времени на документацию и исправление багов, возложив это на плечи искушённых разработчиков (то есть нас).

Раз мы решили заняться настройкой Yiinitializr, то конечно же нас интересует файл конфигурации. Откроем его и посмотрим на правила роутинга API (./api/config/api.php):

'rules' => array(
    // REST patterns
    array('<controller>/index',  'pattern' => 'api/<controller:\w+>',        'verb' => 'POST'),
    array('<controller>/view',   'pattern' => 'api/<controller:\w+>/view',   'verb' => 'POST'),
    array('<controller>/update', 'pattern' => 'api/<controller:\w+>/update', 'verb' => 'PUT'),
    array('<controller>/delete', 'pattern' => 'api/<controller:\w+>/delete', 'verb' => 'DELETE'),
    array('<controller>/create', 'pattern' => 'api/<controller:\w+>/create', 'verb' => 'POST'),
),

Видим что-то непонятное. Комментарий говорит нам, что это REST-шаблон, но на деле получаем не совсем то. REST предполагает, что все запросы идут на единый URL, а действия выбираются на основе HTTP-методов и параметров запроса, т. е. должно быть так:
Адрес HTTP-метод Вызванное действие
api.yiinitializr.dev/test/ GET TestController\actionIndex()
api.yiinitializr.dev/test/1/ GET TestController\actionView(1)
api.yiinitializr.dev/test/ POST TestController\actionCreate()
api.yiinitializr.dev/test/1/ PUT TestController\actionUpdate(1)
api.yiinitializr.dev/test/1/ DELETE TestController\actionDelete(1)

Приводим правила к нужному виду:

'rules' => array(
    // REST patterns
    array('<controller>/index',  'pattern' => '<controller:\w+>',          'verb' => 'GET'),
    array('<controller>/view',   'pattern' => '<controller:\w+>/<id:\d+>', 'verb' => 'GET'),
    array('<controller>/update', 'pattern' => '<controller:\w+>/<id:\d+>', 'verb' => 'PUT'),
    array('<controller>/delete', 'pattern' => '<controller:\w+>/<id:\d+>', 'verb' => 'DELETE'),
    array('<controller>/create', 'pattern' => '<controller:\w+>',          'verb' => 'POST'),
),

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

use YiiRestTools\Helpers\RequestData;
use Yiinitializr\Helpers\ArrayX;

из EApiAccessControlFilter.php в EApiAccessRule.php, т. к. эти классы используются именно во втором файле.

Следующая проблема уже интереснее. Возможно, я чего-то не понял, поэтому предлагаю порассуждать вместе. Внимательно посмотрите на приведённый ниже код (./api/extensions/components/EApiAccessRule.php):

public function isRequestAllowed($user, $controller, $action, $ip, $verb) {
    if ($this->isActionMatched($action)
        && $this->isUserMatched(Yii::app()->user)
        && $this->isRoleMatched(Yii::app()->user)
        && $this->isSignatureMatched($user)
        && $this->isIpMatched($ip)
        && $this->isVerbMatched($verb)
        && $this->isControllerMatched($controller)
    ) {
        return $this->allow ? 1 : -1;
    } else {
        return 0;
    }
}

Метод isRequestAllowed проверяет соответствие запроса правилам. Если цепочка проверок в блоке if истинна, то данное правило применяется, возвращая 1 или -1, в зависимости от того, что делает это правило — разрешает или запрещает. Иначе данное правило не применимо к конкретному запросу и метод возвращает 0. Чтобы стало понятнее, напоминаю, как выглядят правила для фильтров:

public function filters() {
    return array(
        array(
            'EApiAccessControlFilter -error',
            'rules' => array(
                array('allow', 'users' => array('@')),
            )
        )
    );
}

Смущает одно, а именно проверка подписи $this->isSignatureMatched($user) в этой цепочке. Получая неправильную подпись, система решает, что данное правило неприменимо и соответственно пропускает пользователя (или хакера) внутрь. Скорее всего проверка подписи должна производиться у корректного запроса уже после, и по результату впускать или не впускать нас в систему. Следовательно необходимо немного изменить данный метод:

public function isRequestAllowed($user, $controller, $action, $ip, $verb) {
    if ($this->isActionMatched($action)
        && $this->isUserMatched(Yii::app()->user)
        && $this->isRoleMatched(Yii::app()->user)
        && $this->isIpMatched($ip)
        && $this->isVerbMatched($verb)
        && $this->isControllerMatched($controller)
    ) {
        return ($this->allow && $this->isSignatureMatched($user)) ? 1 : -1;
    } else {
        return 0;
    }
}

С недоработками вроде покончено. В игру вступает отсутствие документации. С помощью дебаггера по шагам я изучил механизмы взаимодействия API-клaссов и спешу поделиться наблюдениями с вами.

Для начала проделайте основные настройки, как описано в Большом руководстве. Затем давайте определим в конфигурации название HTTP-заголовка, в котором мы будем отправлять публичный ключ (./api/config/api.php):

'params' => array(
    'api.key.name' => 'APIKEY',
)

Не забываем выставить правильный часовой пояс (он должен быть одинаковым у нашей системы и у клиентского приложения) (common/config/main.php):

'params' => array(
    ...
    'php.timezone' => 'Europe/Moscow',
),

Запускаем установку через Composer, уже можно.
Теперь, чтобы провести тестирование API, нам необходимо зарегистрировать внешнего пользователя. Создаём новую миграцию:

> yiic migrate create create_api_user_table

И приводим методы up() и down() к следующему виду:

public function up() {
    $this->createTable('{{api_user}}', array(
        'id' => 'pk',
        'username' => 'varchar(32) NOT NULL',
        'api_key' => 'varchar(32) NOT NULL',
        'api_secret' => 'varchar(32) NOT NULL',
    ));

    $this->insert('{{api_user}}', array(
        'username' => 'test_user',
        'api_key' => 'e4afe26b5b57083f74b2d01c7066379c', // md5('public_key')
        'api_secret' => '156a17333e77a3c504018cae5ada8c3b', // md5('private_key')
    ));
}

public function down() {
    $this->dropTable('{{api_user}}');
}

Также подредактируем название таблицы, содержащей пользователей нашего API, в модели ApiUser.

class ApiUser extends EApiActiveRecord {
    ...
    public function tableName() {
        return '{{api_user}}';
    }
    ...
}

Применяем нашу миграцию. Результатом станет таблица в базе данных с единственным гипотетическим пользователем нашего API. Идём дальше.

Пишем простое клиентское приложение


Последним шагом станет написание простого клиентского приложения для работы с API Yiinitializr. Для его работы необходимо иметь возможность отправлять запросы различными HTTP-методами, в этом нам поможет библиотека cURL. Без лишних слов весь код разложен под спойлеры. Открываем, смотрим, копируем.

Класс клиента SimpleClient
Метод generateSignature() для генерации подписи основывается на методе prepareData($secretKey) класса RequestData библиотеки YiiRestTools.

/**
 * Class SimpleClient
 *
 * Simple REST-client for Yiinitializr Advanced API.
 */
class SimpleClient {
    private $baseUrl;
    private $apiPublic;
    private $apiSecret;
    private $expiration;

    public function __construct($url, $publicKey, $secretKey, $expiration = '+1 hour') {
        $this->baseUrl = $url;
        $this->apiPublic = $publicKey;
        $this->apiSecret = $secretKey;
        $this->expiration = $expiration;
    }

    public function makeRequest($verb, $controller, $params = array()) {
        $ch = curl_init();
        $signature = $this->generateSignature();
        $url = $this->makeUrl($controller);

        if (!empty($params) && isset($params['id'])) {
            $url .= $params['id'];
        }

        curl_setopt_array($ch, array(
            CURLOPT_URL => $url,
            CURLOPT_CUSTOMREQUEST => $verb,
            CURLOPT_HTTPHEADER => array('APIKEY: ' . $this->apiPublic),
            CURLOPT_POSTFIELDS => json_encode(array(
                'signature' => $signature,
                'expiration' => $this->relativeTimeToAbsolute($this->expiration),
            )),
        ));

        $result = curl_exec($ch);
        curl_close($ch);

        return $result;
    }

    private function generateSignature() {
        $ttdInt = strtotime($this->expiration);
        $raw = json_encode(array('expiration' => gmdate('Y-m-d\TH:i:s\Z', $ttdInt)));
        $jsonPolicy64 = base64_encode($raw);

        $signature = base64_encode(hash_hmac(
            'sha1',
            $jsonPolicy64,
            $this->apiSecret,
            true
        ));

        return $signature;
    }

    private function makeUrl($controller) {
        return 'http://' . rtrim($this->baseUrl, '/') . '/' . $controller . '/';
    }

    private function relativeTimeToAbsolute ($relativeTime) {
        return date('M d Y, H:i:s', strtotime($relativeTime));
    }
}


Работа с классом SimpleClient
$api = new SimpleClient('api.yiinitializr.dev', 'e4afe26b5b57083f74b2d01c7066379c', '156a17333e77a3c504018cae5ada8c3b');

$api->makeRequest('GET', 'test');
$api->makeRequest('GET', 'test', array('id' => 1));
$api->makeRequest('POST', 'test');
$api->makeRequest('PUT', 'test', array('id' => 1));
$api->makeRequest('DELETE', 'test', array('id' => 1));


Изменённая версия тестового контроллера
class TestController extends EApiController {
    public function actionIndex() {
        // just drop API request :)
        $this->renderJson(array('response' => 'index'));
    }

    public function actionView($id) {
        $this->renderJson(array('response' => 'view#' . $id));
    }

    public function actionCreate() {
        $this->renderJson(array('response' => 'created'));
    }

    public function actionUpdate($id) {
        $this->renderJson(array('response' => 'updated#' . $id));
    }

    public function actionDelete($id) {
        $this->renderJson(array('response' => 'deleted#' . $id));
    }
}


Настройка Apache
Чтобы Apache перестал блокировать PUT и DELETE запросы, необходимо добавить в файл ./api/www/.htaccess следующие строки:

<Limit GET POST PUT DELETE>
order deny,allow
allow from all
</Limit>

Решение взято здесь.

Подводим итоги


Вот таким нехитрым способом мы заставили нашу машину завестись. На то, чтобы понять что к чему, мне понадобилось потратить не один день (и даже не два). В любом случае, подобное решение будет вполне уместно в качестве стартовой позиции, основываясь на которой гораздо легче сделать качественное API, не обладая глубокими знаниями в этом вопросе. Из дополнительных ссылок могу посоветовать посмотреть Большое руководство по Yiinitializr (неужели вы до сих пор этого не сделали?) и статью Как сделать REST API для Yii (на английском).

В комментариях предлагаю поделиться вашими соображениями по поводу реализации API на Yii, покритиковать данный способ и предложить улучшения. Спасибо за внимание.
  • +12
  • 4,9k
  • 8
Поделиться публикацией

Похожие публикации

Комментарии 8

    +1
    renderJson? Seriously?
      0
      Прошу пояснить.
      Метод renderJson используется в тестовом контроллере самими авторами Yiinitializr. Мы же немного его подредактировали, чтобы он возвращал более осмысленные данные. Но проблемы я всё равно не вижу.
        +1
        из названия метода я ожидаю увидеть формирование представления. В то же время, как я понимаю, renderJson лишь подставляет нужные заголовки. Так почему render а не send?
          +2
          + кроме как http verbs ни одного принципа rest-архитектуры не соблюдено. Ни тебе нормальной структуры ссылок, ни статус кодов…
            0
            До качественной REST-архитектуры, конечно же, ещё нужно поработать.
            Зачем нужна эта статья? Разработчики Yiinitializr выложили в сеть шаблон Advanced без единого описания как с ним работать, да и вообще с значительным количеством различных моментов, требующих доработки. Прочитав статью, теперь можно заставить работать эту штуку значительно быстрее. А наращивать функционал уже можно в зависимости от требований к системе и опыта.
            0
            Метод render() и все ему сопутствующие подают информацию на вывод. Метод renderJson(), являющийся частью Yiinitializr (о чём я уже позабыл, принимая его за родной метод Yii), так же выводит информацию в формате JSON с соответствующим заголовком, так что никаких парадоксов в названии я не вижу.
              0
              Тогда зачем делать вручную json_encode?
                0
                Здесь вы абсолютно правы, на вход нужно подавать обычный массив.
                Сейчас исправлю в статье. Спасибо за внимательность :)

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

      Самое читаемое