feature #20569 [HttpFoundation] Create cookie from string + synchronize response cookies (ro0NL)
This PR was merged into the 3.3-dev branch.
Discussion
----------
[HttpFoundation] Create cookie from string + synchronize response cookies
| Q | A
| ------------- | ---
| Branch? | "master"
| Bug fix? | yes
| New feature? | yes
| BC breaks? | no-ish
| Deprecations? | no
| Tests pass? | yes
| Fixed tickets | comma-separated list of tickets fixed by the PR, if any
| License | MIT
| Doc PR | reference to the documentation PR, if any
Fixes the conversion/synchroniczation of cookies between the object API and string API, ie;
```php
$headers->setCookie(new Cookie('foo', 'bar'));
$headers->has('set-cookie'); // true
$headers->set('set-cookie', 'foo2=bar2', false);
count($headers->getCookies()); // 2
```
With an additional feature `Cookie::fromString($cookie)`
The BC break happens in terms you cannot set a custom `Set-Cookie` header anymore, all cookies are set via `set(raw)cookie` by PHP.
We could go one step further by tracking misc. attributes (`Cookie::getAttributes()`) and populate accordingly from `Cookie::fromString`. If the cookie has attributes we should bypass `setcookie` and write a header instead, but im not sure it's worth it.
Commits
-------
7314456
[HttpFoundation] Create cookie from string + synchronize response cookies
This commit is contained in:
commit
0ab6628431
@ -31,14 +31,63 @@ class Cookie
|
||||
const SAMESITE_LAX = 'lax';
|
||||
const SAMESITE_STRICT = 'strict';
|
||||
|
||||
/**
|
||||
* Creates cookie from raw header string.
|
||||
*
|
||||
* @param string $cookie
|
||||
* @param bool $decode
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public static function fromString($cookie, $decode = false)
|
||||
{
|
||||
$data = array(
|
||||
'expires' => 0,
|
||||
'path' => '/',
|
||||
'domain' => null,
|
||||
'secure' => false,
|
||||
'httponly' => true,
|
||||
'raw' => !$decode,
|
||||
'samesite' => null,
|
||||
);
|
||||
foreach (explode(';', $cookie) as $part) {
|
||||
if (false === strpos($part, '=')) {
|
||||
$key = trim($part);
|
||||
$value = true;
|
||||
} else {
|
||||
list($key, $value) = explode('=', trim($part), 2);
|
||||
$key = trim($key);
|
||||
$value = trim($value);
|
||||
}
|
||||
if (!isset($data['name'])) {
|
||||
$data['name'] = $decode ? urldecode($key) : $key;
|
||||
$data['value'] = true === $value ? null : ($decode ? urldecode($value) : $value);
|
||||
continue;
|
||||
}
|
||||
switch ($key = strtolower($key)) {
|
||||
case 'name':
|
||||
case 'value':
|
||||
break;
|
||||
case 'max-age':
|
||||
$data['expires'] = time() + (int) $value;
|
||||
break;
|
||||
default:
|
||||
$data[$key] = $value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new static($data['name'], $data['value'], $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param string $name The name of the cookie
|
||||
* @param string $value The value of the cookie
|
||||
* @param string|null $value The value of the cookie
|
||||
* @param int|string|\DateTimeInterface $expire The time the cookie expires
|
||||
* @param string $path The path on the server in which the cookie will be available on
|
||||
* @param string $domain The domain that the cookie is available to
|
||||
* @param string|null $domain The domain that the cookie is available to
|
||||
* @param bool $secure Whether the cookie should only be transmitted over a secure HTTPS connection from the client
|
||||
* @param bool $httpOnly Whether the cookie will be made accessible only through the HTTP protocol
|
||||
* @param bool $raw Whether the cookie value should be sent with no url encoding
|
||||
|
@ -40,14 +40,14 @@ class HeaderBag implements \IteratorAggregate, \Countable
|
||||
*/
|
||||
public function __toString()
|
||||
{
|
||||
if (!$this->headers) {
|
||||
if (!$headers = $this->all()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$max = max(array_map('strlen', array_keys($this->headers))) + 1;
|
||||
ksort($headers);
|
||||
$max = max(array_map('strlen', array_keys($headers))) + 1;
|
||||
$content = '';
|
||||
ksort($this->headers);
|
||||
foreach ($this->headers as $name => $values) {
|
||||
foreach ($headers as $name => $values) {
|
||||
$name = implode('-', array_map('ucfirst', explode('-', $name)));
|
||||
foreach ($values as $value) {
|
||||
$content .= sprintf("%-{$max}s %s\r\n", $name.':', $value);
|
||||
@ -74,7 +74,7 @@ class HeaderBag implements \IteratorAggregate, \Countable
|
||||
*/
|
||||
public function keys()
|
||||
{
|
||||
return array_keys($this->headers);
|
||||
return array_keys($this->all());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -112,8 +112,9 @@ class HeaderBag implements \IteratorAggregate, \Countable
|
||||
public function get($key, $default = null, $first = true)
|
||||
{
|
||||
$key = str_replace('_', '-', strtolower($key));
|
||||
$headers = $this->all();
|
||||
|
||||
if (!array_key_exists($key, $this->headers)) {
|
||||
if (!array_key_exists($key, $headers)) {
|
||||
if (null === $default) {
|
||||
return $first ? null : array();
|
||||
}
|
||||
@ -122,10 +123,10 @@ class HeaderBag implements \IteratorAggregate, \Countable
|
||||
}
|
||||
|
||||
if ($first) {
|
||||
return count($this->headers[$key]) ? $this->headers[$key][0] : $default;
|
||||
return count($headers[$key]) ? $headers[$key][0] : $default;
|
||||
}
|
||||
|
||||
return $this->headers[$key];
|
||||
return $headers[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -161,7 +162,7 @@ class HeaderBag implements \IteratorAggregate, \Countable
|
||||
*/
|
||||
public function has($key)
|
||||
{
|
||||
return array_key_exists(str_replace('_', '-', strtolower($key)), $this->headers);
|
||||
return array_key_exists(str_replace('_', '-', strtolower($key)), $this->all());
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -375,7 +375,7 @@ class Response
|
||||
}
|
||||
|
||||
// headers
|
||||
foreach ($this->headers->allPreserveCase() as $name => $values) {
|
||||
foreach ($this->headers->allPreserveCaseWithoutCookies() as $name => $values) {
|
||||
foreach ($values as $value) {
|
||||
header($name.': '.$value, false, $this->statusCode);
|
||||
}
|
||||
|
@ -53,21 +53,6 @@ class ResponseHeaderBag extends HeaderBag
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function __toString()
|
||||
{
|
||||
$cookies = '';
|
||||
foreach ($this->getCookies() as $cookie) {
|
||||
$cookies .= 'Set-Cookie: '.$cookie."\r\n";
|
||||
}
|
||||
|
||||
ksort($this->headerNames);
|
||||
|
||||
return parent::__toString().$cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the headers, with original capitalizations.
|
||||
*
|
||||
@ -75,7 +60,22 @@ class ResponseHeaderBag extends HeaderBag
|
||||
*/
|
||||
public function allPreserveCase()
|
||||
{
|
||||
return array_combine($this->headerNames, $this->headers);
|
||||
$headers = array();
|
||||
foreach ($this->all() as $name => $value) {
|
||||
$headers[isset($this->headerNames[$name]) ? $this->headerNames[$name] : $name] = $value;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
public function allPreserveCaseWithoutCookies()
|
||||
{
|
||||
$headers = $this->allPreserveCase();
|
||||
if (isset($this->headerNames['set-cookie'])) {
|
||||
unset($headers[$this->headerNames['set-cookie']]);
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -92,16 +92,42 @@ class ResponseHeaderBag extends HeaderBag
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function all()
|
||||
{
|
||||
$headers = parent::all();
|
||||
foreach ($this->getCookies() as $cookie) {
|
||||
$headers['set-cookie'][] = (string) $cookie;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function set($key, $values, $replace = true)
|
||||
{
|
||||
parent::set($key, $values, $replace);
|
||||
|
||||
$uniqueKey = str_replace('_', '-', strtolower($key));
|
||||
|
||||
if ('set-cookie' === $uniqueKey) {
|
||||
if ($replace) {
|
||||
$this->cookies = array();
|
||||
}
|
||||
foreach ((array) $values as $cookie) {
|
||||
$this->setCookie(Cookie::fromString($cookie));
|
||||
}
|
||||
$this->headerNames[$uniqueKey] = $key;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->headerNames[$uniqueKey] = $key;
|
||||
|
||||
parent::set($key, $values, $replace);
|
||||
|
||||
// ensure the cache-control header has sensible defaults
|
||||
if (in_array($uniqueKey, array('cache-control', 'etag', 'last-modified', 'expires'))) {
|
||||
$computed = $this->computeCacheControlValue();
|
||||
@ -116,11 +142,17 @@ class ResponseHeaderBag extends HeaderBag
|
||||
*/
|
||||
public function remove($key)
|
||||
{
|
||||
parent::remove($key);
|
||||
|
||||
$uniqueKey = str_replace('_', '-', strtolower($key));
|
||||
unset($this->headerNames[$uniqueKey]);
|
||||
|
||||
if ('set-cookie' === $uniqueKey) {
|
||||
$this->cookies = array();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
parent::remove($key);
|
||||
|
||||
if ('cache-control' === $uniqueKey) {
|
||||
$this->computedCacheControl = array();
|
||||
}
|
||||
@ -150,6 +182,7 @@ class ResponseHeaderBag extends HeaderBag
|
||||
public function setCookie(Cookie $cookie)
|
||||
{
|
||||
$this->cookies[$cookie->getDomain()][$cookie->getPath()][$cookie->getName()] = $cookie;
|
||||
$this->headerNames['set-cookie'] = 'Set-Cookie';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -174,6 +207,10 @@ class ResponseHeaderBag extends HeaderBag
|
||||
unset($this->cookies[$domain]);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($this->cookies)) {
|
||||
unset($this->headerNames['set-cookie']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -178,4 +178,13 @@ class CookieTest extends \PHPUnit_Framework_TestCase
|
||||
$cookie = new Cookie('foo', 'bar', $expire = time() - 100);
|
||||
$this->assertEquals($expire - time(), $cookie->getMaxAge());
|
||||
}
|
||||
|
||||
public function testFromString()
|
||||
{
|
||||
$cookie = Cookie::fromString('foo=bar; expires=Fri, 20-May-2011 15:25:52 GMT; path=/; domain=.myfoodomain.com; secure; httponly');
|
||||
$this->assertEquals(new Cookie('foo', 'bar', strtotime('Fri, 20-May-2011 15:25:52 GMT'), '/', '.myfoodomain.com', true, true, true), $cookie);
|
||||
|
||||
$cookie = Cookie::fromString('foo=bar', true);
|
||||
$this->assertEquals(new Cookie('foo', 'bar'), $cookie);
|
||||
}
|
||||
}
|
||||
|
@ -124,11 +124,11 @@ class ResponseHeaderBagTest extends \PHPUnit_Framework_TestCase
|
||||
$bag = new ResponseHeaderBag(array());
|
||||
$bag->setCookie(new Cookie('foo', 'bar'));
|
||||
|
||||
$this->assertContains('Set-Cookie: foo=bar; path=/; httponly', explode("\r\n", $bag->__toString()));
|
||||
$this->assertSetCookieHeader('foo=bar; path=/; httponly', $bag);
|
||||
|
||||
$bag->clearCookie('foo');
|
||||
|
||||
$this->assertRegExp('#^Set-Cookie: foo=deleted; expires='.gmdate('D, d-M-Y H:i:s T', time() - 31536001).'; max-age=-31536001; path=/; httponly#m', $bag->__toString());
|
||||
$this->assertSetCookieHeader('foo=deleted; expires='.gmdate('D, d-M-Y H:i:s T', time() - 31536001).'; max-age=-31536001; path=/; httponly', $bag);
|
||||
}
|
||||
|
||||
public function testClearCookieSecureNotHttpOnly()
|
||||
@ -137,7 +137,7 @@ class ResponseHeaderBagTest extends \PHPUnit_Framework_TestCase
|
||||
|
||||
$bag->clearCookie('foo', '/', null, true, false);
|
||||
|
||||
$this->assertRegExp('#^Set-Cookie: foo=deleted; expires='.gmdate('D, d-M-Y H:i:s T', time() - 31536001).'; max-age=-31536001; path=/; secure#m', $bag->__toString());
|
||||
$this->assertSetCookieHeader('foo=deleted; expires='.gmdate('D, d-M-Y H:i:s T', time() - 31536001).'; max-age=-31536001; path=/; secure', $bag);
|
||||
}
|
||||
|
||||
public function testReplace()
|
||||
@ -172,14 +172,21 @@ class ResponseHeaderBagTest extends \PHPUnit_Framework_TestCase
|
||||
$bag->setCookie(new Cookie('foo', 'bar'));
|
||||
|
||||
$this->assertCount(4, $bag->getCookies());
|
||||
$this->assertEquals('foo=bar; path=/path/foo; domain=foo.bar; httponly', $bag->get('set-cookie'));
|
||||
$this->assertEquals(array(
|
||||
'foo=bar; path=/path/foo; domain=foo.bar; httponly',
|
||||
'foo=bar; path=/path/bar; domain=foo.bar; httponly',
|
||||
'foo=bar; path=/path/bar; domain=bar.foo; httponly',
|
||||
'foo=bar; path=/; httponly',
|
||||
), $bag->get('set-cookie', null, false));
|
||||
|
||||
$headers = explode("\r\n", $bag->__toString());
|
||||
$this->assertContains('Set-Cookie: foo=bar; path=/path/foo; domain=foo.bar; httponly', $headers);
|
||||
$this->assertContains('Set-Cookie: foo=bar; path=/path/foo; domain=foo.bar; httponly', $headers);
|
||||
$this->assertContains('Set-Cookie: foo=bar; path=/path/bar; domain=bar.foo; httponly', $headers);
|
||||
$this->assertContains('Set-Cookie: foo=bar; path=/; httponly', $headers);
|
||||
$this->assertSetCookieHeader('foo=bar; path=/path/foo; domain=foo.bar; httponly', $bag);
|
||||
$this->assertSetCookieHeader('foo=bar; path=/path/bar; domain=foo.bar; httponly', $bag);
|
||||
$this->assertSetCookieHeader('foo=bar; path=/path/bar; domain=bar.foo; httponly', $bag);
|
||||
$this->assertSetCookieHeader('foo=bar; path=/; httponly', $bag);
|
||||
|
||||
$cookies = $bag->getCookies(ResponseHeaderBag::COOKIES_ARRAY);
|
||||
|
||||
$this->assertTrue(isset($cookies['foo.bar']['/path/foo']['foo']));
|
||||
$this->assertTrue(isset($cookies['foo.bar']['/path/bar']['foo']));
|
||||
$this->assertTrue(isset($cookies['bar.foo']['/path/bar']['foo']));
|
||||
@ -189,18 +196,23 @@ class ResponseHeaderBagTest extends \PHPUnit_Framework_TestCase
|
||||
public function testRemoveCookie()
|
||||
{
|
||||
$bag = new ResponseHeaderBag();
|
||||
$this->assertFalse($bag->has('set-cookie'));
|
||||
|
||||
$bag->setCookie(new Cookie('foo', 'bar', 0, '/path/foo', 'foo.bar'));
|
||||
$bag->setCookie(new Cookie('bar', 'foo', 0, '/path/bar', 'foo.bar'));
|
||||
$this->assertTrue($bag->has('set-cookie'));
|
||||
|
||||
$cookies = $bag->getCookies(ResponseHeaderBag::COOKIES_ARRAY);
|
||||
$this->assertTrue(isset($cookies['foo.bar']['/path/foo']));
|
||||
|
||||
$bag->removeCookie('foo', '/path/foo', 'foo.bar');
|
||||
$this->assertTrue($bag->has('set-cookie'));
|
||||
|
||||
$cookies = $bag->getCookies(ResponseHeaderBag::COOKIES_ARRAY);
|
||||
$this->assertFalse(isset($cookies['foo.bar']['/path/foo']));
|
||||
|
||||
$bag->removeCookie('bar', '/path/bar', 'foo.bar');
|
||||
$this->assertFalse($bag->has('set-cookie'));
|
||||
|
||||
$cookies = $bag->getCookies(ResponseHeaderBag::COOKIES_ARRAY);
|
||||
$this->assertFalse(isset($cookies['foo.bar']));
|
||||
@ -224,6 +236,22 @@ class ResponseHeaderBagTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertFalse(isset($cookies['']['/']['bar']));
|
||||
}
|
||||
|
||||
public function testSetCookieHeader()
|
||||
{
|
||||
$bag = new ResponseHeaderBag();
|
||||
$bag->set('set-cookie', 'foo=bar');
|
||||
$this->assertEquals(array(new Cookie('foo', 'bar', 0, '/', null, false, true, true)), $bag->getCookies());
|
||||
|
||||
$bag->set('set-cookie', 'foo2=bar2', false);
|
||||
$this->assertEquals(array(
|
||||
new Cookie('foo', 'bar', 0, '/', null, false, true, true),
|
||||
new Cookie('foo2', 'bar2', 0, '/', null, false, true, true),
|
||||
), $bag->getCookies());
|
||||
|
||||
$bag->remove('set-cookie');
|
||||
$this->assertEquals(array(), $bag->getCookies());
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \InvalidArgumentException
|
||||
*/
|
||||
@ -231,7 +259,7 @@ class ResponseHeaderBagTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
$bag = new ResponseHeaderBag();
|
||||
|
||||
$cookies = $bag->getCookies('invalid_argument');
|
||||
$bag->getCookies('invalid_argument');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -302,4 +330,9 @@ class ResponseHeaderBagTest extends \PHPUnit_Framework_TestCase
|
||||
array('attachment', 'föö.html'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function assertSetCookieHeader($expected, ResponseHeaderBag $actual)
|
||||
{
|
||||
$this->assertRegExp('#^Set-Cookie:\s+'.preg_quote($expected, '#').'$#m', str_replace("\r\n", "\n", (string) $actual));
|
||||
}
|
||||
}
|
||||
|
@ -207,20 +207,11 @@ EOF;
|
||||
*/
|
||||
protected function filterResponse($response)
|
||||
{
|
||||
$headers = $response->headers->all();
|
||||
if ($response->headers->getCookies()) {
|
||||
$cookies = array();
|
||||
foreach ($response->headers->getCookies() as $cookie) {
|
||||
$cookies[] = new DomCookie($cookie->getName(), $cookie->getValue(), $cookie->getExpiresTime(), $cookie->getPath(), $cookie->getDomain(), $cookie->isSecure(), $cookie->isHttpOnly());
|
||||
}
|
||||
$headers['Set-Cookie'] = $cookies;
|
||||
}
|
||||
|
||||
// this is needed to support StreamedResponse
|
||||
ob_start();
|
||||
$response->sendContent();
|
||||
$content = ob_get_clean();
|
||||
|
||||
return new DomResponse($content, $response->getStatusCode(), $headers);
|
||||
return new DomResponse($content, $response->getStatusCode(), $response->headers->all());
|
||||
}
|
||||
}
|
||||
|
@ -57,8 +57,8 @@ class ClientTest extends \PHPUnit_Framework_TestCase
|
||||
$m->setAccessible(true);
|
||||
|
||||
$expected = array(
|
||||
'foo=bar; expires=Sun, 15 Feb 2009 20:00:00 GMT; domain=http://example.com; path=/foo; secure; httponly',
|
||||
'foo1=bar1; expires=Sun, 15 Feb 2009 20:00:00 GMT; domain=http://example.com; path=/foo; secure; httponly',
|
||||
'foo=bar; expires=Sun, 15-Feb-2009 20:00:00 GMT; max-age='.(strtotime('Sun, 15-Feb-2009 20:00:00 GMT') - time()).'; path=/foo; domain=http://example.com; secure; httponly',
|
||||
'foo1=bar1; expires=Sun, 15-Feb-2009 20:00:00 GMT; max-age='.(strtotime('Sun, 15-Feb-2009 20:00:00 GMT') - time()).'; path=/foo; domain=http://example.com; secure; httponly',
|
||||
);
|
||||
|
||||
$response = new Response();
|
||||
|
Reference in New Issue
Block a user