API Add streamable response object

This commit is contained in:
Damian Mooyman 2017-05-12 15:17:38 +12:00
parent 44981de560
commit 906a4c444b
No known key found for this signature in database
GPG Key ID: 78B823A10DE27D1A
4 changed files with 317 additions and 46 deletions

View File

@ -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 <<<EOT
if ($this->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 <<<EOT
<p>Redirecting to <a href="{$urlATT}" title="Click this link if your browser does not redirect you">{$title}</a></p>
<meta http-equiv="refresh" content="1; url={$urlATT}" />
<script type="application/javascript">setTimeout(function(){
window.location.href = "{$urlJS}";
}, 50);</script>
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);
}
}

View File

@ -0,0 +1,166 @@
<?php
namespace SilverStripe\Control;
use BadMethodCallException;
/**
* A response which contains a streamable data source.
*
* @package framework
* @subpackage control
*/
class HTTPStreamResponse extends HTTPResponse
{
/**
* Stream source for this response
*
* @var resource
*/
protected $stream = null;
/**
* Set to true if this stream has been consumed.
* A consumed non-seekable stream will not be re-consumable
*
* @var bool
*/
protected $consumed = false;
/**
* HTTPStreamResponse constructor.
* @param resource $stream Data stream
* @param int $contentLength size of the stream in bytes
* @param int $statusCode The numeric status code - 200, 404, etc
* @param string $statusDescription The text to be given alongside the status code.
*/
public function __construct($stream, $contentLength, $statusCode = null, $statusDescription = null)
{
parent::__construct(null, $statusCode, $statusDescription);
$this->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();
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace SilverStripe\Control\Tests;
use SilverStripe\Control\HTTPStreamResponse;
use SilverStripe\Dev\SapphireTest;
class HTTPStreamResponseTest extends SapphireTest
{
/**
* Test replaying of stream from memory
*/
public function testReplayStream()
{
$path = __DIR__ . '/HTTPStreamResponseTest/testfile.txt';
$stream = fopen($path, 'r');
$response = new HTTPStreamResponse($stream, filesize($path));
// Test body (should parse stream directly into memory)
$this->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');
}
}

View File

@ -0,0 +1 @@
Test output