📜 ⬆️ ⬇️

RESTful API on Yii framework with RBAC and tests

There are many ready-made solutions for implementing the RESTFul API on the Yii framework, but when using these solutions in real projects you understand that everything looks beautiful only with examples of dogs and their owners.

It is possible that during the preparation and writing of the article, it lost a bit of relevance with the release of Yii2 with a built-in framework for creating a RESTful API. But the article will still be useful for those who are not familiar with Yii2, or for those who need to quickly and simply implement a full-fledged API for an existing application.

To begin with, I’ll give you a list of some features that I really didn’t have enough for a full-fledged work with the server API when using existing extensions:
')
  1. One of the first problems I encountered was the preservation of various entities in the same table. To obtain such records, it is not enough just to specify the model name as suggested, for example, here . One example of such a mechanism is the AuthItems table, which is used by the framework in the RBAC mechanism (if someone is not familiar with it, there is a wonderful article on this topic). It contains roles, operations and tasks that are defined by the type flag, and to work with these entities through the API, I wanted to use a different type of url:
    GET: /api/authitems/?type=0 -
    GET: /api/authitems/?type=1 -
    GET: /api/authitems/?type=2 -

    and this:
    GET: /api/operations -
    GET: /api/tasks -
    GET: /api/roles -

    Agree, the second option looks clearer and clearer, especially for a person not familiar with the framework and the RBAC device in it.
  2. The second important possibility is the mechanism for searching and filtering data, with the ability to set conditions and combine rules. For example, I wanted to be able to perform an analogue of such a query:
     SELECT * FROM users WHERE (age>25 AND first_name LIKE '%alex%') OR (last_name='shepard'); 

  3. Sometimes there is a lack of the ability to create, update, delete collections. Those. change the n-th number of records by a single request, again using search and filtering. For example, it is often necessary to delete or update all records that fall under any condition, and using separate queries is too expensive.
  4. Another important point was the ability to get related data. For example: get these roles along with all its tasks and operations.
  5. Of course, it is impossible to work in any comfortable way with the API without being able to limit the number of records received ( limit ), shift the beginning of the selection ( offset ), and specify the sort order of records ( order by ). It would also not be bad to be able to group ( group by ).
  6. It is important to be able to verify user rights for each of the operations (the checkAccess method checkAccess still in the same RBAC).
  7. Finally, the whole thing needs to be somehow tested.

As a result of the analysis of about such a list of “hotelok”, my version of the API implementation on this wonderful framework came into being!

For a start on how the API looks for the client.


Consider the example of the same component of RBAC.

Receiving records

Everything is as usual:
GET: /roles -
GET: /roles/42 - id=42

Search and filtering

Their mechanisms are almost identical, the only difference is that when searching, records with partial coincidence fall into the sample, and when filtering with full ones. The combination of fields and their values ​​is specified in JSON format. That he seemed to me the most convenient for the implementation of this functionality. For example:

{"name":"alex", "age":"25"} - corresponds to a query of the form: WHERE name='alex' AND age=25
[{"name":"alex"}, {"age":"25"}] - corresponds to a query of the form: WHERE name='alex' OR age=25

Those. the parameters passed in one object correspond to the AND condition, and the parameters specified by the array of objects correspond to the OR condition.

Also, in addition to the AND and OR conditions, the following conditions can be specified, which should precede the value:

A few examples:
GET: /users?filter={"name":"alex"} - users with the name alex
GET: /users?filter={"name":"alex", "age":">25"} - users with the name alex And over the age of 25
GET: /users?filter=[{"name":"alex"}, {"name":"dmitry"}] - users with the name alex OR dmitry
GET: /users?search={"name":"alex"} - users with the name containing the substring alex (alexey, alexander, alex, etc.)

Work with related data

You can often find the following syntax for working with related data:
GET: /roles/42/operations - get all operations belonging to roles with id = 42

Initially, I used this approach, but in the process I realized that it has several drawbacks.

One to many

If the one-to-many relationship can be used, the approach with the filter described above:
GET: operations?filter={"role_id":"42"} - get all operations belonging to roles with id = 42

Many to many

It is more convenient to work with a many-to-many connection as with a separate entity due to the fact that often the link table is not limited to the parent_id and child_id . Consider the example of goods ( products ) and their characteristics ( features ). The link table must have at least two fields: product_id and feature_id . But, if you need to set the sorting order of the list of characteristics in the item card, you must also add an ordering field to the table, and also add the value value that characteristic.
Using the url of the form:
POST: /products/42/feature/1 - link item 42 to feature 1
GET: /products/42/feature/1 - get product description 1 (entry from the features table)

There is no possibility to get the same sorting order and value of the characteristic (record from the link table). On personal experience, I was convinced that for such connections it is better to use a separate entity, for example, productfeatures .
Thus, we get:
POST: /productfeatures - passing the parameters of product_id , feature_id , ordering and value in the request body, we will connect the characteristic and product, specifying the value and sorting order.
GET: /productfeatures?filter={"product_id":"42"} - we get all the links of the goods with the characteristics. The answer might look something like this:
 [ {"id":"12","feature_id":"1","product_id":"42","value":"33"}, {"id":"13","feature_id":"2","product_id":"42","value":"54"} ] 

PUT: /productfeatures/12 - change connection with id=12

Of course, this approach is also not without flaws, since we cannot get, for example, the name of the product and the name of the characteristic without two additional requests. This is where the mechanism for getting the associated data comes to the rescue.

Getting related data

GET: /productfeatures/12?with=product,feature — getting connected with the product and features. Example of server response:
 { "id":"12", "feature_id":"1", "product_id":"42", "value":"33", "feature":{"id":"1","name":"","unit":""}, "product":{"id":"42","name":"", ...}, } 


In the same way, you can get all the characteristics of the product:
GET: /products/42?with=features - getting product data with id=42 and all its characteristics in the array. Example of server response:
 { "id":"42", "name":"", "features":[{"id":"1","name":"","unit":""}, {"id":"2","name":"","unit":""}], ... } 

Looking ahead, I will say that using with you can get data not only from related tables, but also simply describe an array with values. This is useful, for example, when you need to transfer a list of possible values ​​of its status along with the product data. The status of the goods is stored in the status field, but the resulting status:0 value will not tell us much. To do this, together with the product data, you can get its possible statuses with their description:
 { ..., "status":1, "statuses":{0:"  ", 1:" ", 2:" "}, ..., } 


Data deletion


DELETE: /role/42 - id=42
DELETE: /role -

When deleting, you can also use search and filtering:
DELETE: /role?filter={"name":"admin"} - "admin"


Data creation

POST: /role -

One request can create both one record and a collection by passing an array of data in the request body, for example:
 [ {"name":"admin"}, {"name":"guest"} ] 

Thus two roles with the corresponding names will be created. The server response in this case will also be an array of records created.

Data change

All by analogy with the creation, it is only necessary to specify the id parameter in the url and the method, of course, PUT:
PUT: /role/42 - 42

Change multiple entries:
PUT: /role
passing in the request body
 [ {"id":"1","name":"admin"}, {"id":"2","name":"guest"} ] 

records with id 1 and 2 will be changed.

Change records found by filter:
PUT: /user?filter={"role":"guest"}' - role=guest


Limit, offset and order of entries

For partial sampling, the usual limit and offset .

offset - starting from zero
limit - number of records
order - sorting order
GET: /users/?offset=10&limit=10
GET: /users/?order=id DESC
GET: /users/?order=id ASC

You can combine:
GET: /users/?order=parent_id ASC,ordering ASC


It is important to mention how the limit and offset appear in the response. I have considered several options, for example, to transmit data in the response body:
 { data:[ {id:1, name:"Alex", role:"admin"}, {id:2, name:"Dmitry", role:"guest"} ], meta:{ total:2, offset:0, limit:10 } } 

On the client side, I used AngularJS. It seemed to me very convenient implementation of the $resource mechanism in it. I will not delve into its features, the fact is that for comfortable work with it, it is better to get clean data without too much information. Therefore, data on the number of selected records were moved to the headers:
GET: roles?limit=5
Content-Range:items 0-4/10 - 0 4, 10.

It is important to note that the heading above indicates that not 4 entries were received, but 5 (zero-based). Those. when all 10 entries are received, the header will look like:
Content-Range:items 0-9/10 - 0 9 10.

Parse such a header on the client is not difficult, and the response body is no longer clogged with “redundant” data.

Implementation on the server.


The first step is to create a module. Of course, this is not a mandatory requirement, but the module fits perfectly for this. You can also include the API version in the module name.

Next, we add several rules to the application config for proper routing according to the url and request method:

  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'), 

I think that for people at least some familiar with the framework, there is nothing to explain.
Next, we include the ApiController.php , Controller.php and ApiRelationProvider.php any convenient way.

API module controllers


All API module controllers must extend the ApiController class.
From the settings of the router it is clear that the following actions should be implemented in the controllers:
actionView() - getting record
actionList() - getting list of entries
actionCreate() - creating an entry
actionUpdate() - record change
actionDelete() - delete record

Consider the example of the user role controller. As I said earlier, the RBAC framework keeps all entities (roles, operations and tasks) in one table ( authitem ). The type of an entity is determined by the type flag in this table. Those. RolesController , OperationsController , TasksController should work with one model ( AuthItems ), but their scope should be limited to only those records that have the appropriate type value.

Controller code:
 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' ) ); } } 


First of all, in the constructor method, we specify the model with which the controller will work, assigning the model instance to the model property of the controller.

baseCriteria specifying the baseCriteria property and assigning a condition to it ( addCondition('type='.AuthItem::ROLE_TYPE) ), we determine that for any data received from the client, this condition must be met. Thus, when selecting records for getting, updating and deleting data, records are used that match the condition type=2 and even if there is an entry in the table with the required id value, but the type will be different from the one specified in baseCriteria client will receive a 404 error.

Also, in the actionCreate() method, the value of the priorityData property is set, which specifies the data set that will override any data received in the request body from the client. Ie even if the client specified in the request body the type property equal to 42, it will still be redefined to the value AuthItem::ROLE_TYPE (2) and will not allow creating an entity other than a role.

Before performing any operation, the user’s rights are checked using the checkAccess() method and the scripts for working with the model are specified, since any validation rules or triggers can be defined in the model logic depending on the scenario.

All action methods ( getView() , getList() , create() , update() , delete() ) by default send data to the user and terminate the execution of the application. When the first parameter is set to false , the methods will return the response as an array. This can be useful when you need to clear some attributes (passwords, etc.) in the data obtained from the model before sending it to the user. The response code in this case can be obtained through the statusCode property, which will be filled after the method is executed.

The latter method of the getRelations() controller is used to configure model relationships. The method must return an array describing the set of links. In this case, specifying the ...?with=roleoperations parameter in the url, we will receive along with the role data all the operations assigned to it:
 { bizrule: null description: "Administrator" id: "1" name: "admin" operations: [{...}, {...},...] type: "2" } 

In the array returned by the getRelations() method, the array key is the name of the connection that corresponds to the GET parameter (in this case, roleoperations ).
The value of the elements of the array configuring the connection:
relationName
string
The name of the connection in the model. If the model has no connection with the corresponding. the name of the framework will try to get a property with that name or execute the method by substituting get with it. For example, the model method can also act as a connection: to do this, you need to specify the name of the connection, for example, possibleValues and create a getPossibleValues() method in the model that returns an array of data.
columnName
string
The name of the attribute to which the found entries in the server response will be added.
return
string ('array' | 'object')
Return an array of objects (models) or an array of values.


I must say that in most cases, the controllers look much simpler than the one above. Here is an example of a controller from one of my projects:
 <?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(); } } 


Brief description of the ApiController class:

Properties:
Property
Type of
Description
data
array
Data from the request body. Both the data from the request using Content-Type: x-www-form-urlencoded and the Content-Type: application/json will get into the array.
priorityData
array
The data to be replaced or added to the data from the request body (data) when performing data creation and modification operations.
model
CActiveRecord
An instance of the model for working with data.
statusCode
integer
Server response code. The original value is 200 .
criteriaParams
array
Initial sampling parameters ( limit , offset , order ). The values ​​obtained from the GET request parameters override the corresponding values ​​in the array.
Initial value:
 array( 'limit' => 100, 'offset' => 0, 'order' => 'id ASC' ) 

contentRange
array
Data on the number of selected records. Example:
 array( 'total'=>10, 'start'=>6, 'end'=>15 ) 

sendToEndUser
boolean
Whether to send data to the user after completion of the operation (view, create, modify, delete) or return the result of the action as an array.
criteria
CDbCriteria
An instance of the CDbCriteria class for fetching data. Configurable based on data from the request (limit, offset, order, filter, search, etc.)
baseCriteria
CDbCriteria
The base instance of the CDbCriteria class for fetching data. Object conditions take precedence over criteria .
notFoundErrorResponse
array
Server response with no entry found.

Methods:



Testing


Studying the question of testing API, I considered a variety of approaches. Most advised not to use unit testing, but functional. But having tried several functional testing methods (using Selenium and even PhantomJs ), with such incredible methods as creating a form with the means of selenium, adding input fields to it, filling them with data and sending them by clicking on the submit button and then analyzing the server response, I realized that so testing will take years!

Immersed in the search for deeper, and analyzing the experience of other developers, I wrote a class for testing API using curl. To use it, you need to connect a class ApiTestCaseand extend test classes from it.

The first problem I encountered when testing the API was an access problem. During testing, a test base is used. Thus, it is necessary to constantly monitor that it always contains actual data in the tables used by RBAC, otherwise trying to test the creation of an entity, you can get a response {"error":{"access":"You do not have sufficient permissions to access."}}with the code 403. And besides, you need to teach the tests to authorize and send authorization cookies for the same reason for restricting access rights in the actions of the API controllers. To solve this problem, I decided to use the working base for component operation.authManager which deals with access rights, specifying the following in the configuration file of the test environment (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', //   ), ... 

The only limitation of this approach is that you need to ensure that in the user table the id value of the user being logged in is the same in both databases, since if your admin database is on the test database id=1, the administrator is assigned to the user as a working member, id=42then the component will not consider that user as the administrator!

Test example:
 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']) ); } } 


In the beginning we specify the fixtures used in the tests. Further, in the test method, we make a request using the method ApiTestCase::get()(performing the request with the GET method) passing the url and authorization cookies to it obtained by calling the method ApiTestCase::getAuthCookies(). In order to get these same cookies you need to specify the parameters $loginUrland $loginData. I have them listed directly in the class ApiTestCasein order not to register them in each class of the test:
 public $loginUrl = 'api/login'; public $loginData = array('login'=>'admin', 'password'=>'admin'); 

I must say that the method ApiTestCase::getAuthCookies()is smart enough not to make an authorization request for each call, but to return cached data. To re-run the query, you can pass the first parameters true.

Method ApiTestCase :: get () (as ApiTestCase::post(), ApiTestCase::put(), ApiTestCase::delete()) returns an array of data executed query with the following structure:
body
string
Server response
code
integer
Response code
cookies
array
An array of cookies received in response
headers
array
An array of headers received in the response (header name => header value). For example:
 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
Array of decoded (json_decode) server response

This data is enough to fully test and analyze the server response.

After receiving the answer to the request, various asserts are checked which are quite obvious and do not need comments. Of course, this is not the complete test code for the entity, but this example is enough to understand the principle of working with the class ApiTestCase.

Brief class description ApiTestCase:
Properties:
Property
Type of
Description
authCookies
array
Cookies received after authorization (method call ApiTestCase::getAuthCookies())
loginUrl
string
The address for completing the authorization request for authorization cookies.
loginData
array ()
The array that will be transmitted in the body of the authorization request. Default:
 array('login'=>'admin', 'password'=>'admin'); 


:


Link to github .

Conclusion


Of course, problems may arise with large loads, as it is used to work with data ActiveRecord. I think this can be partially solved by caching (good for that in Yii is everything you need).
I hope that there will be developers who will be useful if not the whole extension, then any parts or just ideas applied in it.
Future plans still have a lot of various improvements and changes, so I will be grateful for any comments and suggestions.

PS


The article turned out to be large (although it did not work out to describe even half of what was intended) and somewhat "torn". If the information will be useful in the future I would like to describe some more points. For example, how authorization was implemented, collections were received (a combination of requests into one), etc. I would also like to talk about how I interacted with the API on the client side using AngularJS tools and how to create one-page applications search engine friendly (with page rendering through PhantomJs).

Source: https://habr.com/ru/post/224199/


All Articles