Skip to content

Commit

Permalink
🎉 Initial implementation of PSR-7 Stream Response
Browse files Browse the repository at this point in the history
Heavily based on Symfony's BinaryFileResponse, it's able to return a PSR-7 Stream instead.
  • Loading branch information
giggsey committed Aug 8, 2018
1 parent bb45f06 commit daabf60
Show file tree
Hide file tree
Showing 4 changed files with 248 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/vendor/
/composer.lock
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# PSR-7 Stream Response

## Why?

Symfony's BinaryFileResponse allows presenting files to download to HTTP Clients. However, this expects full file paths.
Some projects may want to stream a PSR-7 Stream to the client instead.

## How to use

Instead of returning a BinaryFileResponse, create a PSR7StreamResponse, and return that.

### Before

```php
$response = new BinaryFileResponse($filePath);
$response = $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'my-file.mp3');

return $response;
```

### After

```php
$response = new PSR7StreamResponse($stream);
$response = $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, 'my-file.mp3');

return $response;
```
22 changes: 22 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "giggsey/psr7-stream-response",
"description": "Build a File Response from a PSR-7 Stream",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Joshua Gigg",
"email": "[email protected]"
}
],
"minimum-stability": "stable",
"require": {
"symfony/http-foundation": "^2.8|^3.0|^4.1",
"psr/http-message": "^1.0"
},
"autoload": {
"psr-4": {
"giggsey\\PSR7StreamResponse\\": "src/"
}
}
}
196 changes: 196 additions & 0 deletions src/PSR7StreamResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
<?php
/**
*
* User: giggsey
* Date: 08/08/18
* Time: 13:21
*/

namespace giggsey\PSR7StreamResponse;

use Psr\Http\Message\StreamInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class PSR7StreamResponse extends Response
{
/**
* @var StreamInterface
*/
protected $stream;
protected $mimeType;
protected $offset;
protected $maxlen;

public function __construct(
StreamInterface $stream,
$mimeType,
$status = 200,
$headers = array(),
$public = true
) {
parent::__construct(null, $status, $headers);

$this->setStream($stream, $mimeType);

if ($public) {
$this->setPublic();
}
}

/**
* Sets the file to stream.
*
* @param StreamInterface $stream
* @param string $mimeType
*
* @return $this
*/
public function setStream(StreamInterface $stream, $mimeType)
{
$this->stream = $stream;
$this->mimeType = $mimeType;

return $this;
}

/**
* Sets the Content-Disposition header with the given filename.
*
* @param string $disposition ResponseHeaderBag::DISPOSITION_INLINE or ResponseHeaderBag::DISPOSITION_ATTACHMENT
* @param string $filename Use this UTF-8 encoded filename instead of the real name of the file
*
* @return $this
*/
public function setContentDisposition($disposition, $filename)
{
$dispositionHeader = $this->headers->makeDisposition($disposition, $filename);
$this->headers->set('Content-Disposition', $dispositionHeader);

return $this;
}

/**
* {@inheritdoc}
*/
public function prepare(Request $request)
{
$this->headers->set('Content-Length', $this->stream->getSize());

if (!$this->headers->has('Accept-Ranges')) {
// Only accept ranges on safe HTTP methods
$this->headers->set('Accept-Ranges', $request->isMethodSafe(false) ? 'bytes' : 'none');
}

if (!$this->headers->has('Content-Type')) {
$this->headers->set('Content-Type', $this->mimeType ?: 'application/octet-stream');
}

if ('HTTP/1.0' !== $request->server->get('SERVER_PROTOCOL')) {
$this->setProtocolVersion('1.1');
}

$this->ensureIEOverSSLCompatibility($request);

$this->offset = 0;
$this->maxlen = -1;

if ($request->headers->has('Range')) {
// Process the range headers.
if (!$request->headers->has('If-Range') || $this->hasValidIfRangeHeader($request->headers->get('If-Range'))) {
$range = $request->headers->get('Range');
$fileSize = $this->stream->getSize();

list($start, $end) = explode('-', substr($range, 6), 2) + array(0);

$end = ('' === $end) ? $fileSize - 1 : (int)$end;

if ('' === $start) {
$start = $fileSize - $end;
$end = $fileSize - 1;
} else {
$start = (int)$start;
}

if ($start <= $end) {
if ($start < 0 || $end > $fileSize - 1) {
$this->setStatusCode(416);
$this->headers->set('Content-Range', sprintf('bytes */%s', $fileSize));
} elseif (0 !== $start || $end !== $fileSize - 1) {
$this->maxlen = $end < $fileSize ? $end - $start + 1 : -1;
$this->offset = $start;

$this->setStatusCode(206);
$this->headers->set('Content-Range', sprintf('bytes %s-%s/%s', $start, $end, $fileSize));
$this->headers->set('Content-Length', $end - $start + 1);
}
}
}
}

return $this;
}


private function hasValidIfRangeHeader($header)
{
if ($this->getEtag() === $header) {
return true;
}

if (null === $lastModified = $this->getLastModified()) {
return false;
}

return $lastModified->format('D, d M Y H:i:s') . ' GMT' === $header;
}

/**
* Sends the file.
*
* {@inheritdoc}
*/
public function sendContent()
{
if (!$this->isSuccessful()) {
return parent::sendContent();
}

if (0 === $this->maxlen) {
return $this;
}

$this->stream->seek($this->offset);

if ($this->maxlen === -1) {
// Read the entire stream
$this->maxlen = $this->stream->getSize() - $this->offset;
}

echo $this->stream->read($this->maxlen);

return $this;
}

/**
* {@inheritdoc}
*
* @throws \LogicException when the content is not null
*/
public function setContent($content)
{
if (null !== $content) {
throw new \LogicException('The content cannot be set on a PSR7StreamResponse instance.');
}
}

/**
* {@inheritdoc}
*
* @return false
*/
public function getContent()
{
return false;
}
}

0 comments on commit daabf60

Please sign in to comment.