diff --git a/src/AsyncClient.php b/src/AsyncClient.php new file mode 100644 index 00000000..70aa23aa --- /dev/null +++ b/src/AsyncClient.php @@ -0,0 +1,10 @@ + $query]; + if (! is_null($variables)) { + $json['variables'] = $variables; + } + + $promise = $this->guzzle->postAsync($this->uri, ['json' => $json]); + + return $promise->then( + function (ResponseInterface $response) { + return Response::fromResponseInterface($response); + } + ); + } } diff --git a/src/Codegen/OperationBuilder.php b/src/Codegen/OperationBuilder.php index faa63798..3609fe2f 100644 --- a/src/Codegen/OperationBuilder.php +++ b/src/Codegen/OperationBuilder.php @@ -19,6 +19,8 @@ class OperationBuilder private Method $execute; + private Method $executeAsync; + private Method $converters; /** @var array */ @@ -40,6 +42,11 @@ public function __construct(string $name, string $namespace) $execute->addBody('return self::executeOperation('); $this->execute = $execute; + $executeAsync = $class->addMethod('executeAsync'); + $executeAsync->setStatic(true); + $executeAsync->addBody('return self::executeOperationAsync('); + $this->executeAsync = $executeAsync; + $converters = $class->addMethod('converters'); $converters->setStatic(true); $converters->setProtected(); @@ -101,6 +108,7 @@ public function build(): ClassType $this->converters->addBody(/** @lang PHP */ '];'); $this->execute->addBody(');'); + $this->executeAsync->addBody(');'); return $this->class; } @@ -120,6 +128,7 @@ protected function buildVariable(string $name, Type $type, string $typeReference $this->converters->addBody(/** @lang PHP */ " ['{$name}', {$wrappedTypeConverter}],"); $this->execute->addComment(/** @lang PHPDoc */ "@param {$wrappedPhpDocType} \${$name}"); + $this->executeAsync->addComment(/** @lang PHPDoc */ "@param {$wrappedPhpDocType} \${$name}"); // TODO support default values properly $parameter = $this->execute->addParameter($name); @@ -128,6 +137,13 @@ protected function buildVariable(string $name, Type $type, string $typeReference $parameter->setDefaultValue(ObjectLike::UNDEFINED); } + $parameterAsync = $this->executeAsync->addParameter($name); + if (! $type instanceof NonNull || $defaultValue !== null) { + $parameterAsync->setNullable(true); + $parameterAsync->setDefaultValue(ObjectLike::UNDEFINED); + } + $this->execute->addBody(/** @lang PHP */ " \${$name},"); + $this->executeAsync->addBody(/** @lang PHP */ " \${$name},"); } } diff --git a/src/Operation.php b/src/Operation.php index 4ccce203..2879cb70 100644 --- a/src/Operation.php +++ b/src/Operation.php @@ -87,6 +87,64 @@ protected static function fetchResponse(array $args): Response return $response; } + + /** + * @param mixed ...$args type depends on the subclass + * + * @return \GuzzleHttp\Promise\PromiseInterface + */ + protected static function executeOperationAsync(...$args) + { + $mock = self::$mocks[static::class] ?? null; + if ($mock !== null) { + // @phpstan-ignore-next-line This function is only present on child classes + return $mock::executeAsync(...$args); + } + + return self::fetchResponseAsync($args)->then( + function ($response) { + $child = static::class; + $parts = explode('\\', $child); + $basename = end($parts); + + $resultClass = $child . '\\' . $basename . 'Result'; + assert(class_exists($resultClass)); + + return $resultClass::fromResponse($response); + } + ); + } + + /** + * Send an operation through the client and return the response. + * + * @param array $args + */ + protected static function fetchResponseAsync(array $args) + { + $endpointConfig = Configuration::endpoint(static::config(), static::endpoint()); + + $document = static::document(); + $variables = static::variables($args); + + $endpointConfig->handleEvent(new StartRequest($document, $variables)); + + $client = self::$clients[static::class] ??= $endpointConfig->makeClient(); + if (! $client instanceof AsyncClient) { + throw new \InvalidArgumentException('Please provide an async client in order to perform async requests.'); + } + + $promise = $client->requestAsync($document, $variables); + + return $promise->then( + function (Response $response) use ($endpointConfig) { + $endpointConfig->handleEvent(new ReceiveResponse($response)); + return $response; + } + ); + } + + /** @param array $args */ protected static function variables(array $args): \stdClass { diff --git a/src/Testing/MockClient.php b/src/Testing/MockClient.php index c4ae5cfd..13a60dcc 100644 --- a/src/Testing/MockClient.php +++ b/src/Testing/MockClient.php @@ -2,13 +2,14 @@ namespace Spawnia\Sailor\Testing; -use Spawnia\Sailor\Client; +use GuzzleHttp\Promise\Promise; +use Spawnia\Sailor\AsyncClient; use Spawnia\Sailor\Response; /** * @phpstan-type Request callable(string, \stdClass|null): Response */ -class MockClient implements Client +class MockClient implements AsyncClient { /** @var Request */ private $request; @@ -28,4 +29,15 @@ public function request(string $query, \stdClass $variables = null): Response return ($this->request)($query, $variables); } + + public function requestAsync(string $query, ?\stdClass $variables = null): Promise + { + $this->storedRequests[] = new MockRequest($query, $variables); + $promise = new Promise(function () use (&$promise, $query, $variables) { + $response = ($this->request)($query, $variables); + /** @var Promise $promise */ + $promise->resolve($response); + }); + return $promise; + } } diff --git a/tests/Unit/Client/GuzzleTest.php b/tests/Unit/Client/GuzzleTest.php index eb269539..ea7344bc 100644 --- a/tests/Unit/Client/GuzzleTest.php +++ b/tests/Unit/Client/GuzzleTest.php @@ -39,4 +39,34 @@ public function testRequest(): void self::assertSame(/* @lang JSON */ '{"query":"{simple}"}', $request->getBody()->getContents()); self::assertSame($uri, $request->getUri()->__toString()); } + + public function testAsyncRequest(): void + { + $container = []; + $history = Middleware::history($container); + + $mock = new MockHandler([ + new Response(200, [], /* @lang JSON */ '{"data": {"simple": "bar"}}'), + ]); + $stack = HandlerStack::create($mock); + $stack->push($history); + + $uri = 'https://simple.bar/graphql'; + $client = new Guzzle($uri, ['handler' => $stack]); + $promise = $client->requestAsync(/* @lang GraphQL */ '{simple}'); + + $response = $promise->wait(); + + self::assertEquals( + (object) ['simple' => 'bar'], + $response->data + ); + + $request = $container[0]['request']; + assert($request instanceof Request); + + self::assertSame('POST', $request->getMethod()); + self::assertSame(/* @lang JSON */ '{"query":"{simple}"}', $request->getBody()->getContents()); + self::assertSame($uri, $request->getUri()->__toString()); + } } diff --git a/tests/Unit/Testing/MockClientTest.php b/tests/Unit/Testing/MockClientTest.php index 2417baa4..81cc7b43 100644 --- a/tests/Unit/Testing/MockClientTest.php +++ b/tests/Unit/Testing/MockClientTest.php @@ -16,7 +16,7 @@ public function testCallsMock(): void $response = new Response(); $responseMock = self::createPartialMock(Invokable::class, ['__invoke']); - $responseMock->expects(self::once()) + $responseMock->expects(self::exactly(2)) ->method('__invoke') ->with($query, $variables) ->willReturn($response); @@ -29,5 +29,12 @@ public function testCallsMock(): void self::assertSame($query, $storedRequest->query); self::assertSame($variables, $storedRequest->variables); + + $mockClient->requestAsync($query, $variables)->wait(); + + $storedRequest = $mockClient->storedRequests[1]; + + self::assertSame($query, $storedRequest->query); + self::assertSame($variables, $storedRequest->variables); } }