📜 ⬆️ ⬇️

REST client and server on Yii

Introduction


Everyone who uses the Yii framework in development knows that the most commonly used access to databases is the built-in ORM component ActiveRecord. However, at one point, I was faced with the fact that it was necessary to work with data physically located on several remote servers. It was the development of a centralized management system for FTP and Radius users in a distributed network of the company where I work, connecting branches with the central office.

In fact, there may be many situations when it may be necessary to work with data located on servers in different networks. Short thoughts led to the decision to use the HTTP protocol and the REST approach based on it. There were two reasons, the first and the main one is to learn how to develop both server and client parts using REST. The second is the convenience of using the HTTP protocol, and in my case the fact that it is open on the vast majority of firewalls, and can also use proxy servers.

Part of the source had to be inserted into the body of the article, because it turned out quite volume.

Getting started


So the decision is made. At first glance it turns out a strange bunch. Typically, the REST API is used by mobile applications, not sites. It turns out that the user makes an HTTP request to my account management page, and the web server serving the page makes another HTTP request further to the server where the accounts are located directly. And also implemented a REST API for managing them.
')
Server part

It was possible to use one of the ready-made solutions, for example, restfullyii , however, I study and there was a desire to understand how it works or should work from the inside. So let's invent our Wunderlike bike.

How to make the server part yourself very detailed in the official project wiki . This decision was taken as a basis.

The main magic of the REST authentication of the Yii application occurs in the urlManager settings at protected / config / main.php :

'urlManager' => array( 'urlFormat' => 'path', 'showScriptName' => false, 'rules' => array( // REST patterns array('api/list', 'pattern' => 'api/<model:\w+>', 'verb' => 'GET'), array('api/view', 'pattern' => 'api/<model:\w+>/<id:\d+>', 'verb' => 'GET'), array('api/update', 'pattern' => 'api/<model:\w+>/<id:\d+>', 'verb' => 'PUT'), array('api/delete', 'pattern' => 'api/<model:\w+>/<id:\d+>', 'verb' => 'DELETE'), array('api/create', 'pattern' => 'api/<model:\w+>', 'verb' => 'POST'), // Other rules '<controller:\w+>/<id:\d+>'=>'<controller>/view', '<controller:\w+>/<action:\w+>/<id:\d+>'=>'<controller>/<action>', '<controller:\w+>/<action:\w+>'=>'<controller>/<action>', ), ), 

These rules translate the query of the form:

POST api.domain.ru/api/users

at

POST api.domain.ru/api/create?model=users

What was disliked in this example is the approach when the model is loaded into the switch block in action-ah. This implies, in the case of adding a new model to the project, modifying the controller, I wanted to make a more universal solution. As a result, the following construction was used to create a model in action-ah:

  if (isset($_GET['model'])) $_model = CActiveRecord::model(ucfirst($_GET['model'])); 

Next, I give a full listing of the controller, which turned out in my case (I deliberately removed the implementation of the helper methods of the class, which are taken from the example by the link above without changes, and at the end of the chapter there is a link to the full source code for the Yii application):

 <?php class ApiController extends Controller { Const APPLICATION_ID = 'ASCCPE'; private $format = 'json'; public function filters() { return array(); } public function actionList() { if (isset($_GET['model'])) $_model = CActiveRecord::model(ucfirst($_GET['model'])); if (isset($_model)) { $_data = $_model->summary($_GET)->findAll(); if (empty($_data)) $this->_sendResponse(200, sprintf('No items were found for model <b>%s</b>', $_GET['model'])); else { $_rows = array(); foreach ($_data as $_d) $_rows[] = $_d->attributes; $this->_sendResponse(200, CJSON::encode($_rows)); } } else { $this->_sendResponse(501, sprintf( 'Error: Mode <b>list</b> is not implemented for model <b>%s</b>', $_GET['model'])); Yii::app()->end(); } } public function actionView() { if (isset($_GET['model'])) $_model = CActiveRecord::model(ucfirst($_GET['model'])); if (isset($_model)) { $_data = $_model->findByPk($_GET['id']); if (empty($_data)) $this->_sendResponse(200, sprintf('No items were found for model <b>%s</b>', $_GET['model'])); else $this->_sendResponse(200, CJSON::encode($_data)); } else { $this->_sendResponse(501, sprintf( 'Error: Mode <b>list</b> is not implemented for model <b>%s</b>', $_GET['model'])); Yii::app()->end(); } } public function actionCreate() { $post = Yii::app()->request->rawBody; if (isset($_GET['model'])) { $_modelName = ucfirst($_GET['model']); $_model = new $_modelName; } if (isset($_model)) { if (!empty($post)) { $_data = CJSON::decode($post, true); foreach($_data as $var => $value) $_model->$var = $value; if($_model->save()) $this->_sendResponse(200, CJSON::encode($_model)); else { // Errors occurred $msg = "<h1>Error</h1>"; $msg .= sprintf("Couldn't create model <b>%s</b>", $_GET['model']); $msg .= "<ul>"; foreach($_model->errors as $attribute => $attr_errors) { $msg .= "<li>Attribute: $attribute</li>"; $msg .= "<ul>"; foreach($attr_errors as $attr_error) $msg .= "<li>$attr_error</li>"; $msg .= "</ul>"; } $msg .= "</ul>"; $this->_sendResponse(500, $msg); } } } else { $this->_sendResponse(501, sprintf( 'Error: Mode <b>create</b> is not implemented for model <b>%s</b>', $_GET['model'])); Yii::app()->end(); } } public function actionUpdate() { $post = Yii::app()->request->rawBody; if (isset($_GET['model'])) { $_model = CActiveRecord::model(ucfirst($_GET['model']))->findByPk($_GET['id']); $_model->scenario = 'update'; } if (isset($_model)) { if (!empty($post)) { $_data = CJSON::decode($post, true); foreach($_data as $var => $value) $_model->$var = $value; if($_model->save()) { Yii::log('API update -> '.$post, 'info'); $this->_sendResponse(200, CJSON::encode($_model)); } else { // Errors occurred $msg = "<h1>Error</h1>"; $msg .= sprintf("Couldn't update model <b>%s</b>", $_GET['model']); $msg .= "<ul>"; foreach($_model->errors as $attribute => $attr_errors) { $msg .= "<li>Attribute: $attribute</li>"; $msg .= "<ul>"; foreach($attr_errors as $attr_error) $msg .= "<li>$attr_error</li>"; $msg .= "</ul>"; } $msg .= "</ul>"; $this->_sendResponse(500, $msg); } } else Yii::log('POST data is empty'); } else { $this->_sendResponse(501, sprintf( 'Error: Mode <b>update</b> is not implemented for model <b>%s</b>', $_GET['model'])); Yii::app()->end(); } } public function actionDelete() { if (isset($_GET['model'])) $_model = CActiveRecord::model(ucfirst($_GET['model'])); if (isset($_model)) { $_data = $_model->findByPk($_GET['id']); if (!empty($_data)) { $num = $_data->delete(); if($num > 0) $this->_sendResponse(200, $num); //this is the only way to work with backbone else $this->_sendResponse(500, sprintf("Error: Couldn't delete model <b>%s</b> with ID <b>%s</b>.", $_GET['model'], $_GET['id']) ); } else $this->_sendResponse(400, sprintf("Error: Didn't find any model <b>%s</b> with ID <b>%s</b>.", $_GET['model'], $_GET['id'])); } else { $this->_sendResponse(501, sprintf('Error: Mode <b>delete</b> is not implemented for model <b>%s</b>', ucfirst($_GET['model']))); Yii::app()->end(); } } private function _sendResponse($status = 200, $body = '', $content_type = 'text/html') { ... } private function _getStatusCodeMessage($status) { ... } private function _checkAuth() { ... } } 

With this approach, appropriate model preparation is necessary. For example, the attributes array array embedded in ActiveRecord is formed solely on the basis of the structure of the table in the database. If there is a need to include fields from linked tables or computable fields in the sample, it is necessary in the model to overload the getAttributes methods and, if necessary, hasAttribute . As an example, my implementation of getAttributes :

  public function getAttributes($names = true) { $_attrs = parent::getAttributes($names); $_attrs['quota_limit'] = $this->limit['bytes_in_avail']; $_attrs['quota_used'] = $this->tally['bytes_in_used']; return $_attrs; } 

You also need to create named scope summary in the model for paginal output and sorting to work properly .:

  public function summary($_getvars = null) { $_criteria = new CDbCriteria(); if (isset($_getvars['count'])) { $_criteria->limit = $_getvars['count']; if (isset($_getvars['page'])) $_criteria->offset = ($_getvars['page']) * $_getvars['count']; } if (isset($_getvars['sort'])) $_criteria->order = str_replace('.', ' ', $_getvars['sort']); $this->getDbCriteria()->mergeWith($_criteria); return $this; } 

Full text of the model:

 <?php /** * This is the model class for table "ftpuser". * * The followings are the available columns in table 'ftpuser': * @property string $id * @property string $userid * @property string $passwd * @property integer $uid * @property integer $gid * @property string $homedir * @property string $shell * @property integer $count * @property string $accessed * @property string $modified */ class Ftpuser extends CActiveRecord { // Additional quota parameters public $quota_limit; public $quota_used; /** * Returns the static model of the specified AR class. * @return ftpuser the static model class */ public static function model($className=__CLASS__) { return parent::model($className); } /** * @return string the associated database table name */ public function tableName() { return 'ftpuser'; } /** * @return array validation rules for model attributes. */ public function rules() { // NOTE: you should only define rules for those attributes that // will receive user inputs. return array( array('uid, gid, count', 'numerical', 'integerOnly' => true), array('userid, passwd, homedir', 'required'), array('userid, passwd', 'length', 'max' => 32), array('homedir', 'length', 'max' => 255), array('shell', 'length', 'max' => 16), array('accessed, modified, quota_limit, quota_used', 'safe'), //array('userid', 'unique'), // The following rule is used by search(). // Please remove those attributes that should not be searched. array('id, userid, passwd, uid, gid, homedir, shell, count, accessed, modified', 'safe', 'on' => 'search'), ); } /** * @return array relational rules. */ public function relations() { // NOTE: you may need to adjust the relation name and the related // class name for the relations automatically generated below. return array( 'limit' => array(self::HAS_ONE, 'FTPQuotaLimits', 'user_id'), 'tally' => array(self::HAS_ONE, 'FTPQuotaTallies', 'user_id'), ); } /** * @return array customized attribute labels (name=>label) */ public function attributeLabels() { return array( 'id' => 'Id', 'userid' => 'Userid', 'passwd' => 'Passwd', 'uid' => 'Uid', 'gid' => 'Gid', 'homedir' => 'Homedir', 'shell' => 'Shell', 'count' => 'Count', 'accessed' => 'Accessed', 'modified' => 'Modified', ); } /** * Retrieves a list of models based on the current search/filter conditions. * @return CActiveDataProvider the data provider that can return the models based on the search/filter conditions. */ public function search() { // Warning: Please modify the following code to remove attributes that // should not be searched. $criteria = new CDbCriteria; $criteria->compare('userid', $this->userid, true); $criteria->compare('homedir', $this->homedir, true); return new CActiveDataProvider('ftpuser', array( 'criteria' => $criteria, )); } public function summary($_getvars = null) { $_criteria = new CDbCriteria(); if (isset($_getvars['count'])) { $_criteria->limit = $_getvars['count']; if (isset($_getvars['page'])) $_criteria->offset = ($_getvars['page']) * $_getvars['count']; } if (isset($_getvars['sort'])) $_criteria->order = str_replace('.', ' ', $_getvars['sort']); $this->getDbCriteria()->mergeWith($_criteria); return $this; } public function getAttributes($names = true) { $_attrs = parent::getAttributes($names); $_attrs['quota_limit'] = $this->limit['bytes_in_avail']; $_attrs['quota_used'] = $this->tally['bytes_in_used']; return $_attrs; } protected function afterFind() { parent::afterFind(); $this->quota_limit = $this->limit['bytes_in_avail']; $this->quota_used = $this->tally['bytes_in_used']; } protected function afterSave() { parent::afterSave(); if ($this->isNewRecord && !empty($this->quota_limit)) { $_quota = new FTPQuotaLimits(); $_quota->user_id = $this->id; $_quota->name = $this->userid; $_quota->bytes_in_avail = $this->quota_limit; $_quota->save(); } } protected function beforeValidate() { if ($this->isNewRecord) { if (empty($this->passwd)) $this->passwd = $this->genPassword(); $this->homedir = Yii::app()->params['baseFTPDir'].$this->userid; } elseif ($this->scenario == 'update') { if (empty($this->quota_limit)) { FTPQuotaLimits::model()->deleteAllByAttributes(array('name' => $this->userid)); FTPQuotaTallies::model()->deleteAllByAttributes(array('name' => $this->userid)); } else { $_quota_limit = FTPQuotaLimits::model()->find('name = :name', array(':name' => $this->userid)); if (isset($_quota_limit)) { $_quota_limit->bytes_in_avail = $this->quota_limit; $_quota_limit->save(); } else { $_quota_limit = new FTPQuotaLimits(); $_quota_limit->name = $this->userid; $_quota_limit->user_id = $this->id; $_quota_limit->bytes_in_avail = $this->quota_limit; $_quota_limit->save(); } } } return parent::beforeValidate(); } protected function beforeDelete() { FTPQuotaLimits::model()->deleteAllByAttributes(array('name' => $this->userid)); FTPQuotaTallies::model()->deleteAllByAttributes(array('name' => $this->userid)); return parent::beforeDelete(); } private function genPassword($len = 6) { $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; $count = mb_strlen($chars); for ($i = 0, $result = ''; $i < $len; $i++) { $index = rand(0, $count - 1); $result .= mb_substr($chars, $index, 1); } return $result; } } 

What is not enough for complete happiness - there is no possibility to make the processing of subqueries of the form / users / 156 / records , but then this is a framework, not a CMS, if necessary - we finished it ourselves. My case is simple, this is not required.

With the server part done, go to the client. For those interested, I post the full source code for the Yii server application here . I do not know how long the link will live, if there are more sensible suggestions where to put it more reliably - please note in the comments.

Client part

In order not to write your bike, a little search work was carried out and an excellent ActiveResource extension was found. As the author writes, the source of inspiration was the implementation of ActiveResource in Ruby on Rails. On the page there is a brief description of how to install and how to use.

However, almost immediately I came across the fact that it is just a component that is compatible with the ActiveRecord interface, but a component that is compatible with ActiveDataProvider is required for use in Yii GridView or ListView widgets. A quick search brought me to the improvements made to a separate branch , including EActiveResourceDataProvider and EActiveResourceQueryCriteria , as well as discussing them in the forum thread where the author of the extension participated. The corrected versions of ESort and EActiveResourceDataProvider were also published there .

Despite all the elegance of the solution without a file has not done. The problem was the malfunction of the pagination component in the grid. A cursory examination of the source showed that the actual offset, expressed in the number of entries, was used as the offset in the extension, whereas the pagination in the GridView uses the page number. It turned out that when you set up 10 entries per page, when you went to page 2, we were transferred to page 20. We climb into the code and rule. To do this, in the protected / extensions / EActiveResource / EActiveResourceQueryCriteria.php file in the body of the buildQueryString method, we make the following edit:

 if($this->offset>0) // array_push($parameters, $this->offsetKey.'='.$this->offset); array_push($parameters, $this->offsetKey.'='.$this->offset / $this->limit); 

After that, it is necessary to remove the overload of the getOffset method from the EActiveResourcePagination as more unnecessary.

So when creating an application that uses a REST data source, you need to manually create the necessary models, the rest will be created through the GII without problems.

Separately, I would like to mention the work with several servers. Initially, the connection to the remote REST API is described in the config, so by default we can use only one connection on our site. In order for the connection information to be stored in the database table and used by the ActiveResource component, from there I had to create a child with the overloaded getConnection method (this is my case with FTP users, server data is stored in the table described by the FTPServers model):

 abstract class EActiveResourceSelect extends EActiveResource { /** * Returns the EactiveResourceConnection used to talk to the service. * @return EActiveResourceConnection The connection component as pecified in the config */ public function getConnection() { $_server_id = Yii::app()->session['ftp_server_id']; $_db_params = array(); if (isset($_server_id)) { $_srv = FTPServers::model()->findByPk($_server_id); if (isset($_srv)) $_db_params = $_srv->attributes; else Yii::log('info', "No FTP server with ID: $_server_id were found"); } else { $_srv = FTPServers::model()->find(); if (isset($_srv)) { $_db_params = $_srv->attributes; Yii::app()->session['ftp_server_id'] = $_srv->id; } else Yii::log("No FTP servers were found", CLogger::LEVEL_INFO); } self::$_connection = new EActiveResourceConnection(); self::$_connection->init(); self::$_connection->site = $_db_params['site']; self::$_connection->acceptType = $_db_params['acceptType']; self::$_connection->contentType = $_db_params['contentType']; if (isset($_db_params['username']) && isset($_db_params['password'])) { self::$_connection->auth = array( 'type' => 'basic', 'username' => $_db_params['username'], 'password' => $_db_params['password'], ); } return self::$_connection; } } 

Further development of the client part is not particularly different from the development using the usual ActiveRecord. What, in my opinion, is the main charm of the ActiveResource extension.

I hope the article will be useful.

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


All Articles