mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
API Add streamable response object
This commit is contained in:
parent
44981de560
commit
906a4c444b
@ -3,7 +3,6 @@
|
|||||||
namespace SilverStripe\Control;
|
namespace SilverStripe\Control;
|
||||||
|
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Monolog\Formatter\FormatterInterface;
|
|
||||||
use Monolog\Handler\HandlerInterface;
|
use Monolog\Handler\HandlerInterface;
|
||||||
use SilverStripe\Core\Convert;
|
use SilverStripe\Core\Convert;
|
||||||
use SilverStripe\Core\Injector\Injectable;
|
use SilverStripe\Core\Injector\Injectable;
|
||||||
@ -176,7 +175,8 @@ class HTTPResponse
|
|||||||
*/
|
*/
|
||||||
public function isError()
|
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;
|
$code = 302;
|
||||||
}
|
}
|
||||||
$this->setStatusCode($code);
|
$this->setStatusCode($code);
|
||||||
$this->headers['Location'] = $dest;
|
$this->addHeader('Location', $dest);
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send this HTTPReponse to the browser
|
* Send this HTTPResponse to the browser
|
||||||
*/
|
*/
|
||||||
public function output()
|
public function output()
|
||||||
{
|
{
|
||||||
@ -271,55 +271,86 @@ class HTTPResponse
|
|||||||
Requirements::include_in_response($this);
|
Requirements::include_in_response($this);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($this->statusCode, self::$redirect_codes) && headers_sent($file, $line)) {
|
if ($this->isRedirect() && headers_sent()) {
|
||||||
$url = Director::absoluteURL($this->headers['Location'], true);
|
$this->htmlRedirect();
|
||||||
$urlATT = Convert::raw2htmlatt($url);
|
} else {
|
||||||
$urlJS = Convert::raw2js($url);
|
$this->outputHeaders();
|
||||||
$title = Director::isDev()
|
$this->outputBody();
|
||||||
? "{$urlATT}... (output started on {$file}, line {$line})"
|
}
|
||||||
: "{$urlATT}...";
|
}
|
||||||
echo <<<EOT
|
|
||||||
|
/**
|
||||||
|
* 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>
|
<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}" />
|
<meta http-equiv="refresh" content="1; url={$urlATT}" />
|
||||||
<script type="application/javascript">setTimeout(function(){
|
<script type="application/javascript">setTimeout(function(){
|
||||||
window.location.href = "{$urlJS}";
|
window.location.href = "{$urlJS}";
|
||||||
}, 50);</script>
|
}, 50);</script>
|
||||||
EOT
|
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.
|
* Output HTTP headers to the browser
|
||||||
if ($this->isError() && !$this->body) {
|
*/
|
||||||
/** @var HandlerInterface $handler */
|
protected function outputHeaders()
|
||||||
$handler = Injector::inst()->get(HandlerInterface::class);
|
{
|
||||||
$formatter = $handler->getFormatter();
|
$headersSent = headers_sent($file, $line);
|
||||||
echo $formatter->format(array(
|
if (!$headersSent) {
|
||||||
'code' => $this->statusCode
|
$method = sprintf(
|
||||||
));
|
"%s %d %s",
|
||||||
} else {
|
$_SERVER['SERVER_PROTOCOL'],
|
||||||
echo $this->body;
|
$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()
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
166
src/Control/HTTPStreamResponse.php
Normal file
166
src/Control/HTTPStreamResponse.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
63
tests/php/Control/HTTPStreamResponseTest.php
Normal file
63
tests/php/Control/HTTPStreamResponseTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
1
tests/php/Control/HTTPStreamResponseTest/testfile.txt
Normal file
1
tests/php/Control/HTTPStreamResponseTest/testfile.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
Test output
|
Loading…
Reference in New Issue
Block a user