Существует множество готовых решений для реализации RESTFul API на Yii framework, но при использовании этих решений в реальных проектах понимаешь что все красиво выглядит только на примерах с собачками и их хозяевами.
Возможно, за время подготовки и написания статьи она немного потеряла актуальность с выходом 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).
Автор: oledje