RESTful API на Yii framework с RBAC и тестами

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

Возможно, за время подготовки и написания статьи она немного потеряла актуальность с выходом Yii2 со встроенным фреймворком для создания RESTful API. Но статья по прежнему будет полезна для тех, кто пока не знаком с Yii2, или для тех, кому необходимо быстро и просто реализовать полноценное API для уже существующего приложения.

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

  1. Одна из первых проблем с которой я столкнулся — сохранение различных сущностей в одной таблице. Для получения таких записей уже не достаточно просто указать имя модели как это предлагается, например тут. Один из примеров такого механизма — таблица 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 в нем.
  2. Вторая немаловажная возможность — механизм поиска и фильтрации данных, с возможностью задавать условия и комбинировать правила. Например, мне хотелось иметь возможность выполнить аналог такого запроса:
    SELECT * FROM users WHERE (age>25 AND first_name LIKE '%alex%') OR (last_name='shepard');
    

  3. Порой не хватает возможности создания, обновления, удаления коллекций. Т.е. изменение n-ого количества записей одним запросом опять же используя поиск и фильтрацию. Например, зачастую требуется удалить или обновить все записи, попадающие под какое-либо условие, а использовать отдельные запросы слишком накладно.
  4. Еще одним важным моментом была возможность получать связанные данные. Например: получить данные роли вместе со всеми её задачами и операциями.
  5. Конечно невозможно хоть сколько-нибудь комфортно работать с API не имея возможности ограничить количество получаемых записей (limit), сместить начало выборки (offset), и указать порядок сортировки записей (order by). Так же не плохо бы иметь возможность группировки (group by).
  6. Важно иметь возможность для каждой из операций проверять права пользователя (метод checkAccess все в том же RBAC).
  7. Ну и наконец, все это дело нужно как-то тестировать.

В результате анализа примерно такого списка «хотелок» и появился на свет мой вариант реализации 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 параметров запроса переопределяют соответствующие значения в массиве.
Исходное значение:
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).
Поделиться публикацией

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

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

    –4
    Честно говоря, не сильно понимаю именно RESTful API, когда можно принимать на вход, например, JSON-объект, в который можно запихать любые параметры, какие душе угодно. И проблем подобных не возникнет.

    Кстати, хотелось бы услышать ваше мнение по поводу нашей реализации
      0
      Немного непонятно что вы хотели сказать первым высказыванием. Его можно трактовать как «зачем заморачиваться, можно сделать так же примерно, как это делается в SOAP».

      Ваша реализация — только реализация автодокументации, исходники к слову сомнительного качества. Если вам интересны способы автоматизации в плане API (если у вас по многу пишутся клиент-серверные приложения), то стоит обратить внимание на HATEOAS, на JSON API и т.д.
        0
        Мой вопрос был не конкретно к вашему решению, мне просто хотелось услышать именно мнение человека, который работает именно с RESTful API, а не с JSON, как мы, в чем преимущества подхода. То есть именно не «зачем заморачиваться», а чем это лучше, а JSON, например, хуже
          +1
          Просто лично для меня передача JSON в качестве GET-параметра, например, выглядит несколько странновато
            0
            Я работаю именно с RESTFull API, и немного не понимаю что вы имеете в виду под фразой «а не с JSON, как мы».

            По поводу JSON в GET параметрах — да, это странно. Плюсы такого подхода — можно очень просто разбирать такие штуки как фильры, не будет проблем с передачей массивов и т.д. Например:
            /api/posts?filter={"name":"admin","type":[1,2,3]}
            привычнее видеть в виде
            /api/posts?filter[name]=admin&filter[type]=1,2,3
            Но запросы такого рода чуть сложнее разбирать на сервере. А так можно просто сделать json_encode и не париться.

            Хотя я согласен, это несколько странно и выглядит не очень опрятно.
              0
              Да, я согласен что выглядит это не лучшим образом. Я сам скрепя сердце остановился на этом подходе. Главным аргументом в его пользу было как раз иметь возможность в фильтрах писать условия AND и OR:
              /api/posts?filter={"name":"admin","type":1} — name=admin AND type=1
              /api/posts?filter={"name":"admin"},{"type":1} — name=admin OR type=1
                0
                1. Разве при таком подходе не приходится вручную разбираться в каком именно формате пришли данные, чтобы подставить OR или AND?
                2. Почему нельзя сделать отдельные методы для получения по тому или иному параметру?

                Я просто не сильно знаком именно с RESTful спецификацией, поэтому может мой вопрос и глупым покажется, но мне интересно.
                0
                К тому же на клиенте это выглядит куда понятнее:

                var posts = posts.query(
                    {
                        filter:{category:categoryName, published:1},
                        order:"id ASC",
                        limit:20
                    }, 
                    function(){...}
                );
                

                Таким образом очень удобно конфигурировать объекты для запросов.
                  0
                  Ну да, в целом теперь понятно. Просто у меня формат задач несколько иной.

                  Спасибо за разъяснения
            0
            Видимо вы ошибочно приняли Fesor за автора статьи.

            Я не совсем понимаю что значит
            принимать на вход, например, JSON-объект, в который можно запихать любые параметры
            Может быть глядя на пример станет яснее.

            Что касается моего выбора в пользу RESTful — то причина как я уже упоминал в том, что на клиенте я использовал AngularJS, и его реализация $resource мне показалась очень удобной. А она как раз требует RESTful API.
            0
            Спасибо, что не поленились опубликовать свои наработки! Я вот поленился, хотя реализация схожа с вашей (вплоть до тестирования курлом) :)
              +1
              Yii в качестве API провайдера, мне кажется довольно не оптимальным решением при высокой нагрузке, особенно, если для работы с бд используется AR.
              Ведь зачастую нужно для этого подключение к бд, обработка RBAC правил, вывод пользователю. На каждый такой запрос Yii скушает 3.5-4.5Мб памяти.
                0
                Да, я упомянул об этом в статье. Но как я уже говорил, проблему можно решить кэшированием запросов. Хотя я и близко не сталкивался с проблемами производительности, используя этот подход.
                0
                Скажите, пожалуйста, в заголовке Content-Range слово items имеет смысловую нагрузку?
                  0
                  Это еденицы измерений. Во всех примерах, учитывая специфику этого заголовка, вы можете увидеть bytes в качестве едениц измерения.
                    0
                    И еще вопрос в догонку. Если делается запрос GET /users, и, соответственно, в ответе ожидается массив сущностей. Если этот массив пустой, то что ваше API возвращает: 404 или 200 с пустым массивом в теле?
                      0
                      Прошу прощения за поздний ответ.

                      В этом случае в ответе вернется пустой массив ([]) с кодом ответа 200. При желании это поведение можно легко изменить.
                  0
                  Как бы вы реализовали следующий функционал:
                  — есть API тороговой площадки
                  — пользователи API — продавцы
                  — есть сущность «комментарий к заказу», совпадает с REST ресурсом «комментарий к заказу».
                  — есть сущность «заказ». Ресурс «заказ» = сущность «заказ» + поле comments, содержащее массив комментариев к данному заказу
                  — при любом изменении данных заказа (статус, время доставки итд) должен быть добавлен комментарий

                  Продавец хочет изменить поле заказа, для этого необходимо совершить действия с двумя ресурсами: обновить поле заказа и создать новый комментарий.
                  Варианты как это реализовать в API:
                  1. Два раздельных REST вызова: PATCH /orders/123 и POST /orders/123/comments. Недостаток — нет гарантии того, что оба вызова будут сделаны.
                  2. Один RPC вызов POST /order/update, который принимает новое значение поля и текст комментария. Недостаток — уходим в сторону от REST

                  Сталкивались ли вы с похожей задачей?

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

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