diff --git a/src/Message/Stream.php b/src/Message/Stream.php index a37df74..4543e38 100644 --- a/src/Message/Stream.php +++ b/src/Message/Stream.php @@ -6,7 +6,10 @@ use Psr\Http\Message\StreamInterface; class Stream implements StreamInterface { - /** @var resource */ + private const READABLE_MODES = ['r', 'r+', 'w+', 'a+', 'x+', 'c+']; + private const WRITABLE_MODES = ['r+', 'w', 'w+', 'a', 'a+', 'x', 'x+', 'c', 'c+']; + + /** @var resource|null */ private $resource; /** @@ -46,18 +49,16 @@ class Stream implements StreamInterface */ public function __toString() { - $string = ""; try { if ($this->isSeekable()) { - rewind($this->resource); + $this->rewind(); } - $string = $this->getContents(); - // @codeCoverageIgnoreStart + return $this->getContents(); } catch (\Exception $e) { - // @codeCoverageIgnoreEnd - // Silence exceptions in order to conform with PHP's string casting operations. + // Silence exceptions in order to conform with PHP's string casting + // operations. + return ''; } - return $string; } /** @@ -67,6 +68,10 @@ class Stream implements StreamInterface */ public function close() { + if ($this->resource === null) { + return; + } + $resource = $this->resource; fclose($resource); $this->resource = null; @@ -93,6 +98,10 @@ class Stream implements StreamInterface */ public function getSize() { + if ($this->resource === null) { + return null; + } + $statistics = fstat($this->resource); if ($statistics && $statistics["size"]) { return $statistics["size"]; @@ -108,6 +117,10 @@ class Stream implements StreamInterface */ public function tell() { + if ($this->resource === null) { + throw new \RuntimeException("Unable to retrieve current position of detached stream."); + } + $position = ftell($this->resource); if ($position === false) { throw new \RuntimeException("Unable to retrieve current position of file pointer."); @@ -122,6 +135,10 @@ class Stream implements StreamInterface */ public function eof() { + if ($this->resource === null) { + return true; + } + return feof($this->resource); } @@ -132,6 +149,10 @@ class Stream implements StreamInterface */ public function isSeekable() { + if ($this->resource === null) { + return false; + } + return $this->getMetadata("seekable") == 1; } @@ -149,6 +170,10 @@ class Stream implements StreamInterface */ public function seek($offset, $whence = SEEK_SET) { + if ($this->resource === null) { + throw new \RuntimeException("Unable to seek detached stream."); + } + $result = -1; if ($this->isSeekable()) { $result = fseek($this->resource, $offset, $whence); @@ -170,12 +195,16 @@ class Stream implements StreamInterface */ public function rewind() { + if ($this->resource === null) { + throw new \RuntimeException("Unable to seek detached stream."); + } + $result = false; if ($this->isSeekable()) { $result = rewind($this->resource); } if ($result === false) { - throw new \RuntimeException("Unable to seek to position."); + throw new \RuntimeException("Unable to rewind."); } } @@ -186,8 +215,12 @@ class Stream implements StreamInterface */ public function isWritable() { - $mode = $this->getMetadata("mode"); - return $mode[0] !== "r" || strpos($mode, "+") !== false; + if ($this->resource === null) { + return false; + } + + $mode = $this->getBasicMode(); + return in_array($mode, self::WRITABLE_MODES); } /** @@ -199,6 +232,10 @@ class Stream implements StreamInterface */ public function write($string) { + if ($this->resource === null) { + throw new \RuntimeException("Unable to write to detached stream."); + } + $result = false; if ($this->isWritable()) { $result = fwrite($this->resource, $string); @@ -216,8 +253,12 @@ class Stream implements StreamInterface */ public function isReadable() { - $mode = $this->getMetadata("mode"); - return strpos($mode, "r") !== false || strpos($mode, "+") !== false; + if ($this->resource === null) { + return false; + } + + $mode = $this->getBasicMode(); + return in_array($mode, self::READABLE_MODES); } /** @@ -232,6 +273,10 @@ class Stream implements StreamInterface */ public function read($length) { + if ($this->resource === null) { + throw new \RuntimeException("Unable to read to detached stream."); + } + $result = false; if ($this->isReadable()) { $result = fread($this->resource, $length); @@ -251,6 +296,10 @@ class Stream implements StreamInterface */ public function getContents() { + if ($this->resource === null) { + throw new \RuntimeException("Unable to read to detached stream."); + } + $result = false; if ($this->isReadable()) { $result = stream_get_contents($this->resource); @@ -268,13 +317,17 @@ class Stream implements StreamInterface * stream_get_meta_data() function. * * @link http://php.net/manual/en/function.stream-get-meta-data.php - * @param string $key Specific metadata to retrieve. + * @param string|null $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 ($this->resource === null) { + return null; + } + $metadata = stream_get_meta_data($this->resource); if ($key === null) { return $metadata; @@ -282,4 +335,14 @@ class Stream implements StreamInterface return $metadata[$key]; } } + + /** + * @return string Mode for the resource reduced to only the characters + * r, w, a, x, c, and + needed to determine readable and writeable status. + */ + private function getBasicMode() + { + $mode = $this->getMetadata('mode') ?? ''; + return preg_replace('/[^rwaxc+]/', '', $mode); + } } diff --git a/test/tests/unit/Message/StreamTest.php b/test/tests/unit/Message/StreamTest.php index 714b981..b89db9f 100644 --- a/test/tests/unit/Message/StreamTest.php +++ b/test/tests/unit/Message/StreamTest.php @@ -13,14 +13,14 @@ class StreamTest extends TestCase private $resourceDevNull; private $content = "Hello, world!"; - public function setUp(): void + protected function setUp(): void { $this->resource = fopen("php://memory", "w+"); $this->resourceDevNull = fopen("/dev/null", "r"); fwrite($this->resource, $this->content); } - public function tearDown(): void + protected function tearDown(): void { if (is_resource($this->resource)) { fclose($this->resource); @@ -269,4 +269,119 @@ class StreamTest extends TestCase ["c+", true, true] ]; } + + // ------------------------------------------------------------------------- + // After Detach + + public function testAfterDetachToStringReturnsEmptyString(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $this->assertEquals('', (string) $stream); + } + + public function testAfterDetachCloseDoesNothing(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $stream->close(); + $this->assertTrue(true); + } + + public function testAfterDetachDetachReturnsNull(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $this->assertNull($stream->detach()); + } + + public function testAfterDetachGetSizeReturnsNull(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $this->assertNull($stream->getSize()); + } + + public function testAfterDetachTellThrowsRuntimeException(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $this->expectException(RuntimeException::class); + $stream->tell(); + } + + public function testAfterDetachEofReturnsTrue(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $this->assertTrue($stream->eof()); + } + + public function testAfterDetachIsSeekableReturnsFalse(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $this->assertFalse($stream->isSeekable()); + } + + public function testAfterDetachSeekThrowsRuntimeException(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $this->expectException(RuntimeException::class); + $stream->seek(0); + } + + public function testAfterDetachRewindThrowsRuntimeException(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $this->expectException(RuntimeException::class); + $stream->rewind(); + } + + public function testAfterDetachIsWritableReturnsFalse(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $this->assertFalse($stream->isWritable()); + } + + public function testAfterDetachWriteThrowsRuntimeException(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $this->expectException(RuntimeException::class); + $stream->write('bork'); + } + + public function testAfterDetachIsReadableReturnsFalse(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $this->assertFalse($stream->isReadable()); + } + + public function testAfterDetachReadThrowsRuntimeException(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $this->expectException(RuntimeException::class); + $stream->read(10); + } + + public function testAfterDetachGetContentsThrowsRuntimeException(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $this->expectException(RuntimeException::class); + $stream->getContents(); + } + + public function testAfterDetachGetMetadataReturnsNull(): void + { + $stream = new Stream($this->resource); + $stream->detach(); + $this->assertNull($stream->getMetadata()); + } }