[Из песочницы] RESTful API на Yii framework с RBAC и тестами

Существует множество готовых решений для реализации 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»} — пользователи с именем alexGET: /users? filter={«name»: «alex», «age»:»>25»} — пользователи с именем alex И возрастом старше 25GET: /users? filter=[{«name»: «alex»}, {«name»: «dmitry»}] — пользователи с именем alex ИЛИ dmitryGET: /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 с характеристикой товара 1GET: /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=42DELETE: /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=10GET: /users/? order=id DESCGET: /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=5Content-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//list', 'pattern'=>'api/', 'verb'=>'GET'), array ('api//view', 'pattern'=>'api//', 'verb'=>'GET'),

array ('api//create', 'pattern'=>'api/', 'verb'=>'POST'),

array ('api//update', 'pattern'=>'api//', 'verb'=>'PUT'), array ('api//update', 'pattern'=>'api/', 'verb'=>'PUT'),

array ('api//delete', 'pattern'=>'api//', 'verb'=>'DELETE'), array ('api//delete', 'pattern'=>'api/', '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') Возвращать массив объектов (моделей) или массив значений. Надо сказать, что в большинстве случаев контроллеры выглядят гораздо проще чем приведенный выше. Вот пример контроллера из одного из моих проектов:

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 параметров запроса переопределяют соответствующие значения в массиве.Исходное значение: array ( 'limit' => 100, 'offset' => 0, 'order' => 'id ASC' ) contentRange array Данные о количестве выбранных записей. Пример: array ( 'total'=>10, 'start'=>6, 'end'=>15 ) 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 Массив заголовков полученных в ответе (имя заголовка=>значение заголовка).Например: array ( 'Date' => «Fri, 23 May 2014 12:10:37 GMT» 'Server' =>«Apache/2.4.7 (Win32) OpenSSL/1.0.1e PHP/5.5.9» … ) decoded array Массив декодированного (json_decode) ответа сервера Этих данных достаточно для полноценного тестирования и анализа ответа сервера.После получения ответа на запрос проверяются различные утверждения (asserts) которые вполне очевидны и в комментариях не нуждаются. Конечно, это далеко не полный код теста для сущности, но этого примера достаточно чтобы понять принцип работы с классом ApiTestCase.

Краткое описание класса ApiTestCase: Свойства:

Свойство Тип Описание authCookies array Cookies полученные после авторизации (вызова метода ApiTestCase: getAuthCookies ()) loginUrl string Адрес выполнения запроса авторизации для получения авторизационных cookies. loginData array () Массив который будет передан в теле запроса авторизации. По умолчанию: array ('login'=>'admin', 'password'=>'admin'); Основные методы: 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).

© Habrahabr.ru