diff --git a/CHANGELOG.md b/CHANGELOG.md index 766451f..4d3e771 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 3.1.1 under development -- no changes in this release. +- New #85: Add `StringStream` (@vjik) ## 3.1.0 December 01, 2025 diff --git a/composer.json b/composer.json index ab35dd3..ae92c58 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "psr/clock": "^1.0", "psr/container": "^1.0 || ^2.0", "psr/event-dispatcher": "^1.0", + "psr/http-message": "^1.1 || ^2.0", "psr/log": "^2.0|^3.0", "psr/simple-cache": "^2.0|^3.0" }, diff --git a/docs/guide/en/README.md b/docs/guide/en/README.md index c56f2ef..90fdd6c 100644 --- a/docs/guide/en/README.md +++ b/docs/guide/en/README.md @@ -129,3 +129,69 @@ sleep(10); echo $clock->now(); // Same value as above. ``` + +## String stream [PSR-7](https://www.php-fig.org/psr/psr-7/) + +The `StringStream` class is a test-specific implementation of `StreamInterface`. +It allows you to create stream instances with configurable behavior for testing HTTP message handling. + +```php +use Yiisoft\Test\Support\HttpMessage\StringStream; + +// Create a stream with content +$stream = new StringStream('Hello, World!'); + +echo $stream; // Hello, World! +echo $stream->getSize(); // 13 +echo $stream->read(5); // Hello +echo $stream->getContents(); // , World! +``` + +You can configure stream behavior through constructor parameters: + +```php +// Create a read-only stream +$readOnlyStream = new StringStream('content', writable: false); + +// Create a non-seekable stream +$nonSeekableStream = new StringStream('content', seekable: false); + +// Create a stream with custom initial position +$stream = new StringStream('Hello', position: 3); +echo $stream->getContents(); // lo +``` + +Custom metadata can be provided as an array or a closure: + +```php +// Array metadata +$stream = new StringStream( + 'content', + metadata: [ + 'uri' => 'php://memory', + 'mode' => 'r+', + ], +); + +// Closure metadata (receives the stream instance) +$stream = new StringStream( + 'content', + metadata: static fn(StringStream $s) => [ + 'size' => $s->getSize(), + 'eof' => $s->eof(), + ], +); +``` + +The stream provides helper methods to check its state: + +```php +$stream = new StringStream('content'); + +$stream->isClosed(); // false +$stream->isDetached(); // false +$stream->getPosition(); // 0 + +$stream->close(); +$stream->isClosed(); // true +``` diff --git a/src/HttpMessage/StringStream.php b/src/HttpMessage/StringStream.php new file mode 100644 index 0000000..faeb473 --- /dev/null +++ b/src/HttpMessage/StringStream.php @@ -0,0 +1,236 @@ + + */ +final class StringStream implements StreamInterface +{ + private bool $closed = false; + private bool $detached = false; + + /** + * @param string $content Initial stream content. + * @param int $position Initial position of the stream pointer. + * @param bool $readable Whether the stream is readable. + * @param bool $writable Whether the stream is writable. + * @param bool $seekable Whether the stream is seekable. + * @param array|Closure|null $metadata Custom metadata as an array or a closure that receives + * the stream instance and returns an array. + * + * @psalm-param MetadataClosure|array|null $metadata + */ + public function __construct( + private string $content = '', + private int $position = 0, + private bool $readable = true, + private bool $writable = true, + private bool $seekable = true, + private Closure|array|null $metadata = null, + ) { + $size = strlen($this->content); + if ($this->position < 0 || $this->position > $size) { + throw new LogicException( + sprintf('Position %d is out of valid range [0, %d].', $this->position, $size) + ); + } + } + + public function __toString(): string + { + return $this->content; + } + + /** + * Checks whether the stream has been closed. + */ + public function isClosed(): bool + { + return $this->closed; + } + + /** + * Checks whether the stream has been detached. + */ + public function isDetached(): bool + { + return $this->detached; + } + + /** + * Returns the current position of the stream pointer. + */ + public function getPosition(): int + { + return $this->position; + } + + public function close(): void + { + $this->closed = true; + } + + public function detach() + { + $this->detached = true; + $this->close(); + return null; + } + + public function getSize(): ?int + { + return $this->getContentSize(); + } + + public function tell(): int + { + if ($this->closed) { + throw new RuntimeException('Stream is closed.'); + } + + return $this->position; + } + + public function eof(): bool + { + return $this->closed || $this->position >= $this->getContentSize(); + } + + public function isSeekable(): bool + { + return $this->seekable && !$this->closed; + } + + public function seek(int $offset, int $whence = SEEK_SET): void + { + if (!$this->seekable) { + throw new RuntimeException('Stream is not seekable.'); + } + + if ($this->closed) { + throw new RuntimeException('Stream is closed.'); + } + + $size = $this->getContentSize(); + + $newPosition = match ($whence) { + SEEK_SET => $offset, + SEEK_CUR => $this->position + $offset, + SEEK_END => $size + $offset, + default => throw new RuntimeException('Invalid whence value.'), + }; + + if ($newPosition < 0 || $newPosition > $size) { + throw new RuntimeException('Invalid seek position.'); + } + + $this->position = $newPosition; + } + + public function rewind(): void + { + $this->seek(0); + } + + public function isWritable(): bool + { + return $this->writable && !$this->closed; + } + + public function write(string $string): int + { + if (!$this->writable) { + throw new RuntimeException('Stream is not writable.'); + } + + if ($this->closed) { + throw new RuntimeException('Stream is closed.'); + } + + $size = strlen($string); + $this->content = substr($this->content, 0, $this->position) + . $string + . substr($this->content, $this->position + $size); + $this->position = min($this->position + $size, $this->getContentSize()); + + return $size; + } + + public function isReadable(): bool + { + return $this->readable && !$this->closed; + } + + public function read(int $length): string + { + if (!$this->readable) { + throw new RuntimeException('Stream is not readable.'); + } + + if ($this->closed) { + throw new RuntimeException('Stream is closed.'); + } + + if ($length < 0) { + throw new RuntimeException('Length cannot be negative.'); + } + + if ($this->position >= $this->getContentSize()) { + return ''; + } + + $result = substr($this->content, $this->position, $length); + $this->position += strlen($result); + + return $result; + } + + public function getContents(): string + { + return $this->read( + $this->getContentSize() - $this->position, + ); + } + + public function getMetadata(?string $key = null) + { + if ($this->closed) { + return $key === null ? [] : null; + } + + $metadata = match (true) { + is_array($this->metadata) => $this->metadata, + $this->metadata instanceof Closure => ($this->metadata)($this), + default => [ + 'eof' => $this->eof(), + 'seekable' => $this->isSeekable(), + ], + }; + + if ($key === null) { + return $metadata; + } + + return $metadata[$key] ?? null; + } + + private function getContentSize(): int + { + return strlen($this->content); + } +} diff --git a/tests/HttpMessage/StringStreamTest.php b/tests/HttpMessage/StringStreamTest.php new file mode 100644 index 0000000..ca3b3f2 --- /dev/null +++ b/tests/HttpMessage/StringStreamTest.php @@ -0,0 +1,586 @@ +assertSame(0, $stream->getSize()); + $this->assertSame(0, $stream->getPosition()); + $this->assertSame('', (string) $stream); + $this->assertSame('', $stream->getContents()); + $this->assertTrue($stream->isReadable()); + $this->assertTrue($stream->isWritable()); + $this->assertTrue($stream->isSeekable()); + $this->assertFalse($stream->isClosed()); + $this->assertFalse($stream->isDetached()); + } + + public function testConstructorWithContent(): void + { + $stream = new StringStream('Hello, World!'); + + $this->assertSame('Hello, World!', (string) $stream); + $this->assertSame(13, $stream->getSize()); + } + + public function testConstructorWithPosition(): void + { + $stream = new StringStream('Hello', position: 3); + + $this->assertSame(3, $stream->getPosition()); + $this->assertSame('lo', $stream->getContents()); + } + + public function testConstructorWithReadableFlag(): void + { + $stream = new StringStream(readable: false); + + $this->assertFalse($stream->isReadable()); + } + + public function testConstructorWithWritableFlag(): void + { + $stream = new StringStream(writable: false); + + $this->assertFalse($stream->isWritable()); + } + + public function testConstructorWithSeekableFlag(): void + { + $stream = new StringStream(seekable: false); + + $this->assertFalse($stream->isSeekable()); + } + + public function testConstructorThrowsExceptionForNegativePosition(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Position -1 is out of valid range [0, 5].'); + new StringStream('Hello', position: -1); + } + + public function testConstructorThrowsExceptionForPositionBeyondContent(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Position 10 is out of valid range [0, 5].'); + new StringStream('Hello', position: 10); + } + + public function testToString(): void + { + $stream = new StringStream('Test content'); + + $this->assertSame('Test content', (string) $stream); + } + + public function testToStringWhenClosed(): void + { + $stream = new StringStream('Test content'); + $stream->close(); + + $this->assertSame('Test content', (string) $stream); + } + + public function testToStringWhenDetached(): void + { + $stream = new StringStream('Test content'); + $stream->detach(); + + $this->assertSame('Test content', (string) $stream); + } + + public function testClose(): void + { + $stream = new StringStream('content'); + + $this->assertFalse($stream->isClosed()); + + $stream->close(); + + $this->assertTrue($stream->isClosed()); + $this->assertFalse($stream->isReadable()); + $this->assertFalse($stream->isWritable()); + $this->assertFalse($stream->isSeekable()); + } + + public function testDetach(): void + { + $stream = new StringStream('content'); + + $this->assertFalse($stream->isDetached()); + + $result = $stream->detach(); + + $this->assertNull($result); + $this->assertTrue($stream->isDetached()); + $this->assertTrue($stream->isClosed()); + } + + public function testGetSize(): void + { + $stream = new StringStream(''); + $this->assertSame(0, $stream->getSize()); + + $stream = new StringStream('Hello'); + $this->assertSame(5, $stream->getSize()); + + $stream = new StringStream('Привет'); + $this->assertSame(12, $stream->getSize()); // UTF-8 bytes + } + + public function testTell(): void + { + $stream = new StringStream('Hello', position: 0); + $this->assertSame(0, $stream->tell()); + + $stream = new StringStream('Hello', position: 3); + $this->assertSame(3, $stream->tell()); + } + + public function testTellThrowsExceptionWhenClosed(): void + { + $stream = new StringStream('Hello'); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->tell(); + } + + public function testEof(): void + { + $stream = new StringStream('Hello', position: 0); + $this->assertFalse($stream->eof()); + + $stream = new StringStream('Hello', position: 5); + $this->assertTrue($stream->eof()); + } + + public function testEofWhenClosed(): void + { + $stream = new StringStream('Hello'); + $stream->close(); + + $this->assertTrue($stream->eof()); + } + + public function testSeekSet(): void + { + $stream = new StringStream('Hello World'); + + $stream->seek(5); + $this->assertSame(5, $stream->getPosition()); + + $stream->seek(0); + $this->assertSame(0, $stream->getPosition()); + } + + public function testSeekCur(): void + { + $stream = new StringStream('Hello World', position: 3); + + $stream->seek(2, SEEK_CUR); + $this->assertSame(5, $stream->getPosition()); + + $stream->seek(-3, SEEK_CUR); + $this->assertSame(2, $stream->getPosition()); + } + + public function testSeekEnd(): void + { + $stream = new StringStream('Hello World'); + + $stream->seek(0, SEEK_END); + $this->assertSame(11, $stream->getPosition()); + + $stream->seek(-5, SEEK_END); + $this->assertSame(6, $stream->getPosition()); + } + + public function testSeekThrowsExceptionWhenNotSeekable(): void + { + $stream = new StringStream('Hello', seekable: false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not seekable.'); + $stream->seek(0); + } + + public function testSeekThrowsExceptionWhenClosed(): void + { + $stream = new StringStream('Hello'); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->seek(0); + } + + public function testSeekThrowsExceptionForInvalidWhence(): void + { + $stream = new StringStream('Hello'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid whence value.'); + $stream->seek(0, 999); + } + + public function testSeekThrowsExceptionForNegativePosition(): void + { + $stream = new StringStream('Hello'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid seek position.'); + $stream->seek(-1); + } + + public function testSeekThrowsExceptionForPositionBeyondSize(): void + { + $stream = new StringStream('Hello'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid seek position.'); + $stream->seek(100); + } + + public function testRewind(): void + { + $stream = new StringStream('Hello', position: 5); + + $stream->rewind(); + + $this->assertSame(0, $stream->getPosition()); + } + + public function testRewindThrowsExceptionWhenNotSeekable(): void + { + $stream = new StringStream('Hello', seekable: false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not seekable.'); + $stream->rewind(); + } + + public function testRewindThrowsExceptionWhenClosed(): void + { + $stream = new StringStream('Hello'); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->rewind(); + } + + public function testWrite(): void + { + $stream = new StringStream(); + + $bytesWritten = $stream->write('Hello'); + + $this->assertSame(5, $bytesWritten); + $this->assertSame('Hello', (string) $stream); + $this->assertSame(5, $stream->getPosition()); + } + + public function testWriteAtPosition(): void + { + $stream = new StringStream('Hello World', position: 6); + + $stream->write('PHP'); + + $this->assertSame('Hello PHPld', (string) $stream); + $this->assertSame(9, $stream->getPosition()); + } + + public function testWriteOverwrite(): void + { + $stream = new StringStream('AAAAA', position: 0); + + $stream->write('BB'); + + $this->assertSame('BBAAA', (string) $stream); + } + + public function testWriteAtEndOfContent(): void + { + $stream = new StringStream('Hello', position: 5); + + $bytesWritten = $stream->write(' World'); + + $this->assertSame(6, $bytesWritten); + $this->assertSame('Hello World', (string) $stream); + $this->assertSame(11, $stream->getPosition()); + } + + public function testWriteThrowsExceptionWhenNotWritable(): void + { + $stream = new StringStream(writable: false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not writable.'); + $stream->write('test'); + } + + public function testWriteThrowsExceptionWhenClosed(): void + { + $stream = new StringStream(); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->write('test'); + } + + public function testRead(): void + { + $stream = new StringStream('Hello World'); + + $result = $stream->read(5); + + $this->assertSame('Hello', $result); + $this->assertSame(5, $stream->getPosition()); + } + + public function testReadFromPosition(): void + { + $stream = new StringStream('Hello World', position: 6); + + $result = $stream->read(5); + + $this->assertSame('World', $result); + } + + public function testReadBeyondContent(): void + { + $stream = new StringStream('Hi'); + + $result = $stream->read(100); + + $this->assertSame('Hi', $result); + $this->assertSame(2, $stream->getPosition()); + } + + public function testReadAtEof(): void + { + $stream = new StringStream('Hello', position: 5); + + $result = $stream->read(10); + + $this->assertSame('', $result); + } + + public function testReadThrowsExceptionWhenNotReadable(): void + { + $stream = new StringStream('content', readable: false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not readable.'); + $stream->read(5); + } + + public function testReadThrowsExceptionWhenClosed(): void + { + $stream = new StringStream('content'); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->read(5); + } + + public function testReadThrowsExceptionForNegativeLength(): void + { + $stream = new StringStream('content'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Length cannot be negative.'); + $stream->read(-1); + } + + public function testGetContents(): void + { + $stream = new StringStream('Hello World'); + + $result = $stream->getContents(); + + $this->assertSame('Hello World', $result); + $this->assertSame(11, $stream->getPosition()); + } + + public function testGetContentsFromPosition(): void + { + $stream = new StringStream('Hello World', position: 6); + + $result = $stream->getContents(); + + $this->assertSame('World', $result); + } + + public function testGetContentsAtEof(): void + { + $stream = new StringStream('Hello', position: 5); + + $result = $stream->getContents(); + + $this->assertSame('', $result); + } + + public function testGetContentsThrowsExceptionWhenNotReadable(): void + { + $stream = new StringStream('content', readable: false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is not readable.'); + $stream->getContents(); + } + + public function testGetContentsThrowsExceptionWhenClosed(): void + { + $stream = new StringStream('content'); + $stream->close(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Stream is closed.'); + $stream->getContents(); + } + + public function testGetMetadataDefault(): void + { + $stream = new StringStream('Hello'); + + $metadata = $stream->getMetadata(); + + $this->assertIsArray($metadata); + $this->assertArrayHasKey('eof', $metadata); + $this->assertArrayHasKey('seekable', $metadata); + $this->assertFalse($metadata['eof']); + $this->assertTrue($metadata['seekable']); + } + + public function testGetMetadataDefaultAtEof(): void + { + $stream = new StringStream('Hello', position: 5); + + $metadata = $stream->getMetadata(); + + $this->assertTrue($metadata['eof']); + } + + public function testGetMetadataWithKey(): void + { + $stream = new StringStream('Hello'); + + $this->assertFalse($stream->getMetadata('eof')); + $this->assertTrue($stream->getMetadata('seekable')); + $this->assertNull($stream->getMetadata('nonexistent')); + } + + public function testGetMetadataWhenClosed(): void + { + $stream = new StringStream('Hello'); + $stream->close(); + + $this->assertSame([], $stream->getMetadata()); + $this->assertNull($stream->getMetadata('eof')); + } + + public function testGetMetadataWithArrayMetadata(): void + { + $customMetadata = [ + 'uri' => 'php://memory', + 'mode' => 'r+', + 'custom' => 'value', + ]; + $stream = new StringStream('Hello', metadata: $customMetadata); + + $metadata = $stream->getMetadata(); + + $this->assertSame($customMetadata, $metadata); + $this->assertSame('php://memory', $stream->getMetadata('uri')); + $this->assertSame('value', $stream->getMetadata('custom')); + $this->assertNull($stream->getMetadata('nonexistent')); + } + + public function testGetMetadataWithClosureMetadata(): void + { + $stream = new StringStream( + 'Hello', + position: 2, + metadata: static fn(StringStream $s) => [ + 'position' => $s->getPosition(), + 'size' => $s->getSize(), + ], + ); + + $metadata = $stream->getMetadata(); + + $this->assertSame(['position' => 2, 'size' => 5], $metadata); + $this->assertSame(2, $stream->getMetadata('position')); + $this->assertSame(5, $stream->getMetadata('size')); + } + + public function testIsReadableWhenClosed(): void + { + $stream = new StringStream(); + $stream->close(); + + $this->assertFalse($stream->isReadable()); + } + + public function testIsWritableWhenClosed(): void + { + $stream = new StringStream(); + $stream->close(); + + $this->assertFalse($stream->isWritable()); + } + + public function testIsSeekableWhenClosed(): void + { + $stream = new StringStream(); + $stream->close(); + + $this->assertFalse($stream->isSeekable()); + } + + public function testMultipleReadOperations(): void + { + $stream = new StringStream('Hello World'); + + $this->assertSame('Hello', $stream->read(5)); + $this->assertSame(' ', $stream->read(1)); + $this->assertSame('World', $stream->read(5)); + $this->assertSame('', $stream->read(1)); + } + + public function testReadWriteCombination(): void + { + $stream = new StringStream('Hello World'); + + $stream->read(6); + $stream->write('PHP'); + + $this->assertSame('Hello PHPld', (string) $stream); + } + + public function testSeekReadCombination(): void + { + $stream = new StringStream('Hello World'); + + $stream->seek(6); + $result = $stream->read(5); + + $this->assertSame('World', $result); + } +}