~ancarda/psr7-string-stream

613485cfa5ed04fe26f6b60807209e098a97ca87 — Mark Dain 1 year, 9 months ago 4156246 1.3
Throw StreamUnusableException after close/detach

This commit adjusts the behavior of close and detach to set the body
to null rather than an empty string. All methods that support throwing
an exception will now throw StreamUnusableException to indicate the
stream has been closed and is thus unusable.

The methods that do not have "@throws RuntimeException" in their
docblocks usually return the zero value of their type. The sole
exception is the eof (End of File) function, since both true and false
is wrong; there's no stream to be at the end of. In this case, true is
returned because that's how the existing implementation works; pointer
is at payload length.

StreamUnusableException is a kind of IllegalOperationException, a new
exception that tells upstream callers which function call failed and
why. ReadOnlyStringStream now throws IllegalOperationException when a
user calls the write() method.

Closes #1
A src/IllegalOperationException.php => src/IllegalOperationException.php +46 -0
@@ 0,0 1,46 @@
<?php

declare(strict_types=1);

namespace Ancarda\Psr7\StringStream;

use RuntimeException;
use Throwable;

/**
 * IllegalOperationException is thrown when a user attempts to perform an unsupported operation.
 *
 * An example might be calling write() on a read only stream.
 */
class IllegalOperationException extends RuntimeException
{
    /** @var string */
    private $operation;

    /**
     * @param string $operation The operation, such as "write", that was refused.
     * @param string $justification Why can't the user perform the operation?
     *   This should complete the sentence "This stream is X", e.g. "This stream is read-only".
     * @param Throwable|null $previous
     */
    public function __construct(string $operation, string $justification, Throwable $previous = null)
    {
        $this->operation = $operation;

        parent::__construct(
            "You cannot call `{$this->operation}' on this stream because it's $justification.",
            0,
            $previous
        );
    }

    /**
     * Returns the operation, such as "write", that was refused.
     *
     * @return string
     */
    public function getOperation(): string
    {
        return $this->operation;
    }
}

M src/ReadOnlyStringStream.php => src/ReadOnlyStringStream.php +1 -1
@@ 30,6 30,6 @@ class ReadOnlyStringStream extends StringStream
     */
    public function write($string): int
    {
        throw new RuntimeException('Cannot write(): Read Only Stream');
        throw new IllegalOperationException(__FUNCTION__, 'read-only');
    }
}

A src/StreamUnusableException.php => src/StreamUnusableException.php +22 -0
@@ 0,0 1,22 @@
<?php

declare(strict_types=1);

namespace Ancarda\Psr7\StringStream;

use Throwable;

/**
 * StreamUnusableException is thrown when a user attempts to read, seek, or write to a closed or detached stream
 */
final class StreamUnusableException extends IllegalOperationException
{
    /**
     * @param string $operation
     * @param Throwable|null $previous
     */
    public function __construct(string $operation, Throwable $previous = null)
    {
        parent::__construct($operation, 'closed', $previous);
    }
}

M src/StringStream.php => src/StringStream.php +35 -7
@@ 12,8 12,8 @@ use RuntimeException;
 */
class StringStream implements StreamInterface
{
    /** @var string */
    private $data = '';
    /** @var string|null */
    private $data;

    /** @var int */
    private $pointer = 0;


@@ 46,7 46,7 @@ class StringStream implements StreamInterface
     */
    public function __toString(): string
    {
        return $this->data;
        return $this->data === null ? '' : $this->data;
    }

    /**


@@ 56,7 56,7 @@ class StringStream implements StreamInterface
     */
    public function close(): void
    {
        $this->data    = '';
        $this->data    = null;
        $this->pointer = 0;
        $this->length  = 0;
    }


@@ 70,6 70,10 @@ class StringStream implements StreamInterface
     */
    public function detach()
    {
        $this->data    = null;
        $this->pointer = 0;
        $this->length  = 0;

        return null;
    }



@@ 91,6 95,10 @@ class StringStream implements StreamInterface
     */
    public function tell(): int
    {
        if ($this->data === null) {
            throw new StreamUnusableException(__FUNCTION__);
        }

        return $this->pointer;
    }



@@ 111,7 119,7 @@ class StringStream implements StreamInterface
     */
    public function isSeekable(): bool
    {
        return true;
        return $this->data !== null;
    }

    /**


@@ 128,6 136,10 @@ class StringStream implements StreamInterface
     */
    public function seek($offset, $whence = SEEK_SET): void
    {
        if ($this->data === null) {
            throw new StreamUnusableException(__FUNCTION__);
        }

        switch ($whence) {
            case SEEK_SET:
                $this->pointer = $offset;


@@ 153,6 165,10 @@ class StringStream implements StreamInterface
     */
    public function rewind(): void
    {
        if ($this->data === null) {
            throw new StreamUnusableException(__FUNCTION__);
        }

        $this->pointer = 0;
    }



@@ 163,7 179,7 @@ class StringStream implements StreamInterface
     */
    public function isWritable(): bool
    {
        return true;
        return $this->data !== null;
    }

    /**


@@ 175,6 191,10 @@ class StringStream implements StreamInterface
     */
    public function write($string): int
    {
        if ($this->data === null) {
            throw new StreamUnusableException(__FUNCTION__);
        }

        // If we're at the end of the data, we can just append.
        if ($this->eof()) {
            $this->length  += strlen($string);


@@ 203,7 223,7 @@ class StringStream implements StreamInterface
     */
    public function isReadable(): bool
    {
        return true;
        return $this->data !== null;
    }

    /**


@@ 218,6 238,10 @@ class StringStream implements StreamInterface
     */
    public function read($length): string
    {
        if ($this->data === null) {
            throw new StreamUnusableException(__FUNCTION__);
        }

        $slice = substr($this->data, $this->pointer, $length);
        $this->pointer = $this->pointer + $length;
        return $slice;


@@ 232,6 256,10 @@ class StringStream implements StreamInterface
     */
    public function getContents(): string
    {
        if ($this->data === null) {
            throw new StreamUnusableException(__FUNCTION__);
        }

        return $this->read($this->length - $this->pointer);
    }


A tests/IllegalOperationExceptionTest.php => tests/IllegalOperationExceptionTest.php +22 -0
@@ 0,0 1,22 @@
<?php

declare(strict_types=1);

namespace Tests;

use Ancarda\Psr7\StringStream\IllegalOperationException;
use PHPUnit\Framework\TestCase;

class IllegalOperationExceptionTest extends TestCase
{
    public function testException(): void
    {
        $exception = new IllegalOperationException('write', 'read-only');
        static::assertSame(0, $exception->getCode());
        static::assertSame(
            "You cannot call `write' on this stream because it's read-only.",
            $exception->getMessage()
        );
        static::assertSame('write', $exception->getOperation());
    }
}

M tests/ReadOnlyStringStreamTest.php => tests/ReadOnlyStringStreamTest.php +3 -1
@@ 4,6 4,7 @@ declare(strict_types=1);

namespace Tests;

use Ancarda\Psr7\StringStream\IllegalOperationException;
use Ancarda\Psr7\StringStream\ReadOnlyStringStream;
use PHPUnit\Framework\TestCase;
use RuntimeException;


@@ 15,7 16,8 @@ class ReadOnlyStringStreamTest extends TestCase
        $stream = new ReadOnlyStringStream('read only string');
        static::assertFalse($stream->isWritable());

        $this->expectException(RuntimeException::class);
        $this->expectException(IllegalOperationException::class);
        $this->expectExceptionMessage("You cannot call `write' on this stream because it's read-only.");
        $stream->write('Cannot write(): Read Only Stream');
    }
}

A tests/StreamUnusableExceptionTest.php => tests/StreamUnusableExceptionTest.php +22 -0
@@ 0,0 1,22 @@
<?php

declare(strict_types=1);

namespace Tests;

use Ancarda\Psr7\StringStream\StreamUnusableException;
use PHPUnit\Framework\TestCase;

class StreamUnusableExceptionTest extends TestCase
{
    public function testException(): void
    {
        $exception = new StreamUnusableException('read');

        static::assertSame(0, $exception->getCode());
        static::assertSame(
            "You cannot call `read' on this stream because it's closed.",
            $exception->getMessage()
        );
    }
}

M tests/StringStreamTest.php => tests/StringStreamTest.php +75 -4
@@ 4,8 4,11 @@ declare(strict_types=1);

namespace Tests;

use Ancarda\Psr7\StringStream\StreamAlreadyClosedException;
use Ancarda\Psr7\StringStream\StreamUnusableException;
use Ancarda\Psr7\StringStream\StringStream;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\StreamInterface;

class StringStreamTest extends TestCase
{


@@ 122,18 125,86 @@ class StringStreamTest extends TestCase
    {
        $stringStream = new StringStream('hello world');

        // These functions do nothing as we don't use strings.
        // These functions do nothing as we don't use streams.
        static::assertNull($stringStream->detach());
        static::assertNull($stringStream->getMetadata());
    }

    private function checkStreamIsDead(StreamInterface $stream): void
    {
        static::assertSame(0, $stream->getSize(), 'Closed/detached streams have no data');
        static::assertFalse($stream->isReadable(), 'Closed/detached streams cannot be read.');
        static::assertFalse($stream->isWritable(), 'Closed/detached streams cannot be written to.');
        static::assertFalse($stream->isSeekable(), 'Closed/detached streams cannot be seeked.');
        static::assertTrue($stream->eof());
        static::assertSame('', (string) $stream);
    }

    public function testDetach(): void
    {
        $stringStream = new StringStream('hello world');
        $stringStream->detach();
        $this->checkStreamIsDead($stringStream);
    }

    public function testClose(): void
    {
        $stringStream = new StringStream('hello world');
        $stringStream->close();
        $this->checkStreamIsDead($stringStream);
    }

    public function testTellThrowsExceptionAfterClose(): void
    {
        $stringStream = new StringStream('hello world');
        $stringStream->close();
        $this->expectException(StreamUnusableException::class);
        $this->expectExceptionMessage("You cannot call `tell' on this stream because it's closed.");
        $stringStream->tell();
    }

    public function testSeekThrowsExceptionAfterClose(): void
    {
        $stringStream = new StringStream('hello world');
        $stringStream->close();
        static::assertSame(0, $stringStream->getSize());
        static::assertSame(0, $stringStream->tell());
        static::assertTrue($stringStream->eof());
        $this->expectException(StreamUnusableException::class);
        $this->expectExceptionMessage("You cannot call `seek' on this stream because it's closed.");
        $stringStream->seek(0);
    }

    public function testRewindThrowsExceptionAfterClose(): void
    {
        $stringStream = new StringStream('hello world');
        $stringStream->close();
        $this->expectException(StreamUnusableException::class);
        $this->expectExceptionMessage("You cannot call `rewind' on this stream because it's closed.");
        $stringStream->rewind();
    }

    public function testWriteThrowsExceptionAfterClose(): void
    {
        $stringStream = new StringStream('hello world');
        $stringStream->close();
        $this->expectException(StreamUnusableException::class);
        $this->expectExceptionMessage("You cannot call `write' on this stream because it's closed.");
        $stringStream->write('!');
    }

    public function testReadThrowsExceptionAfterClose(): void
    {
        $stringStream = new StringStream('hello world');
        $stringStream->close();
        $this->expectException(StreamUnusableException::class);
        $this->expectExceptionMessage("You cannot call `read' on this stream because it's closed.");
        $stringStream->read(1);
    }

    public function testGetContentsThrowsExceptionAfterClose(): void
    {
        $stringStream = new StringStream('hello world');
        $stringStream->close();
        $this->expectException(StreamUnusableException::class);
        $this->expectExceptionMessage("You cannot call `getContents' on this stream because it's closed.");
        $stringStream->getContents();
    }
}