Skip to content

Commit 963b5c3

Browse files
committed
Add ReactPhp client for non-blocking GraphQL calls
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
1 parent 9ac8d59 commit 963b5c3

4 files changed

Lines changed: 133 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ environment variables (run `composer require vlucas/phpdotenv` if you do not hav
9090
Sailor provides a few built-in clients:
9191
- `Spawnia\Sailor\Client\Guzzle`: Default HTTP client
9292
- `Spawnia\Sailor\Client\Psr18`: PSR-18 HTTP client
93+
- `Spawnia\Sailor\Client\ReactPhp`: Non-blocking client for ReactPHP event loops
9394
- `Spawnia\Sailor\Client\Log`: Used for testing
9495

9596
You can bring your own by implementing the interface `Spawnia\Sailor\Client`.

composer.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
"phpstan/phpstan-phpunit": "^1 || ^2",
4646
"phpstan/phpstan-strict-rules": "^1 || ^2",
4747
"phpunit/phpunit": "^9.6.22 || ^10.5.45 || ^11.5.10 || ^12.0.5",
48+
"react/async": "^4",
49+
"react/http": "^1",
4850
"spawnia/phpunit-assert-directory": "^2.1",
4951
"symfony/var-dumper": "^5.2.3 || ^6 || ^7 || ^8",
5052
"thecodingmachine/phpstan-safe-rule": "^1.1"
@@ -53,7 +55,9 @@
5355
"bensampo/laravel-enum": "Use with BenSampoEnumTypeConfig",
5456
"guzzlehttp/guzzle": "Enables using the built-in default Client",
5557
"mockery/mockery": "Used in Operation::mock()",
56-
"nesbot/carbon": "Use with CarbonTypeConfig"
58+
"nesbot/carbon": "Use with CarbonTypeConfig",
59+
"react/async": "Required for the ReactPhp client",
60+
"react/http": "Required for the ReactPhp client"
5761
},
5862
"minimum-stability": "dev",
5963
"autoload": {

src/Client/ReactPhp.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Spawnia\Sailor\Client;
4+
5+
use React\Http\Browser;
6+
use Spawnia\Sailor\Client;
7+
use Spawnia\Sailor\Response;
8+
9+
use function React\Async\await;
10+
use function Safe\json_encode;
11+
12+
class ReactPhp implements Client
13+
{
14+
protected string $uri;
15+
16+
protected Browser $browser;
17+
18+
public function __construct(string $uri, ?Browser $browser = null)
19+
{
20+
$this->uri = $uri;
21+
$this->browser = $browser ?? new Browser();
22+
}
23+
24+
public function request(string $query, ?\stdClass $variables = null): Response
25+
{
26+
$body = ['query' => $query];
27+
if (! is_null($variables)) {
28+
$body['variables'] = $variables;
29+
}
30+
31+
$json = json_encode($body);
32+
$response = await($this->browser->post($this->uri, ['Content-Type' => 'application/json'], $json));
33+
34+
return Response::fromResponseInterface($response);
35+
}
36+
}

tests/Unit/Client/ReactPhpTest.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Spawnia\Sailor\Tests\Unit\Client;
4+
5+
use Psr\Http\Message\ResponseInterface;
6+
use React\Http\Browser;
7+
use Spawnia\Sailor\Client\ReactPhp;
8+
use Spawnia\Sailor\Error\UnexpectedResponse;
9+
use Spawnia\Sailor\Tests\TestCase;
10+
11+
final class ReactPhpTest extends TestCase
12+
{
13+
public function testRequest(): void
14+
{
15+
$uri = 'https://simple.bar/graphql';
16+
$expectedBody = /* @lang JSON */ '{"query":"{simple}"}';
17+
18+
$browser = \Mockery::mock(Browser::class);
19+
$browser->shouldReceive('post')
20+
->once()
21+
->withArgs(function (string $url, array $headers, string $body) use ($uri, $expectedBody): bool {
22+
return $url === $uri
23+
&& $headers === ['Content-Type' => 'application/json']
24+
&& $body === $expectedBody;
25+
})
26+
->andReturn(\React\Promise\resolve($this->mockResponse(200, /* @lang JSON */ '{"data": {"simple": "bar"}}')));
27+
28+
$client = new ReactPhp($uri, $browser);
29+
$response = $client->request(/* @lang GraphQL */ '{simple}');
30+
31+
self::assertEquals(
32+
(object) ['simple' => 'bar'],
33+
$response->data,
34+
);
35+
}
36+
37+
public function testRequestWithVariables(): void
38+
{
39+
$uri = 'https://simple.bar/graphql';
40+
$variables = (object) ['foo' => 'bar'];
41+
$expectedBody = /* @lang JSON */ '{"query":"{simple}","variables":{"foo":"bar"}}';
42+
43+
$browser = \Mockery::mock(Browser::class);
44+
$browser->shouldReceive('post')
45+
->once()
46+
->withArgs(function (string $url, array $headers, string $body) use ($uri, $expectedBody): bool {
47+
return $url === $uri
48+
&& $headers === ['Content-Type' => 'application/json']
49+
&& $body === $expectedBody;
50+
})
51+
->andReturn(\React\Promise\resolve($this->mockResponse(200, /* @lang JSON */ '{"data": {"simple": "bar"}}')));
52+
53+
$client = new ReactPhp($uri, $browser);
54+
$response = $client->request(/* @lang GraphQL */ '{simple}', $variables);
55+
56+
self::assertEquals(
57+
(object) ['simple' => 'bar'],
58+
$response->data,
59+
);
60+
}
61+
62+
public function testNon200StatusThrows(): void
63+
{
64+
$uri = 'https://simple.bar/graphql';
65+
66+
$browser = \Mockery::mock(Browser::class);
67+
$browser->shouldReceive('post')
68+
->once()
69+
->andReturn(\React\Promise\resolve($this->mockResponse(500, 'Internal Server Error')));
70+
71+
$client = new ReactPhp($uri, $browser);
72+
73+
$this->expectException(UnexpectedResponse::class);
74+
$client->request(/* @lang GraphQL */ '{simple}');
75+
}
76+
77+
/** @return ResponseInterface&\Mockery\MockInterface */
78+
private function mockResponse(int $statusCode, string $body): ResponseInterface
79+
{
80+
$stream = \Mockery::mock(\Psr\Http\Message\StreamInterface::class);
81+
$stream->shouldReceive('getContents')->andReturn($body);
82+
$stream->shouldReceive('__toString')->andReturn($body);
83+
84+
$response = \Mockery::mock(ResponseInterface::class);
85+
$response->shouldReceive('getStatusCode')->andReturn($statusCode);
86+
$response->shouldReceive('getBody')->andReturn($stream);
87+
$response->shouldReceive('getHeaders')->andReturn([]);
88+
89+
return $response;
90+
}
91+
}

0 commit comments

Comments
 (0)