Skip to content

Instantly share code, notes, and snippets.

@weierophinney
Last active October 23, 2020 14:16
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save weierophinney/7c450a4ab3080c6a0413 to your computer and use it in GitHub Desktop.
Save weierophinney/7c450a4ab3080c6a0413 to your computer and use it in GitHub Desktop.
Examples of streams you can use for alternate output styles in PSR-7

PSR-7 Stream Examples

PSR-7 uses Psr\Http\Message\StreamableInterface to represent content for a message. This has a variety of benefits, as outlined in the specification. However, for some, the model poses some conceptual challenges:

  • What if I want to emit a file on the server, like I might with fpassthru() or stream_copy_to_stream($fileHandle, fopen('php://output'))?
  • What if I want to use a callback to produce my output?
  • What if I don't want to use output buffering and/or echo/printf/etc. directly?
  • What if I want to iterate over a data structure and iteratively output content?

These patterns are all possible with creative implementations of StreamableInterface.

  • The file copy-stream.php demonstrates how you would emit a file.
  • CallbackStream and php-output.php demonstrate using a callback to generate and return content.
  • CallbackStream and php-output.php also demonstrate how you might use a callback to allow direct output from your code, without first aggregating it.
  • IteratorStream and the files iterator.php and generator.php demonstrate using iterators and generators for creating output.

In each, the assumption is that the application will short-circuit on receiving a response as a return value. Most modern frameworks do this already, and it's a guiding principle of middleware.

Analyzing the code

Emitting a file

For those who are accustomed to using readfile(), fpassthru() or copying a stream into php://output via stream_copy_to_stream(), PSR-7 will look and feel different. Typically, you will not use the aforementioned techniques when building an application to work with PSR-7, as they bypass the HTTP message entirely, and delegate it to PHP itself.

The problem with doing so, however, is that you cannot test your code as easily, as it now has side-effects. One major reason to adopt PSR-7 is if you want to be able to test your web-facing code without worrying about side effects. Adopting frameworks or application architectures that work with HTTP messages lets you pass in a Request, and make assertions on the response.

In the case of emitting a file, this means that you will:

  • Create a Stream instance, passing it the file location.
  • Provide appropriate headers to the response.
  • Provide your stream to the response.
  • Return your response.

Which looks like what we have in copy-stream.php:

$image = __DIR__ . '/cuervo.jpg';

return (new Response())
    ->withHeader('Content-Type', 'image/jpeg')
    ->withHeader('Content-Length', (string) filesize($image))
    ->withBody(new Stream($image));
return $response;

The assumption is that returning a response will bubble out of your application; most modern frameworks do this already, as does middleware. As such, you will typically have minimal additional overhead from the time you create the response until it's streaming your file back to the client.

Direct output

Just like the above example, for those accustomed to directly calling echo, or sending data directly to the php://output stream, PSR-7 will feel strange. However, as noted before as well, these actions are actions that have side effects that act as a barrier to testing and other quality assurance activities.

There is a way to accomplish it, however, with a little trickery: wrapping any output-emitting code in a callback, and passing this to a callback-enabled stream implementation. The CallbackStream implementation in this gist is one potential way to accomplish it.

As an example, from php-output.php:

$output = new CallbackStream(function () use ($request) {
    printf("The requested URI was: %s<br>\n", $request->getUri());
    return '';
});
return (new Response())
    ->withHeader('Content-Type', 'text/html')
    ->withBody($output);

This has a few benefits over directly emitting output from within your web-facing code:

  • We can ensure our headers are sent before emitting output.
  • We can set a non-200 status code if desired.
  • We still get the benefits of the output buffer.

As noted previously, returning a response will generally bubble out of the application immediately, making this a very viable option for emitting output directly.

(Note: the callback could also aggregate content and return it as a string if desired; I wanted to demonstrate specifically how it can be used to work with output buffering.)

Iterators and generators

Ruby's Rack specification uses an iterable body for response messages, vs a stream. In some situations, such as returning large data sets, this could be tremendously useful. Can PSR-7 accomplish it?

The answer is, succinctly, yes. The IteratorStream implementation in this gist is a rough prototype showing how it may work; usage would be as in iterator.php:

$output = new IteratorStream(new ArrayObject([
    "Foo!<br>\n",
    "Bar!<br>\n",
    "Baz!<br>\n",
]));
return (new Response())
    ->withHeader('Content-Type', 'text/html')
    ->withBody($output);

or, with a generator per generator.php:

$generator = function ($count) {
    while ($count) {
        --$count;
        yield(uniqid() . "<br>\n");
    }
};

$output = new IteratorStream($generator(10));

return (new Response())
    ->withHeader('Content-Type', 'text/html')
    ->withBody($output);

This is a nice approach, as you can iteratively generate the data returned; if you are worried about data overhead from aggregating the data before returning it, you can always use print or echo statements instead of aggregation.

Testing it out

You can test it out for yourself:

  • Clone this gist
  • Run composer install
  • Run php -S 0:8080 in the directory, and then browse to http://localhost:8080/{filename}, where {filename} is one of:
    • copy-stream.php
    • generator.php
    • iterator.php
    • php-output.php

Improvements

This was a quick repository built to demonstrate that PSR-7 fulfills these scenarios; however, they are far from comprehensive. Some ideas:

  • IteratorStream could and likely should allow providing a separator, and potentially preamble/postfix for wrapping content.
  • IteratorStream and CallbackStream could be optimized to emit output directly instead of aggregating + returning, if you are worried about large data sets.
  • CallbackStream could cache the contents to allow multiple reads (though using detach() would allow it already).
<?php
/**
* @copyright Copyright (c) 2015 Matthew Weier O'Phinney (https://mwop.net)
* @license http://opensource.org/licenses/BSD-2-Clause BSD-2-Clause
*/
namespace Psr7Examples;
use Psr\Http\Message\StreamableInterface;
/**
* Callback-based stream implementation.
*
* Wraps a callback, and invokes it in order to stream it.
*
* Only one invocation is allowed; multiple invocations will return an empty
* string for the second and subsequent calls.
*/
class CallbackStream implements StreamableInterface
{
/**
* @var callable
*/
private $callback;
/**
* Whether or not the callback has been previously invoked.
*
* @var bool
*/
private $called = false;
public function __construct(callable $callback)
{
$this->callback = $callback;
}
/**
* @return string
*/
public function __toString()
{
if ($this->called) {
return '';
}
$this->called = true;
return call_user_func($this->callback);
}
/**
* @return void
*/
public function close()
{
}
/**
* @return null|callable
*/
public function detach()
{
$callback = $this->callback;
$this->callback = null;
return $callback;
}
/**
* @return int|null Returns the size in bytes if known, or null if unknown.
*/
public function getSize()
{
}
/**
* @return int|bool Position of the file pointer or false on error.
*/
public function tell()
{
return 0;
}
/**
* @return bool
*/
public function eof()
{
return $this->called;
}
/**
* @return bool
*/
public function isSeekable()
{
return false;
}
/**
* @link http://www.php.net/manual/en/function.fseek.php
* @param int $offset Stream offset
* @param int $whence Specifies how the cursor position will be calculated
* based on the seek offset. Valid values are identical to the built-in
* PHP $whence values for `fseek()`. SEEK_SET: Set position equal to
* offset bytes SEEK_CUR: Set position to current location plus offset
* SEEK_END: Set position to end-of-stream plus offset.
* @return bool Returns TRUE on success or FALSE on failure.
*/
public function seek($offset, $whence = SEEK_SET)
{
return false;
}
/**
* @see seek()
* @link http://www.php.net/manual/en/function.fseek.php
* @return bool Returns TRUE on success or FALSE on failure.
*/
public function rewind()
{
return false;
}
/**
* @return bool
*/
public function isWritable()
{
return false;
}
/**
* @param string $string The string that is to be written.
* @return int|bool Returns the number of bytes written to the stream on
* success or FALSE on failure.
*/
public function write($string)
{
return false;
}
/**
* @return bool
*/
public function isReadable()
{
return true;
}
/**
* @param int $length Read up to $length bytes from the object and return
* them. Fewer than $length bytes may be returned if underlying stream
* call returns fewer bytes.
* @return string|false Returns the data read from the stream, false if
* unable to read or if an error occurs.
*/
public function read($length)
{
if ($this->called) {
return false;
}
$this->called = true;
return call_user_func($this->callback);
}
/**
* @return string
*/
public function getContents()
{
if ($this->called) {
return '';
}
$this->called = true;
return call_user_func($this->callback);
}
/**
* @link http://php.net/manual/en/function.stream-get-meta-data.php
* @param string $key Specific metadata to retrieve.
* @return array|mixed|null Returns an associative array if no key is
* provided. Returns a specific key value if a key is provided and the
* value is found, or null if the key is not found.
*/
public function getMetadata($key = null)
{
if ($key === null) {
return array();
}
return null;
}
}
{
"require": {
"psr/http-message": "0.9.*",
"phly/http": "0.11.*"
},
"autoload": {
"psr-4": {
"Psr7Examples\\": "."
}
}
}
<?php
/**
* Example demonstrating returning a file as a stream.
*
* Essentially, point your Phly\Http\Stream at a file, pass it to a response,
* and return the response; if you can provide proper headers, do it.
*
* @copyright Copyright (c) 2015 Matthew Weier O'Phinney (https://mwop.net)
* @license http://opensource.org/licenses/BSD-2-Clause BSD-2-Clause
* @codingStandardsIgnoreFile
*/
use Phly\Http\Response;
use Phly\Http\Server;
use Phly\Http\Stream;
require 'vendor/autoload.php';
$server = Server::createServer(function ($request, $response, $done) {
// Cuervo was our original Basset Hound; this was her in her natural habitat.
$image = __DIR__ . '/cuervo.jpg';
return (new Response())
->withHeader('Content-Type', 'image/jpeg')
->withHeader('Content-Length', (string) filesize($image))
->withBody(new Stream($image));
return $response;
}, $_SERVER, $_GET, $_POST, $_COOKIE, $_FILES);
$server->listen();
<?php
/**
* @copyright Copyright (c) 2015 Matthew Weier O'Phinney (https://mwop.net)
* @license http://opensource.org/licenses/BSD-2-Clause BSD-2-Clause
*/
namespace Psr7Examples;
use Countable;
use IteratorAggregate;
use Traversable;
use Psr\Http\Message\StreamableInterface;
/**
* Iterator-based stream implementation.
*
* Wraps an iterator to allow seeking, reading, and casting to string.
*
* Keys are ignored, and content is concatenated without separators.
*/
class IteratorStream implements StreamableInterface
{
/**
* @var Traversable
*/
private $iterator;
/**
* Current position in iterator
*
* @var int
*/
private $position = 0;
/**
* Construct a stream instance using an iterator.
*
* If the iterator is an IteratorAggregate, pulls the inner iterator
* and composes that instead, to ensure we have access to the various
* iterator capabilities.
*
* @param Traversable $iterator
*/
public function __construct(Traversable $iterator)
{
if ($iterator instanceof IteratorAggregate) {
$iterator = $iterator->getIterator();
}
$this->iterator = $iterator;
}
/**
* @return string
*/
public function __toString()
{
$this->iterator->rewind();
return $this->getContents();
}
/**
* No-op.
*
* @return void
*/
public function close()
{
}
/**
* @return null|Traversable
*/
public function detach()
{
$iterator = $this->iterator;
$this->iterator = null;
return $iterator;
}
/**
* @return int|null Returns the size of the iterator, or null if unknown.
*/
public function getSize()
{
if ($this->iterator instanceof Countable) {
return count($this->iterator);
}
return null;
}
/**
* @return int|bool Position of the iterator or false on error.
*/
public function tell()
{
return $this->position;
}
/**
* @return bool
*/
public function eof()
{
if ($this->iterator instanceof Countable) {
return ($this->position === count($this->iterator));
}
return (! $this->iterator->valid());
}
/**
* @return bool
*/
public function isSeekable()
{
return true;
}
/**
* @param int $offset Stream offset
* @param int $whence Ignored.
* @return bool Returns TRUE on success or FALSE on failure.
*/
public function seek($offset, $whence = SEEK_SET)
{
if (! is_int($offset) && ! is_numeric($offset)) {
return false;
}
$offset = (int) $offset;
if ($offset < 0) {
return false;
}
$key = $this->iterator->key();
if (! is_int($key) && ! is_numeric($key)) {
$key = 0;
$this->iterator->rewind();
}
if ($key >= $offset) {
$key = 0;
$this->iterator->rewind();
}
while ($this->iterator->valid() && $key < $offset) {
$this->iterator->next();
++$key;
}
$this->position = $key;
return true;
}
/**
* @see seek()
* @return bool Returns TRUE on success or FALSE on failure.
*/
public function rewind()
{
$this->iterator->rewind();
$this->position = 0;
return true;
}
/**
* @return bool Always returns false
*/
public function isWritable()
{
return false;
}
/**
* Non-writable
*
* @param string $string The string that is to be written.
* @return int|bool Always returns false
*/
public function write($string)
{
return false;
}
/**
* @return bool Always returns true
*/
public function isReadable()
{
return true;
}
/**
* @param int $length Read up to $length items from the object and return
* them. Fewer than $length items may be returned if underlying iterator
* has fewer items.
* @return string|false Returns the data read from the iterator, false if
* unable to read or if an error occurs.
*/
public function read($length)
{
$index = 0;
$contents = '';
while ($this->iterator->valid() && $index < $length) {
$contents .= $this->iterator->current();
$this->iterator->next();
++$this->position;
++$index;
}
return $contents;
}
/**
* @return string
*/
public function getContents()
{
$contents = '';
while ($this->iterator->valid()) {
$contents .= $this->iterator->current();
$this->iterator->next();
++$this->position;
}
return $contents;
}
/**
* @param string $key Specific metadata to retrieve.
* @return array|null Returns an empty array if no key is provided, and
* null otherwise.
*/
public function getMetadata($key = null)
{
return ($key === null) ? array() : null;
}
}

The BSD 2-Clause License

Copyright (c) 2015, Matthew Weier O'Phinney All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

  1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.

  2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

View raw

(Sorry about that, but we can’t show files that are this big right now.)

@weierophinney
Copy link
Author

I've now moved this to: https://github.com/phly/psr7examples

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment