From 336ba1965ea22f701f568c93df6067abef5939f7 Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Fri, 4 Nov 2011 14:34:49 -0430 Subject: [PATCH 01/22] Adding protocol() method to CakeResponse to be able to change it on the fly --- lib/Cake/Network/CakeResponse.php | 13 +++++++++++++ lib/Cake/Test/Case/Network/CakeResponseTest.php | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index be9ce5ae8..74113c63a 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -683,6 +683,19 @@ class CakeResponse { $this->header('Content-Disposition', 'attachment; filename="' . $filename . '"'); } +/** + * Sets the protocol to be used when sending the response. Defaults to HTTP/1.1 + * If called with no arguments, it will return the current configured protocol + * + * @return string protocol to be used for sending response + */ + public function protocol($protocol = null) { + if ($protocol !== null) { + $this->_protocol = $protocol; + } + return $this->_protocol; + } + /** * String conversion. Fetches the response body as a string. * Does *not* send headers. diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index 33123dd62..fc38218cf 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -500,4 +500,18 @@ class CakeResponseTest extends CakeTestCase { $response->send(); ob_end_clean(); } + +/** + * Tests getting/setting the protocol + * + * @return void + */ + public function testProtocol() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->protocol('HTTP/1.0'); + $this->assertEquals('HTTP/1.0', $response->protocol()); + $response->expects($this->at(0)) + ->method('_sendHeader')->with('HTTP/1.0 200 OK'); + $response->send(); + } } From 5b42cb8130bd22eef6d0eb0c48ce2c6130dc853e Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Fri, 4 Nov 2011 15:04:32 -0430 Subject: [PATCH 02/22] Adding length() method to CakeResponse as a shortcut for Content-Length. If you wish to force not Content-Length use length(false) --- lib/Cake/Network/CakeResponse.php | 26 ++++++++++++++++--- .../Test/Case/Network/CakeResponseTest.php | 21 +++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index 74113c63a..549b71aa3 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -363,13 +363,17 @@ class CakeResponse { * @return void */ protected function _setContentLength() { - $shouldSetLength = empty($this->_headers['Content-Length']) && !in_array($this->_status, range(301, 307)); + $shouldSetLength = !isset($this->_headers['Content-Length']) && !in_array($this->_status, range(301, 307)); + if (isset($this->_headers['Content-Length']) && $this->_headers['Content-Length'] === false) { + unset($this->_headers['Content-Length']); + return; + } if ($shouldSetLength && !$this->outputCompressed()) { $offset = ob_get_level() ? ob_get_length() : 0; if (ini_get('mbstring.func_overload') & 2 && function_exists('mb_strlen')) { - $this->_headers['Content-Length'] = $offset + mb_strlen($this->_body, '8bit'); + $this->length($offset + mb_strlen($this->_body, '8bit')); } else { - $this->_headers['Content-Length'] = $offset + strlen($this->_body); + $this->length($this->_headers['Content-Length'] = $offset + strlen($this->_body)); } } } @@ -696,6 +700,22 @@ class CakeResponse { return $this->_protocol; } +/** + * Sets the Content-Length header for the response + * If called with no arguments returns the last Content-Length set + * + * @return int + */ + public function length($bytes = null) { + if ($bytes !== null ) { + $this->_headers['Content-Length'] = $bytes; + } + if (isset($this->_headers['Content-Length'])) { + return $this->_headers['Content-Length']; + } + return null; + } + /** * String conversion. Fetches the response body as a string. * Does *not* send headers. diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index fc38218cf..cb66280f6 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -514,4 +514,25 @@ class CakeResponseTest extends CakeTestCase { ->method('_sendHeader')->with('HTTP/1.0 200 OK'); $response->send(); } + +/** + * Tests getting/setting the Content-Length + * + * @return void + */ + public function testLength() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->length(100); + $this->assertEquals(100, $response->length()); + $response->expects($this->at(2)) + ->method('_sendHeader')->with('Content-Length', 100); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->length(false); + $this->assertFalse($response->length()); + $response->expects($this->exactly(2)) + ->method('_sendHeader'); + $response->send(); + } } From 4d19d536f6998a2d1dc32564751be674298702f5 Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Fri, 4 Nov 2011 15:36:47 -0430 Subject: [PATCH 03/22] Saving a few bytes by unsetting the content if the response status code is 204 (No Content) or 304 (Not Modified) --- lib/Cake/Network/CakeResponse.php | 12 ++++++++ .../Test/Case/Network/CakeResponseTest.php | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index 549b71aa3..38e863cdc 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -349,6 +349,7 @@ class CakeResponse { $codeMessage = $this->_statusCodes[$this->_status]; $this->_sendHeader("{$this->_protocol} {$this->_status} {$codeMessage}"); $this->_sendHeader('Content-Type', "{$this->_contentType}; charset={$this->_charset}"); + $this->_setContent(); $this->_setContentLength(); foreach ($this->_headers as $header => $value) { $this->_sendHeader($header, $value); @@ -356,6 +357,17 @@ class CakeResponse { $this->_sendContent($this->_body); } +/** + * Sets the response body to an empty text if the status code is 204 or 304 + * + * @return void + */ + protected function _setContent() { + if (in_array($this->_status, array(304, 204))) { + $this->body(''); + } + } + /** * Calculates the correct Content-Length and sets it as a header in the response * Will not set the value if already set or if the output is compressed. diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index cb66280f6..2d886769f 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -535,4 +535,32 @@ class CakeResponseTest extends CakeTestCase { ->method('_sendHeader'); $response->send(); } + +/** + * Tests that the response body is unset if the status code is 304 or 204 + * + * @return void + */ + public function testUnmodifiedContent() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->body('This is a body'); + $response->statusCode(204); + $response->expects($this->once()) + ->method('_sendContent')->with(''); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->body('This is a body'); + $response->statusCode(304); + $response->expects($this->once()) + ->method('_sendContent')->with(''); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->body('This is a body'); + $response->statusCode(200); + $response->expects($this->once()) + ->method('_sendContent')->with('This is a body'); + $response->send(); + } } From f32c703e7e1af296ebd6901ca8b711f3535498c1 Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Fri, 4 Nov 2011 16:56:08 -0430 Subject: [PATCH 04/22] Not appending the charset information for content types that are not text/* in CakeResponse --- lib/Cake/Network/CakeResponse.php | 19 +++++++++- .../Test/Case/Network/CakeResponseTest.php | 37 +++++++++++-------- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index 38e863cdc..0cffcf84a 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -348,15 +348,32 @@ class CakeResponse { $codeMessage = $this->_statusCodes[$this->_status]; $this->_sendHeader("{$this->_protocol} {$this->_status} {$codeMessage}"); - $this->_sendHeader('Content-Type', "{$this->_contentType}; charset={$this->_charset}"); $this->_setContent(); $this->_setContentLength(); + $this->_setContentType(); foreach ($this->_headers as $header => $value) { $this->_sendHeader($header, $value); } $this->_sendContent($this->_body); } +/** + * Formats the Content-Type header based on the configured contentType and charset + * the charset will only be set in the header if the response is of type text/* + * + * @return void + */ + protected function _setContentType() { + if (in_array($this->_status, array(304, 204))) { + return; + } + if (strpos($this->_contentType, 'text/') === 0) { + $this->header('Content-Type', "{$this->_contentType}; charset={$this->_charset}"); + } else { + $this->header('Content-Type', "{$this->_contentType}"); + } + } + /** * Sets the response body to an empty text if the status code is 204 or 304 * diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index 2d886769f..6ad7c50d9 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -182,11 +182,13 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->at(0)) ->method('_sendHeader')->with('HTTP/1.1 200 OK'); $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); - $response->expects($this->at(2)) ->method('_sendHeader')->with('Content-Language', 'es'); - $response->expects($this->at(3)) + $response->expects($this->at(2)) ->method('_sendHeader')->with('WWW-Authenticate', 'Negotiate'); + $response->expects($this->at(3)) + ->method('_sendHeader')->with('Content-Length', 17); + $response->expects($this->at(4)) + ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); $response->send(); } @@ -202,7 +204,9 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->at(0)) ->method('_sendHeader')->with('HTTP/1.1 200 OK'); $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Type', 'audio/mpeg; charset=UTF-8'); + ->method('_sendHeader')->with('Content-Length', 17); + $response->expects($this->at(2)) + ->method('_sendHeader')->with('Content-Type', 'audio/mpeg'); $response->send(); } @@ -218,7 +222,9 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->at(0)) ->method('_sendHeader')->with('HTTP/1.1 200 OK'); $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Type', 'audio/mpeg; charset=UTF-8'); + ->method('_sendHeader')->with('Content-Length', 17); + $response->expects($this->at(2)) + ->method('_sendHeader')->with('Content-Type', 'audio/mpeg'); $response->send(); } @@ -232,9 +238,9 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->at(0)) ->method('_sendHeader')->with('HTTP/1.1 302 Found'); $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); - $response->expects($this->at(2)) ->method('_sendHeader')->with('Location', 'http://www.example.com'); + $response->expects($this->at(2)) + ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); $response->send(); } @@ -439,9 +445,9 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->once())->method('_sendContent')->with('the response body'); $response->expects($this->at(0)) ->method('_sendHeader')->with('HTTP/1.1 200 OK'); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); $response->expects($this->at(2)) + ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); + $response->expects($this->at(1)) ->method('_sendHeader')->with('Content-Length', strlen('the response body')); $response->send(); @@ -451,9 +457,9 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->once())->method('_sendContent')->with($body); $response->expects($this->at(0)) ->method('_sendHeader')->with('HTTP/1.1 200 OK'); - $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); $response->expects($this->at(2)) + ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); + $response->expects($this->at(1)) ->method('_sendHeader')->with('Content-Length', 116); $response->send(); @@ -471,7 +477,7 @@ class CakeResponseTest extends CakeTestCase { $response->header('Content-Length', 1); $response->expects($this->never())->method('outputCompressed'); $response->expects($this->once())->method('_sendContent')->with($body); - $response->expects($this->at(2)) + $response->expects($this->at(1)) ->method('_sendHeader')->with('Content-Length', 1); $response->send(); @@ -494,9 +500,9 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->at(0)) ->method('_sendHeader')->with('HTTP/1.1 200 OK'); $response->expects($this->at(1)) - ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); - $response->expects($this->at(2)) ->method('_sendHeader')->with('Content-Length', strlen($goofyOutput) + 116); + $response->expects($this->at(2)) + ->method('_sendHeader')->with('Content-Type', 'text/html; charset=UTF-8'); $response->send(); ob_end_clean(); } @@ -524,7 +530,7 @@ class CakeResponseTest extends CakeTestCase { $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); $response->length(100); $this->assertEquals(100, $response->length()); - $response->expects($this->at(2)) + $response->expects($this->at(1)) ->method('_sendHeader')->with('Content-Length', 100); $response->send(); @@ -548,6 +554,7 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->once()) ->method('_sendContent')->with(''); $response->send(); + $this->assertFalse(array_key_exists('Content-Type', $response->header())); $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); $response->body('This is a body'); From bab7e772d2e04313409375ae1e0b9ea5ab3790e2 Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Fri, 4 Nov 2011 21:27:49 -0430 Subject: [PATCH 05/22] Adding expires() to CakeResponse to help adding expiration dates to the http response cache directives --- lib/Cake/Network/CakeResponse.php | 46 ++++++++++++++++++- .../Test/Case/Network/CakeResponseTest.php | 39 ++++++++++++++-- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index 0cffcf84a..29ebcd93b 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -677,10 +677,54 @@ class CakeResponse { $this->header(array( 'Date' => gmdate("D, j M Y G:i:s ", time()) . 'GMT', 'Last-Modified' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', - 'Expires' => gmdate("D, j M Y H:i:s", $time) . " GMT", 'Cache-Control' => 'public, max-age=' . ($time - time()), 'Pragma' => 'cache' )); + $this->expires($time); + } + + +/** + * Sets the Expires header for the response by taking an expiration time + * If called with no parameters it will return the current Expires value + * + * ## Examples: + * + * `$response->expires('now')` Will Expire the response cache now + * `$response->expires(new DateTime('+1 day'))` Will set the expiration in next 24 hours + * `$response->expires)` Will return the current expiration header value + * + * @param string|DateTime $time + * @return string + */ + public function expires($time = null) { + if ($time !== null) { + $date = $this->_getUTCDate($time); + $this->_headers['Expires'] = $date->format('D, j M Y H:i:s') . ' GMT'; + } + if (isset($this->_headers['Expires'])) { + return $this->_headers['Expires']; + } + return null; + } + +/** + * Returns a DateTime object initialized at the $time param and using UTC + * as timezone + * + * @param string|int|DateTime $time + * @return DateTime + */ + protected function _getUTCDate($time = null) { + if ($time instanceof DateTime) { + $result = clone $time; + } else if (is_integer($time)) { + $result = new DateTime(date('Y-m-d H:i:s', $time)); + } else { + $result = new DateTime($time); + } + $result->setTimeZone(new DateTimeZone('UTC')); + return $result; } /** diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index 6ad7c50d9..445598f06 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -267,12 +267,13 @@ class CakeResponseTest extends CakeTestCase { public function testCache() { $response = new CakeResponse(); $since = time(); - $time = '+1 day'; + $time = new DateTime('+1 day', new DateTimeZone('UTC')); + $response->expires('+1 day'); $expected = array( 'Date' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', 'Last-Modified' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', - 'Expires' => gmdate("D, j M Y H:i:s", strtotime($time)) . " GMT", - 'Cache-Control' => 'public, max-age=' . (strtotime($time) - time()), + 'Expires' => $time->format('D, j M Y H:i:s') . ' GMT', + 'Cache-Control' => 'public, max-age=' . ($time->format('U') - time()), 'Pragma' => 'cache' ); $response->cache($since); @@ -570,4 +571,36 @@ class CakeResponseTest extends CakeTestCase { ->method('_sendContent')->with('This is a body'); $response->send(); } + +/** + * Tests setting the expiration date + * + * @return void + */ + public function testExpires() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $now = new DateTime('now', new DateTimeZone('America/Los_Angeles')); + $response->expires($now); + $now->setTimeZone(new DateTimeZone('UTC')); + $this->assertEquals($now->format('D, j M Y H:i:s') . ' GMT', $response->expires()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Expires', $now->format('D, j M Y H:i:s') . ' GMT'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $now = time(); + $response->expires($now); + $this->assertEquals(gmdate('D, j M Y H:i:s', $now) . ' GMT', $response->expires()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Expires', gmdate('D, j M Y H:i:s', $now) . ' GMT'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $time = new DateTime('+1 day', new DateTimeZone('UTC')); + $response->expires('+1 day'); + $this->assertEquals($time->format('D, j M Y H:i:s') . ' GMT', $response->expires()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Expires', $time->format('D, j M Y H:i:s') . ' GMT'); + $response->send(); + } } From 130b827e6f7a9d131b2674431e7d9c9dfde6e2f9 Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Fri, 4 Nov 2011 21:47:55 -0430 Subject: [PATCH 06/22] Implementing the modified() method in CakeResponse to have an easier way of setting the modification time --- lib/Cake/Network/CakeResponse.php | 28 +++++++++++++- .../Test/Case/Network/CakeResponseTest.php | 38 +++++++++++++++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index 29ebcd93b..e2545e161 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -676,10 +676,10 @@ class CakeResponse { } $this->header(array( 'Date' => gmdate("D, j M Y G:i:s ", time()) . 'GMT', - 'Last-Modified' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', 'Cache-Control' => 'public, max-age=' . ($time - time()), 'Pragma' => 'cache' )); + $this->modified($since); $this->expires($time); } @@ -692,7 +692,7 @@ class CakeResponse { * * `$response->expires('now')` Will Expire the response cache now * `$response->expires(new DateTime('+1 day'))` Will set the expiration in next 24 hours - * `$response->expires)` Will return the current expiration header value + * `$response->expires()` Will return the current expiration header value * * @param string|DateTime $time * @return string @@ -708,6 +708,30 @@ class CakeResponse { return null; } +/** + * Sets the Last-Modified header for the response by taking an modification time + * If called with no parameters it will return the current Last-Modified value + * + * ## Examples: + * + * `$response->modified('now')` Will set the Last-Modified to the current time + * `$response->modified(new DateTime('+1 day'))` Will set the modification date in the past 24 hours + * `$response->modified()` Will return the current Last-Modified header value + * + * @param string|DateTime $time + * @return string + */ + public function modified($time = null) { + if ($time !== null) { + $date = $this->_getUTCDate($time); + $this->_headers['Last-Modified'] = $date->format('D, j M Y H:i:s') . ' GMT'; + } + if (isset($this->_headers['Last-Modified'])) { + return $this->_headers['Last-Modified']; + } + return null; + } + /** * Returns a DateTime object initialized at the $time param and using UTC * as timezone diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index 445598f06..e9522db67 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -271,7 +271,7 @@ class CakeResponseTest extends CakeTestCase { $response->expires('+1 day'); $expected = array( 'Date' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', - 'Last-Modified' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', + 'Last-Modified' => gmdate("D, j M Y H:i:s ", $since) . 'GMT', 'Expires' => $time->format('D, j M Y H:i:s') . ' GMT', 'Cache-Control' => 'public, max-age=' . ($time->format('U') - time()), 'Pragma' => 'cache' @@ -284,7 +284,7 @@ class CakeResponseTest extends CakeTestCase { $time = '+5 day'; $expected = array( 'Date' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', - 'Last-Modified' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', + 'Last-Modified' => gmdate("D, j M Y H:i:s ", $since) . 'GMT', 'Expires' => gmdate("D, j M Y H:i:s", strtotime($time)) . " GMT", 'Cache-Control' => 'public, max-age=' . (strtotime($time) - time()), 'Pragma' => 'cache' @@ -297,7 +297,7 @@ class CakeResponseTest extends CakeTestCase { $time = time(); $expected = array( 'Date' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', - 'Last-Modified' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', + 'Last-Modified' => gmdate("D, j M Y H:i:s ", $since) . 'GMT', 'Expires' => gmdate("D, j M Y H:i:s", $time) . " GMT", 'Cache-Control' => 'public, max-age=0', 'Pragma' => 'cache' @@ -603,4 +603,36 @@ class CakeResponseTest extends CakeTestCase { ->method('_sendHeader')->with('Expires', $time->format('D, j M Y H:i:s') . ' GMT'); $response->send(); } + +/** + * Tests setting the modification date + * + * @return void + */ + public function testModified() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $now = new DateTime('now', new DateTimeZone('America/Los_Angeles')); + $response->modified($now); + $now->setTimeZone(new DateTimeZone('UTC')); + $this->assertEquals($now->format('D, j M Y H:i:s') . ' GMT', $response->modified()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Last-Modified', $now->format('D, j M Y H:i:s') . ' GMT'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $now = time(); + $response->modified($now); + $this->assertEquals(gmdate('D, j M Y H:i:s', $now) . ' GMT', $response->modified()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Last-Modified', gmdate('D, j M Y H:i:s', $now) . ' GMT'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $time = new DateTime('+1 day', new DateTimeZone('UTC')); + $response->modified('+1 day'); + $this->assertEquals($time->format('D, j M Y H:i:s') . ' GMT', $response->modified()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Last-Modified', $time->format('D, j M Y H:i:s') . ' GMT'); + $response->send(); + } } From d9987c96db8610bdc09b119f197efc6a98c3810f Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Sun, 6 Nov 2011 23:51:16 -0430 Subject: [PATCH 07/22] Implementing sharable() and maxAge() in CakeResponse for a finer grain and easier control of cache headers --- lib/Cake/Network/CakeResponse.php | 81 ++++++++++++++++++- .../Test/Case/Network/CakeResponseTest.php | 34 ++++++++ 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index e2545e161..17d59580d 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -311,6 +311,14 @@ class CakeResponse { */ protected $_charset = 'UTF-8'; +/** + * Holds all the cache directives that will be converted + * into headers when sending the request + * + * @var string + */ + protected $_cacheDirectives = array(); + /** * Class constructor * @@ -675,14 +683,81 @@ class CakeResponse { $time = strtotime($time); } $this->header(array( - 'Date' => gmdate("D, j M Y G:i:s ", time()) . 'GMT', - 'Cache-Control' => 'public, max-age=' . ($time - time()), - 'Pragma' => 'cache' + 'Date' => gmdate("D, j M Y G:i:s ", time()) . 'GMT' )); $this->modified($since); $this->expires($time); + $this->sharable(true); + $this->maxAge($time - time()); } +/** + * Sets whether a response is eligible to be cached by intermediate proxies + * This method controls the `public` or `private` directive in the Cache-Control + * header + * + * @param boolean $public if set to true, the Cache-Control header will be set as public + * if set to false, the response will be set to private + * if no value is provided, it will return whether the response is sharable or not + * @return boolean + */ + public function sharable($public = null) { + if ($public === null) { + $public = array_key_exists('public', $this->_cacheDirectives); + $private = array_key_exists('private', $this->_cacheDirectives); + $noCache = array_key_exists('no-cache', $this->_cacheDirectives); + if (!$public && !$private && !$noCache) { + return null; + } + $sharable = $public || ! ($private || $noCache); + return $sharable; + } + if ($public) { + $this->_cacheDirectives['public'] = null; + unset($this->_cacheDirectives['private']); + } else { + $this->_cacheDirectives['private'] = null; + unset($this->_cacheDirectives['public']); + } + $this->_setCacheControl(); + return (bool) $public; + } + +/** + * Sets the Cache-Control max-age directive. + * The max-age is the number of seconds after which the response should no longer be considered + * a good candidate to be fetched from the local (client) cache. + * If called with no parameters, this function will return the current max-age value if any + * + * @param int $seconds + * @return int + */ + public function maxAge($seconds = null) { + if ($seconds !== null) { + $this->_cacheDirectives['max-age'] = $seconds; + $this->_setCacheControl(); + } + if (isset($this->_cacheDirectives['max-age'])) { + return $this->_cacheDirectives['max-age']; + } + return null; + } + +/** + * Helper method to generate a valid Cache-Control header from the options set + * in other methods + * + * @return void + */ + protected function _setCacheControl() { + $control = ''; + foreach ($this->_cacheDirectives as $key => $val) { + $control .= is_null($val) ? $key : sprintf('%s=%s', $key, $val); + $control .= ', '; + } + $control = rtrim($control, ', '); + $this->header('Cache-Control', $control); + } /** * Sets the Expires header for the response by taking an expiration time diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index e9522db67..d39ff8982 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -635,4 +635,38 @@ class CakeResponseTest extends CakeTestCase { ->method('_sendHeader')->with('Last-Modified', $time->format('D, j M Y H:i:s') . ' GMT'); $response->send(); } + + public function testSharable() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $this->assertNull($response->sharable()); + $response->sharable(true); + $headers = $response->header(); + $this->assertEquals('public', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 'public'); + $response->send(); + + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->sharable(false); + $headers = $response->header(); + $this->assertEquals('private', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 'private'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->sharable(true); + $headers = $response->header(); + $this->assertEquals('public', $headers['Cache-Control']); + $response->sharable(false); + $headers = $response->header(); + $this->assertEquals('private', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 'private'); + $response->send(); + $this->assertFalse($response->sharable()); + $response->sharable(true); + $this->assertTrue($response->sharable()); + } } From 2428e83e3f18f3ab878df5f6bc61b679efcc80fc Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Sun, 6 Nov 2011 23:58:09 -0430 Subject: [PATCH 08/22] Adding test case for maxAge() --- .../Test/Case/Network/CakeResponseTest.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index d39ff8982..03c58f6ec 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -636,6 +636,11 @@ class CakeResponseTest extends CakeTestCase { $response->send(); } +/** + * Tests setting of public/private Cache-Control directives + * + * @return void + */ public function testSharable() { $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); $this->assertNull($response->sharable()); @@ -669,4 +674,30 @@ class CakeResponseTest extends CakeTestCase { $response->sharable(true); $this->assertTrue($response->sharable()); } + +/** + * Tests setting of max-age Cache-Control directive + * + * @return void + */ + public function testMaxAge() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $this->assertNull($response->maxAge()); + $response->maxAge(3600); + $this->assertEquals(3600, $response->maxAge()); + $headers = $response->header(); + $this->assertEquals('max-age=3600', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 'max-age=3600'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->maxAge(3600); + $response->sharable(true); + $headers = $response->header(); + $this->assertEquals('max-age=3600, public', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 'max-age=3600, public'); + $response->send(); + } } From 552c70a5714842dcfc0b32b1e247ce9bc374591b Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Mon, 7 Nov 2011 00:21:05 -0430 Subject: [PATCH 09/22] Removing Pragma headers, implementing sharedMaxAge in CakeResponse --- lib/Cake/Network/CakeResponse.php | 34 ++++++++++-- .../Test/Case/Network/CakeResponseTest.php | 53 +++++++++++++++---- 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index 17d59580d..be9d717a3 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -666,8 +666,7 @@ class CakeResponse { $this->header(array( 'Expires' => 'Mon, 26 Jul 1997 05:00:00 GMT', 'Last-Modified' => gmdate("D, d M Y H:i:s") . " GMT", - 'Cache-Control' => 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0', - 'Pragma' => 'no-cache' + 'Cache-Control' => 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0' )); } @@ -699,9 +698,10 @@ class CakeResponse { * @param boolean $public if set to true, the Cache-Control header will be set as public * if set to false, the response will be set to private * if no value is provided, it will return whether the response is sharable or not + * @param int $time time in seconds after which the response should no longer be considered fresh * @return boolean */ - public function sharable($public = null) { + public function sharable($public = null, $time = null) { if ($public === null) { $public = array_key_exists('public', $this->_cacheDirectives); $private = array_key_exists('private', $this->_cacheDirectives); @@ -715,21 +715,45 @@ class CakeResponse { if ($public) { $this->_cacheDirectives['public'] = null; unset($this->_cacheDirectives['private']); + $this->sharedMaxAge($time); } else { $this->_cacheDirectives['private'] = null; unset($this->_cacheDirectives['public']); + $this->maxAge($time); + } + if ($time == null) { + $this->_setCacheControl(); } - $this->_setCacheControl(); return (bool) $public; } +/** + * Sets the Cache-Control s-maxage directive. + * The max-age is the number of seconds after which the response should no longer be considered + * a good candidate to be fetched from a shared cache (like in a proxy server). + * If called with no parameters, this function will return the current max-age value if any + * + * @param int $seconds if null, the method will return the current s-maxage value + * @return int + */ + public function sharedMaxAge($seconds = null) { + if ($seconds !== null) { + $this->_cacheDirectives['s-maxage'] = $seconds; + $this->_setCacheControl(); + } + if (isset($this->_cacheDirectives['s-maxage'])) { + return $this->_cacheDirectives['s-maxage']; + } + return null; + } + /** * Sets the Cache-Control max-age directive. * The max-age is the number of seconds after which the response should no longer be considered * a good candidate to be fetched from the local (client) cache. * If called with no parameters, this function will return the current max-age value if any * - * @param int $seconds + * @param int $seconds if null, the method will return the current max-age value * @return int */ public function maxAge($seconds = null) { diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index 03c58f6ec..0a6c0f92b 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -253,8 +253,7 @@ class CakeResponseTest extends CakeTestCase { $expected = array( 'Expires' => 'Mon, 26 Jul 1997 05:00:00 GMT', 'Last-Modified' => gmdate("D, d M Y H:i:s") . " GMT", - 'Cache-Control' => 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0', - 'Pragma' => 'no-cache' + 'Cache-Control' => 'no-store, no-cache, must-revalidate, post-check=0, pre-check=0' ); $response->disableCache(); $this->assertEquals($response->header(), $expected); @@ -273,8 +272,7 @@ class CakeResponseTest extends CakeTestCase { 'Date' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', 'Last-Modified' => gmdate("D, j M Y H:i:s ", $since) . 'GMT', 'Expires' => $time->format('D, j M Y H:i:s') . ' GMT', - 'Cache-Control' => 'public, max-age=' . ($time->format('U') - time()), - 'Pragma' => 'cache' + 'Cache-Control' => 'public, max-age=' . ($time->format('U') - time()) ); $response->cache($since); $this->assertEquals($response->header(), $expected); @@ -286,8 +284,7 @@ class CakeResponseTest extends CakeTestCase { 'Date' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', 'Last-Modified' => gmdate("D, j M Y H:i:s ", $since) . 'GMT', 'Expires' => gmdate("D, j M Y H:i:s", strtotime($time)) . " GMT", - 'Cache-Control' => 'public, max-age=' . (strtotime($time) - time()), - 'Pragma' => 'cache' + 'Cache-Control' => 'public, max-age=' . (strtotime($time) - time()) ); $response->cache($since, $time); $this->assertEquals($response->header(), $expected); @@ -299,8 +296,7 @@ class CakeResponseTest extends CakeTestCase { 'Date' => gmdate("D, j M Y G:i:s ", $since) . 'GMT', 'Last-Modified' => gmdate("D, j M Y H:i:s ", $since) . 'GMT', 'Expires' => gmdate("D, j M Y H:i:s", $time) . " GMT", - 'Cache-Control' => 'public, max-age=0', - 'Pragma' => 'cache' + 'Cache-Control' => 'public, max-age=0' ); $response->cache($since, $time); $this->assertEquals($response->header(), $expected); @@ -673,6 +669,17 @@ class CakeResponseTest extends CakeTestCase { $this->assertFalse($response->sharable()); $response->sharable(true); $this->assertTrue($response->sharable()); + + $response = new CakeResponse; + $response->sharable(true, 3600); + $headers = $response->header(); + $this->assertEquals('public, s-maxage=3600', $headers['Cache-Control']); + + $response = new CakeResponse; + $response->sharable(false, 3600); + $headers = $response->header(); + $this->assertEquals('private, max-age=3600', $headers['Cache-Control']); + $response->send(); } /** @@ -693,11 +700,37 @@ class CakeResponseTest extends CakeTestCase { $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); $response->maxAge(3600); + $response->sharable(false); + $headers = $response->header(); + $this->assertEquals('max-age=3600, private', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 'max-age=3600, private'); + $response->send(); + } + +/** + * Tests setting of s-maxage Cache-Control directive + * + * @return void + */ + public function testSharedMaxAge() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $this->assertNull($response->maxAge()); + $response->sharedMaxAge(3600); + $this->assertEquals(3600, $response->sharedMaxAge()); + $headers = $response->header(); + $this->assertEquals('s-maxage=3600', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 's-maxage=3600'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->sharedMaxAge(3600); $response->sharable(true); $headers = $response->header(); - $this->assertEquals('max-age=3600, public', $headers['Cache-Control']); + $this->assertEquals('s-maxage=3600, public', $headers['Cache-Control']); $response->expects($this->at(1)) - ->method('_sendHeader')->with('Cache-Control', 'max-age=3600, public'); + ->method('_sendHeader')->with('Cache-Control', 's-maxage=3600, public'); $response->send(); } } From 3240f6221eb0ca50fc719c8ed1e1a8bd666756a2 Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Thu, 12 Jan 2012 00:49:58 -0430 Subject: [PATCH 10/22] Implementing mustRevaidate() --- lib/Cake/Network/CakeResponse.php | 23 ++++++++++++++ .../Test/Case/Network/CakeResponseTest.php | 30 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index be9d717a3..700ce123e 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -767,6 +767,29 @@ class CakeResponse { return null; } +/** + * Sets the Cache-Control must-revalidate directive. + * must-revalidate indicates that the response should not be served + * stale by a cache under any cirumstance without first revalidating + * with the origin. + * If called with no parameters, this function will return wheter must-revalidate is present. + * + * @param int $seconds if null, the method will return the current + * must-revalidate value + * @return boolean + */ + public function mustRevalidate($enable = null) { + if ($enable !== null) { + if ($enable) { + $this->_cacheDirectives['must-revalidate'] = null; + } else { + unset($this->_cacheDirectives['must-revalidate']); + } + $this->_setCacheControl(); + } + return array_key_exists('must-revalidate', $this->_cacheDirectives); + } + /** * Helper method to generate a valid Cache-Control header from the options set * in other methods diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index 0a6c0f92b..58efb0d57 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -733,4 +733,34 @@ class CakeResponseTest extends CakeTestCase { ->method('_sendHeader')->with('Cache-Control', 's-maxage=3600, public'); $response->send(); } + +/** + * Tests setting of must-revalidate Cache-Control directive + * + * @return void + */ + public function testMustRevalidate() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $this->assertFalse($response->mustRevalidate()); + $response->mustRevalidate(true); + $this->assertTrue($response->mustRevalidate()); + $headers = $response->header(); + $this->assertEquals('must-revalidate', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 'must-revalidate'); + $response->send(); + $response->mustRevalidate(false); + $this->assertFalse($response->mustRevalidate()); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->sharedMaxAge(3600); + $response->mustRevalidate(true); + $headers = $response->header(); + $this->assertEquals('s-maxage=3600, must-revalidate', $headers['Cache-Control']); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Cache-Control', 's-maxage=3600, must-revalidate'); + $response->send(); + + } + } From 803d49c7c6a80c2bfc3894f3af1263e97d069656 Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Thu, 12 Jan 2012 22:15:27 -0430 Subject: [PATCH 11/22] Adding CakeResponse::vary() --- lib/Cake/Network/CakeResponse.php | 21 +++++++++++++++++++ .../Test/Case/Network/CakeResponseTest.php | 21 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index 700ce123e..3e58d15b8 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -854,6 +854,27 @@ class CakeResponse { return null; } +/** + * Sets the Vary header for the response, if an array is passed, + * values will be imploded into a comma separated string. If no + * parameters are passed, then an array with the current Vary header + * value is returned + * + * @param string|array $cacheVariances a single Vary string or a array + * containig the list for variances. + * @return array + **/ + public function vary($cacheVariances = null) { + if ($cacheVariances !== null) { + $cacheVariances = (array) $cacheVariances; + $this->_headers['Vary'] = implode(', ', $cacheVariances); + } + if (isset($this->_headers['Vary'])) { + return explode(', ', $this->_headers['Vary']); + } + return null; + } + /** * Returns a DateTime object initialized at the $time param and using UTC * as timezone diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index 58efb0d57..60f225d40 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -763,4 +763,25 @@ class CakeResponseTest extends CakeTestCase { } +/** + * Tests getting/setting the Vary header + * + * @return void + */ + public function testVary() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->vary('Accept-encoding'); + $this->assertEquals(array('Accept-encoding'), $response->vary()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Vary', 'Accept-encoding'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->vary(array('Accept-language', 'Accept-encoding')); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Vary', 'Accept-language, Accept-encoding'); + $response->send(); + $this->assertEquals(array('Accept-language', 'Accept-encoding'), $response->vary()); + } + } From dbd097debb96f9aff0a32096df65ceebf36b6fa6 Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Thu, 12 Jan 2012 23:06:05 -0430 Subject: [PATCH 12/22] Implementing the CakeResponse::etag() --- lib/Cake/Network/CakeResponse.php | 32 +++++++++++++++++++ .../Test/Case/Network/CakeResponseTest.php | 21 ++++++++++++ 2 files changed, 53 insertions(+) diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index 3e58d15b8..9abc839ba 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -875,6 +875,38 @@ class CakeResponse { return null; } +/** + * Sets the response Etag, Etags are a strong indicative that a response + * can be cached by a HTTP client. A bad way of generaing Etags is + * creating a hash of teh response output, instead generate a unique + * hash of the unique components that identifies a request, such as a + * modification time, a resource Id, and anything else you consider it + * makes it unique. + * + * Second parameter is used to instuct clients that the content has + * changed, but sematicallly, it can be used as the same thing. Think + * for instance of a page with a hit counter, two different page views + * are equivalent, but they differ by a few bytes. This leaves off to + * the Client the decision of using or not the cached page. + * + * If no parameters are passed, current Etag header is returned. + * + * @param string $hash the unique has that identifies this resposnse + * @param boolean $weak whether the response is semantically the same as + * other with th same hash or not + * @return string + **/ + public function etag($tag = null, $weak = false) { + if ($tag !== null) { + $this->_headers['Etag'] = sprintf('%s"%s"', ($weak) ? 'W/' : null, $tag); + } + if (isset($this->_headers['Etag'])) { + return $this->_headers['Etag']; + } + return null; + } + + /** * Returns a DateTime object initialized at the $time param and using UTC * as timezone diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index 60f225d40..1579789de 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -784,4 +784,25 @@ class CakeResponseTest extends CakeTestCase { $this->assertEquals(array('Accept-language', 'Accept-encoding'), $response->vary()); } +/** + * Tests getting/setting the Etag header + * + * @return void + */ + public function testEtag() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->etag('something'); + $this->assertEquals('"something"', $response->etag()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Etag', '"something"'); + $response->send(); + + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->etag('something', true); + $this->assertEquals('W/"something"', $response->etag()); + $response->expects($this->at(1)) + ->method('_sendHeader')->with('Etag', 'W/"something"'); + $response->send(); + + } } From 8e979cc83e1330f4b00a1fd5064d8b63a19d9ff6 Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Fri, 13 Jan 2012 00:03:29 -0430 Subject: [PATCH 13/22] Implementing CakeResponse::notModified() --- lib/Cake/Network/CakeResponse.php | 24 +++++++++++++++++++ .../Test/Case/Network/CakeResponseTest.php | 20 +++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index 9abc839ba..0aa953fa0 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -854,6 +854,30 @@ class CakeResponse { return null; } +/** + * Sets the response as Not Modified by removing any body contents + * setting the status code to "304 Not Modified" and removing all + * conflicting headers + * + * @return void + **/ + public function notModified() { + $this->statusCode(304); + $this->body(''); + $remove = array( + 'Allow', + 'Content-Encoding', + 'Content-Language', + 'Content-Length', + 'Content-MD5', + 'Content-Type', + 'Last-Modified' + ); + foreach ($remove as $header) { + unset($this->_headers[$header]); + } + } + /** * Sets the Vary header for the response, if an array is passed, * values will be imploded into a comma separated string. If no diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index 1579789de..2ebb0320c 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -803,6 +803,24 @@ class CakeResponseTest extends CakeTestCase { $response->expects($this->at(1)) ->method('_sendHeader')->with('Etag', 'W/"something"'); $response->send(); - } + +/** + * Tests that the response is able to be marked as not modified + * + * @return void + */ + public function testNotModified() { + $response = $this->getMock('CakeResponse', array('_sendHeader', '_sendContent')); + $response->body('something'); + $response->statusCode(200); + $response->length(100); + $response->modified('now'); + $response->notModified(); + + $this->assertEmpty($response->header()); + $this->assertEmpty($response->body()); + $this->assertEquals(304, $response->statusCode()); + } + } From dffe84cfbcd1d49dc334f40093484904c45e7779 Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Fri, 13 Jan 2012 01:36:31 -0430 Subject: [PATCH 14/22] Implementing RequestHandler::checkNotModified() as a helper for HTTP caching --- .../Component/RequestHandlerComponent.php | 29 +++++ .../Component/RequestHandlerComponentTest.php | 105 ++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/lib/Cake/Controller/Component/RequestHandlerComponent.php b/lib/Cake/Controller/Component/RequestHandlerComponent.php index af285e040..c52b49d0f 100644 --- a/lib/Cake/Controller/Component/RequestHandlerComponent.php +++ b/lib/Cake/Controller/Component/RequestHandlerComponent.php @@ -704,4 +704,33 @@ class RequestHandlerComponent extends Component { } $this->_inputTypeMap[$type] = $handler; } + +/** + * Checks whether a response has not been modified according to the 'If-None-Match' + * (Etags) and 'If-Modified-Since' (last modification date) request + * headers headers. If the response is detected to be not modified, it + * is marked as so accordingly so the client can be informed of that. + * + * In order to mark a response as not modified, you need to set at least + * the Last-Modified response header or a response etag to be compared + * with the request itself + * + * @return boolean whether the response was marked as not modified or + * not + **/ + public function checkNotModified() { + $etags = preg_split('/\s*,\s*/', $this->request->header('If-None-Match'), null, PREG_SPLIT_NO_EMPTY); + $modifiedSince = $this->request->header('If-Modified-Since'); + if ($responseTag = $this->response->etag()) { + $etagMatches = in_array('*', $etags) || in_array($responseTag, $etags); + } + if ($modifiedSince) { + $timeMatches = strtotime($this->response->modified()) == strtotime($modifiedSince); + } + $notModified = (!isset($etagMatches) || $etagMatches) && (!isset($timeMatches) || $timeMatches); + if ($notModified) { + $this->response->notModified(); + } + return $notModified; + } } diff --git a/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php b/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php index 996dcd1fc..2dd7671be 100644 --- a/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php +++ b/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php @@ -823,4 +823,109 @@ class RequestHandlerComponentTest extends CakeTestCase { public function testAddInputTypeException() { $this->RequestHandler->addInputType('csv', array('I am not callable')); } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagStar() { + $_SERVER['HTTP_IF_NONE_MATCH'] = '*'; + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->etag('something'); + $RequestHandler->response->expects($this->once())->method('notModified'); + $RequestHandler->checkNotModified(); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagExact() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->etag('something', true); + $RequestHandler->response->expects($this->once())->method('notModified'); + $this->assertTrue($RequestHandler->checkNotModified()); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagAndTime() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->etag('something', true); + $RequestHandler->response->modified('2012-01-01 00:00:00'); + $RequestHandler->response->expects($this->once())->method('notModified'); + $this->assertTrue($RequestHandler->checkNotModified()); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagAndTimeMismatch() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->etag('something', true); + $RequestHandler->response->modified('2012-01-01 00:00:01'); + $RequestHandler->response->expects($this->never())->method('notModified'); + $this->assertFalse($RequestHandler->checkNotModified()); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagMismatch() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something-else", "other"'; + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->etag('something', true); + $RequestHandler->response->modified('2012-01-01 00:00:00'); + $RequestHandler->response->expects($this->never())->method('notModified'); + $this->assertFalse($RequestHandler->checkNotModified()); + } + + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByTime() { + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->modified('2012-01-01 00:00:00'); + $RequestHandler->response->expects($this->once())->method('notModified'); + $this->assertTrue($RequestHandler->checkNotModified()); + } + + /** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedNoHints() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->expects($this->never())->method('notModified'); + $this->assertFalse($RequestHandler->checkNotModified()); + } } From a7662eba57965603dd3370207f8bb508020617d6 Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Fri, 13 Jan 2012 01:41:29 -0430 Subject: [PATCH 15/22] fixing doc comment identation --- .../Case/Controller/Component/RequestHandlerComponentTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php b/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php index 2dd7671be..ab2dd8e56 100644 --- a/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php +++ b/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php @@ -915,7 +915,7 @@ class RequestHandlerComponentTest extends CakeTestCase { $this->assertTrue($RequestHandler->checkNotModified()); } - /** +/** * Test checkNotModified method * * @return void From 6839f0c4504b243f4c868b31c6b189daba370f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Lorenzo=20Rodr=C3=ADguez?= Date: Fri, 13 Jan 2012 10:18:24 -0530 Subject: [PATCH 16/22] Fixing typo in docblock --- lib/Cake/Network/CakeResponse.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index 0aa953fa0..1f1731b00 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -902,7 +902,7 @@ class CakeResponse { /** * Sets the response Etag, Etags are a strong indicative that a response * can be cached by a HTTP client. A bad way of generaing Etags is - * creating a hash of teh response output, instead generate a unique + * creating a hash of the response output, instead generate a unique * hash of the unique components that identifies a request, such as a * modification time, a resource Id, and anything else you consider it * makes it unique. From b79e0ad8f37e55854b130388b367314c9ee975cc Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Wed, 18 Jan 2012 23:25:02 -0430 Subject: [PATCH 17/22] Moving checkModified() to CakeResponse, having it in the RequestHandler has too restrivtive --- .../Component/RequestHandlerComponent.php | 28 ----- lib/Cake/Network/CakeResponse.php | 29 +++++ .../Component/RequestHandlerComponentTest.php | 104 ------------------ .../Test/Case/Network/CakeResponseTest.php | 98 +++++++++++++++++ 4 files changed, 127 insertions(+), 132 deletions(-) diff --git a/lib/Cake/Controller/Component/RequestHandlerComponent.php b/lib/Cake/Controller/Component/RequestHandlerComponent.php index c52b49d0f..55a98ba92 100644 --- a/lib/Cake/Controller/Component/RequestHandlerComponent.php +++ b/lib/Cake/Controller/Component/RequestHandlerComponent.php @@ -705,32 +705,4 @@ class RequestHandlerComponent extends Component { $this->_inputTypeMap[$type] = $handler; } -/** - * Checks whether a response has not been modified according to the 'If-None-Match' - * (Etags) and 'If-Modified-Since' (last modification date) request - * headers headers. If the response is detected to be not modified, it - * is marked as so accordingly so the client can be informed of that. - * - * In order to mark a response as not modified, you need to set at least - * the Last-Modified response header or a response etag to be compared - * with the request itself - * - * @return boolean whether the response was marked as not modified or - * not - **/ - public function checkNotModified() { - $etags = preg_split('/\s*,\s*/', $this->request->header('If-None-Match'), null, PREG_SPLIT_NO_EMPTY); - $modifiedSince = $this->request->header('If-Modified-Since'); - if ($responseTag = $this->response->etag()) { - $etagMatches = in_array('*', $etags) || in_array($responseTag, $etags); - } - if ($modifiedSince) { - $timeMatches = strtotime($this->response->modified()) == strtotime($modifiedSince); - } - $notModified = (!isset($etagMatches) || $etagMatches) && (!isset($timeMatches) || $timeMatches); - if ($notModified) { - $this->response->notModified(); - } - return $notModified; - } } diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index 1f1731b00..3877138ab 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -1012,6 +1012,35 @@ class CakeResponse { return null; } +/** + * Checks whether a response has not been modified according to the 'If-None-Match' + * (Etags) and 'If-Modified-Since' (last modification date) request + * headers headers. If the response is detected to be not modified, it + * is marked as so accordingly so the client can be informed of that. + * + * In order to mark a response as not modified, you need to set at least + * the Last-Modified response header or a response etag to be compared + * with the request itself + * + * @return boolean whether the response was marked as not modified or + * not + **/ + public function checkNotModified(CakeRequest $request) { + $etags = preg_split('/\s*,\s*/', $request->header('If-None-Match'), null, PREG_SPLIT_NO_EMPTY); + $modifiedSince = $request->header('If-Modified-Since'); + if ($responseTag = $this->etag()) { + $etagMatches = in_array('*', $etags) || in_array($responseTag, $etags); + } + if ($modifiedSince) { + $timeMatches = strtotime($this->modified()) == strtotime($modifiedSince); + } + $notModified = (!isset($etagMatches) || $etagMatches) && (!isset($timeMatches) || $timeMatches); + if ($notModified) { + $this->notModified(); + } + return $notModified; + } + /** * String conversion. Fetches the response body as a string. * Does *not* send headers. diff --git a/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php b/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php index ab2dd8e56..b24dbf505 100644 --- a/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php +++ b/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php @@ -824,108 +824,4 @@ class RequestHandlerComponentTest extends CakeTestCase { $this->RequestHandler->addInputType('csv', array('I am not callable')); } -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByEtagStar() { - $_SERVER['HTTP_IF_NONE_MATCH'] = '*'; - $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); - $RequestHandler->response->etag('something'); - $RequestHandler->response->expects($this->once())->method('notModified'); - $RequestHandler->checkNotModified(); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByEtagExact() { - $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; - $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); - $RequestHandler->response->etag('something', true); - $RequestHandler->response->expects($this->once())->method('notModified'); - $this->assertTrue($RequestHandler->checkNotModified()); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByEtagAndTime() { - $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; - $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; - $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); - $RequestHandler->response->etag('something', true); - $RequestHandler->response->modified('2012-01-01 00:00:00'); - $RequestHandler->response->expects($this->once())->method('notModified'); - $this->assertTrue($RequestHandler->checkNotModified()); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByEtagAndTimeMismatch() { - $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; - $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; - $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); - $RequestHandler->response->etag('something', true); - $RequestHandler->response->modified('2012-01-01 00:00:01'); - $RequestHandler->response->expects($this->never())->method('notModified'); - $this->assertFalse($RequestHandler->checkNotModified()); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByEtagMismatch() { - $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something-else", "other"'; - $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; - $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); - $RequestHandler->response->etag('something', true); - $RequestHandler->response->modified('2012-01-01 00:00:00'); - $RequestHandler->response->expects($this->never())->method('notModified'); - $this->assertFalse($RequestHandler->checkNotModified()); - } - - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedByTime() { - $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; - $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); - $RequestHandler->response->modified('2012-01-01 00:00:00'); - $RequestHandler->response->expects($this->once())->method('notModified'); - $this->assertTrue($RequestHandler->checkNotModified()); - } - -/** - * Test checkNotModified method - * - * @return void - **/ - public function testCheckNotModifiedNoHints() { - $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; - $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; - $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); - $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); - $RequestHandler->response->expects($this->never())->method('notModified'); - $this->assertFalse($RequestHandler->checkNotModified()); - } } diff --git a/lib/Cake/Test/Case/Network/CakeResponseTest.php b/lib/Cake/Test/Case/Network/CakeResponseTest.php index 2ebb0320c..872042896 100644 --- a/lib/Cake/Test/Case/Network/CakeResponseTest.php +++ b/lib/Cake/Test/Case/Network/CakeResponseTest.php @@ -17,6 +17,7 @@ * @license MIT License (http://www.opensource.org/licenses/mit-license.php) */ App::uses('CakeResponse', 'Network'); +App::uses('CakeRequest', 'Network'); class CakeResponseTest extends CakeTestCase { @@ -823,4 +824,101 @@ class CakeResponseTest extends CakeTestCase { $this->assertEquals(304, $response->statusCode()); } +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagStar() { + $_SERVER['HTTP_IF_NONE_MATCH'] = '*'; + $response = $this->getMock('CakeResponse', array('notModified')); + $response->etag('something'); + $response->expects($this->once())->method('notModified'); + $response->checkNotModified(new CakeRequest); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagExact() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $response = $this->getMock('CakeResponse', array('notModified')); + $response->etag('something', true); + $response->expects($this->once())->method('notModified'); + $this->assertTrue($response->checkNotModified(new CakeRequest)); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagAndTime() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $response = $this->getMock('CakeResponse', array('notModified')); + $response->etag('something', true); + $response->modified('2012-01-01 00:00:00'); + $response->expects($this->once())->method('notModified'); + $this->assertTrue($response->checkNotModified(new CakeRequest)); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagAndTimeMismatch() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $response = $this->getMock('CakeResponse', array('notModified')); + $response->etag('something', true); + $response->modified('2012-01-01 00:00:01'); + $response->expects($this->never())->method('notModified'); + $this->assertFalse($response->checkNotModified(new CakeRequest)); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagMismatch() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something-else", "other"'; + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $response = $this->getMock('CakeResponse', array('notModified')); + $response->etag('something', true); + $response->modified('2012-01-01 00:00:00'); + $response->expects($this->never())->method('notModified'); + $this->assertFalse($response->checkNotModified(new CakeRequest)); + } + + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByTime() { + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $response = $this->getMock('CakeResponse', array('notModified')); + $response->modified('2012-01-01 00:00:00'); + $response->expects($this->once())->method('notModified'); + $this->assertTrue($response->checkNotModified(new CakeRequest)); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedNoHints() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $response = $this->getMock('CakeResponse', array('notModified')); + $response->expects($this->never())->method('notModified'); + $this->assertFalse($response->checkNotModified(new CakeRequest)); + } } From 28ee27e2dd0ae16ee5f2fbdc725bb48af4c27986 Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Thu, 19 Jan 2012 00:03:36 -0430 Subject: [PATCH 18/22] Making it possible to cancel the render() process from any beforeRender listener --- lib/Cake/Controller/Controller.php | 7 +++++- .../Test/Case/Controller/ControllerTest.php | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/Cake/Controller/Controller.php b/lib/Cake/Controller/Controller.php index a2c6d4f07..7c6f90462 100644 --- a/lib/Cake/Controller/Controller.php +++ b/lib/Cake/Controller/Controller.php @@ -896,7 +896,12 @@ class Controller extends Object implements CakeEventListener { * @link http://book.cakephp.org/2.0/en/controllers.html#Controller::render */ public function render($view = null, $layout = null) { - $this->getEventManager()->dispatch(new CakeEvent('Controller.beforeRender', $this)); + $event = new CakeEvent('Controller.beforeRender', $this); + $this->getEventManager()->dispatch($event); + if ($event->isStopped()) { + $this->autoRender = false; + return $this->response; + } $viewClass = $this->viewClass; if ($this->viewClass != 'View') { diff --git a/lib/Cake/Test/Case/Controller/ControllerTest.php b/lib/Cake/Test/Case/Controller/ControllerTest.php index 45793b8cc..37f4603ab 100644 --- a/lib/Cake/Test/Case/Controller/ControllerTest.php +++ b/lib/Cake/Test/Case/Controller/ControllerTest.php @@ -344,6 +344,14 @@ class TestComponent extends Object { } } +class Test2Component extends TestComponent { + + + public function beforeRender($controller) { + return false; + } +} + /** * AnotherTestController class * @@ -670,6 +678,21 @@ class ControllerTest extends CakeTestCase { App::build(); } +/** + * test that a component beforeRender can change the controller view class. + * + * @return void + */ + public function testComponentCancelRender() { + $Controller = new Controller($this->getMock('CakeRequest'), new CakeResponse()); + $Controller->uses = array(); + $Controller->components = array('Test2'); + $Controller->constructClasses(); + $result = $Controller->render('index'); + $this->assertInstanceOf('CakeResponse', $result); + } + + /** * testToBeInheritedGuardmethods method * From 979f7a28b5c2fa274d341e6781f9b119478954d4 Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Thu, 19 Jan 2012 00:32:34 -0430 Subject: [PATCH 19/22] Fixing a couple bugs in CakeResponse::checkNotModified() and implementing conditional rendering in RequestHandlerComponent --- .../Component/RequestHandlerComponent.php | 20 ++++++- lib/Cake/Network/CakeResponse.php | 6 +- .../Component/RequestHandlerComponentTest.php | 55 +++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/lib/Cake/Controller/Component/RequestHandlerComponent.php b/lib/Cake/Controller/Component/RequestHandlerComponent.php index 55a98ba92..63b8ff10e 100644 --- a/lib/Cake/Controller/Component/RequestHandlerComponent.php +++ b/lib/Cake/Controller/Component/RequestHandlerComponent.php @@ -95,7 +95,8 @@ class RequestHandlerComponent extends Component { * @param array $settings Array of settings. */ public function __construct(ComponentCollection $collection, $settings = array()) { - parent::__construct($collection, $settings); + $default = array('checkHttpCache' => true); + parent::__construct($collection, $settings + $default); $this->addInputType('xml', array(array($this, 'convertXml'))); $Controller = $collection->getController(); @@ -240,6 +241,23 @@ class RequestHandlerComponent extends Component { $this->_stop(); } +/** + * Checks if the response can be considered different according to the request + * headers, and the caching response headers. If it was not modified, then the + * render process is skipped. And the client will get a blank response with a + * "304 Not Modified" header. + * + * @params Controller $controller + * @return boolean false if the render process should be aborted + **/ + public function beforeRender($controller) { + $shouldCheck = $this->settings['checkHttpCache']; + if ($shouldCheck && $this->response->checkNotModified($this->request)) { + $this->response->send(); + return false; + } + } + /** * Returns true if the current HTTP request is Ajax, false otherwise * diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index 3877138ab..0bd5e47bf 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -1034,7 +1034,11 @@ class CakeResponse { if ($modifiedSince) { $timeMatches = strtotime($this->modified()) == strtotime($modifiedSince); } - $notModified = (!isset($etagMatches) || $etagMatches) && (!isset($timeMatches) || $timeMatches); + $checks = compact('etagMatches', 'timeMatches'); + if (empty($checks)) { + return false; + } + $notModified = !in_array(false, $checks, true); if ($notModified) { $this->notModified(); } diff --git a/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php b/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php index b24dbf505..d36199e63 100644 --- a/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php +++ b/lib/Cake/Test/Case/Controller/Component/RequestHandlerComponentTest.php @@ -824,4 +824,59 @@ class RequestHandlerComponentTest extends CakeTestCase { $this->RequestHandler->addInputType('csv', array('I am not callable')); } +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagStar() { + $_SERVER['HTTP_IF_NONE_MATCH'] = '*'; + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->etag('something'); + $RequestHandler->response->expects($this->once())->method('notModified'); + $this->assertFalse($RequestHandler->beforeRender($this->Controller)); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagExact() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->etag('something', true); + $RequestHandler->response->expects($this->once())->method('notModified'); + $this->assertFalse($RequestHandler->beforeRender($this->Controller)); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedByEtagAndTime() { + $_SERVER['HTTP_IF_NONE_MATCH'] = 'W/"something", "other"'; + $_SERVER['HTTP_IF_MODIFIED_SINCE'] = '2012-01-01 00:00:00'; + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->etag('something', true); + $RequestHandler->response->modified('2012-01-01 00:00:00'); + $RequestHandler->response->expects($this->once())->method('notModified'); + $this->assertFalse($RequestHandler->beforeRender($this->Controller)); + } + +/** + * Test checkNotModified method + * + * @return void + **/ + public function testCheckNotModifiedNoInfo() { + $RequestHandler = $this->getMock('RequestHandlerComponent', array('_stop'), array(&$this->Controller->Components)); + $RequestHandler->response = $this->getMock('CakeResponse', array('notModified')); + $RequestHandler->response->expects($this->never())->method('notModified'); + $this->assertNull($RequestHandler->beforeRender($this->Controller)); + } } From 769a5c24e66eaecda07b450e7af054fbcaa54e0c Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Thu, 19 Jan 2012 01:00:41 -0430 Subject: [PATCH 20/22] Fixing some failing test cases --- .../Case/Routing/Route/RedirectRouteTest.php | 24 ++++++++++++------- lib/Cake/Test/Case/Routing/RouterTest.php | 3 ++- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/lib/Cake/Test/Case/Routing/Route/RedirectRouteTest.php b/lib/Cake/Test/Case/Routing/Route/RedirectRouteTest.php index ae45bedf1..3ecd200a4 100644 --- a/lib/Cake/Test/Case/Routing/Route/RedirectRouteTest.php +++ b/lib/Cake/Test/Case/Routing/Route/RedirectRouteTest.php @@ -48,51 +48,59 @@ class RedirectRouteTestCase extends CakeTestCase { $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/home'); - $this->assertEquals($route->response->header(), array('Location' => Router::url('/posts', true))); + $header = $route->response->header(); + $this->assertEquals($header['Location'], Router::url('/posts', true)); $route = new RedirectRoute('/home', array('controller' => 'posts', 'action' => 'index')); $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/home'); - $this->assertEquals($route->response->header(), array('Location' => Router::url('/posts', true))); + $header = $route->response->header(); + $this->assertEquals($header['Location'], Router::url('/posts', true)); $this->assertEquals($route->response->statusCode(), 301); $route = new RedirectRoute('/google', 'http://google.com'); $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/google'); - $this->assertEquals($route->response->header(), array('Location' => 'http://google.com')); + $header = $route->response->header(); + $this->assertEquals($header['Location'], 'http://google.com'); $route = new RedirectRoute('/posts/*', array('controller' => 'posts', 'action' => 'view'), array('status' => 302)); $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/posts/2'); - $this->assertEquals($route->response->header(), array('Location' => Router::url('/posts/view', true))); + $header = $route->response->header(); + $this->assertEquals($header['Location'], Router::url('/posts/view', true)); $this->assertEquals($route->response->statusCode(), 302); $route = new RedirectRoute('/posts/*', array('controller' => 'posts', 'action' => 'view'), array('persist' => true)); $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/posts/2'); - $this->assertEquals($route->response->header(), array('Location' => Router::url('/posts/view/2', true))); + $header = $route->response->header(); + $this->assertEquals($header['Location'], Router::url('/posts/view/2', true)); $route = new RedirectRoute('/posts/*', '/test', array('persist' => true)); $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/posts/2'); - $this->assertEquals($route->response->header(), array('Location' => Router::url('/test', true))); + $header = $route->response->header(); + $this->assertEquals($header['Location'], Router::url('/test', true)); $route = new RedirectRoute('/my_controllers/:action/*', array('controller' => 'tags', 'action' => 'add'), array('persist' => true)); $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/my_controllers/do_something/passme/named:param'); - $this->assertEquals($route->response->header(), array('Location' => Router::url('/tags/add/passme/named:param', true))); + $header = $route->response->header(); + $this->assertEquals($header['Location'], Router::url('/tags/add/passme/named:param', true)); $route = new RedirectRoute('/my_controllers/:action/*', array('controller' => 'tags', 'action' => 'add')); $route->stop = false; $route->response = $this->getMock('CakeResponse', array('_sendHeader')); $result = $route->parse('/my_controllers/do_something/passme/named:param'); - $this->assertEquals($route->response->header(), array('Location' => Router::url('/tags/add', true))); + $header = $route->response->header(); + $this->assertEquals($header['Location'], Router::url('/tags/add', true)); } } diff --git a/lib/Cake/Test/Case/Routing/RouterTest.php b/lib/Cake/Test/Case/Routing/RouterTest.php index 3abcb6ec0..5ce238a58 100644 --- a/lib/Cake/Test/Case/Routing/RouterTest.php +++ b/lib/Cake/Test/Case/Routing/RouterTest.php @@ -2498,7 +2498,8 @@ class RouterTest extends CakeTestCase { $this->assertEquals(Router::$routes[0]->options['status'], 302); Router::parse('/blog'); - $this->assertEquals(Router::$routes[0]->response->header(), array('Location' => Router::url('/posts', true))); + $header = Router::$routes[0]->response->header(); + $this->assertEquals($header['Location'], Router::url('/posts', true)); $this->assertEquals(Router::$routes[0]->response->statusCode(), 302); Router::$routes[0]->response = $this->getMock('CakeResponse', array('_sendHeader')); From 5df2a0957f43798133d81fbf30798b7dc41ea67a Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Thu, 19 Jan 2012 22:26:32 -0430 Subject: [PATCH 21/22] Not sending the response in beforeRender, better let Dispatcher do its work --- lib/Cake/Controller/Component/RequestHandlerComponent.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/Cake/Controller/Component/RequestHandlerComponent.php b/lib/Cake/Controller/Component/RequestHandlerComponent.php index 63b8ff10e..6ca9b073f 100644 --- a/lib/Cake/Controller/Component/RequestHandlerComponent.php +++ b/lib/Cake/Controller/Component/RequestHandlerComponent.php @@ -253,7 +253,6 @@ class RequestHandlerComponent extends Component { public function beforeRender($controller) { $shouldCheck = $this->settings['checkHttpCache']; if ($shouldCheck && $this->response->checkNotModified($this->request)) { - $this->response->send(); return false; } } From 00a5510b1deadc5960e50e82acd94bc7f5553f2c Mon Sep 17 00:00:00 2001 From: Jose Lorenzo Rodriguez Date: Thu, 19 Jan 2012 22:26:59 -0430 Subject: [PATCH 22/22] Readability changes --- lib/Cake/Network/CakeResponse.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/Cake/Network/CakeResponse.php b/lib/Cake/Network/CakeResponse.php index 0bd5e47bf..889f860fa 100644 --- a/lib/Cake/Network/CakeResponse.php +++ b/lib/Cake/Network/CakeResponse.php @@ -713,11 +713,11 @@ class CakeResponse { return $sharable; } if ($public) { - $this->_cacheDirectives['public'] = null; + $this->_cacheDirectives['public'] = true; unset($this->_cacheDirectives['private']); $this->sharedMaxAge($time); } else { - $this->_cacheDirectives['private'] = null; + $this->_cacheDirectives['private'] = true; unset($this->_cacheDirectives['public']); $this->maxAge($time); } @@ -781,7 +781,7 @@ class CakeResponse { public function mustRevalidate($enable = null) { if ($enable !== null) { if ($enable) { - $this->_cacheDirectives['must-revalidate'] = null; + $this->_cacheDirectives['must-revalidate'] = true; } else { unset($this->_cacheDirectives['must-revalidate']); } @@ -799,7 +799,7 @@ class CakeResponse { protected function _setCacheControl() { $control = ''; foreach ($this->_cacheDirectives as $key => $val) { - $control .= is_null($val) ? $key : sprintf('%s=%s', $key, $val); + $control .= $val === true ? $key : sprintf('%s=%s', $key, $val); $control .= ', '; } $control = rtrim($control, ', ');