Commit 134d3d46 by Qiang Xue

Fixes #2034: Added `ContentNegotiator` to support response format and language negotiation

parent fa767cea
......@@ -5,7 +5,7 @@ Yii provides a whole set of tools to greatly simplify the task of implementing R
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;
* Response format (supporting JSON and XML by default) 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;
......@@ -187,7 +187,23 @@ 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]].
other formats, you should configure the `contentNegotiator` behavior in your REST controller classes as follows,
```php
use yii\helpers\ArrayHelper;
public function behaviors()
{
return ArrayHelper::merge(parent::behaviors(), [
'contentNegotiator' => [
'formats' => [
// ... other supported formats ...
],
],
]);
}
```
Formatting response data in general involves two steps:
......@@ -808,8 +824,8 @@ The following list summarizes the HTTP status code that are used by the Yii REST
* `500`: Internal server error. This could be caused by internal program errors.
Versioning
----------
API 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
......@@ -902,14 +918,16 @@ As a result, `http://example.com/v1/users` will return the list of users in vers
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]]:
To deal with minor version numbers, you may take advantage of the content negotiation
feature provided by the [[yii\filters\ContentNegotiator|contentNegotiator]] behavior. The `contentNegotiator`
behavior will set the [[yii\web\Response::acceptParams]] property when it determines which
content type to support.
For example, if a request is sent with the HTTP header `Accept: application/json; version=v1`,
after content negotiation, [[yii\web\Response::acceptParams]] will contain the value `['version' => 'v1']`.
* 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.
Based on the version information in `acceptParams`, you may write conditional code in places
such as actions, resource classes, serializers, etc.
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.
......
......@@ -287,6 +287,7 @@ Yii Framework 2 Change Log
- New #1393: [Codeception testing framework integration](https://github.com/yiisoft/yii2-codeception) (Ragazzo)
- New #1438: [MongoDB integration](https://github.com/yiisoft/yii2-mongodb) ActiveRecord and Query (klimov-paul)
- New #1956: Implemented test fixture framework (qiangxue)
- New #2034: Added `ContentNegotiator` to support response format and language negotiation (qiangxue)
- New #2149: Added `yii\base\DynamicModel` to support ad-hoc data validation (qiangxue)
- New #2360: Added `AttributeBehavior` and `BlameableBehavior`, and renamed `AutoTimestamp` to `TimestampBehavior` (lucianobaraglia, qiangxue)
- New #2932: Added `yii\web\ViewAction` that allow you to render views based on GET parameter (samdark)
......
<?php
/**
* @link http://www.yiiframework.com/
* @copyright Copyright (c) 2008 Yii Software LLC
* @license http://www.yiiframework.com/license/
*/
namespace yii\filters;
use Yii;
use yii\base\ActionFilter;
use yii\base\BootstrapInterface;
use yii\base\InvalidConfigException;
use yii\web\Response;
use yii\web\Request;
use yii\web\UnsupportedMediaTypeHttpException;
/**
* ContentNegotiator supports response format negotiation and application language negotiation.
*
* When the [[formats|supported formats]] property is specified, ContentNegotiator will support response format
* negotiation based on the value of the GET parameter [[formatParam]] and the `Accept` HTTP header.
* If a match is found, the [[Response::format]] property will be set as the chosen format.
* The [[Response::acceptMimeType]] as well as [[Response::acceptParams]] will also be updated accordingly.
*
* When the [[languages|supported languages]] is specified, ContentNegotiator will support application
* language negotiation based on the value of the GET parameter [[languageParam]] and the `Accept-Language` HTTP header.
* If a match is found, the [[\yii\base\Application::language]] property will be set as the chosen language.
*
* You may use ContentNegotiator as a bootstrap component as well as an action filter.
*
* The following code shows how you can use ContentNegotiator as a bootstrap component. Note that in this case,
* the content negotiation applies to the whole application.
*
* ```php
* // in application configuration
* use yii\web\Response;
*
* return [
* 'bootstrap' => [
* [
* 'class' => 'yii\filters\ContentNegotiator',
* 'formats' => [
* 'application/json' => Response::FORMAT_JSON,
* 'application/xml' => Response::FORMAT_XML,
* ],
* 'languages' => [
* 'en',
* 'de',
* ],
* ],
* ],
* ];
* ```
*
* The following code shows how you can use ContentNegotiator as an action filter in either a controller or a module.
* In this case, the content negotiation result only applies to the corresponding controller or module, or even
* specific actions if you configure the `only` or `except` property of the filter.
*
* ```php
* use yii\web\Response;
*
* public function behaviors()
* {
* return [
* [
* 'class' => 'yii\filters\ContentNegotiator',
* 'formats' => [
* 'application/json' => Response::FORMAT_JSON,
* 'application/xml' => Response::FORMAT_XML,
* ],
* 'languages' => [
* 'en',
* 'de',
* ],
* ],
* ];
* }
* ```
*
* @author Qiang Xue <qiang.xue@gmail.com>
* @since 2.0
*/
class ContentNegotiator extends ActionFilter implements BootstrapInterface
{
/**
* @var string the name of the GET parameter that specifies the response format.
* Note that if the specified format does not exist in [[formats]], a [[UnsupportedMediaTypeHttpException]]
* exception will be thrown. If the parameter value is empty or if this property is null,
* the response format will be determined based on the `Accept` HTTP header.
* @see formats
*/
public $formatParam = '_format';
/**
* @var string the name of the GET parameter that specifies the [[\yii\base\Application::language|application language]].
* Note that if the specified language does not match any of [[languages]], the first language in [[languages]]
* will be used. If the parameter value is empty or if this property is null,
* the application language will be determined based on the `Accept-Language` HTTP header.
* @see languages
*/
public $languageParam = '_lang';
/**
* @var array list of supported response formats. The keys are MIME types (e.g. `application/json`)
* while the values are the corresponding formats (e.g. `html`, `json`) which must be supported
* as declared in [[\yii\web\Response::formatters]].
*
* If this property is empty or not set, response format negotiation will be skipped.
*/
public $formats;
/**
* @var array a list of supported languages. The array keys are the supported language variants (e.g. `en-GB`, `en-US`),
* while the array values are the corresponding language codes (e.g. `en`, `de`) recognized by the application.
*
* Array keys are not always required. When an array value does not have a key, the matching of the requested language
* will be based on a language fallback mechanism. For example, a value of `en` will match `en`, `en_US`, `en-US`, `en-GB`, etc.
*
* If this property is empty or not set, response format negotiation will be skipped.
*/
public $languages;
/**
* @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 bootstrap($app)
{
$this->negotiate();
}
/**
* @inheritdoc
*/
public function beforeAction($action)
{
$this->negotiate();
return true;
}
/**
* Negotiates the response format and application language.
*/
public function negotiate()
{
$request = $this->request ? : Yii::$app->getRequest();
$response = $this->response ? : Yii::$app->getResponse();
if (!empty($this->formats)) {
$this->negotiateContentType($request, $response);
}
if (!empty($languages)) {
Yii::$app->language = $this->negotiateLanguage($request);
}
}
/**
* Negotiates the response format.
* @param Request $request
* @param Response $response
* @throws InvalidConfigException if [[formats]] is empty
* @throws UnsupportedMediaTypeHttpException if none of the requested content types is accepted.
*/
protected function negotiateContentType($request, $response)
{
if (!empty($this->formatParam) && ($format = $request->get($this->formatParam)) !== null) {
if (in_array($format, $this->formats)) {
$response->format = $format;
$response->acceptMimeType = null;
$response->acceptParams = [];
return;
} else {
throw new UnsupportedMediaTypeHttpException('The requested response format is not supported: ' . $format);
}
}
$types = $request->getAcceptableContentTypes();
if (empty($types)) {
$types['*/*'] = [];
}
foreach ($types as $type => $params) {
if (isset($this->formats[$type])) {
$response->format = $this->formats[$type];
$response->acceptMimeType = $type;
$response->acceptParams = $params;
return;
}
}
if (isset($types['*/*'])) {
// return the first format
foreach ($this->formats as $type => $format) {
$response->format = $this->formats[$type];
$response->acceptMimeType = $type;
$response->acceptParams = [];
return;
}
}
throw new UnsupportedMediaTypeHttpException('None of your requested content types is supported.');
}
/**
* Negotiates the application language.
* @param Request $request
* @return string the chosen language
*/
protected function negotiateLanguage($request)
{
if (!empty($this->languageParam) && ($language = $request->get($this->languageParam)) !== null) {
if (isset($this->languages[$language])) {
return $this->languages[$language];
}
foreach ($this->languages as $key => $supported) {
if (is_integer($key) && $this->isLanguageSupported($language, $supported)) {
return $supported;
}
}
return reset($this->languages);
}
foreach ($request->getAcceptableLanguages() as $language) {
if (isset($this->languages[$language])) {
return $this->languages[$language];
}
foreach ($this->languages as $key => $supported) {
if (is_integer($key) && $this->isLanguageSupported($language, $supported)) {
return $supported;
}
}
}
return reset($this->languages);
}
/**
* Returns a value indicating whether the requested language matches the supported language.
* @param string $requested the requested language code
* @param string $supported the supported language code
* @return boolean whether the requested language is supported
*/
protected function isLanguageSupported($requested, $supported)
{
$supported = str_replace('_', '-', strtolower($supported));
$requested = str_replace('_', '-', strtolower($requested));
return strpos($requested . '-', $supported . '-') === 0;
}
}
......@@ -41,7 +41,8 @@ class CompositeAuth extends AuthMethod
/**
* @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, no authentication will be performed.
*
* If this property is empty, no authentication will be performed.
*
* Note that an auth method class must implement the [[\yii\filters\auth\AuthInterface]] interface.
*/
......@@ -51,6 +52,14 @@ class CompositeAuth extends AuthMethod
/**
* @inheritdoc
*/
public function beforeAction($action)
{
return empty($this->authMethods) ? true : parent::beforeAction($action);
}
/**
* @inheritdoc
*/
public function authenticate($user, $request, $response)
{
foreach ($this->authMethods as $i => $auth) {
......
......@@ -9,6 +9,7 @@ namespace yii\rest;
use Yii;
use yii\filters\auth\CompositeAuth;
use yii\filters\ContentNegotiator;
use yii\filters\RateLimiter;
use yii\web\Response;
use yii\web\UnsupportedMediaTypeHttpException;
......@@ -20,10 +21,10 @@ use yii\web\ForbiddenHttpException;
*
* 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]]);
* 1. Resolving response format (see [[ContentNegotiator]]);
* 2. Validating request method (see [[verbs()]]).
* 3. Authenticating user (see [[\yii\filters\auth\AuthInterface]]);
* 4. Rate limiting (see [[\yii\filters\RateLimiter]]);
* 4. Rate limiting (see [[RateLimiter]]);
* 5. Formatting response data (see [[serializeData()]]).
*
* @author Qiang Xue <qiang.xue@gmail.com>
......@@ -32,10 +33,6 @@ use yii\web\ForbiddenHttpException;
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';
......@@ -43,26 +40,7 @@ class Controller extends \yii\web\Controller
* @inheritdoc
*/
public $enableCsrfValidation = false;
/**
* @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
......@@ -70,6 +48,13 @@ class Controller extends \yii\web\Controller
public function behaviors()
{
return [
'contentNegotiator' => [
'class' => ContentNegotiator::className(),
'formats' => [
'application/json' => Response::FORMAT_JSON,
'application/xml' => Response::FORMAT_XML,
],
],
'verbFilter' => [
'class' => VerbFilter::className(),
'actions' => $this->verbs(),
......@@ -86,15 +71,6 @@ class Controller extends \yii\web\Controller
/**
* @inheritdoc
*/
public function init()
{
parent::init();
$this->resolveFormatAndVersion();
}
/**
* @inheritdoc
*/
public function afterAction($action, $result)
{
$result = parent::afterAction($action, $result);
......@@ -102,39 +78,6 @@ class Controller extends \yii\web\Controller
}
/**
* 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.
......
......@@ -102,23 +102,16 @@ class Response extends \yii\base\Response
*/
public $format = self::FORMAT_HTML;
/**
* @var array a list of supported response formats. The keys are MIME types (e.g. `application/json`)
* while the values are the corresponding formats (e.g. `html`, `json`) which must be supported by [[formatters]].
* When this property is set, a content type negotiation process will be conducted to determine
* the value of [[format]] and the corresponding [[mimeType]] and [[acceptParams]] values.
* @var string the MIME type (e.g. `application/json`) from the request ACCEPT header chosen for this response.
* This property is mainly set by [\yii\filters\ContentNegotiator]].
*/
public $supportedFormats;
public $acceptMimeType;
/**
* @var string the MIME type (e.g. `application/json`) chosen for this response after content type negotiation.
* This property will be set by the content type negotiation process.
* @var array the parameters (e.g. `['q' => 1, 'version' => '1.0']`) associated with the [[acceptMimeType|chosen MIME type]].
* This is a list of name-value pairs associated with [[mimeType]] from the ACCEPT HTTP header.
* This property is mainly set by [\yii\filters\ContentNegotiator]].
*/
public $mimeType;
/**
* @var array the parameters (e.g. `['q' => 1, 'version' => '1.0']`) for the MIME type chosen
* by the content type negotiation. This is a list of name-value pairs associated with [[mimeType]]
* from the ACCEPT HTTP header. This property will be set by the content type negotiation process.
*/
public $acceptParams;
public $acceptParams = [];
/**
* @var array the formatters for converting data into the response content of the specified [[format]].
* The array keys are the format names, and the array values are the corresponding configurations
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment