REST client and server on Yii

    Introduction


    Everyone who uses the Yii framework in development knows that, as access to the databases, it most often uses the built-in ORM component ActiveRecord. However, at one point, I came across the fact that it was necessary to work with data physically located on several remote servers. This was the development of a centralized FTP and Radius user management system in the distributed network of the company where I work, combining branches with a central office.

    In fact, there may be many situations where it may be necessary to work with data located on servers on different networks. A moment of thought led to the decision to use the HTTP protocol and the REST approach based on it.. There were two reasons, the first and main - 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, it is open on the vast majority of firewalls, and can also use proxy servers.

    Part of the source code had to be inserted into the body of the article, so it turned out quite voluminously.

    Getting down


    So the decision has been made. At first glance, we get 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 directly located. And also implemented a REST API for managing them.

    Server side

    It was possible to use one of the ready-made solutions, for example, restfullyii , but I study and had a desire to understand how it works or should work from the inside. Therefore, we will be engaged in the invention of our child prodigy.

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

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

    'urlManager' => array(
        'urlFormat' => 'path',
        'showScriptName' => false,
        'rules' => array(
            // REST patterns
            array('api/list', 'pattern' => 'api/', 'verb' => 'GET'),
            array('api/view', 'pattern' => 'api//', 'verb' => 'GET'),
            array('api/update', 'pattern' => 'api//', 'verb' => 'PUT'),
            array('api/delete', 'pattern' => 'api//', 'verb' => 'DELETE'),
            array('api/create', 'pattern' => 'api/', 'verb' => 'POST'),
            // Other rules
           	'/'=>'/view',
            '//'=>'/',
            '/'=>'/',
        ),
    ),
    

    Именно эти правила транслируют запрос вида:

    POST api.domain.ru/api/users

    в

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

    Что в этом примере не понравилось, так это подход, когда в action-ах модель загружается в switch блоке. Это подразумевает, в случае добавления новой модели в проект, модификацию контроллера, мне хотелось сделать более универсальное решение. В итоге для создания модели в action-ах использовалась конструкция вида:

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

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

    summary($_GET)->findAll();
                if (empty($_data))
                    $this->_sendResponse(200, sprintf('No items were found for model %s', $_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 list is not implemented for model %s',
                    $_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 %s', $_GET['model']));
                else
                    $this->_sendResponse(200, CJSON::encode($_data));
            }
            else
            {
                $this->_sendResponse(501, sprintf(
                    'Error: Mode list is not implemented for model %s',
                    $_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 = "

    Error

    "; $msg .= sprintf("Couldn't create model %s", $_GET['model']); $msg .= "
      "; foreach($_model->errors as $attribute => $attr_errors) { $msg .= "
    • Attribute: $attribute
    • "; $msg .= "
        "; foreach($attr_errors as $attr_error) $msg .= "
      • $attr_error
      • "; $msg .= "
      "; } $msg .= "
    "; $this->_sendResponse(500, $msg); } } } else { $this->_sendResponse(501, sprintf( 'Error: Mode create is not implemented for model %s', $_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 = "

    Error

    "; $msg .= sprintf("Couldn't update model %s", $_GET['model']); $msg .= "
      "; foreach($_model->errors as $attribute => $attr_errors) { $msg .= "
    • Attribute: $attribute
    • "; $msg .= "
        "; foreach($attr_errors as $attr_error) $msg .= "
      • $attr_error
      • "; $msg .= "
      "; } $msg .= "
    "; $this->_sendResponse(500, $msg); } } else Yii::log('POST data is empty'); } else { $this->_sendResponse(501, sprintf( 'Error: Mode update is not implemented for model %s', $_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 %s with ID %s.", $_GET['model'], $_GET['id']) ); } else $this->_sendResponse(400, sprintf("Error: Didn't find any model %s with ID %s.", $_GET['model'], $_GET['id'])); } else { $this->_sendResponse(501, sprintf('Error: Mode delete is not implemented for model %s', 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 required. For example , the array variable attributes built into ActiveRecord is formed solely on the basis of the table structure in the database. If there is a need to include fields from related tables or calculated fields in the selection , you must overload the getAttributes methods and, if necessary, hasAttribute in the model . 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;
        }
    

    It is also necessary to create a named scope summary in the model for pagination and sorting to work correctly .:

        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:

     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 missing for complete happiness is that there is no way to handle the processing of nested requests of the form / users / 156 / records , but this is a framework, not a CMS, if necessary, I’ve finished it myself. My case is simple, this was not required.

    With the server part done, go to the client. For those interested, I post the full source Yii of the server-side application here . I don’t know how long the link will live, if there are more practical suggestions where to put it more reliably - please note in the comments.

    Client part

    In order not to write my bike, a small search was carried out and an excellent ActiveResource extension was found . According to the author, the source of inspiration was the implementation of ActiveResource in Ruby on Rails. The page has a brief description of how to install and how to use.

    However, almost immediately I stumbled upon the fact that it is just a component compatible with the ActiveRecord interface , but for use in Yii GridView or ListView widgets you need a component compatible with ActiveDataProvider . A quick search brought me to improvements made in a separate branch , including EActiveResourceDataProvider andEActiveResourceQueryCriteria , as well as a discussion of them in the forum thread where the author of the extension participated. There were also published revised versions of ESort and EActiveResourceDataProvider .

    Despite all the elegance of the solution, the file was not complete. The problem was the malfunctioning of the pagination component in the grid. A quick study of the source showed that the offset used in the extension was the actual offset expressed in the number of records, while pagination in the GridView uses the page number. It turned out that when setting up 10 records per page when going to page 2, we were thrown to page 20. We climb into the code and edit it. For this in the fileprotected / extensions / EActiveResource / EActiveResourceQueryCriteria.php in the body of the buildQueryString method , do the following:

    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 EActiveResourcePagination as more unnecessary.

    So, when creating an application using a REST data source, you must manually create the necessary models, the rest will be created through GII without any problems.

    I would also like to note 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, we had to create a descendant with the getConnection method overloaded (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 did not differ much from development using the usual ActiveRecord , which, in my opinion, is the main charm of the ActiveResource extension .

    Hope the article will be helpful.

    Also popular now: