diff --git a/apps/advanced/common/models/User.php b/apps/advanced/common/models/User.php index fc74532..d2c80c0 100644 --- a/apps/advanced/common/models/User.php +++ b/apps/advanced/common/models/User.php @@ -1,6 +1,7 @@ <?php namespace common\models; +use yii\base\NotSupportedException; use yii\db\ActiveRecord; use yii\helpers\Security; use yii\web\IdentityInterface; @@ -72,6 +73,14 @@ class User extends ActiveRecord implements IdentityInterface } /** + * @inheritdoc + */ + public static function findIdentityByAccessToken($token) + { + throw new NotSupportedException('"findIdentityByAccessToken" is not implemented.'); + } + + /** * Finds user by username * * @param string $username diff --git a/apps/basic/models/User.php b/apps/basic/models/User.php index b890e69..7a6fcc0 100644 --- a/apps/basic/models/User.php +++ b/apps/basic/models/User.php @@ -8,6 +8,7 @@ class User extends \yii\base\Object implements \yii\web\IdentityInterface public $username; public $password; public $authKey; + public $accessToken; private static $users = [ '100' => [ @@ -15,12 +16,14 @@ class User extends \yii\base\Object implements \yii\web\IdentityInterface 'username' => 'admin', 'password' => 'admin', 'authKey' => 'test100key', + 'accessToken' => '100-token', ], '101' => [ 'id' => '101', 'username' => 'demo', 'password' => 'demo', 'authKey' => 'test101key', + 'accessToken' => '101-token', ], ]; @@ -33,6 +36,19 @@ class User extends \yii\base\Object implements \yii\web\IdentityInterface } /** + * @inheritdoc + */ + public static function findIdentityByAccessToken($token) + { + foreach (self::$users as $user) { + if ($user['accessToken'] === $token) { + return new static($user); + } + } + return null; + } + + /** * Finds user by username * * @param string $username diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md index e7c1d94..f43ff0d 100644 --- a/docs/guide/authentication.md +++ b/docs/guide/authentication.md @@ -5,7 +5,7 @@ Authentication is the act of verifying who a user is, and is the basis of the lo In Yii, this entire process is performed semi-automatically, leaving the developer to merely implement [[yii\web\IdentityInterface]], the most important class in the authentication system. Typically, implementation of `IdentityInterface` is accomplished using the `User` model. -You can find a full featured example of authentication in the +You can find a fully featured example of authentication in the [advanced application template](installation.md). Below, only the interface methods are listed: ```php @@ -25,6 +25,17 @@ class User extends ActiveRecord implements IdentityInterface } /** + * Finds an identity by the given token. + * + * @param string $token the token to be looked for + * @return IdentityInterface|null the identity object that matches the given token. + */ + public static function findIdentityByAccessToken($token) + { + return static::find(['access_token' => $token]); + } + + /** * @return int|string current user ID */ public function getId() diff --git a/docs/guide/rest.md b/docs/guide/rest.md new file mode 100644 index 0000000..6fd215f --- /dev/null +++ b/docs/guide/rest.md @@ -0,0 +1,878 @@ +Implementing RESTful Web Service APIs +===================================== + +Yii provides a whole set of tools to greatly simplify the task of implementing RESTful Web Service APIs. +In particular, Yii provides support for the following aspects regarding RESTful APIs: + +* Quick prototyping with support for common APIs for ActiveRecord; +* Response format (supporting JSON and XML by default) and API version negotiation; +* Customizable object serialization with support for selectable output fields; +* Proper formatting of collection data and validation errors; +* Efficient routing with proper HTTP verb check; +* Support `OPTIONS` and `HEAD` verbs; +* Authentication; +* Authorization; +* Support for HATEOAS; +* Caching via `yii\web\HttpCache`; +* Rate limiting; +* Searching and filtering: TBD +* Testing: TBD +* Automatic generation of API documentation: TBD + + +A Quick Example +--------------- + +Let's use a quick example to show how to build a set of RESTful APIs using Yii. +Assume you want to expose the user data via RESTful APIs. The user data are stored in the user DB table, +and you have already created the ActiveRecord class `app\models\User` to access the user data. + +First, create a controller class `app\controllers\UserController` as follows, + +```php +namespace app\controllers; + +use yii\rest\ActiveController; + +class UserController extends ActiveController +{ + public $modelClass = 'app\models\User'; +} +``` + +Then, modify the configuration about the `urlManager` component in your application configuration: + +```php +'urlManager' => [ + 'enablePrettyUrl' => true, + 'enableStrictParsing' => true, + 'showScriptName' => false, + 'rules' => [ + ['class' => 'yii\rest\UrlRule', 'controller' => 'user'], + ], +] +``` + +With the above minimal amount of effort, you have already finished your task of creating the RESTful APIs +for accessing the user data. The APIs you have created include: + +* `GET /users`: list all users page by page; +* `HEAD /users`: show the overview information of user listing; +* `POST /users`: create a new user; +* `GET /users/123`: return the details of the user 123; +* `HEAD /users/123`: show the overview information of user 123; +* `PATCH /users/123` and `PUT /users/123`: update the user 123; +* `DELETE /users/123`: delete the user 123; +* `OPTIONS /users`: show the supported verbs regarding endpoint `/users`; +* `OPTIONS /users/123`: show the supported verbs regarding endpoint `/users/123`. + +You may access your APIs with the `curl` command like the following, + +``` +curl -i -H "Accept:application/json" "http://localhost/users" +``` + +which may give the following output: + +``` +HTTP/1.1 200 OK +Date: Sun, 02 Mar 2014 05:31:43 GMT +Server: Apache/2.2.26 (Unix) DAV/2 PHP/5.4.20 mod_ssl/2.2.26 OpenSSL/0.9.8y +X-Powered-By: PHP/5.4.20 +X-Pagination-Total-Count: 1000 +X-Pagination-Page-Count: 50 +X-Pagination-Current-Page: 1 +X-Pagination-Per-Page: 20 +Link: <http://localhost/users?page=1>; rel=self, + <http://localhost/users?page=2>; rel=next, + <http://localhost/users?page=50>; rel=last +Transfer-Encoding: chunked +Content-Type: application/json; charset=UTF-8 + +[ + { + "id": 1, + ... + }, + { + "id": 2, + ... + }, + ... +] +``` + +Try changing the acceptable content type to be `application/xml`, and you will see the result +is returned in XML format: + +``` +curl -i -H "Accept:application/xml" "http://localhost/users" +``` + +``` +HTTP/1.1 200 OK +Date: Sun, 02 Mar 2014 05:31:43 GMT +Server: Apache/2.2.26 (Unix) DAV/2 PHP/5.4.20 mod_ssl/2.2.26 OpenSSL/0.9.8y +X-Powered-By: PHP/5.4.20 +X-Pagination-Total-Count: 1000 +X-Pagination-Page-Count: 50 +X-Pagination-Current-Page: 1 +X-Pagination-Per-Page: 20 +Link: <http://localhost/users?page=1>; rel=self, + <http://localhost/users?page=2>; rel=next, + <http://localhost/users?page=50>; rel=last +Transfer-Encoding: chunked +Content-Type: application/xml + +<?xml version="1.0" encoding="UTF-8"?> +<response> + <item> + <id>1</id> + ... + </item> + <item> + <id>2</id> + ... + </item> + ... +</response> +``` + +> Tip: You may also access your APIs via Web browser by entering the URL `http://localhost/users`. + +As you can see, in the response headers, there are information about the total count, page count, etc. +There are also links that allow you to navigate to other pages of data. For example, `http://localhost/users?page=2` +would give you the next page of the user data. + +Using the `fields` and `expand` parameters, you may also request to return a subset of the fields in the result. +For example, the URL `http://localhost/users?fields=id,email` will only return the `id` and `email` fields in the result: + + +> Info: You may have noticed that the result of `http://localhost/users` includes some sensitive fields, +> such as `password_hash`, `auth_key`. You certainly do not want these to appear in your API result. +> You can/should filter out these fields as described in the following sections. + + +In the following sections, we will explain in more details about implementing RESTful APIs. + + +General Architecture +-------------------- + +Using the Yii RESTful API framework, you implement an API endpoint in terms of a controller action, and you use +a controller to organize the actions that implement the endpoints for a single type of resource. + +Resources are represented as data models which extend from the [[yii\base\Model]] class. +If you are working with databases (relational or NoSQL), it is recommended you use ActiveRecord to represent resources. + +You may use [[yii\rest\UrlRule]] to simplify the routing to your API endpoints. + +While not required, it is recommended that you develop your RESTful APIs as an application, separated from +your Web front end and back end. + + +Creating Resource Classes +------------------------- + +RESTful APIs are all about accessing and manipulating resources. In Yii, a resource can be an object of any class. +However, if your resource classes extend from [[yii\base\Model]] or its child classes (e.g. [[yii\db\ActiveRecord]]), +you may enjoy the following benefits: + +* Input data validation; +* Query, create, update and delete data, if extending from [[yii\db\ActiveRecord]]; +* Customizable data formatting (to be explained in the next section). + + +Formatting Response Data +------------------------ + +By default, Yii supports two response formats for RESTful APIs: JSON and XML. If you want to support +other formats, you should configure [[yii\rest\Controller::supportedFormats]] and also [[yii\web\Response::formatters]]. + +Formatting response data in general involves two steps: + +1. The objects (including embedded objects) in the response data are converted into arrays by [[yii\rest\Serializer]]; +2. The array data are converted into different formats (e.g. JSON, XML) by [[yii\web\ResponseFormatterInterface|response formatters]]. + +Step 2 is usually a very mechanical data conversion process and can be well handled by the built-in response formatters. +Step 1 involves some major development effort as explained below. + +When the [[yii\rest\Serializer|serializer]] converts an object into an array, it will call the `toArray()` method +of the object if it implements [[yii\base\ArrayableInterface]]. If an object does not implement this interface, +its public properties will be returned instead. + +For classes extending from [[yii\base\Model]] or [[yii\db\ActiveRecord]], besides directly overriding `toArray()`, +you may also override the `fields()` method and/or the `extraFields()` method to customize the data being returned. + +The method [[yii\base\Model::fields()]] declares a set of *fields* that should be included in the result. +A field is simply a named data item. In a result array, the array keys are the field names, and the array values +are the corresponding field values. The default implementation of [[yii\base\Model::fields()]] is to return +all attributes of a model as the output fields; for [[yii\db\ActiveRecord::fields()]], by default it will return +the names of the attributes whose values have been populated into the object. + +You can override the `fields()` method to add, remove, rename or redefine fields. For example, + +```php +// explicitly list every field, best used when you want to make sure the changes +// in your DB table or model attributes do not cause your field changes (to keep API backward compatibility). +public function fields() +{ + return [ + // field name is the same as the attribute name + 'id', + // field name is "email", the corresponding attribute name is "email_address" + 'email' => 'email_address', + // field name is "name", its value is defined by a PHP callback + 'name' => function () { + return $this->first_name . ' ' . $this->last_name; + }, + ]; +} + +// filter out some fields, best used when you want to inherit the parent implementation +// and blacklist some sensitive fields. +public function fields() +{ + $fields = parent::fields(); + + // remove fields that contain sensitive information + unset($fields['auth_key'], $fields['password_hash'], $fields['password_reset_token']); + + return $fields; +} +``` + +The return value of `fields()` should be an array. The array keys are the field names, and the array values +are the corresponding field definitions which can be either property/attribute names or anonymous functions +returning the corresponding field values. + +> Warning: Because by default all attributes of a model will be included in the API result, you should +> examine your data to make sure they do not contain sensitive information. If there is such information, +> you should override `fields()` or `toArray()` to filter them out. In the above example, we choose +> to filter out `auth_key`, `password_hash` and `password_reset_token`. + +You may use the `fields` query parameter to specify which fields in `fields()` should be included in the result. +If this parameter is not specified, all fields returned by `fields()` will be returned. + +The method [[yii\base\Model::extraFields()]] is very similar to [[yii\base\Model::fields()]]. +The difference between these methods is that the latter declares the fields that should be returned by default, +while the former declares the fields that should only be returned when the user specifies them in the `expand` query parameter. + +For example, `http://localhost/users?fields=id,email&expand=profile` may return the following JSON data: + +```php +[ + { + "id": 100, + "email": "100@example.com", + "profile": { + "id": 100, + "age": 30, + } + }, + ... +] +``` + +You may wonder who triggers the conversion from objects to arrays when an action returns an object or object collection. +The answer is that this is done by [[yii\rest\Controller::serializer]] in the [[yii\base\Controller::afterAction()|afterAction()]] +method. By default, [[yii\rest\Serializer]] is used as the serializer that can recognize resource objects extending from +[[yii\base\Model]] and collection objects implementing [[yii\data\DataProviderInterface]]. The serializer +will call the `toArray()` method of these objects and pass the `fields` and `expand` user parameters to the method. +If there are any embedded objects, they will also be converted into arrays recursively. + +If all your resource objects are of [[yii\base\Model]] or its child classes, such as [[yii\db\ActiveRecord]], +and you only use [[yii\data\DataProviderInterface]] as resource collections, the default data formatting +implementation should work very well. However, if you want to introduce some new resource classes that do not +extend from [[yii\base\Model]], or if you want to use some new collection classes, you will need to +customize the serializer class and configure [[yii\rest\Controller::serializer]] to use it. +You new resource classes may use the trait [[yii\base\ArrayableTrait]] to support selective field output +as explained above. + + +### Pagination + +For API endpoints about resource collections, pagination is supported out-of-box if you use +[[yii\data\DataProviderInterface|data provider]] to serve the response data. In particular, +through query parameters `page` and `per-page`, an API consumer may specify which page of data +to return and how many data items should be included in each page. The corresponding response +will include the pagination information by the following HTTP headers (please also refer to the first example +in this chapter): + +* `X-Pagination-Total-Count`: The total number of data items; +* `X-Pagination-Page-Count`: The number of pages; +* `X-Pagination-Current-Page`: The current page (1-based); +* `X-Pagination-Per-Page`: The number of data items in each page; +* `Link`: A set of navigational links allowing client to traverse the data page by page. + +The response body will contain a list of data items in the requested page. + +Sometimes, you may want to help simplify the client development work by including pagination information +directly in the response body. To do so, configure the [[yii\rest\Serializer::collectionEnvelope]] property +as follows: + +```php +use yii\rest\ActiveController; + +class UserController extends ActiveController +{ + public $modelClass = 'app\models\User'; + public $serializer = [ + 'class' => 'yii\rest\Serializer', + 'collectionEnvelope' => 'items', + ]; +} +``` + +You may then get the following response for request `http://localhost/users`: + +``` +HTTP/1.1 200 OK +Date: Sun, 02 Mar 2014 05:31:43 GMT +Server: Apache/2.2.26 (Unix) DAV/2 PHP/5.4.20 mod_ssl/2.2.26 OpenSSL/0.9.8y +X-Powered-By: PHP/5.4.20 +X-Pagination-Total-Count: 1000 +X-Pagination-Page-Count: 50 +X-Pagination-Current-Page: 1 +X-Pagination-Per-Page: 20 +Link: <http://localhost/users?page=1>; rel=self, + <http://localhost/users?page=2>; rel=next, + <http://localhost/users?page=50>; rel=last +Transfer-Encoding: chunked +Content-Type: application/json; charset=UTF-8 + +{ + "items": [ + { + "id": 1, + ... + }, + { + "id": 2, + ... + }, + ... + ], + "_links": { + "self": "http://localhost/users?page=1", + "next": "http://localhost/users?page=2", + "last": "http://localhost/users?page=50" + }, + "_meta": { + "totalCount": 1000, + "pageCount": 50, + "currentPage": 1, + "perPage": 20 + } +} +``` + + +### HATEOAS Support + +[HATEOAS](http://en.wikipedia.org/wiki/HATEOAS), an abbreviation for Hypermedia as the Engine of Application State, +promotes that RESTful APIs should return information that allow clients to discover actions supported for the returned +resources. The key of HATEOAS is to return a set of hyperlinks with relation information when resource data are served +by APIs. + +You may let your model classes to implement the [[yii\web\Linkable]] interface to support HATEOAS. By implementing +this interface, a class is required to return a list of [[yii\web\Link|links]]. Typically, you should return at least +the `self` link, for example: + +```php +use yii\db\ActiveRecord; +use yii\web\Linkable; +use yii\helpers\Url; + +class User extends ActiveRecord implements Linkable +{ + public function getLinks() + { + return [ + Link::REL_SELF => Url::action(['user', 'id' => $this->id], true), + ]; + } +} +``` + +When a `User` object is returned in a response, it will contain a `_links` element representing the links related +to the user, for example, + +``` +{ + "id": 100, + "email": "user@example.com", + ..., + "_links" => [ + "self": "https://example.com/users/100" + ] +} +``` + + +Creating Controllers and Actions +-------------------------------- + +So you have the resource data and you have specified how the resource data should be formatted, the next thing +to do is to create controller actions to expose the resource data to end users. + +Yii provides two base controller classes to simplify your work of creating RESTful actions: +[[yii\rest\Controller]] and [[yii\rest\ActiveController]]. The difference between these two controllers +is that the latter provides a default set of actions that are specified designed to deal with +resources represented as ActiveRecord. So if you are using ActiveRecord and you are comfortable with +the provided built-in actions, you may consider creating your controller class by extending from +the latter. Otherwise, extending from [[yii\rest\Controller]] will allow you to develop actions +from scratch. + +Both [[yii\rest\Controller]] and [[yii\rest\ActiveController]] provide the following features which will +be described in detail in the next few sections: + +* Response format negotiation; +* API version negotiation; +* HTTP method validation; +* User authentication; +* Rate limiting. + +[[yii\rest\ActiveController]] in addition provides the following features specifically for working +with ActiveRecord: + +* A set of commonly used actions: `index`, `view`, `create`, `update`, `delete`, `options`; +* User authorization in regard to the requested action and resource. + +When creating a new controller class, a convention in naming the controller class is to use +the type name of the resource and use singular form. For example, to serve user information, +the controller may be named as `UserController`. + +Creating a new action is similar to creating an action for a Web application. The only difference +is that instead of rendering the result using a view by calling the `render()` method, for RESTful actions +you directly return the data. The [[yii\rest\Controller::serializer|serializer]] and the +[[yii\web\Response|response object]] will handle the conversion from the original data to the requested +format. For example, + +```php +public function actionSearch($keyword) +{ + $result = SolrService::search($keyword); + return $result; +} +``` + +If your controller class extends from [[yii\rest\ActiveController]], you should set +its [[yii\rest\ActiveController::modelClass||modelClass]] property to be the name of the resource class +that you plan to serve through this controller. The class must implement [[yii\db\ActiveRecordInterface]]. + +With [[yii\rest\ActiveController]], you may want to disable some of the built-in actions or customize them. +To do so, override the `actions()` method like the following: + +```php +public function actions() +{ + $actions = parent::actions(); + + // disable the "delete" and "create" actions + unset($actions['delete'], $actions['create']); + + // customize the data provider preparation with the "prepareDataProvider()" method + $actions['index']['prepareDataProvider'] = [$this, 'prepareDataProvider']; + + return $actions; +} + +public function prepareDataProvider() +{ + // prepare and return a data provider for the "index" action +} +``` + +The following list summarizes the built-in actions supported by [[yii\rest\ActiveController]]: + +* [[yii\rest\IndexAction|index]]: list resources page by page; +* [[yii\rest\ViewAction|view]]: return the details of a specified resource; +* [[yii\rest\CreateAction|create]]: create a new resource; +* [[yii\rest\UpdateAction|update]]: update an existing resource; +* [[yii\rest\DeleteAction|delete]]: delete the specified resource; +* [[yii\rest\OptionsAction|options]]: return the supported HTTP methods. + + +Routing +------- + +With resource and controller classes ready, you can access the resources using the URL like +`http://localhost/index.php?r=user/create`. As you can see, the format of the URL is the same as that +for Web applications. + +In practice, you usually want to enable pretty URLs and take advantage of HTTP verbs. +For example, a request `POST /users` would mean accessing the `user/create` action. +This can be done easily by configuring the `urlManager` application component in the application +configuration like the following: + +```php +'urlManager' => [ + 'enablePrettyUrl' => true, + 'enableStrictParsing' => true, + 'showScriptName' => false, + 'rules' => [ + ['class' => 'yii\rest\UrlRule', 'controller' => 'user'], + ], +] +``` + +Compared to the URL management for Web applications, the main new thing above is the use of +[[yii\rest\UrlRule]] for routing RESTful API requests. This special URL rule class will +create a whole set of child URL rules to support routing and URL creation for the specified controller(s). +For example, the above code is roughly equivalent to the following rules: + +```php +[ + 'PUT,PATCH users/<id>' => 'user/update', + 'DELETE users/<id>' => 'user/delete', + 'GET,HEAD users/<id>' => 'user/view', + 'POST users' => 'user/create', + 'GET,HEAD users' => 'user/index', + 'users/<id>' => 'user/options', + 'users' => 'user/options', +] +``` + +And the following API endpoints are supported by this rule: + +* `GET /users`: list all users page by page; +* `HEAD /users`: show the overview information of user listing; +* `POST /users`: create a new user; +* `GET /users/123`: return the details of the user 123; +* `HEAD /users/123`: show the overview information of user 123; +* `PATCH /users/123` and `PUT /users/123`: update the user 123; +* `DELETE /users/123`: delete the user 123; +* `OPTIONS /users`: show the supported verbs regarding endpoint `/users`; +* `OPTIONS /users/123`: show the supported verbs regarding endpoint `/users/123`. + +You may configure the `only` and `except` options to explicitly list which actions to support or which +actions should be disabled, respectively. For example, + +```php +[ + 'class' => 'yii\rest\UrlRule', + 'controller' => 'user', + 'except' => ['delete', 'create', 'update'], +], +``` + +You may also configure `patterns` or `extra` to redefine existing patterns or add new patterns supported by this rule. +For example, to support a new action `search` by the endpoint `GET /users/search`, configure the `extra` option as follows, + +```php +[ + 'class' => 'yii\rest\UrlRule', + 'controller' => 'user', + 'extra' => [ + 'GET search' => 'search', + ], +``` + +You may have noticed that the controller ID `user` appears in plural form as `users` in the endpoints. +This is because [[yii\rest\UrlRule]] automatically pluralizes controller IDs for them to use in endpoints. +You may disable this behavior by setting [[yii\rest\UrlRule::pluralize]] to be false, or if you want +to use some special names you may configure the [[yii\rest\UrlRule::controller]] property. + + +Authentication +-------------- + +Unlike Web applications, RESTful APIs should be stateless, which means sessions or cookies should not +be used. Therefore, each request should come with some sort of authentication credentials because +the user authentication status may not be maintained by sessions or cookies. A common practice is +to send a secret access token with each request to authenticate the user. Since an access token +can be used to uniquely identify and authenticate a user, **the API requests should always be sent +via HTTPS to prevent from man-in-the-middle (MitM) attacks**. + +There are different ways to send an access token: + +* [HTTP Basic Auth](http://en.wikipedia.org/wiki/Basic_access_authentication): the access token + is sent as the username. This is should only be used when an access token can be safely stored + on the API consumer side. For example, the API consumer is a program running on a server. +* Query parameter: the access token is sent as a query parameter in the API URL, e.g., + `https://example.com/users?access-token=xxxxxxxx`. Because most Web servers will keep query + parameters in server logs, this approach should be mainly used to serve `JSONP` requests which + cannot use HTTP headers to send access tokens. +* [OAuth 2](http://oauth.net/2/): the access token is obtained by the consumer from an authorization + server and sent to the API server via [HTTP Bearer Tokens](http://tools.ietf.org/html/rfc6750), + according to the OAuth2 protocol. + +Yii supports all of the above authentication methods and can be further extended to support other methods. + +To enable authentication for your APIs, do the following two steps: + +1. Configure [[yii\rest\Controller::authMethods]] with the authentication methods you plan to use. +2. Implement [[yii\web\IdentityInterface::findIdentityByAccessToken()]] in your [[yii\web\User::identityClass|user identity class]]. + +For example, to enable all three authentication methods explained above, you would configure `authMethods` +as follows, + +```php +class UserController extends ActiveController +{ + public $authMethods = [ + 'yii\rest\HttpBasicAuth', + 'yii\rest\QueryParamAuth', + 'yii\rest\HttpBearerAuth', + ]; +} +``` + +Each element in `authMethods` should be an auth class name or a configuration array. An auth class +must implement [[yii\rest\AuthInterface]]. + +Implementation of `findIdentityByAccessToken()` is application specific. For example, in simple scenarios +when each user can only have one access token, you may store the access token in an `access_token` column +in the user table. The method can then be readily implemented in the `User` class as follows, + +```php +use yii\db\ActiveRecord; +use yii\web\IdentityInterface; + +class User extends ActiveRecord implements IdentityInterface +{ + public static function findIdentityByAccessToken($token) + { + return static::find(['access_token' => $token]); + } +} +``` + +After authentication is enabled as described above, for every API request, the requested controller +will try to authenticate the user in its `beforeAction()` step. + +If authentication succeeds, the controller will perform other checks (such as rate limiting, authorization) +and then run the action. The authenticated user identity information can be retrieved via `Yii::$app->user->identity`. + +If authentication fails, a response with HTTP status 401 will be sent back together with other appropriate headers +(such as a `WWW-Authenticate` header for HTTP Basic Auth). + + +Authorization +------------- + +After a user is authenticated, you probably want to check if he has the permission to perform the requested +action for the requested resource. This process is called *authorization* which is covered in detail in +the [Authorization chapter](authorization.md). + +You may use the [[yii\web\AccessControl]] filter and/or the Role-Based Access Control (RBAC) component +to implementation authorization. + +To simplify the authorization check, you may also override the [[yii\rest\Controller::checkAccess()]] method +and then call this method in places where authorization is needed. By default, the built-in actions provided +by [[yii\rest\ActiveController]] will call this method when they are about to run. + +```php +/** + * Checks the privilege of the current user. + * + * This method should be overridden to check whether the current user has the privilege + * to run the specified action against the specified data model. + * If the user does not have access, a [[ForbiddenHttpException]] should be thrown. + * + * @param string $action the ID of the action to be executed + * @param \yii\base\Model $model the model to be accessed. If null, it means no specific model is being accessed. + * @param array $params additional parameters + * @throws ForbiddenHttpException if the user does not have access + */ +public function checkAccess($action, $model = null, $params = []) +{ +} +``` + + +Rate Limiting +------------- + +To prevent abuse, you should consider adding rate limiting to your APIs. For example, you may limit the API usage +of each user to be at most 100 API calls within a period of 10 minutes. If too many requests are received from a user +within the period of the time, a response with status code 429 (meaning Too Many Requests) should be returned. + +To enable rate limiting, the [[yii\web\User::identityClass|user identity class]] should implement [[yii\rest\RateLimitInterface]]. +This interface requires implementation of the following three methods: + +* `getRateLimit()`: returns the maximum number of allowed requests and the time period, e.g., `[100, 600]` means + at most 100 API calls within 600 seconds. +* `loadAllowance()`: returns the number of remaining requests allowed and the corresponding UNIX timestamp + when the rate limit is checked last time. +* `saveAllowance()`: saves the number of remaining requests allowed and the current UNIX timestamp. + +You may use two columns in the user table to record the allowance and timestamp information. +And `loadAllowance()` and `saveAllowance()` can then be implementation by reading and saving the values +of the two columns corresponding to the current authenticated user. To improve performance, you may also +consider storing these information in cache or some NoSQL storage. + +Once the identity class implements the required interface, Yii will automatically use the rate limiter +as specified by [[yii\rest\Controller::rateLimiter]] to perform rate limiting check. The rate limiter +will thrown a [[yii\web\TooManyRequestsHttpException]] if rate limit is exceeded. + +When rate limiting is enabled, every response will be sent with the following HTTP headers containing +the current rate limiting information: + +* `X-Rate-Limit-Limit`: The maximum number of requests allowed with a time period; +* `X-Rate-Limit-Remaining`: The number of remaining requests in the current time period; +* `X-Rate-Limit-Reset`: The number of seconds to wait in order to get the maximum number of allowed requests. + + +Error Handling +-------------- + +When handling a RESTful API request, if there is an error in the user request or if something unexpected +happens on the server, you may simply throw an exception to notify the user something wrong happened. +If you can identify the cause of the error (e.g. the requested resource does not exist), you should +consider throwing an exception with a proper HTTP status code (e.g. [[yii\web\NotFoundHttpException]] +representing a 404 HTTP status code). Yii will send the response with the corresponding HTTP status +code and text. It will also include in the response body the serialized representation of the +exception. For example, + +``` +HTTP/1.1 404 Not Found +Date: Sun, 02 Mar 2014 05:31:43 GMT +Server: Apache/2.2.26 (Unix) DAV/2 PHP/5.4.20 mod_ssl/2.2.26 OpenSSL/0.9.8y +Transfer-Encoding: chunked +Content-Type: application/json; charset=UTF-8 + +{ + "type": "yii\\web\\NotFoundHttpException", + "name": "Not Found Exception", + "message": "The requested resource was not found.", + "code": 0, + "status": 404 +} +``` + +The following list summarizes the HTTP status code that are used by the Yii REST framework: + +* `200`: OK. Everything worked as expected. +* `201`: A resource was successfully created in response to a `POST` request. The `Location` header + contains the URL pointing to the newly created resource. +* `204`: The request is handled successfully and the response contains no body content (like a `DELETE` request). +* `304`: Resource was not modified. You can use the cached version. +* `400`: Bad request. This could be caused by various reasons from the user side, such as invalid JSON + data in the request body, invalid action parameters, etc. +* `401`: Authentication failed. +* `403`: The authenticated user is not allowed to access the specified API endpoint. +* `404`: The requested resource does not exist. +* `405`: Method not allowed. Please check the `Allow` header for allowed HTTP methods. +* `415`: Unsupported media type. The requested content type or version number is invalid. +* `422`: Data validation failed (in response to a `POST` request, for example). Please check the response body for detailed error messages. +* `429`: Too many requests. The request is rejected due to rate limiting. +* `500`: Internal server error. This could be caused by internal program errors. + + +Versioning +---------- + +Your APIs should be versioned. Unlike Web applications which you have full control on both client side and server side +code, for APIs you usually do not have control of the client code that consumes the APIs. Therefore, backward +compatibility (BC) of the APIs should be maintained whenever possible, and if some BC-breaking changes must be +introduced to the APIs, you should bump up the version number. You may refer to [Symantic Versioning](http://semver.org/) +for more information about designing the version numbers of your APIs. + +Regarding how to implement API versioning, a common practice is to embed the version number in the API URLs. +For example, `http://example.com/v1/users` stands for `/users` API of version 1. Another method of API versioning +which gains momentum recently is to put version numbers in the HTTP request headers, typically through the `Accept` header, +like the following: + +``` +// via a parameter +Accept: application/json; version=v1 +// via a vendor content type +Accept: application/vnd.company.myapp-v1+json +``` + +Both methods have pros and cons, and there are a lot of debates about them. Below we describe a practical strategy +of API versioning that is a kind of mix of these two methods: + +* Put each major version of API implementation in a separate module whose ID is the major version number (e.g. `v1`, `v2`). + Naturally, the API URLs will contain major version numbers. +* Within each major version (and thus within the corresponding module), use the `Accept` HTTP request header + to determine the minor version number and write conditional code to respond to the minor versions accordingly. + +For each module serving a major version, it should include the resource classes and the controller classes +serving for that specific version. To better separate code responsibility, you may keep a common set of +base resource and controller classes, and subclass them in each individual version module. Within the subclasses, +implement the concrete code such as `Model::fields()`. As a result, your code may be organized like the following: + +``` +api/ + common/ + controllers/ + UserController.php + PostController.php + models/ + User.php + Post.php + modules/ + v1/ + controllers/ + UserController.php + PostController.php + models/ + User.php + Post.php + v2/ + controllers/ + UserController.php + PostController.php + models/ + User.php + Post.php +``` + +Your application configuration would look like: + +```php +return [ + 'modules' => [ + 'v1' => [ + 'basePath' => '@app/modules/v1', + ], + 'v2' => [ + 'basePath' => '@app/modules/v2', + ], + ], + 'components' => [ + 'urlManager' => [ + 'enablePrettyUrl' => true, + 'enableStrictParsing' => true, + 'showScriptName' => false, + 'rules' => [ + ['class' => 'yii\rest\UrlRule', 'controller' => ['v1/user', 'v1/post']], + ['class' => 'yii\rest\UrlRule', 'controller' => ['v2/user', 'v2/post']], + ], + ], + ], +]; +``` + +As a result, `http://example.com/v1/users` will return the list of users in version 1, while +`http://example.com/v2/users` will return version 2 users. + +Using modules, code for different major versions can be well isolated. And it is still possible +to reuse code across modules via common base classes and other shared classes. + +To deal with minor version numbers, you may take advantage of the content type negotiation +feature provided by [[yii\rest\Controller]]: + +* Specify a list of supported minor versions (within the major version of the containing module) + via [[yii\rest\Controller::supportedVersions]]. +* Get the version number by reading [[yii\rest\Controller::version]]. +* In relevant code, such as actions, resource classes, serializers, etc., write conditional + code according to the requested minor version number. + +Since minor versions require maintaining backward compatibility, hopefully there are not much +version checks in your code. Otherwise, chances are that you may need to create a new major version. + + +Caching +------- + + +Documentation +------------- + +Testing +------- + diff --git a/framework/base/ArrayableTrait.php b/framework/base/ArrayableTrait.php new file mode 100644 index 0000000..f5bc9d6 --- /dev/null +++ b/framework/base/ArrayableTrait.php @@ -0,0 +1,163 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\base; + +use Yii; +use yii\helpers\ArrayHelper; +use yii\web\Link; +use yii\web\Linkable; + +/** + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +trait ArrayableTrait +{ + /** + * Returns the list of fields that should be returned by default by [[toArray()]] when no specific fields are specified. + * + * A field is a named element in the returned array by [[toArray()]]. + * + * This method should return an array of field names or field definitions. + * If the former, the field name will be treated as an object property name whose value will be used + * as the field value. If the latter, the array key should be the field name while the array value should be + * the corresponding field definition which can be either an object property name or a PHP callable + * returning the corresponding field value. The signature of the callable should be: + * + * ```php + * function ($field, $model) { + * // return field value + * } + * ``` + * + * For example, the following code declares four fields: + * + * - `email`: the field name is the same as the property name `email`; + * - `firstName` and `lastName`: the field names are `firstName` and `lastName`, and their + * values are obtained from the `first_name` and `last_name` properties; + * - `fullName`: the field name is `fullName`. Its value is obtained by concatenating `first_name` + * and `last_name`. + * + * ```php + * return [ + * 'email', + * 'firstName' => 'first_name', + * 'lastName' => 'last_name', + * 'fullName' => function () { + * return $this->first_name . ' ' . $this->last_name; + * }, + * ]; + * ``` + * + * In this method, you may also want to return different lists of fields based on some context + * information. For example, depending on the privilege of the current application user, + * you may return different sets of visible fields or filter out some fields. + * + * The default implementation of this method returns the public object member variables. + * + * @return array the list of field names or field definitions. + * @see toArray() + */ + public function fields() + { + $fields = array_keys(Yii::getObjectVars($this)); + return array_combine($fields, $fields); + } + + /** + * Returns the list of fields that can be expanded further and returned by [[toArray()]]. + * + * This method is similar to [[fields()]] except that the list of fields returned + * by this method are not returned by default by [[toArray()]]. Only when field names + * to be expanded are explicitly specified when calling [[toArray()]], will their values + * be exported. + * + * The default implementation returns an empty array. + * + * You may override this method to return a list of expandable fields based on some context information + * (e.g. the current application user). + * + * @return array the list of expandable field names or field definitions. Please refer + * to [[fields()]] on the format of the return value. + * @see toArray() + * @see fields() + */ + public function extraFields() + { + return []; + } + + /** + * Converts the model into an array. + * + * This method will first identify which fields to be included in the resulting array by calling [[resolveFields()]]. + * It will then turn the model into an array with these fields. If `$recursive` is true, + * any embedded objects will also be converted into arrays. + * + * If the model implements the [[Linkable]] interface, the resulting array will also have a `_link` element + * which refers to a list of links as specified by the interface. + * + * @param array $fields the fields being requested. If empty, all fields as specified by [[fields()]] will be returned. + * @param array $expand the additional fields being requested for exporting. Only fields declared in [[extraFields()]] + * will be considered. + * @param boolean $recursive whether to recursively return array representation of embedded objects. + * @return array the array representation of the object + */ + public function toArray(array $fields = [], array $expand = [], $recursive = true) + { + $data = []; + foreach ($this->resolveFields($fields, $expand) as $field => $definition) { + $data[$field] = is_string($definition) ? $this->$definition : call_user_func($definition, $field, $this); + } + + if ($this instanceof Linkable) { + $data['_links'] = Link::serialize($this->getLinks()); + } + + return $recursive ? ArrayHelper::toArray($data) : $data; + } + + /** + * Determines which fields can be returned by [[toArray()]]. + * This method will check the requested fields against those declared in [[fields()]] and [[extraFields()]] + * to determine which fields can be returned. + * @param array $fields the fields being requested for exporting + * @param array $expand the additional fields being requested for exporting + * @return array the list of fields to be exported. The array keys are the field names, and the array values + * are the corresponding object property names or PHP callables returning the field values. + */ + protected function resolveFields(array $fields, array $expand) + { + $result = []; + + foreach ($this->fields() as $field => $definition) { + if (is_integer($field)) { + $field = $definition; + } + if (empty($fields) || in_array($field, $fields, true)) { + $result[$field] = $definition; + } + } + + if (empty($expand)) { + return $result; + } + + foreach ($this->extraFields() as $field => $definition) { + if (is_integer($field)) { + $field = $definition; + } + if (in_array($field, $expand, true)) { + $result[$field] = $definition; + } + } + + return $result; + } +} diff --git a/framework/base/ErrorHandler.php b/framework/base/ErrorHandler.php index bf1faad..606a795 100644 --- a/framework/base/ErrorHandler.php +++ b/framework/base/ErrorHandler.php @@ -93,10 +93,9 @@ class ErrorHandler extends Component return; } - $useErrorView = !YII_DEBUG || $exception instanceof UserException; - $response = Yii::$app->getResponse(); - $response->getHeaders()->removeAll(); + + $useErrorView = $response->format === \yii\web\Response::FORMAT_HTML && (!YII_DEBUG || $exception instanceof UserException); if ($useErrorView && $this->errorAction !== null) { $result = Yii::$app->runAction($this->errorAction); @@ -121,7 +120,7 @@ class ErrorHandler extends Component ]); } } elseif ($exception instanceof Arrayable) { - $response->data = $exception; + $response->data = $exception->toArray(); } else { $response->data = [ 'type' => get_class($exception), diff --git a/framework/base/Model.php b/framework/base/Model.php index 8ec6e40..8159bb9 100644 --- a/framework/base/Model.php +++ b/framework/base/Model.php @@ -13,9 +13,12 @@ use ArrayObject; use ArrayIterator; use ReflectionClass; use IteratorAggregate; +use yii\helpers\ArrayHelper; use yii\helpers\Inflector; use yii\validators\RequiredValidator; use yii\validators\Validator; +use yii\web\Link; +use yii\web\Linkable; /** * Model is the base class for data models. @@ -54,11 +57,12 @@ use yii\validators\Validator; */ class Model extends Component implements IteratorAggregate, ArrayAccess, Arrayable { + use ArrayableTrait; + /** * The name of the default scenario. */ const SCENARIO_DEFAULT = 'default'; - /** * @event ModelEvent an event raised at the beginning of [[validate()]]. You may set * [[ModelEvent::isValid]] to be false to stop the validation. @@ -516,7 +520,8 @@ class Model extends Component implements IteratorAggregate, ArrayAccess, Arrayab /** * Returns the first error of every attribute in the model. - * @return array the first errors. An empty array will be returned if there is no error. + * @return array the first errors. The array keys are the attribute names, and the array + * values are the corresponding error messages. An empty array will be returned if there is no error. * @see getErrors() * @see getFirstError() */ @@ -526,13 +531,13 @@ class Model extends Component implements IteratorAggregate, ArrayAccess, Arrayab return []; } else { $errors = []; - foreach ($this->_errors as $attributeErrors) { - if (isset($attributeErrors[0])) { - $errors[] = $attributeErrors[0]; + foreach ($this->_errors as $name => $es) { + if (!empty($es)) { + $errors[$name] = reset($es); } } + return $errors; } - return $errors; } /** @@ -789,13 +794,92 @@ class Model extends Component implements IteratorAggregate, ArrayAccess, Arrayab } /** - * Converts the object into an array. - * The default implementation will return [[attributes]]. - * @return array the array representation of the object + * Returns the list of fields that should be returned by default by [[toArray()]] when no specific fields are specified. + * + * A field is a named element in the returned array by [[toArray()]]. + * + * This method should return an array of field names or field definitions. + * If the former, the field name will be treated as an object property name whose value will be used + * as the field value. If the latter, the array key should be the field name while the array value should be + * the corresponding field definition which can be either an object property name or a PHP callable + * returning the corresponding field value. The signature of the callable should be: + * + * ```php + * function ($field, $model) { + * // return field value + * } + * ``` + * + * For example, the following code declares four fields: + * + * - `email`: the field name is the same as the property name `email`; + * - `firstName` and `lastName`: the field names are `firstName` and `lastName`, and their + * values are obtained from the `first_name` and `last_name` properties; + * - `fullName`: the field name is `fullName`. Its value is obtained by concatenating `first_name` + * and `last_name`. + * + * ```php + * return [ + * 'email', + * 'firstName' => 'first_name', + * 'lastName' => 'last_name', + * 'fullName' => function () { + * return $this->first_name . ' ' . $this->last_name; + * }, + * ]; + * ``` + * + * In this method, you may also want to return different lists of fields based on some context + * information. For example, depending on [[scenario]] or the privilege of the current application user, + * you may return different sets of visible fields or filter out some fields. + * + * The default implementation of this method returns [[attributes()]] indexed by the same attribute names. + * + * @return array the list of field names or field definitions. + * @see toArray() + */ + public function fields() + { + $fields = $this->attributes(); + return array_combine($fields, $fields); + } + + /** + * Determines which fields can be returned by [[toArray()]]. + * This method will check the requested fields against those declared in [[fields()]] and [[extraFields()]] + * to determine which fields can be returned. + * @param array $fields the fields being requested for exporting + * @param array $expand the additional fields being requested for exporting + * @return array the list of fields to be exported. The array keys are the field names, and the array values + * are the corresponding object property names or PHP callables returning the field values. */ - public function toArray() + protected function resolveFields(array $fields, array $expand) { - return $this->getAttributes(); + $result = []; + + foreach ($this->fields() as $field => $definition) { + if (is_integer($field)) { + $field = $definition; + } + if (empty($fields) || in_array($field, $fields, true)) { + $result[$field] = $definition; + } + } + + if (empty($expand)) { + return $result; + } + + foreach ($this->extraFields() as $field => $definition) { + if (is_integer($field)) { + $field = $definition; + } + if (in_array($field, $expand, true)) { + $result[$field] = $definition; + } + } + + return $result; } /** diff --git a/framework/base/Module.php b/framework/base/Module.php index ab5a516..cba919d 100644 --- a/framework/base/Module.php +++ b/framework/base/Module.php @@ -359,6 +359,9 @@ class Module extends Component return $this->_modules[$id]; } elseif ($load) { Yii::trace("Loading module: $id", __METHOD__); + if (is_array($this->_modules[$id]) && !isset($this->_modules[$id]['class'])) { + $this->_modules[$id]['class'] = 'yii\base\Module'; + } return $this->_modules[$id] = Yii::createObject($this->_modules[$id], $id, $this); } } diff --git a/framework/data/Pagination.php b/framework/data/Pagination.php index c2c06d1..29bedb7 100644 --- a/framework/data/Pagination.php +++ b/framework/data/Pagination.php @@ -8,7 +8,10 @@ namespace yii\data; use Yii; +use yii\base\Arrayable; use yii\base\Object; +use yii\web\Link; +use yii\web\Linkable; use yii\web\Request; /** @@ -65,9 +68,8 @@ use yii\web\Request; * @author Qiang Xue <qiang.xue@gmail.com> * @since 2.0 */ -class Pagination extends Object +class Pagination extends Object implements Linkable, Arrayable { - const LINK_SELF = 'self'; const LINK_NEXT = 'next'; const LINK_PREV = 'prev'; const LINK_FIRST = 'first'; @@ -301,7 +303,7 @@ class Pagination extends Object $currentPage = $this->getPage(); $pageCount = $this->getPageCount(); $links = [ - self::LINK_SELF => $this->createUrl($currentPage, $absolute), + Link::REL_SELF => $this->createUrl($currentPage, $absolute), ]; if ($currentPage > 0) { $links[self::LINK_FIRST] = $this->createUrl(0, $absolute); @@ -315,6 +317,19 @@ class Pagination extends Object } /** + * @inheritdoc + */ + public function toArray() + { + return [ + 'totalCount' => $this->totalCount, + 'pageCount' => $this->getPageCount(), + 'currentPage' => $this->getPage(), + 'perPage' => $this->getPageSize(), + ]; + } + + /** * Returns the value of the specified query parameter. * This method returns the named parameter value from [[params]]. Null is returned if the value does not exist. * @param string $name the parameter name diff --git a/framework/db/BaseActiveRecord.php b/framework/db/BaseActiveRecord.php index afca94c..69bc1da 100644 --- a/framework/db/BaseActiveRecord.php +++ b/framework/db/BaseActiveRecord.php @@ -1347,4 +1347,26 @@ abstract class BaseActiveRecord extends Model implements ActiveRecordInterface return $this->generateAttributeLabel($attribute); } + + /** + * @inheritdoc + * + * The default implementation returns the names of the columns whose values have been populated into this record. + */ + public function fields() + { + $fields = array_keys($this->_attributes); + return array_combine($fields, $fields); + } + + /** + * @inheritdoc + * + * The default implementation returns the names of the relations that have been populated into this record. + */ + public function extraFields() + { + $fields = array_keys($this->getRelatedRecords()); + return array_combine($fields, $fields); + } } diff --git a/framework/helpers/BaseArrayHelper.php b/framework/helpers/BaseArrayHelper.php index 0177494..278eaf0 100644 --- a/framework/helpers/BaseArrayHelper.php +++ b/framework/helpers/BaseArrayHelper.php @@ -58,35 +58,42 @@ class BaseArrayHelper */ public static function toArray($object, $properties = [], $recursive = true) { - if (!empty($properties) && is_object($object)) { - $className = get_class($object); - if (!empty($properties[$className])) { - $result = []; - foreach ($properties[$className] as $key => $name) { - if (is_int($key)) { - $result[$name] = $object->$name; - } else { - $result[$key] = static::getValue($object, $name); + if (is_array($object)) { + if ($recursive) { + foreach ($object as $key => $value) { + if (is_array($value) || is_object($value)) { + $object[$key] = static::toArray($value, true); } } - return $result; } - } - if ($object instanceof Arrayable) { - $object = $object->toArray(); - if (!$recursive) { - return $object; + return $object; + } elseif (is_object($object)) { + if (!empty($properties)) { + $className = get_class($object); + if (!empty($properties[$className])) { + $result = []; + foreach ($properties[$className] as $key => $name) { + if (is_int($key)) { + $result[$name] = $object->$name; + } else { + $result[$key] = static::getValue($object, $name); + } + } + return $recursive ? static::toArray($result) : $result; + } } - } - $result = []; - foreach ($object as $key => $value) { - if ($recursive && (is_array($value) || is_object($value))) { - $result[$key] = static::toArray($value, $properties, true); + if ($object instanceof Arrayable) { + $result = $object->toArray(); } else { - $result[$key] = $value; + $result = []; + foreach ($object as $key => $value) { + $result[$key] = $value; + } } + return $recursive ? static::toArray($result) : $result; + } else { + return [$object]; } - return $result; } /** diff --git a/framework/rest/Action.php b/framework/rest/Action.php new file mode 100644 index 0000000..1a98539 --- /dev/null +++ b/framework/rest/Action.php @@ -0,0 +1,106 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use Yii; +use yii\base\InvalidConfigException; +use yii\db\ActiveRecordInterface; +use yii\web\NotFoundHttpException; + +/** + * Action is the base class for action classes that implement RESTful API. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class Action extends \yii\base\Action +{ + /** + * @var string class name of the model which will be handled by this action. + * The model class must implement [[ActiveRecordInterface]]. + * This property must be set. + */ + public $modelClass; + /** + * @var callable a PHP callable that will be called to return the model corresponding + * to the specified primary key value. If not set, [[findModel()]] will be used instead. + * The signature of the callable should be: + * + * ```php + * function ($id, $action) { + * // $id is the primary key value. If composite primary key, the key values + * // will be separated by comma. + * // $action is the action object currently running + * } + * ``` + * + * The callable should return the model found, or throw an exception if not found. + */ + public $findModel; + /** + * @var callable a PHP callable that will be called when running an action to determine + * if the current user has the permission to execute the action. If not set, the access + * check will not be performed. The signature of the callable should be as follows, + * + * ```php + * function ($action, $model = null) { + * // $model is the requested model instance. + * // If null, it means no specific model (e.g. IndexAction) + * } + * ``` + */ + public $checkAccess; + + + /** + * @inheritdoc + */ + public function init() + { + if ($this->modelClass === null) { + throw new InvalidConfigException(get_class($this) . '::$modelClass must be set.'); + } + } + + /** + * Returns the data model based on the primary key given. + * If the data model is not found, a 404 HTTP exception will be raised. + * @param string $id the ID of the model to be loaded. If the model has a composite primary key, + * the ID must be a string of the primary key values separated by commas. + * The order of the primary key values should follow that returned by the `primaryKey()` method + * of the model. + * @return ActiveRecordInterface the model found + * @throws NotFoundHttpException if the model cannot be found + */ + public function findModel($id) + { + if ($this->findModel !== null) { + return call_user_func($this->findModel, $id, $this); + } + + /** + * @var ActiveRecordInterface $modelClass + */ + $modelClass = $this->modelClass; + $keys = $modelClass::primaryKey(); + if (count($keys) > 1) { + $values = explode(',', $id); + if (count($keys) === count($values)) { + $model = $modelClass::find(array_combine($keys, $values)); + } + } elseif ($id !== null) { + $model = $modelClass::find($id); + } + + if (isset($model)) { + return $model; + } else { + throw new NotFoundHttpException("Object not found: $id"); + } + } +} diff --git a/framework/rest/ActiveController.php b/framework/rest/ActiveController.php new file mode 100644 index 0000000..75a4f55 --- /dev/null +++ b/framework/rest/ActiveController.php @@ -0,0 +1,126 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use yii\base\InvalidConfigException; +use yii\base\Model; + +/** + * ActiveController implements a common set of actions for supporting RESTful access to ActiveRecord. + * + * The class of the ActiveRecord should be specified via [[modelClass]], which must implement [[\yii\db\ActiveRecordInterface]]. + * By default, the following actions are supported: + * + * - `index`: list of models + * - `view`: return the details of a model + * - `create`: create a new model + * - `update`: update an existing model + * - `delete`: delete an existing model + * - `options`: return the allowed HTTP methods + * + * You may disable some of these actions by overriding [[actions()]] and unsetting the corresponding actions. + * + * To add a new action, either override [[actions()]] by appending a new action class or write a new action method. + * Make sure you also override [[verbs()]] to properly declare what HTTP methods are allowed by the new action. + * + * You should usually override [[checkAccess()]] to check whether the current user has the privilege to perform + * the specified action against the specified model. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class ActiveController extends Controller +{ + /** + * @var string the model class name. This property must be set. + */ + public $modelClass; + /** + * @var string the scenario used for updating a model. + * @see \yii\base\Model::scenarios() + */ + public $updateScenario = Model::SCENARIO_DEFAULT; + /** + * @var string the scenario used for creating a model. + * @see \yii\base\Model::scenarios() + */ + public $createScenario = Model::SCENARIO_DEFAULT; + /** + * @var boolean whether to use a DB transaction when creating, updating or deleting a model. + * This property is only useful for relational database. + */ + public $transactional = true; + + + /** + * @inheritdoc + */ + public function init() + { + parent::init(); + if ($this->modelClass === null) { + throw new InvalidConfigException('The "modelClass" property must be set.'); + } + } + + /** + * @inheritdoc + */ + public function actions() + { + return [ + 'index' => [ + 'class' => 'yii\rest\IndexAction', + 'modelClass' => $this->modelClass, + 'checkAccess' => [$this, 'checkAccess'], + ], + 'view' => [ + 'class' => 'yii\rest\ViewAction', + 'modelClass' => $this->modelClass, + 'checkAccess' => [$this, 'checkAccess'], + ], + 'create' => [ + 'class' => 'yii\rest\CreateAction', + 'modelClass' => $this->modelClass, + 'checkAccess' => [$this, 'checkAccess'], + 'scenario' => $this->createScenario, + 'transactional' => $this->transactional, + ], + 'update' => [ + 'class' => 'yii\rest\UpdateAction', + 'modelClass' => $this->modelClass, + 'checkAccess' => [$this, 'checkAccess'], + 'scenario' => $this->updateScenario, + 'transactional' => $this->transactional, + ], + 'delete' => [ + 'class' => 'yii\rest\DeleteAction', + 'modelClass' => $this->modelClass, + 'checkAccess' => [$this, 'checkAccess'], + 'transactional' => $this->transactional, + ], + 'options' => [ + 'class' => 'yii\rest\OptionsAction', + ], + ]; + } + + /** + * @inheritdoc + */ + protected function verbs() + { + return [ + 'index' => ['GET', 'HEAD'], + 'view' => ['GET', 'HEAD'], + 'create' => ['POST'], + 'update' => ['PUT', 'PATCH'], + 'delete' => ['DELETE'], + ]; + } +} diff --git a/framework/rest/AuthInterface.php b/framework/rest/AuthInterface.php new file mode 100644 index 0000000..30ccc9f --- /dev/null +++ b/framework/rest/AuthInterface.php @@ -0,0 +1,41 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use yii\web\User; +use yii\web\Request; +use yii\web\Response; +use yii\web\IdentityInterface; +use yii\web\UnauthorizedHttpException; + +/** + * AuthInterface is the interface required by classes that support user authentication. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +interface AuthInterface +{ + /** + * Authenticates the current user. + * + * @param User $user + * @param Request $request + * @param Response $response + * @return IdentityInterface the authenticated user identity. If authentication information is not provided, null will be returned. + * @throws UnauthorizedHttpException if authentication information is provided but is invalid. + */ + public function authenticate($user, $request, $response); + /** + * Handles authentication failure. + * The implementation should normally throw UnauthorizedHttpException to indicate authentication failure. + * @param Response $response + * @throws UnauthorizedHttpException + */ + public function handleFailure($response); +} diff --git a/framework/rest/Controller.php b/framework/rest/Controller.php new file mode 100644 index 0000000..7900b15 --- /dev/null +++ b/framework/rest/Controller.php @@ -0,0 +1,247 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use Yii; +use yii\base\InvalidConfigException; +use yii\web\Response; +use yii\web\UnauthorizedHttpException; +use yii\web\UnsupportedMediaTypeHttpException; +use yii\web\TooManyRequestsHttpException; +use yii\web\VerbFilter; +use yii\web\ForbiddenHttpException; + +/** + * Controller is the base class for RESTful API controller classes. + * + * Controller implements the following steps in a RESTful API request handling cycle: + * + * 1. Resolving response format and API version number (see [[supportedFormats]], [[supportedVersions]] and [[version]]); + * 2. Validating request method (see [[verbs()]]). + * 3. Authenticating user (see [[authenticate()]]); + * 4. Formatting response data (see [[serializeData()]]). + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class Controller extends \yii\web\Controller +{ + /** + * @var string the name of the header parameter representing the API version number. + */ + public $versionHeaderParam = 'version'; + /** + * @var string|array the configuration for creating the serializer that formats the response data. + */ + public $serializer = 'yii\rest\Serializer'; + /** + * @inheritdoc + */ + public $enableCsrfValidation = false; + /** + * @var array the supported authentication methods. This property should take a list of supported + * authentication methods, each represented by an authentication class or configuration. + * If this is not set or empty, it means authentication is disabled. + */ + public $authMethods; + /** + * @var string|array the rate limiter class or configuration. If this is not set or empty, + * the rate limiting will be disabled. Note that if the user is not authenticated, the rate limiting + * will also NOT be performed. + * @see checkRateLimit() + * @see authMethods + */ + public $rateLimiter = 'yii\rest\RateLimiter'; + /** + * @var string the chosen API version number, or null if [[supportedVersions]] is empty. + * @see supportedVersions + */ + public $version; + /** + * @var array list of supported API version numbers. If the current request does not specify a version + * number, the first element will be used as the [[version|chosen version number]]. For this reason, you should + * put the latest version number at the first. If this property is empty, [[version]] will not be set. + */ + public $supportedVersions = []; + /** + * @var array list of supported response formats. The array keys are the requested content MIME types, + * and the array values are the corresponding response formats. The first element will be used + * as the response format if the current request does not specify a content type. + */ + public $supportedFormats = [ + 'application/json' => Response::FORMAT_JSON, + 'application/xml' => Response::FORMAT_XML, + ]; + + /** + * @inheritdoc + */ + public function behaviors() + { + return [ + 'verbFilter' => [ + 'class' => VerbFilter::className(), + 'actions' => $this->verbs(), + ], + ]; + } + + /** + * @inheritdoc + */ + public function init() + { + parent::init(); + $this->resolveFormatAndVersion(); + } + + /** + * @inheritdoc + */ + public function beforeAction($action) + { + if (parent::beforeAction($action)) { + $this->authenticate(); + $this->checkRateLimit($action); + return true; + } else { + return false; + } + } + + /** + * @inheritdoc + */ + public function afterAction($action, $result) + { + $result = parent::afterAction($action, $result); + return $this->serializeData($result); + } + + /** + * Resolves the response format and the API version number. + * @throws UnsupportedMediaTypeHttpException + */ + protected function resolveFormatAndVersion() + { + $this->version = empty($this->supportedVersions) ? null : reset($this->supportedVersions); + Yii::$app->getResponse()->format = reset($this->supportedFormats); + $types = Yii::$app->getRequest()->getAcceptableContentTypes(); + if (empty($types)) { + $types['*/*'] = []; + } + + foreach ($types as $type => $params) { + if (isset($this->supportedFormats[$type])) { + Yii::$app->getResponse()->format = $this->supportedFormats[$type]; + if (isset($params[$this->versionHeaderParam])) { + if (in_array($params[$this->versionHeaderParam], $this->supportedVersions, true)) { + $this->version = $params[$this->versionHeaderParam]; + } else { + throw new UnsupportedMediaTypeHttpException('You are requesting an invalid version number.'); + } + } + return; + } + } + + if (!isset($types['*/*'])) { + throw new UnsupportedMediaTypeHttpException('None of your requested content types is supported.'); + } + } + + /** + * Declares the allowed HTTP verbs. + * Please refer to [[VerbFilter::actions]] on how to declare the allowed verbs. + * @return array the allowed HTTP verbs. + */ + protected function verbs() + { + return []; + } + + /** + * Authenticates the user. + * This method implements the user authentication based on an access token sent through the `Authorization` HTTP header. + * @throws UnauthorizedHttpException if the user is not authenticated successfully + */ + protected function authenticate() + { + if (empty($this->authMethods)) { + return; + } + + $user = Yii::$app->getUser(); + $request = Yii::$app->getRequest(); + $response = Yii::$app->getResponse(); + foreach ($this->authMethods as $i => $auth) { + $this->authMethods[$i] = $auth = Yii::createObject($auth); + if (!$auth instanceof AuthInterface) { + throw new InvalidConfigException(get_class($auth) . ' must implement yii\rest\AuthInterface'); + } elseif ($auth->authenticate($user, $request, $response) !== null) { + return; + } + } + + /** @var AuthInterface $auth */ + $auth = reset($this->authMethods); + $auth->handleFailure($response); + } + + /** + * Ensures the rate limit is not exceeded. + * + * This method will use [[rateLimiter]] to check rate limit. In order to perform rate limiting check, + * the user must be authenticated and the user identity object (`Yii::$app->user->identity`) must + * implement [[RateLimitInterface]]. + * + * @param \yii\base\Action $action the action to be executed + * @throws TooManyRequestsHttpException if the rate limit is exceeded. + */ + protected function checkRateLimit($action) + { + if (empty($this->rateLimiter)) { + return; + } + + $identity = Yii::$app->getUser()->getIdentity(false); + if ($identity instanceof RateLimitInterface) { + /** @var RateLimiter $rateLimiter */ + $rateLimiter = Yii::createObject($this->rateLimiter); + $rateLimiter->check($identity, Yii::$app->getRequest(), Yii::$app->getResponse(), $action); + } + } + + /** + * Serializes the specified data. + * The default implementation will create a serializer based on the configuration given by [[serializer]]. + * It then uses the serializer to serialize the given data. + * @param mixed $data the data to be serialized + * @return mixed the serialized data. + */ + protected function serializeData($data) + { + return Yii::createObject($this->serializer)->serialize($data); + } + + /** + * Checks the privilege of the current user. + * + * This method should be overridden to check whether the current user has the privilege + * to run the specified action against the specified data model. + * If the user does not have access, a [[ForbiddenHttpException]] should be thrown. + * + * @param string $action the ID of the action to be executed + * @param object $model the model to be accessed. If null, it means no specific model is being accessed. + * @param array $params additional parameters + * @throws ForbiddenHttpException if the user does not have access + */ + public function checkAccess($action, $model = null, $params = []) + { + } +} diff --git a/framework/rest/CreateAction.php b/framework/rest/CreateAction.php new file mode 100644 index 0000000..fa818c2 --- /dev/null +++ b/framework/rest/CreateAction.php @@ -0,0 +1,80 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use Yii; +use yii\base\Model; +use yii\db\ActiveRecord; + +/** + * CreateAction implements the API endpoint for creating a new model from the given data. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class CreateAction extends Action +{ + /** + * @var string the scenario to be assigned to the new model before it is validated and saved. + */ + public $scenario = Model::SCENARIO_DEFAULT; + /** + * @var boolean whether to start a DB transaction when saving the model. + */ + public $transactional = true; + /** + * @var string the name of the view action. This property is need to create the URL when the mode is successfully created. + */ + public $viewAction = 'view'; + + + /** + * Creates a new model. + * @return \yii\db\ActiveRecordInterface the model newly created + * @throws \Exception if there is any error when creating the model + */ + public function run() + { + if ($this->checkAccess) { + call_user_func($this->checkAccess, $this->id); + } + + /** + * @var \yii\db\ActiveRecord $model + */ + $model = new $this->modelClass([ + 'scenario' => $this->scenario, + ]); + + $model->load(Yii::$app->getRequest()->getBodyParams(), ''); + + if ($this->transactional && $model instanceof ActiveRecord) { + if ($model->validate()) { + $transaction = $model->getDb()->beginTransaction(); + try { + $model->insert(false); + $transaction->commit(); + } catch (\Exception $e) { + $transaction->rollback(); + throw $e; + } + } + } else { + $model->save(); + } + + if (!$model->hasErrors()) { + $response = Yii::$app->getResponse(); + $response->setStatusCode(201); + $id = implode(',', array_values($model->getPrimaryKey(true))); + $response->getHeaders()->set('Location', $this->controller->createAbsoluteUrl([$this->viewAction, 'id' => $id])); + } + + return $model; + } +} diff --git a/framework/rest/DeleteAction.php b/framework/rest/DeleteAction.php new file mode 100644 index 0000000..a0355c8 --- /dev/null +++ b/framework/rest/DeleteAction.php @@ -0,0 +1,53 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use Yii; +use yii\db\ActiveRecord; + +/** + * DeleteAction implements the API endpoint for deleting a model. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class DeleteAction extends Action +{ + /** + * @var boolean whether to start a DB transaction when deleting the model. + */ + public $transactional = true; + + + /** + * Deletes a model. + */ + public function run($id) + { + $model = $this->findModel($id); + + if ($this->checkAccess) { + call_user_func($this->checkAccess, $this->id, $model); + } + + if ($this->transactional && $model instanceof ActiveRecord) { + $transaction = $model->getDb()->beginTransaction(); + try { + $model->delete(); + $transaction->commit(); + } catch (\Exception $e) { + $transaction->rollback(); + throw $e; + } + } else { + $model->delete(); + } + + Yii::$app->getResponse()->setStatusCode(204); + } +} diff --git a/framework/rest/HttpBasicAuth.php b/framework/rest/HttpBasicAuth.php new file mode 100644 index 0000000..7a69c15 --- /dev/null +++ b/framework/rest/HttpBasicAuth.php @@ -0,0 +1,50 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use Yii; +use yii\base\Component; +use yii\web\UnauthorizedHttpException; + +/** + * HttpBasicAuth implements the HTTP Basic authentication method. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class HttpBasicAuth extends Component implements AuthInterface +{ + /** + * @var string the HTTP authentication realm + */ + public $realm = 'api'; + + /** + * @inheritdoc + */ + public function authenticate($user, $request, $response) + { + if (($accessToken = $request->getAuthUser()) !== null) { + $identity = $user->loginByAccessToken($accessToken); + if ($identity !== null) { + return $identity; + } + $this->handleFailure($response); + } + return null; + } + + /** + * @inheritdoc + */ + public function handleFailure($response) + { + $response->getHeaders()->set('WWW-Authenticate', "Basic realm=\"{$this->realm}\""); + throw new UnauthorizedHttpException('You are requesting with an invalid access token.'); + } +} diff --git a/framework/rest/HttpBearerAuth.php b/framework/rest/HttpBearerAuth.php new file mode 100644 index 0000000..81033c9 --- /dev/null +++ b/framework/rest/HttpBearerAuth.php @@ -0,0 +1,52 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use Yii; +use yii\base\Component; +use yii\web\UnauthorizedHttpException; + +/** + * HttpBearerAuth implements the authentication method based on HTTP Bearer token. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class HttpBearerAuth extends Component implements AuthInterface +{ + /** + * @var string the HTTP authentication realm + */ + public $realm = 'api'; + + /** + * @inheritdoc + */ + public function authenticate($user, $request, $response) + { + $authHeader = $request->getHeaders()->get('Authorization'); + if ($authHeader !== null && preg_match("/^Bearer\\s+(.*?)$/", $authHeader, $matches)) { + $identity = $user->loginByAccessToken($matches[1]); + if ($identity !== null) { + return $identity; + } + + $this->handleFailure($response); + } + return null; + } + + /** + * @inheritdoc + */ + public function handleFailure($response) + { + $response->getHeaders()->set('WWW-Authenticate', "Basic realm=\"{$this->realm}\""); + throw new UnauthorizedHttpException('You are requesting with an invalid access token.'); + } +} diff --git a/framework/rest/IndexAction.php b/framework/rest/IndexAction.php new file mode 100644 index 0000000..ca30220 --- /dev/null +++ b/framework/rest/IndexAction.php @@ -0,0 +1,65 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use Yii; +use yii\data\ActiveDataProvider; + +/** + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class IndexAction extends Action +{ + /** + * @var callable a PHP callable that will be called to prepare a data provider that + * should return a collection of the models. If not set, [[prepareDataProvider()]] will be used instead. + * The signature of the callable should be: + * + * ```php + * function ($action) { + * // $action is the action object currently running + * } + * ``` + * + * The callable should return an instance of [[ActiveDataProvider]]. + */ + public $prepareDataProvider; + + + /** + * @return ActiveDataProvider + */ + public function run() + { + if ($this->checkAccess) { + call_user_func($this->checkAccess, $this->id); + } + + return $this->prepareDataProvider(); + } + + /** + * Prepares the data provider that should return the requested collection of the models. + * @return ActiveDataProvider + */ + protected function prepareDataProvider() + { + if ($this->prepareDataProvider !== null) { + return call_user_func($this->prepareDataProvider, $this); + } + + /** + * @var \yii\db\BaseActiveRecord $modelClass + */ + $modelClass = $this->modelClass; + return new ActiveDataProvider([ + 'query' => $modelClass::find(), + ]); + } +} diff --git a/framework/rest/OptionsAction.php b/framework/rest/OptionsAction.php new file mode 100644 index 0000000..0f9561f --- /dev/null +++ b/framework/rest/OptionsAction.php @@ -0,0 +1,42 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use Yii; + +/** + * OptionsAction responds to the OPTIONS request by sending back an `Allow` header. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class OptionsAction extends \yii\base\Action +{ + /** + * @var array the HTTP verbs that are supported by the collection URL + */ + public $collectionOptions = ['GET', 'POST', 'HEAD', 'OPTIONS']; + /** + * @var array the HTTP verbs that are supported by the resource URL + */ + public $resourceOptions = ['GET', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']; + + + /** + * Responds to the OPTIONS request. + * @param string $id + */ + public function run($id = null) + { + if (Yii::$app->getRequest()->getMethod() !== 'OPTIONS') { + Yii::$app->getResponse()->setStatusCode(405); + } + $options = $id === null ? $this->collectionOptions : $this->resourceOptions; + Yii::$app->getResponse()->getHeaders()->set('Allow', implode(', ', $options)); + } +} diff --git a/framework/rest/QueryParamAuth.php b/framework/rest/QueryParamAuth.php new file mode 100644 index 0000000..f45e4c8 --- /dev/null +++ b/framework/rest/QueryParamAuth.php @@ -0,0 +1,52 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use Yii; +use yii\base\Component; +use yii\web\UnauthorizedHttpException; + +/** + * QueryParamAuth implements the authentication method based on the access token passed through a query parameter. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class QueryParamAuth extends Component implements AuthInterface +{ + /** + * @var string the parameter name for passing the access token + */ + public $tokenParam = 'access-token'; + + /** + * @inheritdoc + */ + public function authenticate($user, $request, $response) + { + $accessToken = $request->get($this->tokenParam); + if (is_string($accessToken)) { + $identity = $user->loginByAccessToken($accessToken); + if ($identity !== null) { + return $identity; + } + } + if ($accessToken !== null) { + $this->handleFailure($response); + } + return null; + } + + /** + * @inheritdoc + */ + public function handleFailure($response) + { + throw new UnauthorizedHttpException('You are requesting with an invalid access token.'); + } +} diff --git a/framework/rest/RateLimitInterface.php b/framework/rest/RateLimitInterface.php new file mode 100644 index 0000000..07f60e0 --- /dev/null +++ b/framework/rest/RateLimitInterface.php @@ -0,0 +1,39 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +/** + * RateLimitInterface is the interface that may be implemented by an identity object to enforce rate limiting. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +interface RateLimitInterface +{ + /** + * Returns the maximum number of allowed requests and the window size. + * @param array $params the additional parameters associated with the rate limit. + * @return array an array of two elements. The first element is the maximum number of allowed requests, + * and the second element is the size of the window in seconds. + */ + public function getRateLimit($params = []); + /** + * Loads the number of allowed requests and the corresponding timestamp from a persistent storage. + * @param array $params the additional parameters associated with the rate limit. + * @return array an array of two elements. The first element is the number of allowed requests, + * and the second element is the corresponding UNIX timestamp. + */ + public function loadAllowance($params = []); + /** + * Saves the number of allowed requests and the corresponding timestamp to a persistent storage. + * @param integer $allowance the number of allowed requests remaining. + * @param integer $timestamp the current timestamp. + * @param array $params the additional parameters associated with the rate limit. + */ + public function saveAllowance($allowance, $timestamp, $params = []); +} diff --git a/framework/rest/RateLimiter.php b/framework/rest/RateLimiter.php new file mode 100644 index 0000000..753a0f0 --- /dev/null +++ b/framework/rest/RateLimiter.php @@ -0,0 +1,85 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use yii\base\Component; +use yii\base\Action; +use yii\web\Request; +use yii\web\Response; +use yii\web\TooManyRequestsHttpException; + +/** + * RateLimiter implements a rate limiting algorithm based on the [leaky bucket algorithm](http://en.wikipedia.org/wiki/Leaky_bucket). + * + * You may call [[check()]] to enforce rate limiting. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class RateLimiter extends Component +{ + /** + * @var boolean whether to include rate limit headers in the response + */ + public $enableRateLimitHeaders = true; + /** + * @var string the message to be displayed when rate limit exceeds + */ + public $errorMessage = 'Rate limit exceeded.'; + + /** + * Checks whether the rate limit exceeds. + * @param RateLimitInterface $user the current user + * @param Request $request + * @param Response $response + * @param Action $action the action to be executed + * @throws TooManyRequestsHttpException if rate limit exceeds + */ + public function check($user, $request, $response, $action) + { + $current = time(); + $params = [ + 'request' => $request, + 'action' => $action, + ]; + + list ($limit, $window) = $user->getRateLimit($params); + list ($allowance, $timestamp) = $user->loadAllowance($params); + + $allowance += (int)(($current - $timestamp) * $limit / $window); + if ($allowance > $limit) { + $allowance = $limit; + } + + if ($allowance < 1) { + $user->saveAllowance(0, $current, $params); + $this->addRateLimitHeaders($response, $limit, 0, $window); + throw new TooManyRequestsHttpException($this->errorMessage); + } else { + $user->saveAllowance($allowance - 1, $current, $params); + $this->addRateLimitHeaders($response, $limit, 0, (int)(($limit - $allowance) * $window / $limit)); + } + } + + /** + * Adds the rate limit headers to the response + * @param Response $response + * @param integer $limit the maximum number of allowed requests during a period + * @param integer $remaining the remaining number of allowed requests within the current period + * @param integer $reset the number of seconds to wait before having maximum number of allowed requests again + */ + protected function addRateLimitHeaders($response, $limit, $remaining, $reset) + { + if ($this->enableRateLimitHeaders) { + $response->getHeaders() + ->set('X-Rate-Limit-Limit', $limit) + ->set('X-Rate-Limit-Remaining', $remaining) + ->set('X-Rate-Limit-Reset', $reset); + } + } +} diff --git a/framework/rest/Serializer.php b/framework/rest/Serializer.php new file mode 100644 index 0000000..75a6664 --- /dev/null +++ b/framework/rest/Serializer.php @@ -0,0 +1,248 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use Yii; +use yii\base\Component; +use yii\base\Model; +use yii\data\DataProviderInterface; +use yii\data\Pagination; +use yii\helpers\ArrayHelper; +use yii\web\Link; +use yii\web\Request; +use yii\web\Response; + +/** + * Serializer converts resource objects and collections into array representation. + * + * Serializer is mainly used by REST controllers to convert different objects into array representation + * so that they can be further turned into different formats, such as JSON, XML, by response formatters. + * + * The default implementation handles resources as [[Model]] objects and collections as objects + * implementing [[DataProviderInterface]]. You may override [[serialize()]] to handle more types. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class Serializer extends Component +{ + /** + * @var string the name of the query parameter containing the information about which fields should be returned + * for a [[Model]] object. If the parameter is not provided or empty, the default set of fields as defined + * by [[Model::fields()]] will be returned. + */ + public $fieldsParam = 'fields'; + /** + * @var string the name of the query parameter containing the information about which fields should be returned + * in addition to those listed in [[fieldsParam]] for a resource object. + */ + public $expandParam = 'expand'; + /** + * @var string the name of the HTTP header containing the information about total number of data items. + * This is used when serving a resource collection with pagination. + */ + public $totalCountHeader = 'X-Pagination-Total-Count'; + /** + * @var string the name of the HTTP header containing the information about total number of pages of data. + * This is used when serving a resource collection with pagination. + */ + public $pageCountHeader = 'X-Pagination-Page-Count'; + /** + * @var string the name of the HTTP header containing the information about the current page number (1-based). + * This is used when serving a resource collection with pagination. + */ + public $currentPageHeader = 'X-Pagination-Current-Page'; + /** + * @var string the name of the HTTP header containing the information about the number of data items in each page. + * This is used when serving a resource collection with pagination. + */ + public $perPageHeader = 'X-Pagination-Per-Page'; + /** + * @var string the name of the envelope (e.g. `items`) for returning the resource objects in a collection. + * This is used when serving a resource collection. When this is set and pagination is enabled, the serializer + * will return a collection in the following format: + * + * ```php + * [ + * 'items' => [...], // assuming collectionEnvelope is "items" + * '_links' => { // pagination links as returned by Pagination::getLinks() + * 'self' => '...', + * 'next' => '...', + * 'last' => '...', + * }, + * '_meta' => { // meta information as returned by Pagination::toArray() + * 'totalCount' => 100, + * 'pageCount' => 5, + * 'currentPage' => 1, + * 'perPage' => 20, + * }, + * ] + * ``` + * + * If this property is not set, the resource arrays will be directly returned without using envelope. + * The pagination information as shown in `_links` and `_meta` can be accessed from the response HTTP headers. + */ + public $collectionEnvelope; + /** + * @var Request the current request. If not set, the `request` application component will be used. + */ + public $request; + /** + * @var Response the response to be sent. If not set, the `response` application component will be used. + */ + public $response; + + /** + * @inheritdoc + */ + public function init() + { + if ($this->request === null) { + $this->request = Yii::$app->getRequest(); + } + if ($this->response === null) { + $this->response = Yii::$app->getResponse(); + } + } + + /** + * Serializes the given data into a format that can be easily turned into other formats. + * This method mainly converts the objects of recognized types into array representation. + * It will not do conversion for unknown object types or non-object data. + * The default implementation will handle [[Model]] and [[DataProviderInterface]]. + * You may override this method to support more object types. + * @param mixed $data the data to be serialized. + * @return mixed the converted data. + */ + public function serialize($data) + { + if ($data instanceof Model) { + return $data->hasErrors() ? $this->serializeModelErrors($data) : $this->serializeModel($data); + } elseif ($data instanceof DataProviderInterface) { + return $this->serializeDataProvider($data); + } else { + return $data; + } + } + + /** + * @return array the names of the requested fields. The first element is an array + * representing the list of default fields requested, while the second element is + * an array of the extra fields requested in addition to the default fields. + * @see Model::fields() + * @see Model::extraFields() + */ + protected function getRequestedFields() + { + $fields = $this->request->get($this->fieldsParam); + $expand = $this->request->get($this->expandParam); + return [ + preg_split('/\s*,\s*/', $fields, -1, PREG_SPLIT_NO_EMPTY), + preg_split('/\s*,\s*/', $expand, -1, PREG_SPLIT_NO_EMPTY), + ]; + } + + /** + * Serializes a data provider. + * @param DataProviderInterface $dataProvider + * @return array the array representation of the data provider. + */ + protected function serializeDataProvider($dataProvider) + { + $models = $this->serializeModels($dataProvider->getModels()); + + if (($pagination = $dataProvider->getPagination()) !== false) { + $this->addPaginationHeaders($pagination); + } + + if ($this->request->getIsHead()) { + return null; + } elseif ($this->collectionEnvelope === null) { + return $models; + } else { + $result = [ + $this->collectionEnvelope => $models, + ]; + if ($pagination !== false) { + $result['_links'] = Link::serialize($pagination->getLinks()); + $result['_meta'] = $pagination->toArray(); + } + return $result; + } + } + + /** + * Adds HTTP headers about the pagination to the response. + * @param Pagination $pagination + */ + protected function addPaginationHeaders($pagination) + { + $links = []; + foreach ($pagination->getLinks(true) as $rel => $url) { + $links[] = "<$url>; rel=$rel"; + } + + $this->response->getHeaders() + ->set($this->totalCountHeader, $pagination->totalCount) + ->set($this->pageCountHeader, $pagination->getPageCount()) + ->set($this->currentPageHeader, $pagination->getPage() + 1) + ->set($this->perPageHeader, $pagination->pageSize) + ->set('Link', implode(', ', $links)); + } + + /** + * Serializes a model object. + * @param Model $model + * @return array the array representation of the model + */ + protected function serializeModel($model) + { + if ($this->request->getIsHead()) { + return null; + } else { + list ($fields, $expand) = $this->getRequestedFields(); + return $model->toArray($fields, $expand); + } + } + + /** + * Serializes the validation errors in a model. + * @param Model $model + * @return array the array representation of the errors + */ + protected function serializeModelErrors($model) + { + $this->response->setStatusCode(422, 'Data Validation Failed.'); + $result = []; + foreach ($model->getFirstErrors() as $name => $message) { + $result[] = [ + 'field' => $name, + 'message' => $message, + ]; + } + return $result; + } + + /** + * Serializes a set of models. + * @param array $models + * @return array the array representation of the models + */ + protected function serializeModels(array $models) + { + list ($fields, $expand) = $this->getRequestedFields(); + foreach ($models as $i => $model) { + if ($model instanceof Model) { + $models[$i] = $model->toArray($fields, $expand); + } elseif (is_array($model)) { + $models[$i] = ArrayHelper::toArray($model); + } + } + return $models; + } +} diff --git a/framework/rest/UpdateAction.php b/framework/rest/UpdateAction.php new file mode 100644 index 0000000..7a14a0a --- /dev/null +++ b/framework/rest/UpdateAction.php @@ -0,0 +1,67 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use Yii; +use yii\base\Model; +use yii\db\ActiveRecord; + +/** + * UpdateAction implements the API endpoint for updating a model. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class UpdateAction extends Action +{ + /** + * @var string the scenario to be assigned to the model before it is validated and updated. + */ + public $scenario = Model::SCENARIO_DEFAULT; + /** + * @var boolean whether to start a DB transaction when saving the model. + */ + public $transactional = true; + + + /** + * Updates an existing model. + * @param string $id the primary key of the model. + * @return \yii\db\ActiveRecordInterface the model being updated + * @throws \Exception if there is any error when updating the model + */ + public function run($id) + { + /** @var ActiveRecord $model */ + $model = $this->findModel($id); + + if ($this->checkAccess) { + call_user_func($this->checkAccess, $this->id, $model); + } + + $model->scenario = $this->scenario; + $model->load(Yii::$app->getRequest()->getBodyParams(), ''); + + if ($this->transactional && $model instanceof ActiveRecord) { + if ($model->validate()) { + $transaction = $model->getDb()->beginTransaction(); + try { + $model->update(false); + $transaction->commit(); + } catch (\Exception $e) { + $transaction->rollback(); + throw $e; + } + } + } else { + $model->save(); + } + + return $model; + } +} diff --git a/framework/rest/UrlRule.php b/framework/rest/UrlRule.php new file mode 100644 index 0000000..5e4b218 --- /dev/null +++ b/framework/rest/UrlRule.php @@ -0,0 +1,250 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use Yii; +use yii\base\InvalidConfigException; +use yii\helpers\Inflector; +use yii\web\CompositeUrlRule; + +/** + * UrlRule is provided to simplify the creation of URL rules for RESTful API support. + * + * The simplest usage of UrlRule is to declare a rule like the following in the application configuration, + * + * ```php + * [ + * 'class' => 'yii\rest\UrlRule', + * 'controller' => 'user', + * ] + * ``` + * + * The above code will create a whole set of URL rules supporting the following RESTful API endpoints: + * + * - `'PUT,PATCH users/<id>' => 'user/update'`: update a user + * - `'DELETE users/<id>' => 'user/delete'`: delete a user + * - `'GET,HEAD users/<id>' => 'user/view'`: return the details/overview/options of a user + * - `'POST users' => 'user/create'`: create a new user + * - `'GET,HEAD users' => 'user/index'`: return a list/overview/options of users + * - `'users/<id>' => 'user/options'`: process all unhandled verbs of a user + * - `'users' => 'user/options'`: process all unhandled verbs of user collection + * + * You may configure [[only]] and/or [[except]] to disable some of the above rules. + * You may configure [[patterns]] to completely redefine your own list of rules. + * You may configure [[controller]] with multiple controller IDs to generate rules for all these controllers. + * For example, the following code will disable the `delete` rule and generate rules for both `user` and `post` controllers: + * + * ```php + * [ + * 'class' => 'yii\rest\UrlRule', + * 'controller' => ['user', 'post'], + * 'except' => ['delete'], + * ] + * ``` + * + * The property [[controller]] is required and should be the controller ID. It should be prefixed with + * the module ID if the controller is within a module. + * + * The controller ID used in the pattern will be automatically pluralized (e.g. `user` becomes `users` + * as shown in the above examples). You may configure [[urlName]] to explicitly specify the controller ID + * in the pattern. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class UrlRule extends CompositeUrlRule +{ + /** + * @var string the common prefix string shared by all patterns. + */ + public $prefix; + /** + * @var string the suffix that will be assigned to [[\yii\web\UrlRule::suffix]] for every generated rule. + */ + public $suffix; + /** + * @var string|array the controller ID (e.g. `user`, `post-comment`) that the rules in this composite rule + * are dealing with. It should be prefixed with the module ID if the controller is within a module (e.g. `admin/user`). + * + * By default, the controller ID will be pluralized automatically when it is put in the patterns of the + * generated rules. If you want to explicitly specify how the controller ID should appear in the patterns, + * you may use an array with the array key being as the controller ID in the pattern, and the array value + * the actual controller ID. For example, `['u' => 'user']`. + * + * You may also pass multiple controller IDs as an array. If this is the case, this composite rule will + * generate applicable URL rules for EVERY specified controller. For example, `['user', 'post']`. + */ + public $controller; + /** + * @var array list of acceptable actions. If not empty, only the actions within this array + * will have the corresponding URL rules created. + * @see patterns + */ + public $only = []; + /** + * @var array list of actions that should be excluded. Any action found in this array + * will NOT have its URL rules created. + * @see patterns + */ + public $except = []; + /** + * @var array patterns for supporting extra actions in addition to those listed in [[patterns]]. + * The keys are the patterns and the values are the corresponding action IDs. + * These extra patterns will take precedence over [[patterns]]. + */ + public $extraPatterns = []; + /** + * @var array list of tokens that should be replaced for each pattern. The keys are the token names, + * and the values are the corresponding replacements. + * @see patterns + */ + public $tokens = [ + '{id}' => '<id:\\d[\\d,]*>', + ]; + /** + * @var array list of possible patterns and the corresponding actions for creating the URL rules. + * The keys are the patterns and the values are the corresponding actions. + * The format of patterns is `Verbs Pattern`, where `Verbs` stands for a list of HTTP verbs separated + * by comma (without space). If `Verbs` is not specified, it means all verbs are allowed. + * `Pattern` is optional. It will be prefixed with [[prefix]]/[[controller]]/, + * and tokens in it will be replaced by [[tokens]]. + */ + public $patterns = [ + 'PUT,PATCH {id}' => 'update', + 'DELETE {id}' => 'delete', + 'GET,HEAD {id}' => 'view', + 'POST' => 'create', + 'GET,HEAD' => 'index', + '{id}' => 'options', + '' => 'options', + ]; + /** + * @var array the default configuration for creating each URL rule contained by this rule. + */ + public $ruleConfig = [ + 'class' => 'yii\web\UrlRule', + ]; + /** + * @var boolean whether to automatically pluralize the URL names for controllers. + * If true, a controller ID will appear in plural form in URLs. For example, `user` controller + * will appear as `users` in URLs. + * @see controllers + */ + public $pluralize = true; + + + /** + * @inheritdoc + */ + public function init() + { + if (empty($this->controller)) { + throw new InvalidConfigException('"controller" must be set.'); + } + + $controllers = []; + foreach ((array)$this->controller as $urlName => $controller) { + if (is_integer($urlName)) { + $urlName = $this->pluralize ? Inflector::pluralize($controller) : $controller; + } + $controllers[$urlName] = $controller; + } + $this->controller = $controllers; + + $this->prefix = trim($this->prefix, '/'); + + parent::init(); + } + + /** + * @inheritdoc + */ + protected function createRules() + { + $only = array_flip($this->only); + $except = array_flip($this->except); + $patterns = array_merge($this->patterns, $this->extraPatterns); + $rules = []; + foreach ($this->controller as $urlName => $controller) { + $prefix = trim($this->prefix . '/' . $urlName, '/'); + foreach ($patterns as $pattern => $action) { + if (!isset($except[$action]) && (empty($only) || isset($only[$action]))) { + $rules[$urlName][] = $this->createRule($pattern, $prefix, $controller . '/' . $action); + } + } + } + return $rules; + } + + /** + * Creates a URL rule using the given pattern and action. + * @param string $pattern + * @param string $prefix + * @param string $action + * @return \yii\web\UrlRuleInterface + */ + protected function createRule($pattern, $prefix, $action) + { + $verbs = 'GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS'; + if (preg_match("/^((?:($verbs),)*($verbs))(?:\\s+(.*))?$/", $pattern, $matches)) { + $verbs = explode(',', $matches[1]); + $pattern = isset($matches[4]) ? $matches[4] : ''; + } else { + $verbs = []; + } + + $config = $this->ruleConfig; + $config['verb'] = $verbs; + $config['pattern'] = rtrim($prefix . '/' . strtr($pattern, $this->tokens), '/'); + $config['route'] = $action; + if (!in_array('GET', $verbs)) { + $config['mode'] = \yii\web\UrlRule::PARSING_ONLY; + } + $config['suffix'] = $this->suffix; + + return Yii::createObject($config); + } + + /** + * @inheritdoc + */ + public function parseRequest($manager, $request) + { + $pathInfo = $request->getPathInfo(); + foreach ($this->rules as $urlName => $rules) { + if (strpos($pathInfo, $urlName) !== false) { + foreach ($rules as $rule) { + /** @var \yii\web\UrlRule $rule */ + if (($result = $rule->parseRequest($manager, $request)) !== false) { + Yii::trace("Request parsed with URL rule: {$rule->name}", __METHOD__); + return $result; + } + } + } + } + return false; + } + + /** + * @inheritdoc + */ + public function createUrl($manager, $route, $params) + { + foreach ($this->controller as $urlName => $controller) { + if (strpos($route, $controller) !== false) { + foreach ($this->rules[$urlName] as $rule) { + /** @var \yii\web\UrlRule $rule */ + if (($url = $rule->createUrl($manager, $route, $params)) !== false) { + return $url; + } + } + } + } + return false; + } +} diff --git a/framework/rest/ViewAction.php b/framework/rest/ViewAction.php new file mode 100644 index 0000000..c37522f --- /dev/null +++ b/framework/rest/ViewAction.php @@ -0,0 +1,33 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\rest; + +use Yii; + +/** + * ViewAction implements the API endpoint for returning the detailed information about a model. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class ViewAction extends Action +{ + /** + * Displays a model. + * @param string $id the primary key of the model. + * @return \yii\db\ActiveRecordInterface the model being displayed + */ + public function run($id) + { + $model = $this->findModel($id); + if ($this->checkAccess) { + call_user_func($this->checkAccess, $this->id, $model); + } + return $model; + } +} diff --git a/framework/web/IdentityInterface.php b/framework/web/IdentityInterface.php index c796b50..2aac17f 100644 --- a/framework/web/IdentityInterface.php +++ b/framework/web/IdentityInterface.php @@ -52,6 +52,14 @@ interface IdentityInterface */ public static function findIdentity($id); /** + * Finds an identity by the given secrete token. + * @param string $token the secrete token + * @return IdentityInterface the identity object that matches the given token. + * Null should be returned if such an identity cannot be found + * or the identity is not in an active state (disabled, deleted, etc.) + */ + public static function findIdentityByAccessToken($token); + /** * Returns an ID that can uniquely identify a user identity. * @return string|integer an ID that uniquely identifies a user identity. */ diff --git a/framework/web/Link.php b/framework/web/Link.php new file mode 100644 index 0000000..9e10e9b --- /dev/null +++ b/framework/web/Link.php @@ -0,0 +1,83 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\web; + +use yii\base\Arrayable; +use yii\base\Object; + +/** + * Link represents a link object as defined in [JSON Hypermedia API Language](https://tools.ietf.org/html/draft-kelly-json-hal-03). + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +class Link extends Object implements Arrayable +{ + /** + * The self link. + */ + const REL_SELF = 'self'; + + /** + * @var string a URI [RFC3986](https://tools.ietf.org/html/rfc3986) or + * URI template [RFC6570](https://tools.ietf.org/html/rfc6570). This property is required. + */ + public $href; + /** + * @var string a secondary key for selecting Link Objects which share the same relation type + */ + public $name; + /** + * @var string a hint to indicate the media type expected when dereferencing the target resource + */ + public $type; + /** + * @var boolean a value indicating whether [[href]] refers to a URI or URI template. + */ + public $templated = false; + /** + * @var string a URI that hints about the profile of the target resource. + */ + public $profile; + /** + * @var string a label describing the link + */ + public $title; + /** + * @var string the language of the target resource + */ + public $hreflang; + + /** + * @inheritdoc + */ + public function toArray() + { + return array_filter((array)$this); + } + + /** + * Serializes a list of links into proper array format. + * @param array $links the links to be serialized + * @return array the proper array representation of the links. + */ + public static function serialize(array $links) + { + foreach ($links as $rel => $link) { + if (is_array($link)) { + foreach ($link as $i => $l) { + $link[$i] = $l instanceof self ? $l->toArray() : ['href' => $l]; + } + $links[$rel] = $link; + } elseif (!$link instanceof self) { + $links[$rel] = ['href' => $link]; + } + } + return $links; + } +} diff --git a/framework/web/Linkable.php b/framework/web/Linkable.php new file mode 100644 index 0000000..8d1558b --- /dev/null +++ b/framework/web/Linkable.php @@ -0,0 +1,42 @@ +<?php +/** + * @link http://www.yiiframework.com/ + * @copyright Copyright (c) 2008 Yii Software LLC + * @license http://www.yiiframework.com/license/ + */ + +namespace yii\web; + +/** + * Linkable is the interface that should be implemented by classes that typically represent locatable resources. + * + * @author Qiang Xue <qiang.xue@gmail.com> + * @since 2.0 + */ +interface Linkable +{ + /** + * Returns a list of links. + * + * Each link is either a URI or a [[Link]] object. The return value of this method should + * be an array whose keys are the relation names and values the corresponding links. + * + * If a relation name corresponds to multiple links, use an array to represent them. + * + * For example, + * + * ```php + * [ + * 'self' => 'http://example.com/users/1', + * 'friends' => [ + * 'http://example.com/users/2', + * 'http://example.com/users/3', + * ], + * 'manager' => $managerLink, // $managerLink is a Link object + * ] + * ``` + * + * @return array the links + */ + public function getLinks(); +} diff --git a/framework/web/UrlRule.php b/framework/web/UrlRule.php index b069cb3..42c9097 100644 --- a/framework/web/UrlRule.php +++ b/framework/web/UrlRule.php @@ -199,7 +199,7 @@ class UrlRule extends Object implements UrlRuleInterface return false; } - if ($this->verb !== null && !in_array($request->getMethod(), $this->verb, true)) { + if (!empty($this->verb) && !in_array($request->getMethod(), $this->verb, true)) { return false; } diff --git a/framework/web/User.php b/framework/web/User.php index ab33004..c32ae9c 100644 --- a/framework/web/User.php +++ b/framework/web/User.php @@ -211,6 +211,23 @@ class User extends Component } /** + * Logs in a user by the given access token. + * Note that unlike [[login()]], this method will NOT start a session to remember the user authentication status. + * Also if the access token is invalid, the user will remain as a guest. + * @param string $token the access token + * @return IdentityInterface the identity associated with the given access token. Null is returned if + * the access token is invalid. + */ + public function loginByAccessToken($token) + { + /** @var IdentityInterface $class */ + $class = $this->identityClass; + $identity = $class::findIdentityByAccessToken($token); + $this->setIdentity($identity); + return $identity; + } + + /** * Logs in a user by cookie. * * This method attempts to log in a user using the ID and authKey information diff --git a/tests/unit/framework/base/ModelTest.php b/tests/unit/framework/base/ModelTest.php index d304741..88926a9 100644 --- a/tests/unit/framework/base/ModelTest.php +++ b/tests/unit/framework/base/ModelTest.php @@ -147,7 +147,7 @@ class ModelTest extends TestCase $this->assertTrue($speaker->hasErrors('firstName')); $this->assertFalse($speaker->hasErrors('lastName')); - $this->assertEquals(['Something is wrong!'], $speaker->getFirstErrors()); + $this->assertEquals(['firstName' => 'Something is wrong!'], $speaker->getFirstErrors()); $this->assertEquals('Something is wrong!', $speaker->getFirstError('firstName')); $this->assertNull($speaker->getFirstError('lastName'));