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:
')
- 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.
- 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');
- 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.
- Another important point was the ability to get related data. For example: get these roles along with all its tasks and operations.
- 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
).
- It is important to be able to verify user rights for each of the operations (the
checkAccess
method checkAccess
still in the same RBAC).
- 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:
<:
less>:
more<=:
less or equal>=:
greater than or equal<>:
not equal=:
equals
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:- getView ()
Performs a record search according to GET parameters. Returns an array of write parameters or sends data to the user. If the record is not found - sends the user an error message with the response code 404 or returns an array with the appropriate information about the error. Sets the statusCode
property to the appropriate value after the query is executed.
getView(boolean $sendToEndUser = true, integer $id)
|
$ sendToEndUser
| boolean
| Whether to send data to the user after the operation is completed or to return the result of the action as an array.
|
$ id
| integer
| Record id parameter. If not transmitted - filled in from GET parameters.
|
- getList ()
Searches for records according to GET parameters. Returns an array of found records or an empty array.
getList(boolean $sendToEndUser = true)
|
$ sendToEndUser
| boolean
| Whether to send data to the user after the operation is completed or to return the result of the action as an array.
|
- create ()
Creates a new record with data retrieved from the request body. If the array of attributes is transmitted in the request body, the corresponding number of records will be recognized. Returns an array with the attributes of the new record.
For example:
array( 'name'=>'Alex', 'age'=>'25' )
array( array( 'name'=>'Alex', 'age'=>'25' ), array( 'name'=>'Dmitry', 'age'=>'33' ) )
create(boolean $sendToEndUser = true)
|
$ sendToEndUser
| boolean
| Whether to send data to the user after the operation is completed or to return the result of the action as an array.
|
- update ()
Updates the record found in accordance with the received GET parameters. Returns an array of write parameters or sends data to the user. If the record is not found - sends the user an error message with the response code 404 or returns an array with the appropriate information about the error. If an array of records is transmitted in the request body, the corresponding number of records will be changed and the array with their values will be returned.
For example:
PUT: users/1
array( 'name'=>'Alex', 'age'=>'25' )
PUT: users
array( array( 'id'=>1, 'name'=>'Alex', 'age'=>'25' ), array( 'id'=>2, 'name'=>'Dmitry', 'age'=>'33' ) )
update(boolean $sendToEndUser = true, integer $id)
|
$ sendToEndUser
| boolean
| Whether to send data to the user after the operation is completed or to return the result of the action as an array.
|
$ id
| integer
| Record id parameter. If not transmitted - filled in from GET parameters.
|
- delete ()
Deletes the entry found in accordance with the received GET parameters. Returns an array of remote write parameters or send data to a user. If the record is not found - sends the user an error message with the response code 404 or returns an array with the appropriate information about the error. If id parameter is not received, all records will be deleted.
delete(boolean $sendToEndUser = true, integer $id)
|
$ sendToEndUser
| boolean
| Whether to send data to the user after the operation is completed or to return the result of the action as an array.
|
$ id
| integer
| Record id parameter. If not transmitted - filled in from GET parameters.
|
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 ApiTestCase
and 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', ),
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=42
then 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 $loginUrl
and $loginData
. I have them listed directly in the class ApiTestCase
in 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');
|
:- 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 ()
Performs a PUT request. Returns an array with server response parameters.
Parameter description see ApiTestCase :: post ()
- delete ()
Executes a query with the DELETE method. Returns an array with server response parameters.
Parameter description see ApiTestCase :: post ()
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).