merged branch jfsimon/accept-header-parsing (PR #5841)

This PR was squashed before being merged into the master branch (closes #5841).

Commits
-------

6b601bd [http-foudation] Better accept header parsing

Discussion
----------

[http-foudation] Better accept header parsing

Bug fix: no
Feature addition: yes
Backwards compatibility break: yes
Symfony2 tests pass: yes

**Quality:**
The special `q` item attribute represents its quality. I had to make some choices:
*  if I set `q` attribute, it's assigned to quality property, but not to attributes
*  the `__toString()` method only render `q` attribute if quality is less than 1

**BC break:**
The return of `Request::splitHttpAcceptHeader()` has changed. It's result was an array of qualities indexed by an accept value, it now returns an array of `AcceptHeaderItem` indexed by its value.

---------------------------------------------------------------------------

by jfsimon at 2012-10-26T08:35:55Z

As dicussed in https://github.com/symfony/symfony/pull/5711.

---------------------------------------------------------------------------

by Seldaek at 2012-10-27T10:35:49Z

Maybe you can pull 5e8a5267f6 into your branch (for some reason I can't send a PR to your repo, it doesn't show up in github's repo selector.. looks like they don't like projects with too many forks). It allows you to use usort() which hopefully is faster than your merge sort, though I did not bench it. I also added tests to confirm the functionality.

---------------------------------------------------------------------------

by Seldaek at 2012-10-27T10:40:27Z

Sorry please check 376dd93c56 instead, I missed a few tests in the RequestTest class.

---------------------------------------------------------------------------

by jfsimon at 2012-10-29T16:26:03Z

@fabpot do you think the introduced BC break is acceptable?

---------------------------------------------------------------------------

by fabpot at 2012-10-29T16:37:06Z

@jfsimon Are all getAccept*() method BC?

---------------------------------------------------------------------------

by jfsimon at 2012-10-29T16:39:26Z

@fabpot nope, just `Request::splitHttpAcceptHeader()`

---------------------------------------------------------------------------

by jfsimon at 2012-10-29T16:43:18Z

@fabpot I think missunderstood... only `Request::splitHttpAcceptHeader()` breaks BC.

---------------------------------------------------------------------------

by fabpot at 2012-10-29T16:53:22Z

So, a BC break on just splitHttpAcceptHeader is possible... but should be documented properly. Another option would be to deprecate the current method (and keep it as is), and just use the new version everywhere. Sounds better as it won"t introduce any BC breaks.

---------------------------------------------------------------------------

by jfsimon at 2012-10-29T16:55:57Z

@fabpot Okay, I'll update this PR according to your second option.

---------------------------------------------------------------------------

by jfsimon at 2012-10-29T20:14:46Z

@fabpot done.

As you can see here: https://github.com/symfony/symfony/pull/5841/files#L5L1029 value returned by `Request::splitHttpAcceptHeader()` is not **exactly** the same as before because all attributes are present (not only those before the `q` one).

---------------------------------------------------------------------------

by fabpot at 2012-10-30T06:16:23Z

The last thing missing before I can merge is a PR to update the documentation (should probably be just a note somewhere with the example you have in the UPGRADE file).

---------------------------------------------------------------------------

by jfsimon at 2012-10-30T07:07:08Z

@fabpot I could add this example here: http://symfony.com/doc/current/components/http_foundation/introduction.html#request after `Accessing the session`, what do you think?

---------------------------------------------------------------------------

by fabpot at 2012-10-30T07:14:10Z

Yes, looks good to me.
This commit is contained in:
Fabien Potencier 2012-11-05 12:25:18 +01:00
commit 53fad04f3e
8 changed files with 650 additions and 36 deletions

22
UPGRADE-2.2.md Normal file
View File

@ -0,0 +1,22 @@
UPGRADE FROM 2.1 to 2.2
=======================
#### Deprecations
* The `Request::splitHttpAcceptHeader()` is deprecated and will be removed in 2.3.
You should now use the `AcceptHeader` class which give you fluent methods to
parse request accept-* headers. Some examples:
```
$accept = AcceptHeader::fromString($request->headers->get('Accept'));
if ($accept->has('text/html') {
$item = $accept->get('html');
$charset = $item->getAttribute('charset', 'utf-8');
$quality = $item->getQuality();
}
// accepts items are sorted by descending quality
$accepts = AcceptHeader::fromString($request->headers->get('Accept'))->all();
```

View File

@ -0,0 +1,172 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation;
/**
* Represents an Accept-* header.
*
* An accept header is compound with a list of items,
* sorted by descending quality.
*
* @author Jean-François Simon <contact@jfsimon.fr>
*/
class AcceptHeader
{
/**
* @var AcceptHeaderItem[]
*/
private $items = array();
/**
* @var bool
*/
private $sorted = true;
/**
* Constructor.
*
* @param AcceptHeaderItem[] $items
*/
public function __construct(array $items)
{
foreach ($items as $item) {
$this->add($item);
}
}
/**
* Builds an AcceptHeader instance from a string.
*
* @param string $headerValue
*
* @return AcceptHeader
*/
public static function fromString($headerValue)
{
$index = 0;
return new self(array_map(function ($itemValue) use (&$index) {
$item = AcceptHeaderItem::fromString($itemValue);
$item->setIndex($index++);
return $item;
}, preg_split('/\s*(?:,*("[^"]+"),*|,*(\'[^\']+\'),*|,+)\s*/', $headerValue, 0, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE)));
}
/**
* Returns header value's string representation.
*
* @return string
*/
public function __toString()
{
return implode(',', $this->items);
}
/**
* Tests if header has given value.
*
* @param string $value
*
* @return Boolean
*/
public function has($value)
{
return isset($this->items[$value]);
}
/**
* Returns given value's item, if exists.
*
* @param string $value
*
* @return AcceptHeaderItem|null
*/
public function get($value)
{
return isset($this->items[$value]) ? $this->items[$value] : null;
}
/**
* Adds an item.
*
* @param AcceptHeaderItem $item
*
* @return AcceptHeader
*/
public function add(AcceptHeaderItem $item)
{
$this->items[$item->getValue()] = $item;
$this->sorted = false;
return $this;
}
/**
* Returns all items.
*
* @return AcceptHeaderItem[]
*/
public function all()
{
$this->sort();
return $this->items;
}
/**
* Filters items on their value using given regex.
*
* @param string $pattern
*
* @return AcceptHeader
*/
public function filter($pattern)
{
return new self(array_filter($this->items, function (AcceptHeaderItem $item) use ($pattern) {
return preg_match($pattern, $item->getValue());
}));
}
/**
* Returns first item.
*
* @return AcceptHeaderItem|null
*/
public function first()
{
$this->sort();
return !empty($this->items) ? current($this->items) : null;
}
/**
* Sorts items by descending quality
*/
private function sort()
{
if (!$this->sorted) {
uasort($this->items, function ($a, $b) {
$qA = $a->getQuality();
$qB = $b->getQuality();
if ($qA === $qB) {
return $a->getIndex() > $b->getIndex() ? 1 : -1;
}
return $qA > $qB ? -1 : 1;
});
$this->sorted = true;
}
}
}

View File

@ -0,0 +1,226 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation;
/**
* Represents an Accept-* header item.
*
* @author Jean-François Simon <contact@jfsimon.fr>
*/
class AcceptHeaderItem
{
/**
* @var string
*/
private $value;
/**
* @var float
*/
private $quality = 1.0;
/**
* @var int
*/
private $index = 0;
/**
* @var array
*/
private $attributes = array();
/**
* Constructor.
*
* @param string $value
* @param array $attributes
*/
public function __construct($value, array $attributes = array())
{
$this->value = $value;
foreach ($attributes as $name => $value) {
$this->setAttribute($name, $value);
}
}
/**
* Builds an AcceptHeaderInstance instance from a string.
*
* @param string $itemValue
*
* @return AcceptHeaderItem
*/
public static function fromString($itemValue)
{
$bits = preg_split('/\s*(?:;*("[^"]+");*|;*(\'[^\']+\');*|;+)\s*/', $itemValue, 0, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE);
$value = array_shift($bits);
$attributes = array();
$lastNullAttribute = null;
foreach ($bits as $bit) {
if (($start = substr($bit, 0, 1)) === ($end = substr($bit, -1)) && ($start === '"' || $start === '\'')) {
$attributes[$lastNullAttribute] = substr($bit, 1, -1);
} elseif ('=' === $end) {
$lastNullAttribute = $bit = substr($bit, 0, -1);
$attributes[$bit] = null;
} else {
$parts = explode('=', $bit);
$attributes[$parts[0]] = isset($parts[1]) && strlen($parts[1]) > 0 ? $parts[1] : '';
}
}
return new self(($start = substr($value, 0, 1)) === ($end = substr($value, -1)) && ($start === '"' || $start === '\'') ? substr($value, 1, -1) : $value, $attributes);
}
/**
* Returns header value's string representation.
*
* @return string
*/
public function __toString()
{
$string = $this->value.($this->quality < 1 ? ';q='.$this->quality : '');
if (count($this->attributes) > 0) {
$string .= ';'.implode(';', array_map(function($name, $value) {
return sprintf(preg_match('/[,;=]/', $value) ? '%s="%s"' : '%s=%s', $name, $value);
}, array_keys($this->attributes), $this->attributes));
}
return $string;
}
/**
* Set the item value.
*
* @param string $value
*
* @return AcceptHeaderItem
*/
public function setValue($value)
{
$this->value = $value;
return $this;
}
/**
* Returns the item value.
*
* @return string
*/
public function getValue()
{
return $this->value;
}
/**
* Set the item quality.
*
* @param float $quality
*
* @return AcceptHeaderItem
*/
public function setQuality($quality)
{
$this->quality = $quality;
return $this;
}
/**
* Returns the item quality.
*
* @return float
*/
public function getQuality()
{
return $this->quality;
}
/**
* Set the item index.
*
* @param int $index
*
* @return AcceptHeaderItem
*/
public function setIndex($index)
{
$this->index = $index;
return $this;
}
/**
* Returns the item index.
*
* @return int
*/
public function getIndex()
{
return $this->index;
}
/**
* Tests if an attribute exists.
*
* @param string $name
*
* @return Boolean
*/
public function hasAttribute($name)
{
return isset($this->attributes[$name]);
}
/**
* Returns an attribute by its name.
*
* @param string $name
* @param mixed $default
*
* @return mixed
*/
public function getAttribute($name, $default = null)
{
return isset($this->attributes[$name]) ? $this->attributes[$name] : $default;
}
/**
* Returns all attributes.
*
* @return array
*/
public function getAttributes()
{
return $this->attributes;
}
/**
* Set an attribute.
*
* @param string $name
* @param string $value
*
* @return AcceptHeaderItem
*/
public function setAttribute($name, $value)
{
if ('q' === $name) {
$this->quality = (float) $value;
} else {
$this->attributes[$name] = (string) $value;
}
return $this;
}
}

View File

@ -1,6 +1,11 @@
CHANGELOG
=========
2.2.0
-----
* Request::splitHttpAcceptHeader() method is deprecated and will be removed in 2.3
2.1.0
-----

View File

@ -1191,9 +1191,9 @@ class Request
return $this->languages;
}
$languages = $this->splitHttpAcceptHeader($this->headers->get('Accept-Language'));
$languages = AcceptHeader::fromString($this->headers->get('Accept-Language'))->all();
$this->languages = array();
foreach ($languages as $lang => $q) {
foreach (array_keys($languages) as $lang) {
if (strstr($lang, '-')) {
$codes = explode('-', $lang);
if ($codes[0] == 'i') {
@ -1233,7 +1233,7 @@ class Request
return $this->charsets;
}
return $this->charsets = array_keys($this->splitHttpAcceptHeader($this->headers->get('Accept-Charset')));
return $this->charsets = array_keys(AcceptHeader::fromString($this->headers->get('Accept-Charset'))->all());
}
/**
@ -1249,7 +1249,7 @@ class Request
return $this->acceptableContentTypes;
}
return $this->acceptableContentTypes = array_keys($this->splitHttpAcceptHeader($this->headers->get('Accept')));
return $this->acceptableContentTypes = array_keys(AcceptHeader::fromString($this->headers->get('Accept'))->all());
}
/**
@ -1273,40 +1273,21 @@ class Request
* @param string $header Header to split
*
* @return array Array indexed by the values of the Accept-* header in preferred order
*
* @deprecated Deprecated since version 2.2, to be removed in 2.3.
*/
public function splitHttpAcceptHeader($header)
{
if (!$header) {
return array();
}
$values = array();
$groups = array();
foreach (array_filter(explode(',', $header)) as $value) {
// Cut off any q-value that might come after a semi-colon
if (preg_match('/;\s*(q=.*$)/', $value, $match)) {
$q = substr(trim($match[1]), 2);
$value = trim(substr($value, 0, -strlen($match[0])));
} else {
$q = 1;
$headers = array();
foreach (AcceptHeader::fromString($header)->all() as $item) {
$key = $item->getValue();
foreach ($item->getAttributes() as $name => $value) {
$key .= sprintf(';%s=%s', $name, $value);
}
$groups[$q][] = $value;
$headers[$key] = $item->getQuality();
}
krsort($groups);
foreach ($groups as $q => $items) {
$q = (float) $q;
if (0 < $q) {
foreach ($items as $value) {
$values[trim($value)] = $q;
}
}
}
return $values;
return $headers;
}
/*

View File

@ -0,0 +1,112 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Tests;
use Symfony\Component\HttpFoundation\AcceptHeaderItem;
class AcceptHeaderItemTest extends \PHPUnit_Framework_TestCase
{
/**
* @dataProvider provideFromStringData
*/
public function testFromString($string, $value, array $attributes)
{
$item = AcceptHeaderItem::fromString($string);
$this->assertEquals($value, $item->getValue());
$this->assertEquals($attributes, $item->getAttributes());
}
/**
* @dataProvider provideToStringData
*/
public function testToString($value, array $attributes, $string)
{
$item = new AcceptHeaderItem($value, $attributes);
$this->assertEquals($string, (string) $item);
}
public function testValue()
{
$item = new AcceptHeaderItem('value', array());
$this->assertEquals('value', $item->getValue());
$item->setValue('new value');
$this->assertEquals('new value', $item->getValue());
$item->setValue(1);
$this->assertEquals('1', $item->getValue());
}
public function testQuality()
{
$item = new AcceptHeaderItem('value', array());
$this->assertEquals(1.0, $item->getQuality());
$item->setQuality(0.5);
$this->assertEquals(0.5, $item->getQuality());
$item->setAttribute('q', 0.75);
$this->assertEquals(0.75, $item->getQuality());
$this->assertFalse($item->hasAttribute('q'));
}
public function testAttribute()
{
$item = new AcceptHeaderItem('value', array());
$this->assertEquals(array(), $item->getAttributes());
$this->assertFalse($item->hasAttribute('test'));
$this->assertNull($item->getAttribute('test'));
$this->assertEquals('default', $item->getAttribute('test', 'default'));
$item->setAttribute('test', 'value');
$this->assertEquals(array('test' => 'value'), $item->getAttributes());
$this->assertTrue($item->hasAttribute('test'));
$this->assertEquals('value', $item->getAttribute('test'));
$this->assertEquals('value', $item->getAttribute('test', 'default'));
}
public function provideFromStringData()
{
return array(
array(
'text/html',
'text/html', array()
),
array(
'"this;should,not=matter"',
'this;should,not=matter', array()
),
array(
"text/plain; charset=utf-8;param=\"this;should,not=matter\";\tfootnotes=true",
'text/plain', array('charset' => 'utf-8', 'param' => 'this;should,not=matter', 'footnotes' => 'true')
),
array(
'"this;should,not=matter";charset=utf-8',
'this;should,not=matter', array('charset' => 'utf-8')
),
);
}
public function provideToStringData()
{
return array(
array(
'text/html', array(),
'text/html'
),
array(
'text/plain', array('charset' => 'utf-8', 'param' => 'this;should,not=matter', 'footnotes' => 'true'),
'text/plain;charset=utf-8;param="this;should,not=matter";footnotes=true'
),
);
}
}

View File

@ -0,0 +1,96 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\HttpFoundation\Tests;
use Symfony\Component\HttpFoundation\AcceptHeader;
use Symfony\Component\HttpFoundation\AcceptHeaderItem;
class AcceptHeaderTest extends \PHPUnit_Framework_TestCase
{
/**
* @dataProvider provideFromStringData
*/
public function testFromString($string, array $items)
{
$header = AcceptHeader::fromString($string);
$parsed = array_values($header->all());
// reset index since the fixtures don't have them set
foreach ($parsed as $item) {
$item->setIndex(0);
}
$this->assertEquals($items, $parsed);
}
/**
* @dataProvider provideToStringData
*/
public function testToString(array $items, $string)
{
$header = new AcceptHeader($items);
$this->assertEquals($string, (string) $header);
}
/**
* @dataProvider provideFilterData
*/
public function testFilter($string, $filter, array $values)
{
$header = AcceptHeader::fromString($string)->filter($filter);
$this->assertEquals($values, array_keys($header->all()));
}
/**
* @dataProvider provideSortingData
*/
public function testSorting($string, array $values)
{
$header = AcceptHeader::fromString($string);
$this->assertEquals($values, array_keys($header->all()));
}
public function provideFromStringData()
{
return array(
array('', array()),
array('gzip', array(new AcceptHeaderItem('gzip'))),
array('gzip,deflate,sdch', array(new AcceptHeaderItem('gzip'), new AcceptHeaderItem('deflate'), new AcceptHeaderItem('sdch'))),
array("gzip, deflate\t,sdch", array(new AcceptHeaderItem('gzip'), new AcceptHeaderItem('deflate'), new AcceptHeaderItem('sdch'))),
array('"this;should,not=matter"', array(new AcceptHeaderItem('this;should,not=matter'))),
);
}
public function provideFilterData()
{
return array(
array('fr-FR,fr;q=0.8,en-US;q=0.6,en;q=0.4', '/fr.*/', array('fr-FR', 'fr')),
);
}
public function provideSortingData()
{
return array(
'quality has priority' => array('*;q=0.3,ISO-8859-1,utf-8;q=0.7', array('ISO-8859-1', 'utf-8', '*')),
'order matters when q is equal' => array('*;q=0.3,ISO-8859-1;q=0.7,utf-8;q=0.7', array('ISO-8859-1', 'utf-8', '*')),
'order matters when q is equal2' => array('*;q=0.3,utf-8;q=0.7,ISO-8859-1;q=0.7', array('utf-8', 'ISO-8859-1', '*')),
);
}
public function provideToStringData()
{
return array(
array(array(), ''),
array(array(new AcceptHeaderItem('gzip')), 'gzip'),
array(array(new AcceptHeaderItem('gzip'), new AcceptHeaderItem('deflate'), new AcceptHeaderItem('sdch')), 'gzip,deflate,sdch'),
array(array(new AcceptHeaderItem('this;should,not=matter')), 'this;should,not=matter'),
);
}
}

View File

@ -1117,10 +1117,10 @@ class RequestTest extends \PHPUnit_Framework_TestCase
array('text/html;q=0.8', array('text/html' => 0.8)),
array('text/html;foo=bar;q=0.8 ', array('text/html;foo=bar' => 0.8)),
array('text/html;charset=utf-8; q=0.8', array('text/html;charset=utf-8' => 0.8)),
array('text/html,application/xml;q=0.9,*/*;charset=utf-8; q=0.8', array('text/html' => 1, 'application/xml' => 0.9, '*/*;charset=utf-8' => 0.8)),
array('text/html,application/xhtml+xml;q=0.9,*/*;q=0.8; foo=bar', array('text/html' => 1, 'application/xhtml+xml' => 0.9, '*/*' => 0.8)),
array('text/html,application/xhtml+xml;charset=utf-8;q=0.9; foo=bar,*/*', array('text/html' => 1, '*/*' => 1, 'application/xhtml+xml;charset=utf-8' => 0.9)),
array('text/html,application/xhtml+xml', array('application/xhtml+xml' => 1, 'text/html' => 1)),
array('text/html,application/xml;q=0.9,*/*;charset=utf-8; q=0.8', array('text/html' => 1.0, 'application/xml' => 0.9, '*/*;charset=utf-8' => 0.8)),
array('text/html,application/xhtml+xml;q=0.9,*/*;q=0.8; foo=bar', array('text/html' => 1.0, 'application/xhtml+xml' => 0.9, '*/*;foo=bar' => 0.8)),
array('text/html,application/xhtml+xml;charset=utf-8;q=0.9; foo=bar,*/*', array('text/html' => 1.0, '*/*' => 1.0, 'application/xhtml+xml;charset=utf-8;foo=bar' => 0.9)),
array('text/html,application/xhtml+xml', array('text/html' => 1.0, 'application/xhtml+xml' => 1.0)),
);
}