Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Comment thread
spawnia marked this conversation as resolved.
Expand Down
6 changes: 5 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
spawnia marked this conversation as resolved.
"spawnia/phpunit-assert-directory": "^2.1",
"symfony/var-dumper": "^5.2.3 || ^6 || ^7 || ^8",
"thecodingmachine/phpstan-safe-rule": "^1.1"
Expand All @@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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 .+#'
Expand Down
36 changes: 36 additions & 0 deletions src/Client/ReactPhp.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php declare(strict_types=1);

namespace Spawnia\Sailor\Client;

use React\Http\Browser;
use Spawnia\Sailor\Client;
use Spawnia\Sailor\Response;

use function React\Async\await;
use function Safe\json_encode;

class ReactPhp implements Client
{
protected string $uri;

protected Browser $browser;

public function __construct(string $uri, ?Browser $browser = null)
{
$this->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);
}
}
96 changes: 96 additions & 0 deletions tests/Unit/Client/ReactPhpTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php declare(strict_types=1);

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
{
public function testRequest(): void
{
$uri = 'https://simple.bar/graphql';
$expectedBody = /* @lang JSON */ '{"query":"{simple}"}';

$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}');

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;
}
}