В продолжение моего предыдущего поста о таком интересном инструменте как 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 | TestControlleractionIndex() |
api.yiinitializr.dev/test/1/ | GET | TestControlleractionView(1) |
api.yiinitializr.dev/test/ | POST | TestControlleractionCreate() |
api.yiinitializr.dev/test/1/ | PUT | TestControlleractionUpdate(1) |
api.yiinitializr.dev/test/1/ | DELETE | TestControlleractionDelete(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 YiiRestToolsHelpersRequestData;
use YiinitializrHelpersArrayX;
из 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. Без лишних слов весь код разложен под спойлеры. Открываем, смотрим, копируем.
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-dTH:i:sZ', $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));
}
}
$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(json_encode(array('response' => 'index')));
}
public function actionView($id) {
$this->renderJson(json_encode(array('response' => 'viewed#' . $id)));
}
public function actionCreate() {
$this->renderJson(json_encode(array('response' => 'created')));
}
public function actionUpdate($id) {
$this->renderJson(json_encode(array('response' => 'updated#' . $id)));
}
public function actionDelete($id) {
$this->renderJson(json_encode(array('response' => 'deleted#' . $id)));
}
}
./api/www/.htaccess
следующие строки:
<Limit GET POST PUT DELETE>
order deny,allow
allow from all
</Limit>
Подводим итоги
Вот таким нехитрым способом мы заставили нашу машину завестись. На то, чтобы понять что к чему, мне понадобилось потратить не один день (и даже не два). В любом случае, подобное решение будет вполне уместно в качестве стартовой позиции, основываясь на которой гораздо легче сделать качественное API, не обладая глубокими знаниями в этом вопросе. Из дополнительных ссылок могу посоветовать посмотреть Большое руководство по Yiinitializr (неужели вы до сих пор этого не сделали?) и статью Как сделать REST API для Yii (на английском).
В комментариях предлагаю поделиться вашими соображениями по поводу реализации API на Yii, покритиковать данный способ и предложить улучшения. Спасибо за внимание.
Автор: jarosluv