framework/filters/ContentNegotiator.php
<?php
/**
* @link https://www.yiiframework.com/
* @copyright Copyright (c) 2008 Yii Software LLC
* @license https://www.yiiframework.com/license/
*/
namespace yii\filters;
use Yii;
use yii\base\ActionFilter;
use yii\base\BootstrapInterface;
use yii\web\BadRequestHttpException;
use yii\web\NotAcceptableHttpException;
use yii\web\Request;
use yii\web\Response;
/**
* 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 bootstrapping component as well as an action filter.
*
* The following code shows how you can use ContentNegotiator as a bootstrapping 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',
* 'only' => ['view', 'index'], // in a controller
* // if in a module, use the following IDs for user actions
* // 'only' => ['user/view', 'user/index']
* 'formats' => [
* 'application/json' => Response::FORMAT_JSON,
* ],
* '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 [[NotAcceptableHttpException]]
* 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 only.
* @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 only.
* @see languages
*/
public $languageParam = '_lang';
/**
* @var array|null 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|null 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, language 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|null 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)) {
if (\count($this->formats) > 1) {
$response->getHeaders()->add('Vary', 'Accept');
}
$this->negotiateContentType($request, $response);
}
if (!empty($this->languages)) {
if (\count($this->languages) > 1) {
$response->getHeaders()->add('Vary', 'Accept-Language');
}
Yii::$app->language = $this->negotiateLanguage($request);
}
}
/**
* Negotiates the response format.
* @param Request $request
* @param Response $response
* @throws BadRequestHttpException if an array received for GET parameter [[formatParam]].
* @throws NotAcceptableHttpException 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 (is_array($format)) {
throw new BadRequestHttpException("Invalid data received for GET parameter '{$this->formatParam}'.");
}
if (in_array($format, $this->formats)) {
$response->format = $format;
$response->acceptMimeType = null;
$response->acceptParams = [];
return;
}
throw new NotAcceptableHttpException('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;
}
}
foreach ($this->formats as $type => $format) {
$response->format = $format;
$response->acceptMimeType = $type;
$response->acceptParams = [];
break;
}
if (isset($types['*/*'])) {
return;
}
throw new NotAcceptableHttpException('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 (is_array($language)) {
// If an array received, then skip it and use the first of supported languages
return reset($this->languages);
}
if (isset($this->languages[$language])) {
return $this->languages[$language];
}
foreach ($this->languages as $key => $supported) {
if (is_int($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_int($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 bool 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;
}
}