freshrest

FreshRest is an elegant Yii extension and module which enables easy RESTful interface development following best practices.
13 followers

FreshRealm

FreshRest

FreshRest is an elegant Yii extension and module which enables easy RESTful interface development following best practices.

The extension contains three basic building blocks: module based API interfaces, API action controllers, and API resource models.

  • Multiple API interfaces can be created as separate modules, allowing for easy versioning maintenance and siloing of access.
  • controllers automatically handle CRUD operations on resource models, and easily allow the addition of custom actions.
  • API resources is an enhancement to Active Record and contains mapping between active record and the RESTful API.

Project Page: https://bitbucket.org/onebesky/freshrest

Installation

  • copy extension files to /extensions/freshRest
  • create a module to act as the defenition of your api (i.e. /modules/api1). The included example module can help you get started.
  • add the module to your yii configuration file

Example yii configurations

routes for subdomain version accessed as api1.myproject.com:

'components' => array(
    'urlManager' => array(
        'rules' => array(
            // custom actions in resource controller
            array('api1/<controller>/<action>', 'pattern' => 'http://api1.*/<controller:\w+>/<action:\w+>/<id:\d+>'),
            // crud for resource controller
            array('api1/<controller>/<action>', 'pattern' => 'http://api1.*/<controller:\w+>/<action:\w+>'),
            // everything else goes to the default controller
            array('api1/default/<action>', 'pattern' => 'http://api1.*/<action:\w+>'),
        ),
    ),
),

routes for simple path version accessed as myproject.com/api1:

'components' => array(
    'urlManager' => array(
        'rules' => array(
            // custom actions in resource controller
            array('api1/<controller>/<action>', 'pattern' => 'api1/<controller:\w+>/<action:\w+>/<id:\d+>'),
            // crud for resource controller
            array('api1/<controller>/<action>', 'pattern' => 'api1/<controller:\w+>/<action:\w+>'),
            // everything else goes to the default controller
            array('api1/default/<action>', 'pattern' => 'api1/<action:\w+>'),
        ),
    ),
),

enable module:

'modules' => array(
    // name and version of the module
    'api1' => array(
        'class' => 'application.modules.api1.ApiModule',
        // optional configuration:        
        'baseUrl' => 'api.myproject.com', // skip to use path format myproject.com/api1
        'lastUpdateAttribute' => 'update_time', // DATETIME field that contains last update time of active record
        'format' => 'json', // only json is supported so far 
        'authModelClass' => 'FrAuthModel', // override this class to change authentication behavior
        'myAuthenticatedModelClass' => 'Organization', // active record that used for login
        'myAuthenticatedModelPasswordField' => 'api_password',
    ),
),

How to Use

Recommended RESTful reading

Ebook from Apigee: http://apigee.com/about/api-best-practices.

Actions and Verbs

Resources should be named in plural. If database table name is project and active record class name is Project, then name your API resource projects. This way we will result in the following urls:

HTTP Method URL Description
GET /projects list of all projects
GET /projects/5547 view one project only
POST /projects create new project
PUT /projects/5547 update one project
DELETE /projects/5547 delete one project

Offset and Limit

The extension supports offset and limit data selection options passed in via the URL: limit=100&offset=50 will display 100 records starting with record 51.

Timestamp Filter

Timestamp filter is useful for data synchronization. Every response contains a timestamp field that can be passed into the next request to load only data changed since the previous request. To enable this behavior all related active records need to have a timestamp column (for example update_time) that is updated with each active record change - typically in the beforeSave() function.

Usage: /api1/items?timestamp=1394048408

User Defined Filters

The filter GET attribute enables search functionality and is applied on top of the default class lookup criteria. This attribute can be a simple array("column"=>"value") column filter or more complex expression. For example, a json representation of the filter GET attribute that one would use to filter for zipcode 93xxx is:

[
    {
        field: 'zipcode',
        operator: '>',
        value: 93000
    },
    {
        field: 'zipcode',
        operator: '<',
        value: 94000  
    }
]

Versions

The base url can be either subdomain (api1.myproject.com) or path (myproject.com/api1). With any significant changes to the interface.

Authentication

The built-in authentication uses a combination of an authentication token loaded from the url and the ip address to authenticate each request. It is attached to active record through a polymorphic connection. Using the authentication component also gives you access to the authenticated model in any API resource.

Setup

  1. Create a password field in a table that represents a client (i.e. user or organization). In the module configuration add the following attributes:
// module setup
'api1' => array(
    'myAuthenticatedModelClass' => 'Organization', // active record class that will be available in all models after authentication
    'myAuthenticatedModelPasswordField' => 'api_password' // table field that contains “secret” password
),
  1. Enable the Auth Filter by adding it to each controller:
public function filters() {
    return array(
        array(
            'ext.freshRest.FrAuthFilter'
        )
    );
}

Authentication Process

  1. Get the authentication token

    • call authenticate action in the default controller passing password through POST (and preferably ssl too)
    • FrAuthModel compares the password to the one stored in the database based on your module configuration
    • FrAuthModel creates a new record with the authentication token and IP address
  2. Use the token add &key=randomauthneticationtoken to the request url

Module Development

Controllers

Controllers inherit from the FrApiBaseController class. Each action translates directly into a controller action. The default controller DefaultController.php handles authentication and the root index action. It should also have all actions that are not resource related. For example, calculateDistance($latituedA, $longitudeA, $latituedB, $longitudeB). These actions should use verbs.

Any other controller manages one API Resource. For instance, ProjectsController.php would handle all CRUD actions for the Projects api resource. Index, view, update, create, and delete actions work out of the box, but can be customized by redefining the action within the resource controller. The controller looks for a model that has the same name, but it can be modified by overriding the resourceClassName class variable. This type of controller will also manage any non-CRUD actions that are resource related. For example, /api1/projects/deploy/5562 will trigger actionDeploy($id){...}.

You can use yii filters in any controller to enforce authentication, disable an action, or accept only post requests.

public function filters() {
    return array(
        // disable builtin actions
        'disabled +delete, update, create',
        // action receiveGoods can be submitted only through POST
        'postOnly +receiveGoods',
        // enable authentication
        array(
            'ext.freshRest.FrAuthFilter'
        )
    );
}

Models

Models (API Resources) inherit from the FrApiResource class. They can be connected to active record or act as standalone form models. Basic setup requires all the attributes to be defined as a public property and list them all in the rules() function.

To integrate with active record two functions must be implemented: activeRecordClassName(), which returns a string name for the active record model, and attributeMap(), which returns a mapping array between the active record attributes (key) and the api resource attributes (value). This function maps attributes 1:1 by default.

Scenarios

Scenarios are used to display or accept different attributes in different actions. Also, they can create different lookup criteria for different actions.

Built-in scenarios:

  • create - called from create action
  • update - called from update action
  • list - called from list (index) action
  • view - called from view action and as a result of successful create or update action
  • setApiParams - used before the model is loaded to pass additional GET attributes to the class

Use Cases

Display extra attributes during the view action

public function rules() {
    $rules = parent::rules();
    return CMap::mergeArray($rules, array(
                array('id, name', 'safe', 'on' => 'view,list'),
                array('valueThatIsExpensiveToLoad', 'safe', 'on' => 'view'),
    ));
}

Disable email attribute update by default, but allow it on create

public function rules() {
    $rules = parent::rules();
    return CMap::mergeArray($rules, array(
                array('id, name, email', 'safe', 'on' => 'view,list'),
                array('name, email', 'safe', 'on' => 'create'),
                array('name', 'safe', 'on' => 'update'),
    ));
}

Don't include records with status="new" in the default list view, but include them in newRecords action:

public function scopes(){
    return array(
        'list' => array(
            'condition' => 'status!="new"'
        ),
        'newRecords' => array(
            'condition' => 'status="new"'
        )
    );
}

Scopes

Extra search criteria are useful when the API is supposed to work only with records that belong to the authenticated user. Default criteria are used for both single and list views. Additional per-scenario criteria can be specified in scopes() function.

public function defaultScope() {
    // get the user that is currently authenticated
    $user = $this->module->getAuthenticatedModel();
    return new CDbCriteria(array(
        // use "with" to enforce eager loading and speed up the api 
        'with' => array(
            'comments',
        ),
        // newest first
        'order' => 't.update_time DESC',
        // limit to results for this user only
        'condition' => 't.user_id=:userId',
        'params' => array(':userId' => $user->id)
    ));
}

Virtual Getters and Setters

Some fields should be presented in a different way than they are stored within the database. See the following timestamp example:

Translated Active Record Example

class Posts extends FrApiResource {
    /**
    * Private variable that stores time in unix timestamp format
    */
    protected $_updateTime;
 
    public function rules() {
        $rules = parent::rules();
        return CMap::mergeArray($rules, array(
                    array('updateTime', 'numerical', 'integerOnly' => true, 'on' => 'view,list,update'),
        ));
    }
 
    /**
     * Connects our _updateTime to the active record's update_time field
     */
    public function attributeMap() {
        return array(
            'updateTime' => 'update_time'
        );
    }
 
    /**
    * Getter for updateTime
    */
    public function getUpdateTime(){
        if ($this->scenario=="update"){
            // the input is coming from user and is returned back to active record
            return date('Y-m-d H:i', $this->_updateTime);
        } else {
            // api time is represented as unix timestamp
            return $this->_updateTime;
        }
    }
 
    /**
    * Setter for updateTime
    */
    public function setUpdateTime($value){
        if (is_int($value)){
            // Field is already stored as a timestamp
            $this->_updateTime = $value;
        } else {
            // Field is a datetime string, so convert it to a timestamp
            $this->_updateTime = strtotime($value);
        }
    }
}

Notice that there is no updateTime attribute defined in the class. The user input value is stored into the private _updateTime variable. For more details see Yii documentation: http://www.yiiframework.com/wiki/167/understanding-virtual-attributes-and-get-set-methods/.

Nested Data Example

If we want to display a list of all comments in a post resource. We will need two models within our api module: Posts and Comments.

class Posts extends FrApiResource {
    protected $_comments;
    /**
    * Get list of all comments in api format.
    */
    public function getComments(){
        if ($this->_comments == null){
            // load comments first
            $this->_comments = array();
            // use relation from post model to get comments
            foreach ($this->model->comments as $model) {
                // each api resource needs to be created with the module instance
                $_comment = new Comments($this->module, $this->scenario);
                // load the comment's model into the object
                $_comment->loadFromModel($model);
                // add it to our post
                $this->_comments[] = $_comment;
            }
        }
        // prepare output as simple array
        $output = array();
        foreach ($this->_comments as $comment){
            $output[] = $comment->getApiOutput();
        }
        return $output;
    }
 
    /**
    * Set comments
    */
    public function setComments(){
        if (!is_array($value)){
            return;
        }
        $this->_comments = array();
        foreach ($value as $comment) {
            // each api resource needs to be created with the module instance
            $_comment = new Comments($this->module, $this->scenario);
            // the comment is passed in as an array in API format. If it is active record we could use $_comment->loadFromModel($comment) instead
            $_comment->attributes = $comment;
            $this->_comments[] = $_comment;
        }
    }
}

Processing User Input

Use the built-in beforeValidate(), afterValidate(), beforeSave(), and afterSave() functions to modify the model before it is being saved. Create and update process is execute in the following order:

  1. find or create the model
  2. execute afterFind()
  3. set attributes on the API Resource - only safe attributes will be applied
    • set attributes from API Resource to Active Record
  4. validate the API Resource (and execute before validate)
  5. validate the Active Record
  6. save the Active Record
  7. change scenario to "view" and render the API Resource

There are a couple of places where additional data processing can happen:

afterFind() to prepare data after the model is loaded from the database

beforeValidate() to process data directly passed to the API Resource and set attributes to Active Record for fields that require additional validation

afterValidate() to update attributes of the Active Record that are not directly mapped using attributeMap() and do not require validation

Total 20 comments

#17359 report it
seletar6 at 2014/05/29 01:03am
How to specify the Method for a new Action?

For a new action in a controller, how do I specify the Method (either GET, POST, PUT, DELETE etc) for this action?

#17215 report it
Ondrej Nebesky at 2014/05/13 12:29pm
Enable authentication filter on some actions but not on others

It is pretty easy to enable authentication just for certain actions. Just list the actions you want to authenticate using '+' for include or '-' exclude public function filters() { return array( array( 'ext.freshRest.FrAuthFilter -index,view' ) ); }

#17195 report it
Fire at 2014/05/12 07:44am
Enable authentication filter on some actions but not on others

I have a question about filters. Is it possible to enable authentication for some actions and not others?

#17120 report it
Ondrej Nebesky at 2014/05/05 12:28pm
RE: Not able to insert new Item by POST

It seems the problem is a missing validation rule. Make sure there are "type" and "name" in rules function:

public function rules() {
    $rules = parent::rules();
    return CMap::mergeArray($rules, array(
        array('id, name, type', 'safe', 'on' => 'view, list'),
        array('name, type', 'safe', 'on' => 'create, update, view, list'),
    ));
}

also modify attributeMap function to match model attributes:

public function attributeMap() {
    return array(
        'id' => 'id',
        'name' => 'item_name',
        'type' => 'item_type',
    );
}
#17111 report it
seletar6 at 2014/05/05 03:47am
Not able to insert new Item by POST [solved]

Hi, I temporarily disabled the authentication for insert and update. But when I tried to do a POST. Following is the error I got. Any idea why?

URL: http://localhost/MySite/index.php/api1/items

method: POST

request body: {"type": "D","name": "name D"} or {"item_type": "D","item_name": "name D"}

Response I got:

{ "data" : { "message" : "Array ( [item_type] => Array ( [0] => Item Type cannot be blank. ) [item_name] => Array ( [0] => Item Name cannot be blank. ) )" }, "code" : 400, "timestamp" : 1399275935 }

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Update:

Thanks to Ondrej's answer. Adding of the rule solves the problem.

#17099 report it
Fire at 2014/05/03 01:13am
@seletar6

@seletar6

Here is what I do in a model called fles.php in /modules/api1/models/Fles.php

...

public function activeRecordClassName()
    {
        return 'Fle'; 
       //this tells yii that that associated ActiveRecord is called 
       //"Fle"
    }

..

#17098 report it
Ondrej Nebesky at 2014/05/03 12:22am
Active record

Hi,

to load corresponding ActiveRecord you have to override activeRecordClassName function:

public function activeRecordClassName(){ return "Address"; }

Also, try to print out $lookupCriteria. The problem might be composite primary key or default scope specified in the model.

#17097 report it
seletar6 at 2014/05/03 12:00am
Problem in Loading ActiveRecord [solved]

I have ActiveRecord models defined in protected/models/ folder, But freshRest models (of FrApiResource type) in modules/api1/models folder just can't load it's corresponding ActiveRecord Class, which I have defined it in activeRecordClassName() function.

Anyone can help on this?

public function findAll()
    {
        $output = array();
        if ($this->activeRecordClassName() == null) {
            return $output;
        }
 
        $className = $this->activeRecordClassName();
        $lookupCriteria = $this->getLookupCriteria();
        $models = $className::model()->findAll($lookupCriteria);   <-----

Sorry, found my problem.

I extends the model from ActiveRecord instead of CActiveRecord. It works fine after I fixed it.

#17092 report it
Fire at 2014/05/02 07:27am
Items Model

Hi everyone, The easiest way to get started with Freshrest is to look at the Items Controller, and the Items Model that comes as an example with FreshRest.

It is important to understand (as mentioned in the docs) that freshrest models are supposed to be named in plural - ie: "items" and your models of your yii app should be singular ... therefore the freshrest "Items" model, represents the "Item" model of your yii app.

I don't think Ondrej included the mysql for the actual Item Model, so I'll paste it below. Anyhow, I hope from the sql below, and from his example of Items model and ItemController you can get started

SET FOREIGN_KEY_CHECKS=0;-- ------------------------------ Table structure for item-- ----------------------------DROP TABLE IF EXISTS item;CREATE TABLE item ( id int(11) NOT NULL, item_type varchar(255) DEFAULT NULL, item_name varchar(255) DEFAULT NULL, update_time datetime DEFAULT NULL, uid int(11) DEFAULT NULL, PRIMARY KEY (id)) ENGINE=InnoDB DEFAULT CHARSET=latin1;

#17070 report it
Fire at 2014/04/29 11:17pm
tweaking

@Jim K,

Hi Jim, I ended up not having to tweak anything. There was a bug in Ondrej's code which I reported as an issue (issue #1, #2) on the bitbucket site that prevented the token from authenticating on PUT / POST requests - therefore create and update were not working... but he aptly fixed them the next day. I advise a re-pull of the git code so you can get the latest fixed.

After I applied the fix, GET, PUT, POST and DELETE all work for me.

#17065 report it
Jim K at 2014/04/29 05:18pm
@Fire: Setting Auth required as filter

@Fire, I'm not seeing Ondrej reply so here's mine:

I'd be very interested in knowing what you had to tweak with PUT/POST. I've only got a read-only API so far, but will be extending it shortly.

I believe the fr_api_device table is the only one created. I think it's supposed to create automatically first time it's used, but went ahead and created it manually by replicating the code from the extension. It seems to work fine.

In this example, I've disabled the auth filter on my apiVersion method. I have a separate controller for each resource so I also have the ability to apply auth filters to each controller-method.

modules/api/controllers/MyController.php

public function filters()
    {
        return array(
            // only list and view actions are allowed
            'disabled +update,create,delete',
            array(
                // authenticate except simple "version" action
                'ext.freshRest.FrAuthFilter -apiVersion'
            )
        );
    }
#17042 report it
Fire at 2014/04/28 08:54am
Hi Onderj

I am chugging along with your extension. I had to hack it abit to get it to work with PUT and POST requests... Seams that the token doesnt get validated properly unless its a GET request sent.

I am wondering, is there a way we can specify that no authentication token is needed for specific actions? Ie: I am using Angular.js as a front end, and I want to do anyonymous json requests to get data, how can I do this without a token?

#16933 report it
Fire at 2014/04/14 07:53am
fr_api_device table

I noticed that your extension creates a table called fr_api_device, are there any other tables it creates that the developer should know about?

#16932 report it
Fire at 2014/04/14 07:43am
authentication - in which model does it store the IP and Token?

In the "Authentication Process" you mention that "FrAuthModel creates a new record with the authentication token and IP address".

But where and in which model?

#16928 report it
Fire at 2014/04/14 05:52am
Trouble understanding how the authentication works - can you please provide an example?

Hi there, I', sorry, but I don't understand how the authentication works, could you please provide an example?

#16924 report it
Fire at 2014/04/14 03:10am
I created a model for the api

In step one, you say: "Create a password field in a table that represents a client (i.e. user or organization). In the module configuration add the following attributes"

Is this correct?

CREATE TABLE api ( id int(11) NOT NULL, oranization varchar(255) DEFAULT NULL, api_secret varchar(255) DEFAULT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=latin1;

#16923 report it
Fire at 2014/04/14 02:54am
is it possible to please make a sample yii installation with a workable example?

Hi there, I am hopeful about this extension, but its really hard to get started when there is no example.

Can you please provide one with all applicable models? Ie: Organization table etc

#16863 report it
Jim K at 2014/04/04 04:07pm
Got it working

SOLVED: Turns out I needed to initialize "cache" component in config/main.php. I presume you can use any of the cache implementations, but FileCache worked just fine for my use.

'cache'=>array(
            'class'=>'system.caching.CFileCache',
    ),

@Ondrej: Thanks for the catch on the routing order. I fixed that and added the secret param and it seems to be working until FrAuthModel attempts to set the cache: On this line:

Yii::app()->cache->set("api-auth-token-" . $this->token, $newRecord, $this->module->authCacheDuration);

I'm getting this error:

Fatal error: Call to a member function set() on a non-object in C:\wamp\www\sommelier\protected\extensions\freshRest\FrAuthModel.php on line 204

In debug, I can see that token, newRecord and authCache have values. It's almost like the App()->cache is not instantiated. Jim

#16862 report it
Ondrej Nebesky at 2014/04/04 03:20pm
Routing

I think I can see the problem - you have to put API routing rules before general routing rules: ~~~php 'urlManager' => array( 'urlFormat' => 'path', 'rules' => array(

            // custom actions in resource controller
            array('api/<controller>/<action>', 'pattern' => 'api/<controller:\w+>/<action:\w+>/<id:\d+>'),
            // crud for resource controller
            array('api/<controller>/<action>', 'pattern' => 'api/<controller:\w+>/<action:\w+>'),
            // everything else goes to the default controller
            array('api/default/<action>', 'pattern' => 'api/<action:\w+>'),

            // Standard rules for application
            '<controller:\w+>/<id:\d+>'=>'<controller>/view',
            '<controller:\w+>/<action:\w+>/<id:\d+>'=>'<controller>/<action>',
            '<controller:\w+>/<action:\w+>'=>'<controller>/<action>',
        ),

~~

Also, the authenticate function accepts HTTP POST data - there is no key in url needed. You could modify it to look for app id and app secret in your tbl_api_secret table. It will return key/token if the authentication process is successful.

#16861 report it
Jim K at 2014/04/04 01:58pm
still having trouble calling authenticate

I'm trying to authenticate through the default auth method by calling:

POST:  http://localhost/{my application}/api/authenticate&key={my secret key}

I'm getting:

Home » Error
 
Error 404
 
Unable to resolve the request "api/authenticate".

It looks like my routing rules are not finding authenticate in the default controller.

Here's my "config/main.php"

'modules' => array(
        // api module implements a RESTFul API into the sommolier system
        'api' => array(
            'class' => 'application.modules.api.ApiModule',
            // optional configuration:        
            'lastUpdateAttribute' => 'update_dt', // DATETIME field that contains last update time of active record
            'format' => 'json', // only json is supported so far 
            'authModelClass' => 'FrAuthModel', // override this class to change authentication behavior
            'myAuthenticatedModelClass' => 'ApiAccess', // active record that used for login
            'myAuthenticatedModelPasswordField' => 'api_key_secret',
        ),
.
.
.
 
        'urlManager' => array(
            'urlFormat' => 'path',
            'rules' => array(
                // Standard rules for application
                '<controller:\w+>/<id:\d+>'=>'<controller>/view',
                '<controller:\w+>/<action:\w+>/<id:\d+>'=>'<controller>/<action>',
                '<controller:\w+>/<action:\w+>'=>'<controller>/<action>',
                // custom actions in resource controller
                array('api/<controller>/<action>', 'pattern' => 'api/<controller:\w+>/<action:\w+>/<id:\d+>'),
                // crud for resource controller
                array('api/<controller>/<action>', 'pattern' => 'api/<controller:\w+>/<action:\w+>'),
                // everything else goes to the default controller
                array('api/default/<action>', 'pattern' => 'api/<action:\w+>'),
            ),

And my "api/DefaultController.cfg"

class DefaultController extends FrApiBaseController
{
    /**
     * Default action - in most cases returns please login or redirects to documentation
     */
    public function actionIndex()
    {
        $this->renderOutput("MySommelier");
    }
 
    /**
     * @return array action filters
     */
    public function filters()
    {
        return array(
            array(
                'ext.freshRest.FrAuthFilter -authenticate -index'
            )
        );
    }
 
    public function actionAuthenticate()
    {
        $data = $this->getData();
        if (isset($data['secret']))
        {
            $model = $this->module->getAuthModel();
            if ($model->authenticate($data['secret']))
            {
                // return temporary auth token and exit
                $this->renderOutput(array('token' => $model->token));
            }
            // wrong password provided
            $this->renderError('403', 'Wrong password provided.');
        }
 
        // wrong format
        $this->renderError('403', 'Wrong format, probably missing "secret" key.');
    }
}

I've set a breakpoint in actionAuthenticate and it's never getting there.

I'm sure I'm missing something obvious in the routing, but can't figure it out. Any help would be greatly appreciated.

Jim

Leave a comment

Please to leave your comment.

Create extension