From 34b8c47e2f0dfdf4cde57496481b7ec75670de90 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Sun, 8 Oct 2023 14:03:42 +0300 Subject: [PATCH] Stream & Composite handlers (#95) Co-authored-by: Alexey Rogachev Co-authored-by: Sergei Predvoditelev --- .github/workflows/build.yml | 2 +- .../workflows/composer-require-checker.yml | 2 +- .github/workflows/mutation.yml | 2 +- .github/workflows/static.yml | 2 +- CHANGELOG.md | 1 + README.md | 13 ++ composer-require-checker.json | 6 + composer.json | 3 + src/Handler/CompositeHandler.php | 30 +++ src/Handler/StreamHandler.php | 138 ++++++++++++ tests/Handler/CompositeHandlerTest.php | 26 +++ tests/Handler/EchoHandlerTest.php | 33 +++ tests/Handler/StreamHandlerTest.php | 205 ++++++++++++++++++ tests/Support/InMemoryHandler.php | 22 ++ 14 files changed, 481 insertions(+), 4 deletions(-) create mode 100644 composer-require-checker.json create mode 100644 src/Handler/CompositeHandler.php create mode 100644 src/Handler/StreamHandler.php create mode 100644 tests/Handler/CompositeHandlerTest.php create mode 100644 tests/Handler/EchoHandlerTest.php create mode 100644 tests/Handler/StreamHandlerTest.php create mode 100644 tests/Support/InMemoryHandler.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8506ea1..ed9a60b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,4 +28,4 @@ jobs: os: >- ['ubuntu-latest', 'windows-latest'] php: >- - ['8.0', '8.1'] + ['8.0', '8.1', '8.2'] diff --git a/.github/workflows/composer-require-checker.yml b/.github/workflows/composer-require-checker.yml index ae5893f..6cf3cef 100644 --- a/.github/workflows/composer-require-checker.yml +++ b/.github/workflows/composer-require-checker.yml @@ -30,4 +30,4 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.0', '8.1'] + ['8.0', '8.1', '8.2'] diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index c1aca98..03b72c0 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -26,6 +26,6 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.1'] + ['8.2'] secrets: STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 96b2679..fb7fc77 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -28,4 +28,4 @@ jobs: os: >- ['ubuntu-latest'] php: >- - ['8.0', '8.1'] + ['8.0', '8.1', '8.2'] diff --git a/CHANGELOG.md b/CHANGELOG.md index 5033fec..9b5d988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 1.7.0 under development +- New #95: Add `StreamHandler` and `CompositeHandler` (@xepozz) - Enh #94: Add a middleware handler to control dumps' output destination (@xepozz) - New #94: Add `dump` function (@xepozz) diff --git a/README.md b/README.md index 4332e09..aa12c6d 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,19 @@ In the above `asJson()` will give you nicely formatted code. You can remove form `$depth` argument allows you to set maximum recursion depth. +## Output destination + +Choose one of existing classes or create a new one to control the destination where "dumps" will be sent to: +- [EchoHandler](./src/Handler/EchoHandler.php) + - Uses `echo` to write to stdout stream. + - Used by default. +- [StreamHandler](./src/Handler/StreamHandler.php) + - Uses `ext-sockets` to sent dumps encoded with `json_encode` to a UDP socket. +- [CompositeHandler](./src/Handler/CompositeHandler.php) + - Helpful class to sent dumps to multiple handlers in a row, for example `EchoHandler` and `StreamHandler`. + +Output handlers are set via `VarDumper::setDefaultHandler()` method. + ## Limitations Current limitations are: diff --git a/composer-require-checker.json b/composer-require-checker.json new file mode 100644 index 0000000..908afb6 --- /dev/null +++ b/composer-require-checker.json @@ -0,0 +1,6 @@ +{ + "symbol-whitelist": [ + "Socket", + "socket_write" + ] +} diff --git a/composer.json b/composer.json index a5cc3ba..bd945b3 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,9 @@ "spatie/phpunit-watcher": "^1.23", "vimeo/psalm": "^4.30|^5.3" }, + "suggest": { + "ext-sockets": "Send dumps to a server through UDP/TCP protocols" + }, "autoload": { "psr-4": { "Yiisoft\\VarDumper\\": "src" diff --git a/src/Handler/CompositeHandler.php b/src/Handler/CompositeHandler.php new file mode 100644 index 0000000..92f5e74 --- /dev/null +++ b/src/Handler/CompositeHandler.php @@ -0,0 +1,30 @@ +handlers as $handler) { + $handler->handle($variable, $depth, $highlight); + } + } +} diff --git a/src/Handler/StreamHandler.php b/src/Handler/StreamHandler.php new file mode 100644 index 0000000..27a6fb5 --- /dev/null +++ b/src/Handler/StreamHandler.php @@ -0,0 +1,138 @@ +uri = $uri; + } + + public function __destruct() + { + if (!is_string($this->uri) || !is_resource($this->stream)) { + return; + } + fclose($this->stream); + } + + /** + * Encodes {@param $variable} with {@see self::$encoder} and sends the result to the stream. + */ + public function handle(mixed $variable, int $depth, bool $highlight = false): void + { + $data = ($this->encoder ?? '\json_encode')($variable); + if (!is_string($data)) { + throw new RuntimeException( + sprintf( + 'Encoder must return a string, "%s" returned.', + get_debug_type($data) + ) + ); + } + + if (!is_resource($this->stream) && !$this->stream instanceof Socket) { + $this->initializeStream(); + } + + if (!$this->writeToStream($data)) { + $this->initializeStream(); + + if (!$this->writeToStream($data)) { + throw new RuntimeException('Cannot write a stream.'); + } + } + } + + /** + * @param callable(mixed $variable): string $encoder Encoder that will be used to encode variable before sending it to the stream. + */ + public function withEncoder(callable $encoder): static + { + $new = clone $this; + $new->encoder = $encoder; + return $new; + } + + private function initializeStream(): void + { + if (!is_string($this->uri)) { + $this->stream = $this->uri; + } else { + $uriHasSocketProtocol = false; + foreach (self::SOCKET_PROTOCOLS as $protocol) { + if (str_starts_with($this->uri, "$protocol://")) { + $uriHasSocketProtocol = true; + break; + } + } + + $this->stream = $uriHasSocketProtocol ? fsockopen($this->uri) : fopen($this->uri, 'wb+'); + } + + if (!is_resource($this->stream) && !$this->stream instanceof Socket) { + throw new RuntimeException('Cannot initialize a stream.'); + } + } + + private function writeToStream(string $data): bool + { + if ($this->stream === null) { + return false; + } + + if ($this->stream instanceof Socket) { + socket_write($this->stream, $data, strlen($data)); + + return true; + } + + return @fwrite($this->stream, $data) !== false; + } +} diff --git a/tests/Handler/CompositeHandlerTest.php b/tests/Handler/CompositeHandlerTest.php new file mode 100644 index 0000000..05e8f0d --- /dev/null +++ b/tests/Handler/CompositeHandlerTest.php @@ -0,0 +1,26 @@ +handle($variable, 1, true); + + $this->assertEquals([[$variable, 1, true]], $inMemoryHandler1->getVariables()); + $this->assertEquals($inMemoryHandler1->getVariables(), $inMemoryHandler2->getVariables()); + } +} diff --git a/tests/Handler/EchoHandlerTest.php b/tests/Handler/EchoHandlerTest.php new file mode 100644 index 0000000..2150894 --- /dev/null +++ b/tests/Handler/EchoHandlerTest.php @@ -0,0 +1,33 @@ +handle('test', 1); + + $this->expectOutputString("'test'"); + } + + public function testHighlight(): void + { + $handler = new EchoHandler(); + + $handler->handle('test', 1, true); + + $this->expectOutputString( + <<\n'test'\n\n +HTML + ); + } +} diff --git a/tests/Handler/StreamHandlerTest.php b/tests/Handler/StreamHandlerTest.php new file mode 100644 index 0000000..8807a4a --- /dev/null +++ b/tests/Handler/StreamHandlerTest.php @@ -0,0 +1,205 @@ +createStreamHandler('udg://' . $path); + + $handler->handle('test', 1); + + $this->assertEquals('"test"', socket_read($socket, 10)); + } + + /** + * @requires OS Linux|Darwin + */ + public function testUnixDomainSocket(): void + { + $path = '/tmp/test.sock'; + @unlink($path); + $socket = socket_create(AF_UNIX, SOCK_DGRAM, 0); + socket_bind($socket, $path); + socket_connect($socket, $path); + + $handler = $this->createStreamHandler($socket); + + $handler->handle('test', 1); + + $this->assertEquals('"test"', socket_read($socket, 10)); + } + + public function testInMemoryStream(): void + { + $stream = fopen('php://memory', 'wb+'); + $handler = $this->createStreamHandler($stream); + + $handler->handle('test', 1); + + rewind($stream); + + $this->assertEquals('"test"', fread($stream, 255)); + } + + public function testDifferentEncoder(): void + { + $stream = fopen('php://memory', 'wb+'); + $handler = $this->createStreamHandler($stream); + + $handler = $handler->withEncoder(fn (mixed $variable): string => (string) strlen($variable)); + + $handler->handle('test', 1); + + rewind($stream); + + $this->assertEquals('4', fread($stream, 255)); + } + + /** + * @requires OS Linux|Darwin + */ + public function testReopenStream(): void + { + $path = '/tmp/test.sock'; + @unlink($path); + $socket = socket_create(AF_UNIX, SOCK_DGRAM, 0); + socket_bind($socket, $path); + + $handler = $this->createStreamHandler('udg://' . $path); + $handler->handle('test', 1); + + socket_close($socket); + + $path = '/tmp/test.sock'; + @unlink($path); + $socket = socket_create(AF_UNIX, SOCK_DGRAM, 0); + socket_bind($socket, $path); + $handler->handle('test', 1); + + $this->assertEquals('"test"', socket_read($socket, 10)); + } + + public function testFailedToReopenStream(): void + { + $stream = fopen('php://memory', 'wb+'); + $handler = $this->createStreamHandler($stream); + + $handler->handle('test', 1); + + fclose($stream); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Cannot initialize a stream.'); + $handler->handle('test', 1); + } + + /** + * @dataProvider differentVariablesProvider + */ + public function testDifferentVariables(mixed $variable): void + { + $stream = fopen('php://memory', 'wb+'); + $handler = $this->createStreamHandler($stream); + + $handler->handle($variable, 1); + + rewind($stream); + + $this->assertEquals(json_encode($variable), fread($stream, 255)); + } + + public static function differentVariablesProvider(): Generator + { + yield 'string' => ['test']; + yield 'integer' => [1]; + yield 'float' => [1.1]; + yield 'array' => [['test']]; + yield 'object' => [new stdClass()]; + yield 'null' => [null]; + } + + public function testIncorrectValue(): void + { + $this->expectException(InvalidArgumentException::class); + $message = 'Argument $uri must be either a string, a resource or a Socket instance, "array" given.'; + $this->expectExceptionMessage($message); + $this->createStreamHandler([]); + } + + public function testIncorrectEncoderReturnType(): void + { + $stream = fopen('php://memory', 'wb+'); + $handler = $this->createStreamHandler($stream); + + $handler = $handler->withEncoder(fn (mixed $variable): int => strlen($variable)); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Encoder must return a string, "int" returned.'); + $handler->handle('test', 1); + } + + public function testDestructStreamResource(): void + { + $stream = fopen('php://memory', 'wb+'); + $handler = $this->createStreamHandler($stream); + $handler->handle('test', 1); + unset($handler); + + $this->assertTrue(is_resource($stream)); + } + + public function testDestructStringResource(): void + { + $handler = $this->createStreamHandler('php://memory'); + + $handler->handle('test', 1); + + $reflection = new ReflectionObject($handler); + $property = $reflection->getProperty('stream'); + $property->setAccessible(true); + $resource = $property->getValue($handler); + + $this->assertTrue(is_resource($resource)); + + $handler->__destruct(); + + $this->assertFalse(is_resource($resource)); + } + + public function testImmutability(): void + { + $handler1 = $this->createStreamHandler('php://memory'); + $handler2 = $handler1->withEncoder(fn (mixed $variable): string => (string) strlen($variable)); + + $this->assertInstanceOf(StreamHandler::class, $handler2); + $this->assertNotSame($handler1, $handler2); + } + + /** + * @param mixed|resource|string $stream + */ + private function createStreamHandler(mixed $stream): StreamHandler + { + return new StreamHandler($stream); + } +} diff --git a/tests/Support/InMemoryHandler.php b/tests/Support/InMemoryHandler.php new file mode 100644 index 0000000..bde23a9 --- /dev/null +++ b/tests/Support/InMemoryHandler.php @@ -0,0 +1,22 @@ +variables[] = [$variable, $depth, $highlight]; + } + + public function getVariables(): array + { + return $this->variables; + } +}