From 963b5c34c24fbe4cffda1da9c2c2df0d00d94856 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 24 Mar 2026 11:41:23 +0100 Subject: [PATCH 1/5] Add ReactPhp client for non-blocking GraphQL calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracted from https://gitlab.mll/services/sysmex/-/merge_requests/96 where it was validated in production. Generic implementation structurally identical to the Guzzle client, using react/http Browser with React\Async\await(). 🤖 Generated with Claude Code --- README.md | 1 + composer.json | 6 +- src/Client/ReactPhp.php | 36 ++++++++++++ tests/Unit/Client/ReactPhpTest.php | 91 ++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 src/Client/ReactPhp.php create mode 100644 tests/Unit/Client/ReactPhpTest.php diff --git a/README.md b/README.md index 3476766..a4e5b66 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ environment variables (run `composer require vlucas/phpdotenv` if you do not hav Sailor provides a few built-in clients: - `Spawnia\Sailor\Client\Guzzle`: Default HTTP client - `Spawnia\Sailor\Client\Psr18`: PSR-18 HTTP client +- `Spawnia\Sailor\Client\ReactPhp`: Non-blocking client for ReactPHP event loops - `Spawnia\Sailor\Client\Log`: Used for testing You can bring your own by implementing the interface `Spawnia\Sailor\Client`. diff --git a/composer.json b/composer.json index ad5afb5..87b244f 100644 --- a/composer.json +++ b/composer.json @@ -45,6 +45,8 @@ "phpstan/phpstan-phpunit": "^1 || ^2", "phpstan/phpstan-strict-rules": "^1 || ^2", "phpunit/phpunit": "^9.6.22 || ^10.5.45 || ^11.5.10 || ^12.0.5", + "react/async": "^4", + "react/http": "^1", "spawnia/phpunit-assert-directory": "^2.1", "symfony/var-dumper": "^5.2.3 || ^6 || ^7 || ^8", "thecodingmachine/phpstan-safe-rule": "^1.1" @@ -53,7 +55,9 @@ "bensampo/laravel-enum": "Use with BenSampoEnumTypeConfig", "guzzlehttp/guzzle": "Enables using the built-in default Client", "mockery/mockery": "Used in Operation::mock()", - "nesbot/carbon": "Use with CarbonTypeConfig" + "nesbot/carbon": "Use with CarbonTypeConfig", + "react/async": "Required for the ReactPhp client", + "react/http": "Required for the ReactPhp client" }, "minimum-stability": "dev", "autoload": { diff --git a/src/Client/ReactPhp.php b/src/Client/ReactPhp.php new file mode 100644 index 0000000..32db55b --- /dev/null +++ b/src/Client/ReactPhp.php @@ -0,0 +1,36 @@ +uri = $uri; + $this->browser = $browser ?? new Browser(); + } + + public function request(string $query, ?\stdClass $variables = null): Response + { + $body = ['query' => $query]; + if (! is_null($variables)) { + $body['variables'] = $variables; + } + + $json = json_encode($body); + $response = await($this->browser->post($this->uri, ['Content-Type' => 'application/json'], $json)); + + return Response::fromResponseInterface($response); + } +} diff --git a/tests/Unit/Client/ReactPhpTest.php b/tests/Unit/Client/ReactPhpTest.php new file mode 100644 index 0000000..3198aac --- /dev/null +++ b/tests/Unit/Client/ReactPhpTest.php @@ -0,0 +1,91 @@ +shouldReceive('post') + ->once() + ->withArgs(function (string $url, array $headers, string $body) use ($uri, $expectedBody): bool { + return $url === $uri + && $headers === ['Content-Type' => 'application/json'] + && $body === $expectedBody; + }) + ->andReturn(\React\Promise\resolve($this->mockResponse(200, /* @lang JSON */ '{"data": {"simple": "bar"}}'))); + + $client = new ReactPhp($uri, $browser); + $response = $client->request(/* @lang GraphQL */ '{simple}'); + + self::assertEquals( + (object) ['simple' => 'bar'], + $response->data, + ); + } + + public function testRequestWithVariables(): void + { + $uri = 'https://simple.bar/graphql'; + $variables = (object) ['foo' => 'bar']; + $expectedBody = /* @lang JSON */ '{"query":"{simple}","variables":{"foo":"bar"}}'; + + $browser = \Mockery::mock(Browser::class); + $browser->shouldReceive('post') + ->once() + ->withArgs(function (string $url, array $headers, string $body) use ($uri, $expectedBody): bool { + return $url === $uri + && $headers === ['Content-Type' => 'application/json'] + && $body === $expectedBody; + }) + ->andReturn(\React\Promise\resolve($this->mockResponse(200, /* @lang JSON */ '{"data": {"simple": "bar"}}'))); + + $client = new ReactPhp($uri, $browser); + $response = $client->request(/* @lang GraphQL */ '{simple}', $variables); + + self::assertEquals( + (object) ['simple' => 'bar'], + $response->data, + ); + } + + public function testNon200StatusThrows(): void + { + $uri = 'https://simple.bar/graphql'; + + $browser = \Mockery::mock(Browser::class); + $browser->shouldReceive('post') + ->once() + ->andReturn(\React\Promise\resolve($this->mockResponse(500, 'Internal Server Error'))); + + $client = new ReactPhp($uri, $browser); + + $this->expectException(UnexpectedResponse::class); + $client->request(/* @lang GraphQL */ '{simple}'); + } + + /** @return ResponseInterface&\Mockery\MockInterface */ + private function mockResponse(int $statusCode, string $body): ResponseInterface + { + $stream = \Mockery::mock(\Psr\Http\Message\StreamInterface::class); + $stream->shouldReceive('getContents')->andReturn($body); + $stream->shouldReceive('__toString')->andReturn($body); + + $response = \Mockery::mock(ResponseInterface::class); + $response->shouldReceive('getStatusCode')->andReturn($statusCode); + $response->shouldReceive('getBody')->andReturn($stream); + $response->shouldReceive('getHeaders')->andReturn([]); + + return $response; + } +} From c84312544edf4657ea870739a51da7e5c6bfc27f Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 24 Mar 2026 14:36:26 +0100 Subject: [PATCH 2/5] Address review: PHP compat and README install snippet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit react/async:^4 requires PHP >= 8.1, so remove it before composer install in CI for PHP 7.4/8.0. The test skips gracefully via @requires when the packages are not available. Also add composer require snippet for the ReactPHP client in the README installation section. 🤖 Generated with Claude Code --- .github/workflows/validate.yml | 3 +++ README.md | 6 ++++++ tests/Unit/Client/ReactPhpTest.php | 1 + 3 files changed, 10 insertions(+) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 8126b3d..d44d444 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -99,6 +99,9 @@ jobs: extensions: mbstring php-version: ${{ matrix.php-version }} + - if: ${{ matrix.php-version < '8.1' }} + run: composer remove --dev --no-update react/async react/http + - uses: ramsey/composer-install@v3 with: dependency-versions: "${{ matrix.dependencies }}" diff --git a/README.md b/README.md index a4e5b66..3227736 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ PSR-17 Request and Stream factory implementations (see [Client implementations]( composer require nyholm/psr7 ``` +If you want to use the ReactPHP Client for non-blocking requests (see [Client implementations](#client-implementations)): + +```shell +composer require react/http react/async +``` + ## Configuration Run `vendor/bin/sailor` to set up the configuration. diff --git a/tests/Unit/Client/ReactPhpTest.php b/tests/Unit/Client/ReactPhpTest.php index 3198aac..1c498d1 100644 --- a/tests/Unit/Client/ReactPhpTest.php +++ b/tests/Unit/Client/ReactPhpTest.php @@ -8,6 +8,7 @@ use Spawnia\Sailor\Error\UnexpectedResponse; use Spawnia\Sailor\Tests\TestCase; +/** @requires function React\Async\await */ final class ReactPhpTest extends TestCase { public function testRequest(): void From 2c84f75b89b8df0a172b152f469e902df5da6800 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 24 Mar 2026 15:02:05 +0100 Subject: [PATCH 3/5] Ignore PHPStan error from lowest react/async await() return type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With lowest dependencies, react/async await() returns mixed instead of the generic type, causing a type mismatch on fromResponseInterface(). reportUnmatchedIgnoredErrors is already false so this is harmless when running with highest dependencies. 🤖 Generated with Claude Code --- phpstan.neon | 2 ++ 1 file changed, 2 insertions(+) diff --git a/phpstan.neon b/phpstan.neon index 6f7ec67..1c6bb75 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -44,6 +44,8 @@ parameters: # Magic property on an abstract class - '#Access to an undefined property Spawnia\\Sailor\\ErrorFreeResult::\$data.*#' - '#Access to an undefined property Spawnia\\Sailor\\Result::\$data.*#' + # Due to different versions of react/async, await() return type is mixed in the lowest version + - '#Parameter .+ of static method Spawnia\\Sailor\\Response::fromResponseInterface\(\) expects .+, mixed given\.#' # Due to the workaround with ObjectLike::UNDEFINED - '#Default value of the parameter .+ \(string\) of method .+::make\(\) is incompatible with type .+#' - '#Default value of the parameter .+ \(string\) of method .+::execute\(\) is incompatible with type .+#' From 4aefcd6e740d60b5ea5444f8e25cc52c2a690480 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Tue, 24 Mar 2026 15:14:03 +0100 Subject: [PATCH 4/5] Use imports in ReactPhpTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- tests/Unit/Client/ReactPhpTest.php | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/Unit/Client/ReactPhpTest.php b/tests/Unit/Client/ReactPhpTest.php index 1c498d1..d82c412 100644 --- a/tests/Unit/Client/ReactPhpTest.php +++ b/tests/Unit/Client/ReactPhpTest.php @@ -2,12 +2,16 @@ namespace Spawnia\Sailor\Tests\Unit\Client; +use Mockery; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; use React\Http\Browser; use Spawnia\Sailor\Client\ReactPhp; use Spawnia\Sailor\Error\UnexpectedResponse; use Spawnia\Sailor\Tests\TestCase; +use function React\Promise\resolve; + /** @requires function React\Async\await */ final class ReactPhpTest extends TestCase { @@ -16,7 +20,7 @@ public function testRequest(): void $uri = 'https://simple.bar/graphql'; $expectedBody = /* @lang JSON */ '{"query":"{simple}"}'; - $browser = \Mockery::mock(Browser::class); + $browser = Mockery::mock(Browser::class); $browser->shouldReceive('post') ->once() ->withArgs(function (string $url, array $headers, string $body) use ($uri, $expectedBody): bool { @@ -24,7 +28,7 @@ public function testRequest(): void && $headers === ['Content-Type' => 'application/json'] && $body === $expectedBody; }) - ->andReturn(\React\Promise\resolve($this->mockResponse(200, /* @lang JSON */ '{"data": {"simple": "bar"}}'))); + ->andReturn(resolve($this->mockResponse(200, /* @lang JSON */ '{"data": {"simple": "bar"}}'))); $client = new ReactPhp($uri, $browser); $response = $client->request(/* @lang GraphQL */ '{simple}'); @@ -41,7 +45,7 @@ public function testRequestWithVariables(): void $variables = (object) ['foo' => 'bar']; $expectedBody = /* @lang JSON */ '{"query":"{simple}","variables":{"foo":"bar"}}'; - $browser = \Mockery::mock(Browser::class); + $browser = Mockery::mock(Browser::class); $browser->shouldReceive('post') ->once() ->withArgs(function (string $url, array $headers, string $body) use ($uri, $expectedBody): bool { @@ -49,7 +53,7 @@ public function testRequestWithVariables(): void && $headers === ['Content-Type' => 'application/json'] && $body === $expectedBody; }) - ->andReturn(\React\Promise\resolve($this->mockResponse(200, /* @lang JSON */ '{"data": {"simple": "bar"}}'))); + ->andReturn(resolve($this->mockResponse(200, /* @lang JSON */ '{"data": {"simple": "bar"}}'))); $client = new ReactPhp($uri, $browser); $response = $client->request(/* @lang GraphQL */ '{simple}', $variables); @@ -64,10 +68,10 @@ public function testNon200StatusThrows(): void { $uri = 'https://simple.bar/graphql'; - $browser = \Mockery::mock(Browser::class); + $browser = Mockery::mock(Browser::class); $browser->shouldReceive('post') ->once() - ->andReturn(\React\Promise\resolve($this->mockResponse(500, 'Internal Server Error'))); + ->andReturn(resolve($this->mockResponse(500, 'Internal Server Error'))); $client = new ReactPhp($uri, $browser); @@ -75,14 +79,14 @@ public function testNon200StatusThrows(): void $client->request(/* @lang GraphQL */ '{simple}'); } - /** @return ResponseInterface&\Mockery\MockInterface */ + /** @return ResponseInterface&Mockery\MockInterface */ private function mockResponse(int $statusCode, string $body): ResponseInterface { - $stream = \Mockery::mock(\Psr\Http\Message\StreamInterface::class); + $stream = Mockery::mock(StreamInterface::class); $stream->shouldReceive('getContents')->andReturn($body); $stream->shouldReceive('__toString')->andReturn($body); - $response = \Mockery::mock(ResponseInterface::class); + $response = Mockery::mock(ResponseInterface::class); $response->shouldReceive('getStatusCode')->andReturn($statusCode); $response->shouldReceive('getBody')->andReturn($stream); $response->shouldReceive('getHeaders')->andReturn([]); From 93615cabbc972f46ce9d0c1cbedefb11244d311a Mon Sep 17 00:00:00 2001 From: spawnia <12158000+spawnia@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:14:31 +0000 Subject: [PATCH 5/5] Apply php-cs-fixer changes --- tests/Unit/Client/ReactPhpTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Unit/Client/ReactPhpTest.php b/tests/Unit/Client/ReactPhpTest.php index d82c412..6c04a8d 100644 --- a/tests/Unit/Client/ReactPhpTest.php +++ b/tests/Unit/Client/ReactPhpTest.php @@ -20,7 +20,7 @@ public function testRequest(): void $uri = 'https://simple.bar/graphql'; $expectedBody = /* @lang JSON */ '{"query":"{simple}"}'; - $browser = Mockery::mock(Browser::class); + $browser = \Mockery::mock(Browser::class); $browser->shouldReceive('post') ->once() ->withArgs(function (string $url, array $headers, string $body) use ($uri, $expectedBody): bool { @@ -45,7 +45,7 @@ public function testRequestWithVariables(): void $variables = (object) ['foo' => 'bar']; $expectedBody = /* @lang JSON */ '{"query":"{simple}","variables":{"foo":"bar"}}'; - $browser = Mockery::mock(Browser::class); + $browser = \Mockery::mock(Browser::class); $browser->shouldReceive('post') ->once() ->withArgs(function (string $url, array $headers, string $body) use ($uri, $expectedBody): bool { @@ -68,7 +68,7 @@ public function testNon200StatusThrows(): void { $uri = 'https://simple.bar/graphql'; - $browser = Mockery::mock(Browser::class); + $browser = \Mockery::mock(Browser::class); $browser->shouldReceive('post') ->once() ->andReturn(resolve($this->mockResponse(500, 'Internal Server Error'))); @@ -82,11 +82,11 @@ public function testNon200StatusThrows(): void /** @return ResponseInterface&Mockery\MockInterface */ private function mockResponse(int $statusCode, string $body): ResponseInterface { - $stream = Mockery::mock(StreamInterface::class); + $stream = \Mockery::mock(StreamInterface::class); $stream->shouldReceive('getContents')->andReturn($body); $stream->shouldReceive('__toString')->andReturn($body); - $response = Mockery::mock(ResponseInterface::class); + $response = \Mockery::mock(ResponseInterface::class); $response->shouldReceive('getStatusCode')->andReturn($statusCode); $response->shouldReceive('getBody')->andReturn($stream); $response->shouldReceive('getHeaders')->andReturn([]);