diff options
-rw-r--r-- | includes/libs/http/HttpAcceptParser.php | 110 | ||||
-rw-r--r-- | tests/phpunit/includes/libs/http/HttpAcceptParserTest.php | 115 |
2 files changed, 176 insertions, 49 deletions
diff --git a/includes/libs/http/HttpAcceptParser.php b/includes/libs/http/HttpAcceptParser.php index 93f5b0bce64d..25811ab7431f 100644 --- a/includes/libs/http/HttpAcceptParser.php +++ b/includes/libs/http/HttpAcceptParser.php @@ -13,66 +13,98 @@ namespace Wikimedia\Http; class HttpAcceptParser { /** + * Parse media types from an Accept header and sort them by q-factor. + * + * Note that his was mostly ported from, + * https://github.com/arlolra/negotiator/blob/full-parse-access/lib/mediaType.js + * + * @param string $accept + * @return array[] + * - type: (string) + * - subtype: (string) + * - q: (float) q-factor weighting + * - i: (int) index + * - params: (array) + */ + public function parseAccept( $accept ): array { + $accepts = explode( ',', $accept ); // FIXME: Allow commas in quotes + $ret = []; + + foreach ( $accepts as $i => $a ) { + preg_match( '!^([^\s/;]+)/([^;\s]+)\s*(?:;(.*))?$!D', trim( $a ), $matches ); + if ( !$matches ) { + continue; + } + $q = 1; + $params = []; + if ( isset( $matches[3] ) ) { + $kvps = explode( ';', $matches[3] ); // FIXME: Allow semi-colon in quotes + foreach ( $kvps as $kv ) { + [ $key, $val ] = explode( '=', trim( $kv ), 2 ); + $key = strtolower( trim( $key ) ); + $val = trim( $val ); + if ( $key === 'q' ) { + $q = (float)$val; // FIXME: Spec is stricter about this + } else { + if ( $val && $val[0] === '"' && $val[ strlen( $val ) - 1 ] === '"' ) { + $val = substr( $val, 1, strlen( $val ) - 2 ); + } + $params[$key] = $val; + } + } + } + $ret[] = [ + 'type' => $matches[1], + 'subtype' => $matches[2], + 'q' => $q, + 'i' => $i, + 'params' => $params, + ]; + } + + // Sort list. First by q values, then by order + usort( $ret, function ( $a, $b ) { + if ( $b['q'] > $a['q'] ) { + return 1; + } elseif ( $b['q'] === $a['q'] ) { + return $a['i'] - $b['i']; + } else { + return -1; + } + } ); + + return $ret; + } + + /** * Parses an HTTP header into a weight map, that is an associative array * mapping values to their respective weights. Any header name preceding * weight spec is ignored for convenience. * - * This implementation is partially based on the code at - * http://www.thefutureoftheweb.com/blog/use-accept-language-header - * * Note that type parameters and accept extension like the "level" parameter * are not supported, weights are derived from "q" values only. * - * @todo If additional type parameters are present, ignore them cleanly. - * At present, they often confuse the result. - * - * See HTTP/1.1 section 14 for details. + * See RFC 7231 section 5.3.2 for details. * * @param string $rawHeader * * @return array */ public function parseWeights( $rawHeader ) { - //FIXME: The code below was copied and adapted from WebRequest::getAcceptLang. - // Move this utility class into core for reuse! - // first, strip header name $rawHeader = preg_replace( '/^[-\w]+:\s*/', '', $rawHeader ); // Return values in lower case $rawHeader = strtolower( $rawHeader ); - // Break up string into pieces (values and q factors) - $value_parse = null; - preg_match_all( '@([a-z\d*]+([-+/.][a-z\d*]+)*)\s*(;\s*q\s*=\s*(1(\.0{0,3})?|0(\.\d{0,3})?)?)?@', - $rawHeader, $value_parse ); - - if ( !count( $value_parse[1] ) ) { - return []; - } - - $values = $value_parse[1]; - $qvalues = $value_parse[4]; - $indices = range( 0, count( $value_parse[1] ) - 1 ); - - // Set default q factor to 1 - foreach ( $indices as $index ) { - if ( $qvalues[$index] === '' ) { - $qvalues[$index] = 1; - } elseif ( $qvalues[$index] == 0 ) { - unset( $values[$index], $qvalues[$index], $indices[$index] ); - } else { - $qvalues[$index] = (float)$qvalues[$index]; - } - } - - // Sort list. First by $qvalues, then by order. Reorder $values the same way - array_multisort( $qvalues, SORT_DESC, SORT_NUMERIC, $indices, $values ); + $accepts = $this->parseAccept( $rawHeader ); // Create a list like "en" => 0.8 - $weights = array_combine( $values, $qvalues ); - - return $weights; + return array_reduce( $accepts, function ( $prev, $next ) { + $type = "{$next['type']}/{$next['subtype']}"; + $prev[$type] = $next['q']; + return $prev; + }, [] ); } } diff --git a/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php b/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php index e4b47b46d57f..60e3addf2162 100644 --- a/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php +++ b/tests/phpunit/includes/libs/http/HttpAcceptParserTest.php @@ -28,18 +28,17 @@ class HttpAcceptParserTest extends \PHPUnit\Framework\TestCase { [ 'application/vnd.php.serialized' => 1, 'application/rdf+xml' => 1 ] ], [ // #4 - 'foo; q=0.2, xoo; q=0,text/n3', - [ 'text/n3' => 1, 'foo' => 0.2 ] + 'foo/*; q=0.2, xoo; q=0,text/n3', + [ 'text/n3' => 1, 'foo/*' => 0.2 ] ], [ // #5 - '*; q=0.2, */*; q=0.1,text/*', - [ 'text/*' => 1, '*' => 0.2, '*/*' => 0.1 ] - ], - // TODO: nicely ignore additional type paramerters - //[ // #6 - // 'Foo; q=0.2, Xoo; level=3, Bar; charset=xyz; q=0.4', - // [ 'xoo' => 1, 'bar' => 0.4, 'foo' => 0.1 ] - //], + 'foo/*; q=0.2, */*; q=0.1,text/*', + [ 'text/*' => 1, 'foo/*' => 0.2, '*/*' => 0.1 ] + ], + [ // #6 + 'Foo/*; q=0.2, Xoo/*; level=3, Bar/*; charset=xyz; q=0.4', + [ 'xoo/*' => 1, 'bar/*' => 0.4, 'foo/*' => 0.2 ] + ], ]; } @@ -53,4 +52,100 @@ class HttpAcceptParserTest extends \PHPUnit\Framework\TestCase { $this->assertEquals( $expected, $actual ); // shouldn't be sensitive to order } + public function provideParseAccept() { + return [ + [ + // Sort by decending q + 'test/123; q=0.5, test/456; q=0.8', + [ + [ + 'type' => 'test', + 'subtype' => '456', + 'q' => 0.8, + 'i' => 1, + 'params' => [] + ], + [ + 'type' => 'test', + 'subtype' => '123', + 'q' => 0.5, + 'i' => 0, + 'params' => [] + ], + ] + ], + [ + // Sort by decending q, ascending order + 'test/123; q=0.5, test/789; q=0.8, test/456; q=0.8', + [ + [ + 'type' => 'test', + 'subtype' => '789', + 'q' => 0.8, + 'i' => 1, + 'params' => [] + ], + [ + 'type' => 'test', + 'subtype' => '456', + 'q' => 0.8, + 'i' => 2, + 'params' => [] + ], + [ + 'type' => 'test', + 'subtype' => '123', + 'q' => 0.5, + 'i' => 0, + 'params' => [] + ] + ] + ], + [ + // Test types and subtypes that contain non-alphanumeric characters + 'hi-ho/12.3; q=0.5, hi/ho+456; q=0.8', + [ + [ + 'type' => 'hi', + 'subtype' => 'ho+456', + 'q' => 0.8, + 'i' => 1, + 'params' => [] + ], + [ + 'type' => 'hi-ho', + 'subtype' => '12.3', + 'q' => 0.5, + 'i' => 0, + 'params' => [] + ] + ] + ], + [ + // Test for params + 'text/html; profile="https://www.mediawiki.org/wiki/Specs/HTML/0.0.0"', + [ + [ + 'type' => 'text', + 'subtype' => 'html', + 'q' => 1, + 'i' => 0, + 'params' => [ + 'profile' => 'https://www.mediawiki.org/wiki/Specs/HTML/0.0.0' + ] + ] + ] + ], + ]; + } + + /** + * @dataProvider provideParseAccept + */ + public function testParseAccept( $header, $expected ) { + $parser = new HttpAcceptParser(); + $actual = $parser->parseAccept( $header ); + $this->assertEquals( $expected, $actual ); + } + } |