27 мая 2014 в 00:00

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

API*, Yii*, PHP*
Существует множество готовых решений для реализации 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).
Олег @goodnickoff
карма
2,0
рейтинг 0,0
Самое читаемое Разработка

Комментарии (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

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

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