Skip to content

Commit

Permalink
Fixes #2034: Added ContentNegotiator to support response format and…
Browse files Browse the repository at this point in the history
… language negotiation
  • Loading branch information
qiangxue committed Apr 9, 2014
1 parent fa767ce commit 134d3d4
Show file tree
Hide file tree
Showing 6 changed files with 312 additions and 94 deletions.
40 changes: 29 additions & 11 deletions docs/guide/rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions framework/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
254 changes: 254 additions & 0 deletions framework/filters/ContentNegotiator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
<?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',
* ],
* ],
* ];
* }

This comment has been minimized.

Copy link
@bryglen

bryglen Apr 10, 2014

Can you add phpdoc for 'only' and ''except' param. So people can know what to put it there. Currently examples are missing how to determine a module, controller or specific action which can lead to confusion

This comment has been minimized.

Copy link
@qiangxue

qiangxue Apr 10, 2014

Author Member

Done.

This comment has been minimized.

Copy link
@bryglen

bryglen Apr 10, 2014

thanks! this class is very helpful! you are very good @qiangxue

* ```
*
* @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;
}
}
11 changes: 10 additions & 1 deletion framework/filters/auth/CompositeAuth.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,22 @@ 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.
*/
public $authMethods = [];


/**
* @inheritdoc
*/
public function beforeAction($action)
{
return empty($this->authMethods) ? true : parent::beforeAction($action);
}

/**
* @inheritdoc
*/
Expand Down
Loading

0 comments on commit 134d3d4

Please sign in to comment.