diff --git a/.travis.yml b/.travis.yml index 5820908..b52d2be 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,9 +5,9 @@ dist: trusty matrix: include: - php: 7.1 - env: ANALYSIS='true' - php: 7.2 - php: 7.3 + env: ANALYSIS='true' - php: nightly allow_failures: - php: nightly diff --git a/src/Factory/ServerRequestFactory.php b/src/Factory/ServerRequestFactory.php index 44c2973..a8115b6 100644 --- a/src/Factory/ServerRequestFactory.php +++ b/src/Factory/ServerRequestFactory.php @@ -18,6 +18,7 @@ use Slim\Psr7\Cookies; use Slim\Psr7\Headers; use Slim\Psr7\Request; +use Slim\Psr7\Stream; use Slim\Psr7\UploadedFile; class ServerRequestFactory implements ServerRequestFactoryInterface @@ -90,7 +91,11 @@ public static function createFromGlobals(): Request $headers = Headers::createFromGlobals(); $cookies = Cookies::parseHeader($headers->getHeader('Cookie', [])); - $body = (new StreamFactory())->createStreamFromFile('php://input'); + // Cache the php://input stream as it cannot be re-read + $cacheResource = fopen('php://temp', 'wb+'); + $cache = $cacheResource ? new Stream($cacheResource) : null; + + $body = (new StreamFactory())->createStreamFromFile('php://input', 'r', $cache); $uploadedFiles = UploadedFile::createFromGlobals($_SERVER); $request = new Request($method, $uri, $headers, $cookies, $_SERVER, $body, $uploadedFiles); diff --git a/src/Factory/StreamFactory.php b/src/Factory/StreamFactory.php index 8cdbd5d..26341a2 100644 --- a/src/Factory/StreamFactory.php +++ b/src/Factory/StreamFactory.php @@ -39,8 +39,11 @@ public function createStream(string $content = ''): StreamInterface /** * {@inheritdoc} */ - public function createStreamFromFile(string $filename, string $mode = 'r'): StreamInterface - { + public function createStreamFromFile( + string $filename, + string $mode = 'r', + StreamInterface $cache = null + ): StreamInterface { $resource = fopen($filename, $mode); if (!is_resource($resource)) { @@ -49,13 +52,13 @@ public function createStreamFromFile(string $filename, string $mode = 'r'): Stre ); } - return new Stream($resource); + return new Stream($resource, $cache); } /** * {@inheritdoc} */ - public function createStreamFromResource($resource): StreamInterface + public function createStreamFromResource($resource, StreamInterface $cache = null): StreamInterface { if (!is_resource($resource)) { throw new InvalidArgumentException( @@ -63,6 +66,6 @@ public function createStreamFromResource($resource): StreamInterface ); } - return new Stream($resource); + return new Stream($resource, $cache); } } diff --git a/src/Stream.php b/src/Stream.php index 3dbac64..88b9494 100644 --- a/src/Stream.php +++ b/src/Stream.php @@ -60,13 +60,29 @@ class Stream implements StreamInterface protected $isPipe; /** - * @param resource $stream A PHP resource handle. + * @var bool + */ + protected $finished; + + /** + * @var StreamInterface | null + */ + protected $cache; + + /** + * @param resource $stream A PHP resource handle. + * @param StreamInterface $cache A stream to cache $stream (useful for non-seekable streams) * * @throws InvalidArgumentException If argument is not a resource. */ - public function __construct($stream) + public function __construct($stream, StreamInterface $cache = null) { $this->attach($stream); + + if ($cache && (!$cache->isSeekable() || !$cache->isWritable())) { + throw new RuntimeException('Cache stream must be seekable and writable'); + } + $this->cache = $cache; } /** @@ -137,6 +153,11 @@ public function __toString(): string return ''; } + if ($this->cache && $this->finished) { + $this->cache->rewind(); + return $this->cache->getContents(); + } + try { $this->rewind(); return $this->getContents(); @@ -292,6 +313,12 @@ public function read($length): string } if (is_string($data)) { + if ($this->cache) { + $this->cache->write($data); + } + if ($this->eof()) { + $this->finished = true; + } return $data; } @@ -322,6 +349,11 @@ public function write($string) */ public function getContents(): string { + if ($this->cache && $this->finished) { + $this->cache->rewind(); + return $this->cache->getContents(); + } + $contents = false; if ($this->stream) { @@ -329,6 +361,12 @@ public function getContents(): string } if (is_string($contents)) { + if ($this->cache) { + $this->cache->write($contents); + } + if ($this->eof()) { + $this->finished = true; + } return $contents; } diff --git a/tests/Factory/ServerRequestFactoryTest.php b/tests/Factory/ServerRequestFactoryTest.php index 6fad89e..d0aba12 100644 --- a/tests/Factory/ServerRequestFactoryTest.php +++ b/tests/Factory/ServerRequestFactoryTest.php @@ -12,6 +12,7 @@ use Interop\Http\Factory\ServerRequestFactoryTestCase; use InvalidArgumentException; use Psr\Http\Message\UriInterface; +use ReflectionClass; use Slim\Psr7\Environment; use Slim\Psr7\Factory\ServerRequestFactory; use Slim\Psr7\Factory\UriFactory; @@ -117,6 +118,19 @@ public function testCreateFromGlobalsBodyPointsToPhpInput() $this->assertEquals('php://input', $request->getBody()->getMetadata('uri')); } + public function testCreateFromGlobalsSetsACache() + { + $request = ServerRequestFactory::createFromGlobals(); + + // ensure that the Stream's $cache property has been set for this php://input stream + $stream = $request->getBody(); + $class = new ReflectionClass($stream); + $property = $class->getProperty('cache'); + $property->setAccessible(true); + $cacheStreamValue = $property->getValue($stream); + $this->assertNotNull($cacheStreamValue); + } + public function testCreateFromGlobalsWithUploadedFiles() { $_SERVER = Environment::mock([ diff --git a/tests/StreamTest.php b/tests/StreamTest.php index f27aead..862ecc9 100644 --- a/tests/StreamTest.php +++ b/tests/StreamTest.php @@ -201,4 +201,43 @@ private function openPipeStream() $this->pipeFh = popen('echo 12', 'r'); $this->pipeStream = new Stream($this->pipeFh); } + + public function testReadOnlyCachedStreamsAreDisallowed() + { + $resource = fopen('php://temp', 'w+'); + $cache = new Stream(fopen('php://temp', 'r')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cache stream must be seekable and writable'); + new Stream($resource, $cache); + } + + public function testNonSeekableCachedStreamsAreDisallowed() + { + $resource = fopen('php://temp', 'w+'); + $cache = new Stream(fopen('php://output', 'w')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cache stream must be seekable and writable'); + + new Stream($resource, $cache); + } + + public function testCachedStreamsGetsContentFromTheCache() + { + $resource = popen('echo HelloWorld', 'r'); + $stream = new Stream($resource, new Stream(fopen('php://temp', 'w+'))); + + $this->assertEquals("HelloWorld\n", $stream->getContents()); + $this->assertEquals("HelloWorld\n", $stream->getContents()); + } + + public function testCachedStreamsFillsCacheOnRead() + { + $resource = fopen('data://,0', 'r'); + $stream = new Stream($resource, new Stream(fopen('php://temp', 'w+'))); + + $this->assertEquals("0", $stream->read(100)); + $this->assertEquals("0", $stream->__toString()); + } }