Permalink
Browse files

Merge pull request #2132 branch '2131-add-accept-header-parsing' of h…

…ttps://github.com/Rupert-RR/yii into Rupert-RR-2131-add-accept-header-parsing

* '2131-add-accept-header-parsing' of https://github.com/Rupert-RR/yii:
  Yii code style correction
  Removed unnecessary spaces.
  Transferred data for unit tests from the test functions into data providers.
  Undo accidental permissions change on bootstrap.php
  Added unit test file for CHttpRequest for the methods parseAcceptHeader() and compareAcceptTypes(). Modified the regexp in parseAcceptHeader() to accept wildcards in the path. Modified the description of compareAcceptTypes() to better reflect the comparison result (higher preference returns lower value, so that most preferred is first in the array).
  parseAcceptHeader() function description tidy up.
  Typo corrections and code tidy up.
  Altered parseAcceptHeader() to use only one regexp. Thanks to Ka on StackExchange for this expression.
  Typo corrections
  Separated out parse and compare functions. Reduced regular expression count to 2 from 3. Modified MIME type array map structure.
  Added #135 back in changelog, which got lost somehow..
  Moved position of line to follow numerical order.
  Enh #2131: Added Accept header parsing to CHttpRequest to give an array of accepted types in order of preference

Conflicts:
	CHANGELOG
  • Loading branch information...
2 parents 1b24643 + 006a893 commit d27c9412ce4e1b8d04fd7dfe2e34ab1639c5e044 @cebe cebe committed Mar 27, 2013
Showing with 407 additions and 0 deletions.
  1. +1 −0 CHANGELOG
  2. +133 −0 framework/web/CHttpRequest.php
  3. +273 −0 tests/framework/web/CHttpRequestTest.php
View
1 CHANGELOG
@@ -48,6 +48,7 @@ Version 1.1.14 work in progress
- Enh #2038: CFormatter::formatNtext() method can replace newlines with `<p></p>` not just with `<br />` as it was before (resurtm)
- Enh #2090: Allow passing array of columns to CDbSchema::addPrimaryKey() (paystey)
- Enh #2096: CAPTCHA: non-free Duality.ttf font replaced by open/free SpicyRice.ttf (licensed under SIL OFL v1.1) (resurtm)
+- Enh #2131: Added Accept header parsing to CHttpRequest to give an array of accepted types in order of preference (Rupert-RR)
- Enh #2135: MessageCommand can now handles Yii::t() messages with files in subfolders (firsyura)
- Enh #2205: CActiveForm::error() now depends on CHtml::$errorContainerTag (malyshev)
- Enh #2217: Support of the empty option for CHtml::radioButtonList() has been introduced (resurtm)
View
133 framework/web/CHttpRequest.php
@@ -50,6 +50,8 @@
* @property integer $port Port number for insecure requests.
* @property integer $securePort Port number for secure requests.
* @property CCookieCollection|CHttpCookie[] $cookies The cookie collection.
+ * @property array $preferredAcceptType The user preferred accept type as an array map, e.g. array('type' => 'application', 'subType' => 'xhtml', 'baseType' => 'xml', 'params' => array('q' => 0.9)).
+ * @property array $preferredAcceptTypes An array of all user accepted types (as array maps like array('type' => 'application', 'subType' => 'xhtml', 'baseType' => 'xml', 'params' => array('q' => 0.9)) ) in order of preference.
* @property string $preferredLanguage The user preferred language.
* @property array $preferredLanguages An array of all user accepted languages in order of preference.
* @property string $csrfToken The random token for CSRF validation.
@@ -93,6 +95,7 @@ class CHttpRequest extends CApplicationComponent
private $_hostInfo;
private $_baseUrl;
private $_cookies;
+ private $_preferredAcceptTypes;
private $_preferredLanguages;
private $_csrfToken;
private $_restParams;
@@ -801,6 +804,136 @@ public function redirect($url,$terminate=true,$statusCode=302)
}
/**
+ * Parses an HTTP Accept header, returning an array map with all parts of each entry.
+ * Each array entry consists of a map with the type, subType, baseType and params, an array map of key-value parameters,
+ * obligatorily including a `q` value (i.e. preference ranking) as a double.
+ * For example, an Accept value of 'application/xhtml+xml;q=0.9;level=1' would give an array entry of
+ * array(
+ * 'type' => 'application',
+ * 'subType' => 'xhtml',
+ * 'baseType' => 'xml',
+ * 'params' => array(
+ * 'q' => 0.9,
+ * 'level' => '1',
+ * ),
+ * )
+ * NB: to avoid great complexity, there are no steps taken to ensure that quoted strings are treated properly.
+ * If the header text includes quoted strings containing space or the , or ; characters then the results may not be correct!
+ * @return array the user accepted MIME types.
+ * See {@link http://tools.ietf.org/html/rfc2616#section-14.1}
+ */
+ public static function parseAcceptHeader($header)
+ {
+ $matches=array();
+ $accepts=array();
+ // get individual entries with their type, subtype, basetype and params
+ preg_match_all('/(?:\G\s?,\s?|^)(\w+|\*)\/(\w+|\*)(?:\+(\w+))?|(?<!^)\G(?:\s?;\s?(\w+)=([\w\.]+))/',$header,$matches);
+ // the regexp should (in theory) always return an array of 6 arrays
+ if(count($matches)===6)
+ {
+ $i=0;
+ $itemLen=count($matches[1]);
+ while($i<$itemLen)
+ {
+ // fill out a content type
+ $accept=array(
+ 'type'=>$matches[1][$i],
+ 'subType'=>$matches[2][$i],
+ 'baseType'=>null,
+ 'params'=>array(),
+ );
+ // fill in the base type if it exists
+ if($matches[3][$i]!==null && $matches[3][$i]!=='')
+ $accept['baseType']=$matches[3][$i];
+ // continue looping while there is no new content type, to fill in all accompanying params
+ for($i++;$i<$itemLen;$i++)
+ {
+ // if the next content type is null, then the item is a param for the current content type
+ if($matches[1][$i]===null || $matches[1][$i]==='')
+ {
+ // if this is the quality param, convert it to a double
+ if($matches[4][$i]==='q')
+ {
+ // sanity check on q value
+ $q=(double)$matches[5][$i];
+ if($q>1)
+ $q=(double)1;
+ elseif($q<0)
+ $q=(double)0;
+ $accept['params'][$matches[4][$i]]=$q;
+ }
+ else
+ $accept['params'][$matches[4][$i]]=$matches[5][$i];
+ }
+ else
+ break;
+ }
+ // q defaults to 1 if not explicitly given
+ if(!isset($accept['params']['q']))
+ $accept['params']['q']=(double)1;
+ $accepts[] = $accept;
+ }
+ }
+ return $accepts;
+ }
+
+ /**
+ * Compare function for determining the preference of accepted MIME type array maps
+ * @param array $a user accepted MIME type as an array map
+ * @param array $b user accepted MIME type as an array map
+ * @return integer -1, 0 or 1 if $a has respectively greater preference, equal preference or less preference than $b (higher preference comes first).
+ * See {@link parseAcceptHeader()} for the format of $a and $b
+ */
+ public static function compareAcceptTypes($a,$b)
+ {
+ // check for equal quality first
+ if($a['params']['q']===$b['params']['q'])
+ if(!($a['type']==='*' xor $b['type']==='*'))
+ if (!($a['subType']==='*' xor $b['subType']==='*'))
+ // finally, higher number of parameters counts as greater precedence
+ if(count($a['params'])===count($b['params']))
+ return 0;
+ else
+ return count($a['params'])<count($b['params']) ? 1 : -1;
+ // more specific takes precedence - whichever one doesn't have a * subType
+ else
+ return $a['subType']==='*' ? 1 : -1;
+ // more specific takes precedence - whichever one doesn't have a * type
+ else
+ return $a['type']==='*' ? 1 : -1;
+ else
+ return ($a['params']['q']<$b['params']['q']) ? 1 : -1;
+ }
+
+ /**
+ * Returns an array of user accepted MIME types in order of preference.
+ * Each array entry consists of a map with the type, subType, baseType and params, an array map of key-value parameters.
+ * See {@link parseAcceptHeader()} for a description of the array map.
+ * @return array the user accepted MIME types, as array maps, in the order of preference.
+ */
+ public function getPreferredAcceptTypes()
+ {
+ if($this->_preferredAcceptTypes===null)
+ {
+ $accepts=self::parseAcceptHeader($this->getAcceptTypes());
+ usort($accepts,array(get_class($this),'compareAcceptTypes'));
+ $this->_preferredAcceptTypes=$accepts;
+ }
+ return $this->_preferredAcceptTypes;
+ }
+
+ /**
+ * Returns the user preferred accept MIME type.
+ * The MIME type is returned as an array map (see {@link parseAcceptHeader()}.
+ * @return array the user preferred accept MIME type or false if the user does not have any.
+ */
+ public function getPreferredAcceptType()
+ {
+ $preferredAcceptTypes=$this->getPreferredAcceptTypes();
+ return empty($preferredAcceptTypes) ? false : $preferredAcceptTypes[0];
+ }
+
+ /**
* Returns an array of user accepted languages in order of preference.
* The returned language IDs will NOT be canonicalized using {@link CLocale::getCanonicalID}.
* @return array the user accepted languages in the order of preference.
View
273 tests/framework/web/CHttpRequestTest.php
@@ -0,0 +1,273 @@
+<?php
+
+class CHttpRequestTest extends CTestCase
+{
+ /**
+ * @covers CHttpRequest::parseAcceptHeader
+ * @dataProvider acceptHeaderDataProvider
+ */
+ public function testParseAcceptHeader($header,$result,$errorString='Parse of header did not give expected result')
+ {
+ $this->assertEquals($result,CHttpRequest::parseAcceptHeader($header),$errorString);
+ }
+
+ /**
+ * @covers CHttpRequest::compareAcceptTypes
+ * @dataProvider acceptContentTypeArrayMapDataProvider
+ */
+ public function testCompareAcceptTypes($a,$b,$result,$errorString='Compare of content type array maps did not give expected preference')
+ {
+ $this->assertEquals($result,CHttpRequest::compareAcceptTypes($a,$b),$errorString);
+ // make sure that inverse comparison holds
+ $this->assertEquals($result*-1,CHttpRequest::compareAcceptTypes($b,$a),'(Inverse) '.$errorString);
+ }
+
+ public function acceptHeaderDataProvider()
+ {
+ return array(
+ // null header
+ array(
+ null,
+ array(),
+ 'Parsing null Accept header did not return empty array',
+ ),
+ // empty header
+ array(
+ '',
+ array(),
+ 'Parsing empty Accept header did not return empty array',
+ ),
+ // nonsense header, containing no valid accept types (but containing the characters that the header is split on)
+ array(
+ 'gsf,\'yas\'erys"rt;,";s,y s;,',
+ array(),
+ 'Parsing completely invalid Accept header did not return empty array',
+ ),
+ // valid header containing only content types
+ array(
+ 'application/xhtml+xml,text/html,*/json,image/png',
+ array(
+ array(
+ 'type'=>'application',
+ 'subType'=>'xhtml',
+ 'baseType'=>'xml',
+ 'params'=>array(
+ 'q'=>1,
+ ),
+ ),
+ array(
+ 'type'=>'text',
+ 'subType'=>'html',
+ 'baseType'=>null,
+ 'params'=>array(
+ 'q'=>1,
+ ),
+ ),
+ array(
+ 'type'=>'*',
+ 'subType'=>'json',
+ 'baseType'=>null,
+ 'params'=>array(
+ 'q'=>1,
+ ),
+ ),
+ array(
+ 'type'=>'image',
+ 'subType'=>'png',
+ 'baseType'=>null,
+ 'params'=>array(
+ 'q'=>1,
+ ),
+ ),
+ ),
+ 'Parsing valid Accept header containing only content types did not return correct result',
+ ),
+ // valid header containing all details
+ array(
+ 'application/xhtml+xml;q=0.9,text/html,*/json;q=4;level=three,image/png;a=1;b=2;c=3',
+ array(
+ array(
+ 'type'=>'application',
+ 'subType'=>'xhtml',
+ 'baseType'=>'xml',
+ 'params'=>array(
+ 'q'=>0.9,
+ ),
+ ),
+ array(
+ 'type'=>'text',
+ 'subType'=>'html',
+ 'baseType'=>null,
+ 'params'=>array(
+ 'q'=>1,
+ ),
+ ),
+ array(
+ 'type'=>'*',
+ 'subType'=>'json',
+ 'baseType'=>null,
+ 'params'=>array(
+ 'q'=>1,
+ 'level'=>'three',
+ ),
+ ),
+ array(
+ 'type'=>'image',
+ 'subType'=>'png',
+ 'baseType'=>null,
+ 'params'=>array(
+ 'q'=>1,
+ 'a'=>1,
+ 'b'=>2,
+ 'c'=>3,
+ ),
+ ),
+ ),
+ 'Parsing valid Accept header containing all details did not return correct result',
+ ),
+ // partially valid header containing all details (no , after */json)
+ array(
+ 'application/xhtml+xml;q=0.9,text/html,*/json;q=4;level=three image/png;a=1;b=2;c=3',
+ array(
+ array(
+ 'type'=>'application',
+ 'subType'=>'xhtml',
+ 'baseType'=>'xml',
+ 'params'=>array(
+ 'q'=>0.9,
+ ),
+ ),
+ array(
+ 'type'=>'text',
+ 'subType'=>'html',
+ 'baseType'=>null,
+ 'params'=>array(
+ 'q'=>1,
+ ),
+ ),
+ array(
+ 'type'=>'*',
+ 'subType'=>'json',
+ 'baseType'=>null,
+ 'params'=>array(
+ 'q'=>1,
+ 'level'=>'three',
+ ),
+ ),
+ ),
+ 'Parsing partially valid Accept header containing all details did not return correct result',
+ ),
+ );
+ }
+
+ public function acceptContentTypeArrayMapDataProvider()
+ {
+ return array(
+ array(
+ array(
+ 'type'=>'application',
+ 'subType'=>'xhtml',
+ 'baseType'=>'xml',
+ 'params'=>array(
+ 'q'=>0.99,
+ ),
+ ),
+ array(
+ 'type'=>'text',
+ 'subType'=>'html',
+ 'baseType'=>null,
+ 'params'=>array(
+ 'q'=>(double)1,
+ ),
+ ),
+ 1,
+ 'Comparing different q did not assign correct preference',
+ ),
+ array(
+ array(
+ 'type'=>'application',
+ 'subType'=>'xhtml',
+ 'baseType'=>'xml',
+ 'params'=>array(
+ 'q'=>0.5,
+ ),
+ ),
+ array(
+ 'type'=>'*',
+ 'subType'=>'html',
+ 'baseType'=>null,
+ 'params'=>array(
+ 'q'=>0.5,
+ ),
+ ),
+ -1,
+ 'Comparing type wildcard with specific type did not assign correct preference',
+ ),
+ array(
+ array(
+ 'type'=>'application',
+ 'subType'=>'*',
+ 'baseType'=>'xml',
+ 'params'=>array(
+ 'q'=>0.5,
+ ),
+ ),
+ array(
+ 'type'=>'text',
+ 'subType'=>'html',
+ 'baseType'=>null,
+ 'params'=>array(
+ 'q'=>0.5,
+ ),
+ ),
+ 1,
+ 'Comparing subType wildcard with specific subType did not assign correct preference',
+ ),
+ array(
+ array(
+ 'type'=>'*',
+ 'subType'=>'xhtml',
+ 'baseType'=>'xml',
+ 'params'=>array(
+ 'q'=>0.9,
+ 'foo'=>'bar2',
+ ),
+ ),
+ array(
+ 'type'=>'*',
+ 'subType'=>'html',
+ 'baseType'=>null,
+ 'params'=>array(
+ 'q'=>0.9,
+ 'foo'=>'bar',
+ 'test'=>'drive',
+ ),
+ ),
+ 1,
+ 'Comparing different number of params did not assign correct preference',
+ ),
+ array(
+ array(
+ 'type'=>'*',
+ 'subType'=>'xhtml',
+ 'baseType'=>'xml',
+ 'params'=>array(
+ 'q'=>0.9,
+ 'foo'=>'bar',
+ ),
+ ),
+ array(
+ 'type'=>'*',
+ 'subType'=>'html',
+ 'baseType'=>null,
+ 'params'=>array(
+ 'q'=>0.9,
+ 'foo'=>'bar',
+ ),
+ ),
+ 0,
+ 'Comparing equal type, subType, q and number of params did not return equality',
+ ),
+ );
+ }
+}

0 comments on commit d27c941

Please sign in to comment.