From 906a4c444b43fddc243f905962a40f6fc25a2dac Mon Sep 17 00:00:00 2001
From: Damian Mooyman
Date: Fri, 12 May 2017 15:17:38 +1200
Subject: [PATCH] API Add streamable response object
---
src/Control/HTTPResponse.php | 133 +++++++++-----
src/Control/HTTPStreamResponse.php | 166 ++++++++++++++++++
tests/php/Control/HTTPStreamResponseTest.php | 63 +++++++
.../HTTPStreamResponseTest/testfile.txt | 1 +
4 files changed, 317 insertions(+), 46 deletions(-)
create mode 100644 src/Control/HTTPStreamResponse.php
create mode 100644 tests/php/Control/HTTPStreamResponseTest.php
create mode 100644 tests/php/Control/HTTPStreamResponseTest/testfile.txt
diff --git a/src/Control/HTTPResponse.php b/src/Control/HTTPResponse.php
index 4060509c8..ee7eb427b 100644
--- a/src/Control/HTTPResponse.php
+++ b/src/Control/HTTPResponse.php
@@ -3,7 +3,6 @@
namespace SilverStripe\Control;
use InvalidArgumentException;
-use Monolog\Formatter\FormatterInterface;
use Monolog\Handler\HandlerInterface;
use SilverStripe\Core\Convert;
use SilverStripe\Core\Injector\Injectable;
@@ -176,7 +175,8 @@ class HTTPResponse
*/
public function isError()
{
- return $this->statusCode && ($this->statusCode < 200 || $this->statusCode > 399);
+ $statusCode = $this->getStatusCode();
+ return $statusCode && ($statusCode < 200 || $statusCode > 399);
}
/**
@@ -257,12 +257,12 @@ class HTTPResponse
$code = 302;
}
$this->setStatusCode($code);
- $this->headers['Location'] = $dest;
+ $this->addHeader('Location', $dest);
return $this;
}
/**
- * Send this HTTPReponse to the browser
+ * Send this HTTPResponse to the browser
*/
public function output()
{
@@ -271,55 +271,86 @@ class HTTPResponse
Requirements::include_in_response($this);
}
- if (in_array($this->statusCode, self::$redirect_codes) && headers_sent($file, $line)) {
- $url = Director::absoluteURL($this->headers['Location'], true);
- $urlATT = Convert::raw2htmlatt($url);
- $urlJS = Convert::raw2js($url);
- $title = Director::isDev()
- ? "{$urlATT}... (output started on {$file}, line {$line})"
- : "{$urlATT}...";
- echo <<isRedirect() && headers_sent()) {
+ $this->htmlRedirect();
+ } else {
+ $this->outputHeaders();
+ $this->outputBody();
+ }
+ }
+
+ /**
+ * Generate a browser redirect without setting headers
+ */
+ protected function htmlRedirect()
+ {
+ $headersSent = headers_sent($file, $line);
+ $location = $this->getHeader('Location');
+ $url = Director::absoluteURL($location);
+ $urlATT = Convert::raw2htmlatt($url);
+ $urlJS = Convert::raw2js($url);
+ $title = (Director::isDev() && $headersSent)
+ ? "{$urlATT}... (output started on {$file}, line {$line})"
+ : "{$urlATT}...";
+ echo <<Redirecting to {$title}
EOT
- ;
- } else {
- $line = $file = null;
- if (!headers_sent($file, $line)) {
- header($_SERVER['SERVER_PROTOCOL'] . " $this->statusCode " . $this->getStatusDescription());
- foreach ($this->headers as $header => $value) {
- //etags need to be quoted
- if (strcasecmp('etag', $header) === 0 && 0 !== strpos($value, '"')) {
- $value = sprintf('"%s"', $value);
- }
- header("$header: $value", true, $this->statusCode);
- }
- } else {
- // It's critical that these status codes are sent; we need to report a failure if not.
- if ($this->statusCode >= 300) {
- user_error(
- "Couldn't set response type to $this->statusCode because " .
- "of output on line $line of $file",
- E_USER_WARNING
- );
- }
- }
+ ;
+ }
- // If this is an error but no error body has yet been generated,
- // delegate formatting to current error handler.
- if ($this->isError() && !$this->body) {
- /** @var HandlerInterface $handler */
- $handler = Injector::inst()->get(HandlerInterface::class);
- $formatter = $handler->getFormatter();
- echo $formatter->format(array(
- 'code' => $this->statusCode
- ));
- } else {
- echo $this->body;
+ /**
+ * Output HTTP headers to the browser
+ */
+ protected function outputHeaders()
+ {
+ $headersSent = headers_sent($file, $line);
+ if (!$headersSent) {
+ $method = sprintf(
+ "%s %d %s",
+ $_SERVER['SERVER_PROTOCOL'],
+ $this->getStatusCode(),
+ $this->getStatusDescription()
+ );
+ header($method);
+ foreach ($this->getHeaders() as $header => $value) {
+ header("{$header}: {$value}", true, $this->getStatusCode());
}
+ } elseif ($this->getStatusCode() >= 300) {
+ // It's critical that these status codes are sent; we need to report a failure if not.
+ user_error(
+ sprintf(
+ "Couldn't set response type to %d because of output on line %s of %s",
+ $this->getStatusCode(),
+ $line,
+ $file
+ ),
+ E_USER_WARNING
+ );
+ }
+ }
+
+ /**
+ * Output body of this response to the browser
+ */
+ protected function outputBody()
+ {
+ // Only show error pages or generic "friendly" errors if the status code signifies
+ // an error, and the response doesn't have any body yet that might contain
+ // a more specific error description.
+ $body = $this->getBody();
+ if ($this->isError() && empty($body)) {
+ /** @var HandlerInterface $handler */
+ $handler = Injector::inst()->get(HandlerInterface::class);
+ $formatter = $handler->getFormatter();
+ echo $formatter->format(array(
+ 'code' => $this->statusCode
+ ));
+ } else {
+ echo $this->body;
}
}
@@ -331,6 +362,16 @@ EOT
*/
public function isFinished()
{
- return in_array($this->statusCode, array(301, 302, 303, 304, 305, 307, 401, 403));
+ return $this->isRedirect() || $this->isError();
+ }
+
+ /**
+ * Determine if this response is a redirect
+ *
+ * @return bool
+ */
+ public function isRedirect()
+ {
+ return in_array($this->getStatusCode(), self::$redirect_codes);
}
}
diff --git a/src/Control/HTTPStreamResponse.php b/src/Control/HTTPStreamResponse.php
new file mode 100644
index 000000000..c547c8ce8
--- /dev/null
+++ b/src/Control/HTTPStreamResponse.php
@@ -0,0 +1,166 @@
+setStream($stream);
+ if ($contentLength) {
+ $this->addHeader('Content-Length', $contentLength);
+ }
+ }
+
+ /**
+ * Determine if a stream is seekable
+ *
+ * @return bool
+ */
+ protected function isSeekable()
+ {
+ $stream = $this->getStream();
+ if (!$stream) {
+ return false;
+ }
+ $metadata = stream_get_meta_data($stream);
+ return $metadata['seekable'];
+ }
+
+ /**
+ * @return resource
+ */
+ public function getStream()
+ {
+ return $this->stream;
+ }
+
+ /**
+ * @param resource $stream
+ * @return $this
+ */
+ public function setStream($stream)
+ {
+ $this->setBody(null);
+ $this->stream = $stream;
+ return $this;
+ }
+
+ /**
+ * Get body prior to stream traversal
+ *
+ * @return string
+ */
+ public function getSavedBody()
+ {
+ return parent::getBody();
+ }
+
+ public function getBody()
+ {
+ $body = $this->getSavedBody();
+ if (isset($body)) {
+ return $body;
+ }
+
+ // Consume stream into string
+ $body = $this->consumeStream(function ($stream) {
+ $body = stream_get_contents($stream);
+
+ // If this stream isn't seekable, we'll need to save the body
+ // in case of subsequent requests.
+ if (!$this->isSeekable()) {
+ $this->setBody($body);
+ }
+ return $body;
+ });
+ return $body;
+ }
+
+ /**
+ * Safely consume the stream
+ *
+ * @param callable $callback Callback which will perform the consumable action on the stream
+ * @return mixed Result of $callback($stream) or null if no stream available
+ * @throws BadMethodCallException Throws exception if stream can't be re-consumed
+ */
+ protected function consumeStream($callback)
+ {
+ // Load from stream
+ $stream = $this->getStream();
+ if (!$stream) {
+ return null;
+ }
+
+ // Check if stream must be rewound
+ if ($this->consumed) {
+ if (!$this->isSeekable()) {
+ throw new BadMethodCallException(
+ "Unseekable stream has already been consumed"
+ );
+ }
+ rewind($stream);
+ }
+
+ // Consume
+ $this->consumed = true;
+ return $callback($stream);
+ }
+
+ /**
+ * Output body of this response to the browser
+ */
+ protected function outputBody()
+ {
+ // If the output has been overwritten, or the stream is irreversable and has
+ // already been consumed, return the cached body.
+ $body = $this->getSavedBody();
+ if ($body) {
+ echo $body;
+ return;
+ }
+
+ // Stream to output
+ if ($this->getStream()) {
+ $this->consumeStream(function ($stream) {
+ fpassthru($stream);
+ });
+ return;
+ }
+
+ // Fail over
+ parent::outputBody();
+ }
+}
diff --git a/tests/php/Control/HTTPStreamResponseTest.php b/tests/php/Control/HTTPStreamResponseTest.php
new file mode 100644
index 000000000..9ef389d41
--- /dev/null
+++ b/tests/php/Control/HTTPStreamResponseTest.php
@@ -0,0 +1,63 @@
+assertEquals("Test output\n", $response->getBody());
+
+ // Test stream output
+ ob_start();
+ $response->output();
+ $result = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertEquals(12, $response->getHeader('Content-Length'));
+ $this->assertEquals("Test output\n", $result);
+ }
+
+ /**
+ * Test stream directly without loading into memory
+ */
+ public function testDirectStream()
+ {
+ $path = __DIR__ . '/HTTPStreamResponseTest/testfile.txt';
+ $stream = fopen($path, 'r');
+ $metadata = stream_get_meta_data($stream);
+ $this->assertTrue($metadata['seekable']);
+ $response = new HTTPStreamResponse($stream, filesize($path));
+
+ // Test stream output
+ ob_start();
+ $response->output();
+ $result = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertEquals(12, $response->getHeader('Content-Length'));
+ $this->assertEquals("Test output\n", $result);
+ $this->assertEmpty($response->getSavedBody(), 'Body of seekable stream is un-cached');
+
+ // Seekable stream can be repeated
+ ob_start();
+ $response->output();
+ $result = ob_get_contents();
+ ob_end_clean();
+
+ $this->assertEquals(12, $response->getHeader('Content-Length'));
+ $this->assertEquals("Test output\n", $result);
+ $this->assertEmpty($response->getSavedBody(), 'Body of seekable stream is un-cached');
+ }
+}
diff --git a/tests/php/Control/HTTPStreamResponseTest/testfile.txt b/tests/php/Control/HTTPStreamResponseTest/testfile.txt
new file mode 100644
index 000000000..400627996
--- /dev/null
+++ b/tests/php/Control/HTTPStreamResponseTest/testfile.txt
@@ -0,0 +1 @@
+Test output