From 89525294de2ee4940e88dbc254b9922c69ff4a62 Mon Sep 17 00:00:00 2001 From: Henrik Hautakoski Date: Sat, 23 May 2026 21:42:25 +0200 Subject: [PATCH] stream: adopt PSR-7 StreamInterface --- composer.json | 3 +- composer.lock | 58 +++++++++++++++- src/BinaryReader.php | 12 +++- src/Buffer.php | 117 +++++++++++++++++++++++++++----- src/Resource.php | 55 ++++++++++++--- src/SeekMode.php | 18 ----- src/StreamInterface.php | 69 ------------------- tests/Unit/BinaryReaderTest.php | 19 +++--- tests/Unit/BufferTest.php | 24 ++++--- tests/Unit/FileTest.php | 10 +-- tests/Unit/ResourceTest.php | 8 +-- tests/Unit/SeekModeTest.php | 9 --- 12 files changed, 250 insertions(+), 152 deletions(-) delete mode 100644 src/SeekMode.php delete mode 100644 src/StreamInterface.php delete mode 100644 tests/Unit/SeekModeTest.php diff --git a/composer.json b/composer.json index aedb659..65c7e22 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,8 @@ } ], "require": { - "php": ">=8.0" + "php": ">=8.0", + "psr/http-message": "^2.0" }, "require-dev": { "pestphp/pest": "^3.0" diff --git a/composer.lock b/composer.lock index 625fd85..ef0f2fa 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,62 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "01336b98a52a118feaa53d4ed39377bd", - "packages": [], + "content-hash": "c3d163cb00c11d9951f136537f938f25", + "packages": [ + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + } + ], "packages-dev": [ { "name": "brianium/paratest", diff --git a/src/BinaryReader.php b/src/BinaryReader.php index 65de1d3..e72d6bc 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -3,6 +3,7 @@ namespace Shufflingpixels\IO; use InvalidArgumentException; +use Psr\Http\Message\StreamInterface; use RuntimeException; class BinaryReader @@ -23,7 +24,12 @@ class BinaryReader public function length() : int { - return $this->stream->length(); + $size = $this->stream->getSize(); + if ($size === null) { + throw new RuntimeException('Stream size is not known'); + } + + return $size; } public function tell() : int @@ -36,9 +42,9 @@ class BinaryReader return $this->stream->eof(); } - public function seek(int $position, SeekMode $mode = SeekMode::SET) + public function seek(int $position, int $whence = SEEK_SET): void { - $this->stream->seek($position, $mode); + $this->stream->seek($position, $whence); } /** diff --git a/src/Buffer.php b/src/Buffer.php index c333776..b3f89ab 100644 --- a/src/Buffer.php +++ b/src/Buffer.php @@ -4,11 +4,13 @@ namespace Shufflingpixels\IO; use InvalidArgumentException; use OutOfBoundsException; -use Shufflingpixels\IO\Exception\EndOfStreamException; +use Psr\Http\Message\StreamInterface; +use RuntimeException; class Buffer implements StreamInterface { private int $position = 0; + private bool $detached = false; public function __construct(protected string $data) { @@ -16,6 +18,25 @@ class Buffer implements StreamInterface public function close(): void { + $this->detach(); + } + + public function detach(): mixed + { + if ($this->detached) { + return null; + } + + $this->detached = true; + $this->data = ''; + $this->position = 0; + + return null; + } + + public function getSize(): ?int + { + return $this->detached ? null : $this->length(); } public function length(): int @@ -25,6 +46,10 @@ class Buffer implements StreamInterface public function remaining() : int { + if ($this->detached) { + return 0; + } + return $this->length() - $this->position; } @@ -35,6 +60,8 @@ class Buffer implements StreamInterface public function tell() : int { + $this->ensureAttached(); + return $this->position; } @@ -43,22 +70,29 @@ class Buffer implements StreamInterface return true; } - public function seek(int $offset, SeekMode $mode = SeekMode::SET): void + public function seek(int $offset, int $whence = SEEK_SET): void { - $position = match($mode) { - SeekMode::SET => $offset, - SeekMode::CUR => $this->position + $offset, - SeekMode::END => $this->length() + $offset, + $this->ensureAttached(); + + $position = match($whence) { + SEEK_SET => $offset, + SEEK_CUR => $this->position + $offset, + SEEK_END => $this->length() + $offset, default => throw new InvalidArgumentException("Invalid seek mode") }; - if ($position > $this->length()) { + if ($position < 0 || $position > $this->length()) { throw new OutOfBoundsException("Seek position out of bounds: {$position}"); } $this->position = $position; } + public function rewind(): void + { + $this->seek(0); + } + public function isReadable(): bool { return true; @@ -66,30 +100,37 @@ class Buffer implements StreamInterface public function read(int $length): string { + $this->ensureAttached(); + if ($length < 0) { throw new InvalidArgumentException("Length must be >= 0"); } - if ($this->remaining() < $length) { - throw new EndOfStreamException( - "Not enough bytes to read {$length} byte(s), {$this->remaining()} remaining" - ); + if ($length === 0 || $this->eof()) { + return ''; } - $result = substr($this->data, $this->position, $length); - $this->position += $length; + $result = substr($this->data, $this->position, min($length, $this->remaining())); + $this->position += strlen($result); return $result; } - public function isWriteable(): bool + public function isWritable(): bool { return true; } - public function write(string $data): int + public function isWriteable(): bool { - $length = \strlen($data); + return $this->isWritable(); + } + + public function write(string $string): int + { + $this->ensureAttached(); + + $length = \strlen($string); if ($length === 0) { return 0; @@ -101,9 +142,51 @@ class Buffer implements StreamInterface ? \substr($this->data, $suffixStart) : ''; - $this->data = $prefix . $data . $suffix; + $this->data = $prefix . $string . $suffix; $this->position += $length; return $length; } + + public function getContents(): string + { + $this->ensureAttached(); + + if ($this->eof()) { + return ''; + } + + $result = substr($this->data, $this->position); + $this->position = $this->length(); + + return $result; + } + + public function getMetadata(?string $key = null): mixed + { + $metadata = [ + 'seekable' => true, + 'readable' => true, + 'writable' => true, + 'uri' => null, + ]; + + return $key !== null ? $metadata[$key] ?? null : $metadata; + } + + public function __toString(): string + { + if ($this->detached) { + return ''; + } + + return $this->data; + } + + private function ensureAttached(): void + { + if ($this->detached) { + throw new RuntimeException('Stream is detached'); + } + } } diff --git a/src/Resource.php b/src/Resource.php index dcfe840..5379f40 100644 --- a/src/Resource.php +++ b/src/Resource.php @@ -2,6 +2,7 @@ namespace Shufflingpixels\IO; +use Psr\Http\Message\StreamInterface; use Shufflingpixels\IO\Exception\IOException; abstract class Resource implements StreamInterface @@ -21,13 +22,24 @@ abstract class Resource implements StreamInterface public function close(): void { - fclose($this->resource); + if ($this->resource !== null) { + fclose($this->resource); + $this->resource = null; + } } - public function length() : int + public function detach(): mixed + { + $resource = $this->resource; + $this->resource = null; + + return $resource; + } + + public function getSize(): ?int { if (!$this->isSeekable()) { - throw new IOException("Unable to get length on a non-seekable stream"); + return null; } if ($this->size === null) { @@ -56,13 +68,13 @@ abstract class Resource implements StreamInterface return ftell($this->resource); } - public function seek(int $position, SeekMode $mode = SeekMode::SET): void + public function seek(int $position, int $whence = SEEK_SET): void { if (!$this->isSeekable()) { throw new IOException("Unable to seek on a non-seekable stream"); } - if (fseek($this->resource, $position, $mode->value) < 0) { + if (fseek($this->resource, $position, $whence) < 0) { throw new IOException("Unable to seek to the given position"); } } @@ -77,18 +89,23 @@ abstract class Resource implements StreamInterface return fread($this->resource, $length); } - public function isWriteable(): bool + public function isWritable(): bool { return $this->writeable; } - public function write(string $data): int + public function isWriteable(): bool + { + return $this->isWritable(); + } + + public function write(string $string): int { if (!$this->writeable) { throw new IOException("Stream is not writeable"); } - $result = fwrite($this->resource, $data); + $result = fwrite($this->resource, $string); if ($result === false) { throw new IOException("Failed to write to stream"); } @@ -96,4 +113,26 @@ abstract class Resource implements StreamInterface $this->size = null; return $result; } + + public function rewind(): void + { + $this->seek(0); + } + + public function getContents(): string + { + return (string) stream_get_contents($this->resource); + } + + public function getMetadata(?string $key = null): mixed + { + $data = stream_get_meta_data($this->resource); + + return $key !== null ? $data[$key] ?? null : $data; + } + + public function __toString(): string + { + return $this->getContents(); + } } diff --git a/src/SeekMode.php b/src/SeekMode.php deleted file mode 100644 index 74bf878..0000000 --- a/src/SeekMode.php +++ /dev/null @@ -1,18 +0,0 @@ -tell())->toBe(2) ->and($reader->read(1))->toBe('c'); - $reader->seek(-1, SeekMode::END); + $reader->seek(-1, SEEK_END); expect($reader->read(1))->toBe('d') ->and($reader->eof())->toBeTrue(); }); @@ -36,17 +34,22 @@ it('throws for negative read length', function () { }); it('throws when stream returns fewer bytes than requested', function () { - $stream = new class implements StreamInterface { + $stream = new class implements \Psr\Http\Message\StreamInterface { + public function __toString(): string { return ''; } public function close(): void {} + public function detach(): mixed { return null; } + public function getSize(): ?int { return 0; } public function eof(): bool { return false; } - public function length(): int { return 0; } public function isSeekable(): bool { return false; } - public function seek(int $position, Shufflingpixels\IO\SeekMode $mode = Shufflingpixels\IO\SeekMode::SET): void {} + public function seek(int $position, int $whence = SEEK_SET): void {} + public function rewind(): void {} public function tell(): int { return 0; } + public function isWritable(): bool { return false; } + public function write(string $string): int { return 0; } public function isReadable(): bool { return true; } public function read(int $length): string { return 'x'; } - public function isWriteable(): bool { return false; } - public function write($data): int { return 0; } + public function getContents(): string { return ''; } + public function getMetadata(?string $key = null): mixed { return null; } }; $reader = BinaryReader::stream($stream); diff --git a/tests/Unit/BufferTest.php b/tests/Unit/BufferTest.php index e43ffca..5796a31 100644 --- a/tests/Unit/BufferTest.php +++ b/tests/Unit/BufferTest.php @@ -1,13 +1,11 @@ length())->toBe(6) + expect($buffer->getSize())->toBe(6) ->and($buffer->tell())->toBe(0) ->and($buffer->remaining())->toBe(6) ->and($buffer->eof())->toBeFalse(); @@ -16,7 +14,7 @@ it('reads, seeks and tracks position', function () { ->and($buffer->tell())->toBe(2) ->and($buffer->remaining())->toBe(4); - $buffer->seek(-1, SeekMode::END); + $buffer->seek(-1, SEEK_END); expect($buffer->tell())->toBe(5) ->and($buffer->read(1))->toBe('f') @@ -27,7 +25,7 @@ it('supports relative seek modes', function () { $buffer = new Buffer('abcdef'); $buffer->seek(3); - $buffer->seek(-2, SeekMode::CUR); + $buffer->seek(-2, SEEK_CUR); expect($buffer->tell())->toBe(1) ->and($buffer->read(2))->toBe('bc'); @@ -45,10 +43,11 @@ it('throws for negative read length', function () { expect(fn () => $buffer->read(-1))->toThrow(InvalidArgumentException::class); }); -it('throws when reading beyond end of stream', function () { +it('returns available bytes when reading beyond end of stream', function () { $buffer = new Buffer('abc'); - expect(fn () => $buffer->read(4))->toThrow(EndOfStreamException::class); + expect($buffer->read(4))->toBe('abc') + ->and($buffer->eof())->toBeTrue(); }); it('writes at current position and updates contents', function () { @@ -68,5 +67,14 @@ it('writes nothing for empty payload', function () { expect($buffer->write(''))->toBe(0) ->and($buffer->tell())->toBe(0) - ->and($buffer->length())->toBe(3); + ->and($buffer->getSize())->toBe(3); +}); + +it('returns remaining contents and advances cursor', function () { + $buffer = new Buffer('abcdef'); + $buffer->seek(2); + + expect($buffer->getContents())->toBe('cdef') + ->and($buffer->tell())->toBe(6) + ->and($buffer->getContents())->toBe(''); }); diff --git a/tests/Unit/FileTest.php b/tests/Unit/FileTest.php index df9adf7..abc38be 100644 --- a/tests/Unit/FileTest.php +++ b/tests/Unit/FileTest.php @@ -11,8 +11,8 @@ it('opens readable files and reads contents', function () { $file = File::open($path, FileMode::READ); expect($file->isReadable())->toBeTrue() - ->and($file->isWriteable())->toBeFalse() - ->and($file->length())->toBe(5) + ->and($file->isWritable())->toBeFalse() + ->and($file->getSize())->toBe(5) ->and($file->read(5))->toBe('hello'); $file->close(); @@ -27,7 +27,7 @@ it('opens read-write files and persists writes', function () { $file->seek(0); expect($file->isReadable())->toBeTrue() - ->and($file->isWriteable())->toBeTrue() + ->and($file->isWritable())->toBeTrue() ->and($file->write('X'))->toBe(1); $file->seek(0); @@ -44,8 +44,8 @@ it('opens write mode files and truncates existing contents', function () { $file = File::open($path, FileMode::WRITE); expect($file->isReadable())->toBeFalse() - ->and($file->isWriteable())->toBeTrue() - ->and($file->length())->toBe(0) + ->and($file->isWritable())->toBeTrue() + ->and($file->getSize())->toBe(0) ->and($file->write('xy'))->toBe(2); $file->close(); diff --git a/tests/Unit/ResourceTest.php b/tests/Unit/ResourceTest.php index 4800430..32d56bb 100644 --- a/tests/Unit/ResourceTest.php +++ b/tests/Unit/ResourceTest.php @@ -15,11 +15,11 @@ it('reads, writes, seeks and reports stream state', function () { } }; - expect($stream->length())->toBe(5) + expect($stream->getSize())->toBe(5) ->and($stream->tell())->toBe(0) ->and($stream->isSeekable())->toBeTrue() ->and($stream->isReadable())->toBeTrue() - ->and($stream->isWriteable())->toBeTrue() + ->and($stream->isWritable())->toBeTrue() ->and($stream->read(2))->toBe('he'); $stream->seek(0); @@ -27,7 +27,7 @@ it('reads, writes, seeks and reports stream state', function () { $stream->seek(0); expect($stream->read(5))->toBe('Hello') - ->and($stream->length())->toBe(5) + ->and($stream->getSize())->toBe(5) ->and($stream->eof())->toBeFalse(); $stream->read(1); @@ -46,7 +46,7 @@ it('throws when seeking or getting length on non-seekable stream', function () { } }; - expect(fn () => $stream->length())->toThrow(IOException::class) + expect($stream->getSize())->toBeNull() ->and(fn () => $stream->seek(0))->toThrow(IOException::class); $stream->close(); diff --git a/tests/Unit/SeekModeTest.php b/tests/Unit/SeekModeTest.php deleted file mode 100644 index f8fa81e..0000000 --- a/tests/Unit/SeekModeTest.php +++ /dev/null @@ -1,9 +0,0 @@ -value)->toBe(SEEK_SET) - ->and(SeekMode::CUR->value)->toBe(SEEK_CUR) - ->and(SeekMode::END->value)->toBe(SEEK_END); -});