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;
|
||||
|
||||
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,11 +271,25 @@ 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);
|
||||
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()
|
||||
$title = (Director::isDev() && $headersSent)
|
||||
? "{$urlATT}... (output started on {$file}, line {$line})"
|
||||
: "{$urlATT}...";
|
||||
echo <<<EOT
|
||||
@ -286,31 +300,49 @@ class HTTPResponse
|
||||
}, 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);
|
||||
|
||||
/**
|
||||
* 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());
|
||||
}
|
||||
} else {
|
||||
} elseif ($this->getStatusCode() >= 300) {
|
||||
// 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",
|
||||
sprintf(
|
||||
"Couldn't set response type to %d because of output on line %s of %s",
|
||||
$this->getStatusCode(),
|
||||
$line,
|
||||
$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) {
|
||||
/**
|
||||
* 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();
|
||||
@ -321,7 +353,6 @@ EOT
|
||||
echo $this->body;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this response is "finished", that is, no more script execution should be done.
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
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