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 3476766..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. @@ -90,6 +96,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/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 .+#' 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..6c04a8d --- /dev/null +++ b/tests/Unit/Client/ReactPhpTest.php @@ -0,0 +1,96 @@ +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(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(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(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(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; + } +}