Существует множество готовых решений для реализации RESTFul API на Yii framework, но при использовании этих решений в реальных проектах понимаешь что все красиво выглядит только на примерах с собачками и их хозяевами.
Возможно, за время подготовки и написания статьи она немного потеряла актуальность с выходом Yii2 со встроенным фреймворком для создания RESTful API. Но статья по прежнему будет полезна для тех, кто пока не знаком с Yii2, или для тех, кому необходимо быстро и просто реализовать полноценное API для уже существующего приложения.
Для начала приведу список некоторых возможностей, которых мне очень не хватало для полноценной работой с серверным API при использовании существующих расширений:
В результате анализа примерно такого списка «хотелок» и появился на свет мой вариант реализации API на этом замечательном фреймворке!
Рассмотрим на примере все того же компонента RBAC.
Все как обычно:
Механизмы у них практически одинаковые, разница лишь в том, что при поиске в выборку попадают записи с частичным совпадением, а при фильтрации с полным. Комбинация полей и их значений задается в JSON формате. Именно он мне показался наиболее удобным для реализации этого функционала. Например:
Т.е. параметры переданные в одном объекте соответствуют условию AND, а параметры заданные массивом объектов соответствуют условию OR.
Так же кроме условий И и ИЛИ можно указывать следующие условия, которые должны предшествовать значению:
Несколько примеров:
Зачастую можно встретить следующий синтаксис для работы со связанными данными:
Изначально я использовал именно этот подход, но в процессе понял, что он имеет несколько недостатков.
В случае если связь один ко многим можно использовать подход с фильтром, который описан выше:
Работать же со связью многие ко многим удобнее как с отдельной сущностью по причине того, что зачастую таблица связи не ограничена полями
Используя url вида:
нет возможности получить тот самый порядок сортировки и значение характеристики (запись из таблицы связи). На личном опыте я убедился, что для подобного рода связей лучше использовать отдельную сущность, например
Таким образом, мы получим:
Данный подход конечно тоже не без недостатков, так как мы не можем получить, например имя товара и имя характеристики без двух дополнительных запросов. Тут нам на помощь приходит механизм получения связанных данных.
Таким же образом можно получить все характеристики товара:
Забегая вперед, скажу что используя
При удалении можно также использовать поиск и фильтрацию:
Одним запросом можно создать как одну запись, так и коллекцию передав в теле запроса массив данных, например такого вида:
Таким образом будут созданы две роли с соответствующими именами. Ответом сервера в таком случае будет так же массив созданных записей.
Все по аналогии с созданием, только необходимо указать параметр id в url ну и метод, конечно же, PUT:
Изменение нескольких записей:
Изменение записей найденных по фильтру:
Для частичной выборки используются привычные
Можно комбинировать:
Важно упомянуть о том, как лимит и смещение отобразятся в ответе. Мною были рассмотрены несколько вариантов, например, передавать данные в теле ответа:
На стороне клиента я использовал AngularJS. Мне показалось очень удобной реализация механизма
Важно обратить внимание, что заголовок выше указывает на то, что получено не 4 записи, а 5 (zero-based). Т.е. при получении всех 10 записей заголовок примет вид:
Распарсить такой заголовок на клиенте не составляет труда, а тело ответа теперь не засоряется «лишними» данными.
Первым делом необходимо создать модуль. Конечно это не обязательное требование, но модуль для этого подходит как нельзя лучше. В имя модуля можно также включить версию API.
Далее в конфиг приложения добавляем несколько правил для правильного роутинга в соответствии с url и методом запроса:
Думаю что для людей хоть сколько-нибудь знакомых с фреймворком объяснять тут нечего.
Далее подключаем файлы
Все контроллеры модуля API должны расширять класс
Из настроек роутера понятно, что в контроллерах должны быть реализованы следующие методы (
Рассмотрим на примере контроллер ролей пользователей. Как я уже говорил ранее, механизм RBAC фреймворка хранит все сущности (роли, операции и задачи) в одной таблице (
Код контроллера:
Первым делом в методе-конструкторе указываем модель с которой будет работать контроллер, присвоив экземпляр модели свойству
Указав свойство
Так же в методе
Перед выполнением любой операции проверяются права пользователя методом
Все методы действий (
Последний метод контроллера
В массиве, возвращаемом методом
Значение элементов массива конфигурирующего связь:
Надо сказать, что в большинстве случаев контроллеры выглядят гораздо проще чем приведенный выше. Вот пример контроллера из одного из моих проектов:
Краткое описание класса
Свойства:
Методы:
Изучая вопрос тестирования API, я рассмотрел множество подходов. Большинство советовало использовать не модульное тестирование, а функциональное. Но опробовав несколько способов функционального тестирования (с использованием Selenium и даже PhantomJs), с такими невероятными методами как создание формы средствами selenium, добавление в нее полей ввода, заполнение их данными и отправкой путем клика по кнопке submit с последующим анализом ответа сервера, я понял что на тестирование таким образом уйдут годы!
Погрузившись в поиски глубже, и проанализировав опыт других разработчиков, я написал класс для тестирования API при помощи curl. Для его использования необходимо подключить класс
Первой проблемой, с которой я столкнулся тестируя API, была проблема прав доступа. Во время тестирования используется тестовая база. Таким образом, необходимо постоянно следить чтобы на ней всегда были актуальные данные в таблицах используемых RBAC, иначе попробовав протестировать создание сущности можно получить ответ
Единственное ограничение данного подхода — нужно следить за тем чтобы в таблице пользователей значение id авторизуемого пользователя было одинаковым в обеих базах, так как если на тестовой базе ваш пользователь admin имеет
Пример теста:
В начале указываем фикстуры используемые в тестах. Далее в методе теста делаем запрос при помощи метода
Надо сказать что метод
Метод ApiTestCase::get() (как и
Этих данных достаточно для полноценного тестирования и анализа ответа сервера.
После получения ответа на запрос проверяются различные утверждения (asserts) которые вполне очевидны и в комментариях не нуждаются. Конечно, это далеко не полный код теста для сущности, но этого примера достаточно чтобы понять принцип работы с классом
Краткое описание класса
Свойства:
Основные методы:
Ссылка на github.
Конечно, при больших нагрузках могут возникнуть проблемы, так как для работы с данными используется
Надеюсь, что найдутся разработчики, которым будет полезно если не все расширение, то какие-либо части или просто идеи, примененные в нем.
В планах на будущее еще много различных доработок и изменений, так что буду благодарен за любые замечания и предложения.
Статья получилась большой (хотя не вышло описать и половины того что было задумано) и несколько «рваной». Если информация окажется полезной в будущем хотелось бы описать еще некоторые моменты. Например, каким образом была реализована авторизация, получение коллекций (комбинирование запросов в один) и.т.д. Так же хотелось бы рассказать о том, как я взаимодействовал с API на стороне клиента используя средства AngularJS и каким образом создавать одностраничные приложения дружественное для поисковиков (с рендером страниц через PhantomJs).
Возможно, за время подготовки и написания статьи она немного потеряла актуальность с выходом Yii2 со встроенным фреймворком для создания RESTful API. Но статья по прежнему будет полезна для тех, кто пока не знаком с Yii2, или для тех, кому необходимо быстро и просто реализовать полноценное API для уже существующего приложения.
Для начала приведу список некоторых возможностей, которых мне очень не хватало для полноценной работой с серверным API при использовании существующих расширений:
- Одна из первых проблем с которой я столкнулся — сохранение различных сущностей в одной таблице. Для получения таких записей уже не достаточно просто указать имя модели как это предлагается, например тут. Один из примеров такого механизма — таблица
AuthItems
, которая используется фреймворком в механизме RBAC (если кто-то не знаком с ним — есть замечательная статья на эту тему). В ней содержатся роли, операции и задачи которые определяются флагомtype
, и для работы с этими сущностями через API мне хотелось использовать url не такого типа:
GET: /api/authitems/?type=0 - получение списка операций
GET: /api/authitems/?type=1 - получение списка задач
GET: /api/authitems/?type=2 - получение списка ролей
а такого:
GET: /api/operations - получение списка операций
GET: /api/tasks - получение списка задач
GET: /api/roles - получение списка ролей
Согласитесь, второй вариант выглядит очевиднее и понятнее, тем более для человека не знакомого с фрейморком и устройством RBAC в нем.
- Вторая немаловажная возможность — механизм поиска и фильтрации данных, с возможностью задавать условия и комбинировать правила. Например, мне хотелось иметь возможность выполнить аналог такого запроса:
SELECT * FROM users WHERE (age>25 AND first_name LIKE '%alex%') OR (last_name='shepard');
- Порой не хватает возможности создания, обновления, удаления коллекций. Т.е. изменение n-ого количества записей одним запросом опять же используя поиск и фильтрацию. Например, зачастую требуется удалить или обновить все записи, попадающие под какое-либо условие, а использовать отдельные запросы слишком накладно.
- Еще одним важным моментом была возможность получать связанные данные. Например: получить данные роли вместе со всеми её задачами и операциями.
- Конечно невозможно хоть сколько-нибудь комфортно работать с API не имея возможности ограничить количество получаемых записей (
limit
), сместить начало выборки (offset
), и указать порядок сортировки записей (order by
). Так же не плохо бы иметь возможность группировки (group by
).
- Важно иметь возможность для каждой из операций проверять права пользователя (метод
checkAccess
все в том же RBAC).
- Ну и наконец, все это дело нужно как-то тестировать.
В результате анализа примерно такого списка «хотелок» и появился на свет мой вариант реализации API на этом замечательном фреймворке!
Для начала о том, как API выглядит для клиента.
Рассмотрим на примере все того же компонента RBAC.
Получение записей
Все как обычно:
GET: /roles - список ролей
GET: /roles/42 - роль с id=42
Поиск и фильтрация
Механизмы у них практически одинаковые, разница лишь в том, что при поиске в выборку попадают записи с частичным совпадением, а при фильтрации с полным. Комбинация полей и их значений задается в JSON формате. Именно он мне показался наиболее удобным для реализации этого функционала. Например:
{"name":"alex", "age":"25"}
— соответствует запросу вида: WHERE name='alex' AND age=25
[{"name":"alex"}, {"age":"25"}]
— соответствует запросу вида: WHERE name='alex' OR age=25
Т.е. параметры переданные в одном объекте соответствуют условию AND, а параметры заданные массивом объектов соответствуют условию OR.
Так же кроме условий И и ИЛИ можно указывать следующие условия, которые должны предшествовать значению:
-
<:
меньне -
>:
больше -
<=:
меньне или равно -
>=:
больше или равно -
<>:
не равно -
=:
равно
Несколько примеров:
GET: /users?filter={"name":"alex"}
— пользователи с именемalex
GET: /users?filter={"name":"alex", "age":">25"}
— пользователи с именемalex
И возрастом старше25
GET: /users?filter=[{"name":"alex"}, {"name":"dmitry"}]
— пользователи с именемalex
ИЛИdmitry
GET: /users?search={"name":"alex"}
— пользователи с именем содержащим подстрокуalex
(alexey, alexander, alex и.т.д)
Работа со связанными данными
Зачастую можно встретить следующий синтаксис для работы со связанными данными:
GET: /roles/42/operations
— получить все операции принадлежащие роли сid = 42
Изначально я использовал именно этот подход, но в процессе понял, что он имеет несколько недостатков.
Один ко многим
В случае если связь один ко многим можно использовать подход с фильтром, который описан выше:
GET: operations?filter={"role_id":"42"}
— получить все операции принадлежащие роли сid = 42
Многие ко многим
Работать же со связью многие ко многим удобнее как с отдельной сущностью по причине того, что зачастую таблица связи не ограничена полями
parent_id
и child_id
. Рассмотрим на примере товаров (products
) и их характеристик (features
). Таблица связи должна иметь минимум два поля: product_id
и feature_id
. Но, если потребуется задать порядок сортировки списка характеристик в карточке товара, в таблицу также нужно добавить поле ordering
, а также необходимо добавить значение value
той самой характеристики. Используя url вида:
POST: /products/42/feature/1
— связать товар42
с характеристикой товара1
GET: /products/42/feature/1
— получить характеристику товара1
(запись из таблицыfeatures
)
нет возможности получить тот самый порядок сортировки и значение характеристики (запись из таблицы связи). На личном опыте я убедился, что для подобного рода связей лучше использовать отдельную сущность, например
productfeatures
. Таким образом, мы получим:
POST: /productfeatures
— передав в теле запроса параметрыproduct_id
,feature_id
,ordering
иvalue
мы свяжем характеристику и товар, указав значение и порядок сортировки.
GET: /productfeatures?filter={"product_id":"42"}
— получим все связи товара с характеристиками. Ответ может выглядеть примерно так:
[ {"id":"12","feature_id":"1","product_id":"42","value":"33"}, {"id":"13","feature_id":"2","product_id":"42","value":"54"} ]
PUT: /productfeatures/12
— изменить связь сid=12
Данный подход конечно тоже не без недостатков, так как мы не можем получить, например имя товара и имя характеристики без двух дополнительных запросов. Тут нам на помощь приходит механизм получения связанных данных.
Получение связанных данных
GET: /productfeatures/12?with=product,feature
— получение связи вместе с товаром и характеристиками. Пример ответа сервера:
{ "id":"12", "feature_id":"1", "product_id":"42", "value":"33", "feature":{"id":"1","name":"Вес","unit":"кг"}, "product":{"id":"42","name":"Стул", ...}, }
Таким же образом можно получить все характеристики товара:
GET: /products/42?with=features
— получение данных товара сid=42
и всех его характеристик в массиве. Пример ответа сервера:
{ "id":"42", "name":"Стул", "features":[{"id":"1","name":"Вес","unit":"кг"}, {"id":"2","name":"Высота","unit":"см"}], ... }
Забегая вперед, скажу что используя
with
можно получать данные не только из связанных таблиц, но и просто описать массив со значениями. Это бывает полезно, например, когда нужно вместе с данными товара передать список возможных значений его статуса. Статус товара хранится в поле status
, но полученное значение status:0
нам скажет не много. Для этого вместе с данными товара можно получить его возможные статусы с их описанием:{
...,
"status":1,
"statuses":{0:"Нет в наличии", 1:"На складе", 2:"Под заказ"},
...,
}
Удаление данных
DELETE: /role/42 - удалить роль с id=42
DELETE: /role - удалить все роли
При удалении можно также использовать поиск и фильтрацию:
DELETE: /role?filter={"name":"admin"} - удалить роли с именем "admin"
Создание данных
POST: /role - создать роль
Одним запросом можно создать как одну запись, так и коллекцию передав в теле запроса массив данных, например такого вида:
[
{"name":"admin"},
{"name":"guest"}
]
Таким образом будут созданы две роли с соответствующими именами. Ответом сервера в таком случае будет так же массив созданных записей.
Изменение данных
Все по аналогии с созданием, только необходимо указать параметр id в url ну и метод, конечно же, PUT:
PUT: /role/42 - изменить запись 42
Изменение нескольких записей:
PUT: /role
передав в теле запроса
[ {"id":"1","name":"admin"}, {"id":"2","name":"guest"} ]
будут изменены записи с id 1 и 2.
Изменение записей найденных по фильтру:
PUT: /user?filter={"role":"guest"}' - изменить записи с role=guest
Лимит, смещение и порядок записей
Для частичной выборки используются привычные
limit
и offset
.offset
— смещение, начиная с нуляlimit
— количество записейorder
— порядок сортировкиGET: /users/?offset=10&limit=10
GET: /users/?order=id DESC
GET: /users/?order=id ASC
Можно комбинировать:
GET: /users/?order=parent_id ASC,ordering ASC
Важно упомянуть о том, как лимит и смещение отобразятся в ответе. Мною были рассмотрены несколько вариантов, например, передавать данные в теле ответа:
{
data:[
{id:1, name:"Alex", role:"admin"},
{id:2, name:"Dmitry", role:"guest"}
],
meta:{
total:2,
offset:0,
limit:10
}
}
На стороне клиента я использовал AngularJS. Мне показалось очень удобной реализация механизма
$resource
в нем. Не буду углубляться в его особенности, дело в том что для комфортной работы с ним лучше получать чистые данные без лишней информации. Поэтому данные о количестве выбранных записей были перемещены в заголовки:GET: roles?limit=5
Content-Range:items 0-4/10 - получены записи с 0 по 4, всего 10.
Важно обратить внимание, что заголовок выше указывает на то, что получено не 4 записи, а 5 (zero-based). Т.е. при получении всех 10 записей заголовок примет вид:
Content-Range:items 0-9/10 - получены записи с 0 по 9 всего 10.
Распарсить такой заголовок на клиенте не составляет труда, а тело ответа теперь не засоряется «лишними» данными.
Реализация на сервере.
Первым делом необходимо создать модуль. Конечно это не обязательное требование, но модуль для этого подходит как нельзя лучше. В имя модуля можно также включить версию API.
Далее в конфиг приложения добавляем несколько правил для правильного роутинга в соответствии с url и методом запроса:
array('api/<controller>/list', 'pattern'=>'api/<controller:\w+>', 'verb'=>'GET'),
array('api/<controller>/view', 'pattern'=>'api/<controller:\w+>/<id:\d+>', 'verb'=>'GET'),
array('api/<controller>/create', 'pattern'=>'api/<controller:\w+>', 'verb'=>'POST'),
array('api/<controller>/update', 'pattern'=>'api/<controller:\w+>/<id:\d+>', 'verb'=>'PUT'),
array('api/<controller>/update', 'pattern'=>'api/<controller:\w+>', 'verb'=>'PUT'),
array('api/<controller>/delete', 'pattern'=>'api/<controller:\w+>/<id:\d+>', 'verb'=>'DELETE'),
array('api/<controller>/delete', 'pattern'=>'api/<controller:\w+>', 'verb'=>'DELETE'),
Думаю что для людей хоть сколько-нибудь знакомых с фреймворком объяснять тут нечего.
Далее подключаем файлы
ApiController.php
, Controller.php
и ApiRelationProvider.php
любым удобным способом.Контроллеры модуля API
Все контроллеры модуля API должны расширять класс
ApiController
. Из настроек роутера понятно, что в контроллерах должны быть реализованы следующие методы (
actions
):actionView()
— получение записи
actionList()
— получение списка записей
actionCreate()
— создание записи
actionUpdate()
— изменение записи
actionDelete()
— удаление записи
Рассмотрим на примере контроллер ролей пользователей. Как я уже говорил ранее, механизм RBAC фреймворка хранит все сущности (роли, операции и задачи) в одной таблице (
authitem
). Тип сущности определяется флагом type
в этой таблице. Т.е. контроллеры RolesController
, OperationsController
, TasksController
должны работать с одной моделью (AuthItems
), но их сферу действия нужно ограничить только теми записями, которые имеют соответствующее значение type
.Код контроллера:
class RolesController extends ApiController
{
public function __construct($id, $module = null) {
$this->model = new AuthItem('read');
$this->baseCriteria = new CDbCriteria();
$this->baseCriteria->addCondition('type='.AuthItem::ROLE_TYPE);
parent::__construct($id, $module);
}
public function actionView(){
if(!Yii::app()->user->checkAccess('getRole')){
$this->accessDenied();
}
$this->getView();
}
public function actionList(){
if(!Yii::app()->user->checkAccess('getRole')){
$this->accessDenied();
}
$this->getList();
}
public function actionCreate(){
if(!Yii::app()->user->checkAccess('createRole')){
$this->accessDenied();
}
$this->model->setScenario('create');
$this->priorityData = array('type'=>AuthItem::ROLE_TYPE);
$this->create();
}
public function actionUpdate( ){
if(!Yii::app()->user->checkAccess('updateRole')){
$this->accessDenied();
}
$this->model->setScenario('update');
$this->priorityData = array('type'=>AuthItem::ROLE_TYPE);
$this->update();
}
public function actionDelete( ){
if(!Yii::app()->user->checkAccess('deleteRole')){
$this->accessDenied();
}
$this->model->setScenario('delete');
$this->delete();
}
public function getRelations() {
return array(
'roleoperations'=>array(
'relationName'=>'operations',
'columnName'=>'operations',
'return'=>'array'
)
);
}
}
Первым делом в методе-конструкторе указываем модель с которой будет работать контроллер, присвоив экземпляр модели свойству
model
контроллера.Указав свойство
baseCriteria
и назначив для него условие (addCondition('type='.AuthItem::ROLE_TYPE)
), мы определяем, что при любых полученных данных от клиента это условие должно выполнятся. Таким образом, при выборке записей для получения, обновления и удаления данных используются записи подходящие под условие type=2
и даже если в таблице будет существовать запись с искомым значением id
, но type
будет отличным от указанного в baseCriteria
клиент получит 404 ошибку. Так же в методе
actionCreate()
устанавливается значение свойства priorityData
, в котором указывается набор данных, который переопределит любые данные полученные в теле запроса от клиента. Т.е даже если клиент указал в теле запроса свойство type
равным 42, оно все равно переопределится на значение AuthItem::ROLE_TYPE
(2) и не позволит создать сущность отличную от роли.Перед выполнением любой операции проверяются права пользователя методом
checkAccess()
и указывается сценарии работы с моделью, так как в логике модели могут быть определены какие-либо правила валидации или триггеры в зависимости от сценария.Все методы действий (
getView()
, getList()
, create()
, update()
, delete()
) по умолчанию отправляют пользователю данные и прекращают выполнение приложения. Получив первым параметром false
, методы будут возвращать ответ в виде массива. Это может быть полезно, когда нужно очистить некоторые атрибуты (пароли и.т.д.) в данных полученных из модели перед отправкой пользователю. Код ответа в таком случае можно получить через свойство statusCode
, которое заполнится после выполнения метода.Последний метод контроллера
getRelations()
служит для конфигурирования связей модели. Метод должен возвращать массив, описывающий набор связей. В данном случае, указав в url параметр ...?with=roleoperations
мы получим вместе с данными роли также все операции назначенные ей:{
bizrule: null
description: "Administrator"
id: "1"
name: "admin"
operations: [{...}, {...},...]
type: "2"
}
В массиве, возвращаемом методом
getRelations()
ключ массива — имя связи которое соответствует GET параметру (в данном случае roleoperations
). Значение элементов массива конфигурирующего связь:
relationName |
string |
Имя связи в модели. Если в модели нет связи с соотв. именем механизм фреймворка попытается получить свойство с таким именем или выполнить метод подставив к нему get . Например, в роли связи может выступать и метод модели: для этого нужно указать имя связи, например possibleValues и создать в модели метод getPossibleValues() , возвращающий массив данных. |
columnName |
string |
Имя атрибута в который будут добавлены найденные записи в ответе сервера. |
return |
string ('array' | 'object') |
Возвращать массив объектов (моделей) или массив значений. |
Надо сказать, что в большинстве случаев контроллеры выглядят гораздо проще чем приведенный выше. Вот пример контроллера из одного из моих проектов:
<?php
class TagController extends ApiController
{
public function __construct($id, $module = null) {
$this->model = new Tag('read');
parent::__construct($id, $module);
}
public function actionView(){
$this->getView();
}
public function actionList(){
$this->getList();
}
public function actionCreate(){
if(!Yii::app()->user->checkAccess('createTag')){
$this->accessDenied();
}
$this->create();
}
public function actionUpdate(){
if(!Yii::app()->user->checkAccess('updateTag')){
$this->accessDenied();
}
$this->update();
}
public function actionDelete(){
if(!Yii::app()->user->checkAccess('deleteTag')){
$this->accessDenied();
}
$this->delete();
}
}
Краткое описание класса
ApiController
:Свойства:
Свойство |
Тип |
Описание |
---|---|---|
data |
array |
Данные из тела запроса. В массив попадут как данные из запроса с использованием Content-Type: x-www-form-urlencoded так и с использованием Content-Type: application/json |
priorityData |
array |
Данные которые будут заменены или дополнены к данным из тела запроса (data) при выполнении операций создания и изменения данных. |
model |
CActiveRecord |
Экземпляр модели для работы с данными. |
statusCode |
integer |
Код ответа сервера. Исходное значение 200 . |
criteriaParams |
array |
Исходные параметры выборки (limit , offset , order ). Значения полученные из GET параметров запроса переопределяют соответствующие значения в массиве.Исходное значение:
|
contentRange |
array |
Данные о количестве выбранных записей. Пример:
|
sendToEndUser |
boolean |
Отправлять ли данные пользователю после завершения операции (просмотр, создание, изменение, удаление) или же вернуть результат действия в виде массива. |
criteria |
CDbCriteria |
Экземпляр класса CDbCriteria для выборки данных. Конфигурируется на основе данных из запроса (limit, offset, order, filter, search и т.д.) |
baseCriteria |
CDbCriteria |
Базовый экземпляр класса CDbCriteria для выборки данных. Условия объекта имеют приоритет над условиями criteria . |
notFoundErrorResponse |
array |
Ответ сервера при не найденной записи. |
Методы:
- getView()
Выполняет поиск записи в соответствии с GET параметрами. Возвращает массив параметров записи или отправляет данные пользователю. В случае если запись не найдена — отправляет пользователю сообщение об ошибке с кодом ответа 404 или возвращает массив с соответствующей информацией об ошибке. Устанавливает свойствоstatusCode
в соответствующее значение после выполнения запроса.
getView(boolean $sendToEndUser = true, integer $id)
$sendToEndUser
boolean
Отправлять ли данные пользователю после завершения операции или же вернуть результат действия в виде массива.
$id
integer
Параметр id записи. Если не передан — заполняется из GET параметров.
- getList()
Выполняет поиск записей в соответствии с GET параметрами. Возвращает массив найденных записей или пустой массив.
getList(boolean $sendToEndUser = true)
$sendToEndUser
boolean
Отправлять ли данные пользователю после завершения операции или же вернуть результат действия в виде массива.
- create()
Создает новую запись с данными полученными из тела запроса. В случае если в теле запроса передан массив атрибутов — будет сознано соответствующее количество записей. Возвращает массив с атрибутами новой записи.
Например:
array( 'name'=>'Alex', 'age'=>'25' ) //будет создана запись с соотв. параметрами
array( array( 'name'=>'Alex', 'age'=>'25' ), array( 'name'=>'Dmitry', 'age'=>'33' ) ) //будет создана коллекция записей с соотв. параметрами
create(boolean $sendToEndUser = true)
$sendToEndUser
boolean
Отправлять ли данные пользователю после завершения операции или же вернуть результат действия в виде массива.
- update()
Обновляет запись, найденную в соответствии с полученными GET параметрами. Возвращает массив параметров записи или отправляет данные пользователю. В случае если запись не найдена — отправляет пользователю сообщение об ошибке с кодом ответа 404 или возвращает массив с соответствующей информацией об ошибке. В случае если в теле запроса передан массив записей — будет изменено соответствующее количество записей и возвращен массив с их значениями.
Например:
PUT: users/1
array( 'name'=>'Alex', 'age'=>'25' ) //будет изменена запись найденная в соответствии с полученными GET параметрами
PUT: users
array( array( 'id'=>1, 'name'=>'Alex', 'age'=>'25' ), array( 'id'=>2, 'name'=>'Dmitry', 'age'=>'33' ) ) //будет изменена коллекция записей с соотв с параметром id переданным для каждой из них. Номер записи не передается в url
update(boolean $sendToEndUser = true, integer $id)
$sendToEndUser
boolean
Отправлять ли данные пользователю после завершения операции или же вернуть результат действия в виде массива.
$id
integer
Параметр id записи. Если не передан — заполняется из GET параметров.
- delete()
Удаляет запись, найденную в соответствии с полученными GET параметрами. Возвращает массив параметров удаленной записи или отправляет данные пользователю. В случае если запись не найдена — отправляет пользователю сообщение об ошибке с кодом ответа 404 или возвращает массив с соответствующей информацией об ошибке. Если не получен параметр id — будут удалены все записи.
delete(boolean $sendToEndUser = true, integer $id)
$sendToEndUser
boolean
Отправлять ли данные пользователю после завершения операции или же вернуть результат действия в виде массива.
$id
integer
Параметр id записи. Если не передан — заполняется из GET параметров.
Тестирование
Изучая вопрос тестирования API, я рассмотрел множество подходов. Большинство советовало использовать не модульное тестирование, а функциональное. Но опробовав несколько способов функционального тестирования (с использованием Selenium и даже PhantomJs), с такими невероятными методами как создание формы средствами selenium, добавление в нее полей ввода, заполнение их данными и отправкой путем клика по кнопке submit с последующим анализом ответа сервера, я понял что на тестирование таким образом уйдут годы!
Погрузившись в поиски глубже, и проанализировав опыт других разработчиков, я написал класс для тестирования API при помощи curl. Для его использования необходимо подключить класс
ApiTestCase
и расширять классы тестов от него.Первой проблемой, с которой я столкнулся тестируя API, была проблема прав доступа. Во время тестирования используется тестовая база. Таким образом, необходимо постоянно следить чтобы на ней всегда были актуальные данные в таблицах используемых RBAC, иначе попробовав протестировать создание сущности можно получить ответ
{"error":{"access":"You do not have sufficient permissions to access."}}
с кодом 403. Да и к тому же нужно научить тесты авторизоваться и отправлять куки авторизации по той же причине ограничения прав доступа в действиях контроллеров API. Для решения этой проблемы я решил использовать рабочую базу для работы компонента authManager
, который как раз и занимается правами доступа, указав в конфигурационном файле тестового окружения (config/test.php) следующее: ...
'proddb'=>array(
'class'=>'CDbConnection',
'connectionString' => 'mysql:host=localhost;dbname=yiirestmodel',
'emulatePrepare' => true,
'username' => '',
'password' => '',
'charset' => 'utf8',
), //коннект к рабочей базе
'db'=>array(
'connectionString' => 'mysql:host=localhost;dbname=yiirestmodel-test',
), //коннект к тестовой базе
'authManager'=>array(
'connectionID'=>'proddb', //использовать рабочую базу
),
...
Единственное ограничение данного подхода — нужно следить за тем чтобы в таблице пользователей значение id авторизуемого пользователя было одинаковым в обеих базах, так как если на тестовой базе ваш пользователь admin имеет
id=1
, а на рабочей роль админа назначена пользователю с id=42
то компонент не посчитает такого пользователя администратором!Пример теста:
class UsersControllerTest extends ApiTestCase
{
public $fixtures = array(
'users'=>'User'
);
public function testActionView(){
$user = $this->users('admin');
$response = $this->get('api/users/'.$user->id, array(), array('cookies'=>$this->getAuthCookies()));
$this->assertEquals($response['code'], 200);
$this->assertNotNull($response['decoded']);
$this->assertEquals($response['decoded']['id'], $user->id);
$this->assertArrayNotHasKey('password', $response['decoded']);
$this->assertArrayNotHasKey('guid', $response['decoded']);
}
public function testActionList(){
$response = $this->get('api/users', array(), array('cookies'=>$this->getAuthCookies()));
$this->assertEquals($response['code'], 200);
$this->assertEquals(count($response['decoded']), User::model()->count());
}
public function testActionCreate(){
$response = $this->post(
'api/users',
array(
'first_name' => 'new_first_name',
'middle_name' => 'new_middle_name',
'last_name' => 'new_last_name',
'password' => 'new_user_psw',
'password_repeat' => 'new_user_psw',
'role' => 'guest',
),
array('cookies'=>$this->getAuthCookies())
);
$this->assertEquals($response['code'], 200);
$this->assertNotNull($response['decoded']);
$this->assertArrayHasKey('id', $response['decoded']);
$this->assertArrayNotHasKey('password', $response['decoded']);
$this->assertNotNull( User::model()->findByPk($response['decoded']['id']) );
}
}
В начале указываем фикстуры используемые в тестах. Далее в методе теста делаем запрос при помощи метода
ApiTestCase::get()
(выполняющего запрос методом GET) передав в него url и куки авторизации полученные при помощи вызова метода ApiTestCase::getAuthCookies()
. Для того чтобы получить эти самые куки нужно указать параметры $loginUrl
и $loginData
. У меня они указаны прямо в классе ApiTestCase
для того чтобы не прописывать их в каждом классе теста:public $loginUrl = 'api/login';
public $loginData = array('login'=>'admin', 'password'=>'admin');
Надо сказать что метод
ApiTestCase::getAuthCookies()
достаточно умен чтобы не делать запрос авторизации при каждом вызове, а возвращать кешированные данные. Для повторного выполнения запроса можно передать первым параметров true
. Метод ApiTestCase::get() (как и
ApiTestCase::post()
, ApiTestCase::put()
, ApiTestCase::delete()
) вернет массив данных выполненного запроса со следующей структурой:body |
string |
Ответ сервера |
||
code |
integer |
Код ответа |
||
cookies |
array |
Массив cookies полученный в ответе |
||
headers |
array |
Массив заголовков полученных в ответе (имя заголовка=>значение заголовка).Например:
|
||
decoded |
array |
Массив декодированного (json_decode) ответа сервера |
Этих данных достаточно для полноценного тестирования и анализа ответа сервера.
После получения ответа на запрос проверяются различные утверждения (asserts) которые вполне очевидны и в комментариях не нуждаются. Конечно, это далеко не полный код теста для сущности, но этого примера достаточно чтобы понять принцип работы с классом
ApiTestCase
.Краткое описание класса
ApiTestCase
:Свойства:
Свойство |
Тип |
Описание |
---|---|---|
authCookies |
array |
Cookies полученные после авторизации (вызова метода ApiTestCase::getAuthCookies() ) |
loginUrl |
string |
Адрес выполнения запроса авторизации для получения авторизационных cookies. |
loginData |
array() |
Массив который будет передан в теле запроса авторизации. По умолчанию:
|
Основные методы:
- getAuthCookies()
Выполняет запрос авторизации.
getAuthCookies(boolean $reload = false)
$reload
boolean
Выполнять ли запрос при повторном вызове или вернуть значение полученное при первом вызове.
- get()
Выполняет запрос методом GET. Возвращает массив с параметрами ответа сервера.
get( string $url, array $params = array(), array $options = array()){
$url
string
Url адрес для выполнения запроса
$params
array
Массив GET параметров запроса
$options
array
Опции запроса, которые будут подставлены в метод curl_setopt_array
. Также в массиве может присутствовать элементcookies
, значением которого должен быть массив (имя=>значение) кук для отправки их в заголовках запроса.
- post()
Выполняет запрос методом POST. Возвращает массив с параметрами ответа сервера.
post( string $url, array $params = array(), array $options = array()){
$url
string
Url адрес для выполнения запроса
$params
array
Массив параметров запроса передаваемых в теле запроса
$options
array
Опции запроса, которые будут подставлены в метод curl_setopt_array
. Также в массиве может присутствовать элементcookies
, значением которого должен быть массив (имя=>значение) кук для отправки их в заголовках запроса.
- put()
Выполняет запрос методом PUT. Возвращает массив с параметрами ответа сервера.
Описание параметров см. ApiTestCase::post()
- delete()
Выполняет запрос методом DELETE. Возвращает массив с параметрами ответа сервера.
Описание параметров см. ApiTestCase::post()
Ссылка на github.
Заключение
Конечно, при больших нагрузках могут возникнуть проблемы, так как для работы с данными используется
ActiveRecord
. Я думаю частично это можно решить кэшированием (благо для этого в Yii есть все необходимое). Надеюсь, что найдутся разработчики, которым будет полезно если не все расширение, то какие-либо части или просто идеи, примененные в нем.
В планах на будущее еще много различных доработок и изменений, так что буду благодарен за любые замечания и предложения.
P.S.
Статья получилась большой (хотя не вышло описать и половины того что было задумано) и несколько «рваной». Если информация окажется полезной в будущем хотелось бы описать еще некоторые моменты. Например, каким образом была реализована авторизация, получение коллекций (комбинирование запросов в один) и.т.д. Так же хотелось бы рассказать о том, как я взаимодействовал с API на стороне клиента используя средства AngularJS и каким образом создавать одностраничные приложения дружественное для поисковиков (с рендером страниц через PhantomJs).