diff --git a/composer.json b/composer.json index 49f9ef9..7956c91 100644 --- a/composer.json +++ b/composer.json @@ -45,7 +45,7 @@ "google/protobuf": "^3.7 || ^4.0", "spiral/roadrunner-worker": "^3.0", "spiral/goridge": "^4.0", - "spiral/roadrunner": "^2023.1 || ^2024.1" + "spiral/roadrunner": "^2024.3" }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.0", diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 119d221..5c064bb 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,2 +1,2 @@ - + diff --git a/src/Internal/CallContext.php b/src/Internal/CallContext.php new file mode 100644 index 0000000..5f6ce29 --- /dev/null +++ b/src/Internal/CallContext.php @@ -0,0 +1,46 @@ + $service + * @param non-empty-string $method + * @param array> $context + */ + public function __construct( + public readonly string $service, + public readonly string $method, + public readonly array $context, + ) {} + + /** + * @throws \JsonException + */ + public static function decode(string $payload): self + { + /** + * @psalm-var array{ + * service: class-string, + * method: non-empty-string, + * context: array> + * } $data + */ + $data = Json::decode($payload); + + return new self( + service: $data['service'], + method: $data['method'], + context: $data['context'], + ); + } +} diff --git a/src/ResponseTrailers.php b/src/ResponseTrailers.php new file mode 100644 index 0000000..d0e4fca --- /dev/null +++ b/src/ResponseTrailers.php @@ -0,0 +1,72 @@ + + */ +final class ResponseTrailers implements \IteratorAggregate, \Countable +{ + /** + * @var array + */ + private array $trailers = []; + + /** + * @param iterable $trailers + */ + public function __construct(iterable $trailers = []) + { + foreach ($trailers as $key => $value) { + $this->set($key, $value); + } + } + + /** + * @param THeaderKey $key + * @param THeaderValue $value + */ + public function set(string $key, string $value): void + { + $this->trailers[$key] = $value; + } + + /** + * @param THeaderKey $key + * @return THeaderValue|null + */ + public function get(string $key, ?string $default = null): ?string + { + return $this->trailers[$key] ?? $default; + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->trailers); + } + + public function count(): int + { + return \count($this->trailers); + } + + /** + * @throws \JsonException + */ + public function packTrailers(): string + { + // If an empty array is serialized, it is cast to the string "[]" + // instead of object string "{}" + if ($this->trailers === []) { + return '{}'; + } + + return Json::encode($this->trailers); + } +} diff --git a/src/Server.php b/src/Server.php index 7868569..78607fc 100644 --- a/src/Server.php +++ b/src/Server.php @@ -10,6 +10,7 @@ use Spiral\RoadRunner\GRPC\Exception\GRPCExceptionInterface; use Spiral\RoadRunner\GRPC\Exception\NotFoundException; use Spiral\RoadRunner\GRPC\Exception\ServiceException; +use Spiral\RoadRunner\GRPC\Internal\CallContext; use Spiral\RoadRunner\GRPC\Internal\Json; use Spiral\RoadRunner\Payload; use Spiral\RoadRunner\Worker; @@ -21,12 +22,6 @@ * @psalm-type ServerOptions = array{ * debug?: bool * } - * - * @psalm-type ContextResponse = array{ - * service: class-string, - * method: non-empty-string, - * context: array> - * } */ final class Server { @@ -77,15 +72,43 @@ public function serve(?WorkerInterface $worker = null, ?callable $finalize = nul return; } - try { - /** @var ContextResponse $context */ - $context = Json::decode($request->header); + $responseHeaders = new ResponseHeaders(); + $responseTrailers = new ResponseTrailers(); - [$answerBody, $answerHeaders] = $this->tick($request->body, $context); - - $this->workerSend($worker, $answerBody, $answerHeaders); + try { + $call = CallContext::decode($request->header); + + $context = new Context(\array_merge( + $call->context, + [ + ResponseHeaders::class => $responseHeaders, + ResponseTrailers::class => $responseTrailers, + ], + )); + + $response = $this->invoke($call->service, $call->method, $context, $request->body); + + $headers = []; + $responseHeaders->count() === 0 or $headers['headers'] = $responseHeaders->packHeaders(); + $responseTrailers->count() === 0 or $headers['trailers'] = $responseTrailers->packTrailers(); + + $this->workerSend( + worker: $worker, + body: $response, + headers: $headers === [] ? '{}' : Json::encode($headers), + ); } catch (GRPCExceptionInterface $e) { - $this->workerGrpcError($worker, $e); + $headers = [ + 'error' => $this->createGrpcError($e), + ]; + $responseHeaders->count() === 0 or $headers['headers'] = $responseHeaders->packHeaders(); + $responseTrailers->count() === 0 or $headers['trailers'] = $responseTrailers->packTrailers(); + + $this->workerSend( + worker: $worker, + body: '', + headers: Json::encode($headers), + ); } catch (\Throwable $e) { $this->workerError($worker, $this->isDebugMode() ? (string) $e : $e->getMessage()); } finally { @@ -112,24 +135,9 @@ protected function invoke(string $service, string $method, ContextInterface $con return $this->services[$service]->invoke($method, $context, $body); } - /** - * @param ContextResponse $data - * @return array{0: string, 1: string} - * @throws \JsonException - * @throws \Throwable - */ - private function tick(string $body, array $data): array + private function workerError(WorkerInterface $worker, string $message): void { - $context = (new Context($data['context'])) - ->withValue(ResponseHeaders::class, new ResponseHeaders()); - - $response = $this->invoke($data['service'], $data['method'], $context, $body); - - /** @var ResponseHeaders|null $responseHeaders */ - $responseHeaders = $context->getValue(ResponseHeaders::class); - $responseHeadersString = $responseHeaders ? $responseHeaders->packHeaders() : '{}'; - - return [$response, $responseHeadersString]; + $worker->error($message); } /** @@ -140,12 +148,7 @@ private function workerSend(WorkerInterface $worker, string $body, string $heade $worker->respond(new Payload($body, $headers)); } - private function workerError(WorkerInterface $worker, string $message): void - { - $worker->error($message); - } - - private function workerGrpcError(WorkerInterface $worker, GRPCExceptionInterface $e): void + private function createGrpcError(GRPCExceptionInterface $e): string { $status = new Status([ 'code' => $e->getCode(), @@ -161,13 +164,7 @@ static function ($detail) { ), ]); - $this->workerSend( - $worker, - '', - Json::encode([ - 'error' => \base64_encode($status->serializeToString()), - ]), - ); + return \base64_encode($status->serializeToString()); } /** diff --git a/tests/ContextTest.php b/tests/ContextTest.php index 88b360e..ee79f95 100644 --- a/tests/ContextTest.php +++ b/tests/ContextTest.php @@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase; use Spiral\RoadRunner\GRPC\Context; use Spiral\RoadRunner\GRPC\ResponseHeaders; +use Spiral\RoadRunner\GRPC\ResponseTrailers; class ContextTest extends TestCase { @@ -75,4 +76,24 @@ public function testGetOutgoingHeaders(): void $ctx = new Context([ResponseHeaders::class => $outgoingHeaders]); $this->assertSame($outgoingHeaders, $ctx->getValue(ResponseHeaders::class)); } + + public function testGetOutgoingTrailer(): void + { + $outgoingTrailers = [ + 'X-Some-Trailer' => 'foobar', + ]; + $ctx = new Context([ResponseTrailers::class => new ResponseTrailers($outgoingTrailers)]); + + $this->assertSame($outgoingTrailers['X-Some-Trailer'], $ctx->getValue(ResponseTrailers::class)->get('X-Some-Trailer')); + $this->assertNull($ctx->getValue(ResponseTrailers::class)->get('not-existing')); + } + + public function testGetOutgoingTrailers(): void + { + $outgoingTrailers = new ResponseTrailers([ + 'X-Some-Trailer' => 'foobar', + ]); + $ctx = new Context([ResponseTrailers::class => $outgoingTrailers]); + $this->assertSame($outgoingTrailers, $ctx->getValue(ResponseTrailers::class)); + } } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index 7d93822..b375df3 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -7,17 +7,18 @@ use Google\Rpc\Status; use Mockery as m; use PHPUnit\Framework\TestCase; +use Service\DetailsMessageForException; use Service\Message; use Service\TestInterface; use Spiral\Goridge\Frame; use Spiral\Goridge\RelayInterface; use Spiral\RoadRunner\GRPC\Exception\ServiceException; +use Spiral\RoadRunner\GRPC\InvokerInterface; use Spiral\RoadRunner\GRPC\Server; use Spiral\RoadRunner\GRPC\Tests\Stub\TestService; use Spiral\RoadRunner\Payload; use Spiral\RoadRunner\Worker; use Spiral\RoadRunner\WorkerInterface; -use Spiral\RoadRunner\GRPC\InvokerInterface; class ServerTest extends TestCase { @@ -143,6 +144,40 @@ public function testExceptionDetails(): void $server->serve($worker); } + public function testInvokeGrpcException(): void + { + $worker = m::mock(WorkerInterface::class); + $worker->shouldReceive('waitPayload') + ->times(2) + ->andReturn( + new Payload( + body: $this->packMessage('withDetailsAndHeaders'), + header: '{"context": {}, "service": "service.Test", "method": "Throw"}', + ), + null, + ); + + $worker->shouldReceive('respond')->once() + ->withArgs(static function (Payload $payload) { + $header = \json_decode($payload->header, true); + + $status = new Status(); + $status->mergeFromString(\base64_decode($header['error'])); + /** @var DetailsMessageForException $message */ + $message = $status->getDetails()->offsetGet(0)->unpack(); + + $outgoingHeaders = \json_decode($header['headers'], true); + $outgoingTrailers = \json_decode($header['trailers'], true); + + return $message instanceof DetailsMessageForException + && $message->getMessage() === 'details message' + && $outgoingHeaders === ['foo' => 'bar'] + && $outgoingTrailers === ['baz' => 'bar']; + }); + + $this->server->serve($worker); + } + protected function setUp(): void { parent::setUp(); diff --git a/tests/Stub/TestService.php b/tests/Stub/TestService.php index 3c10674..b98d914 100644 --- a/tests/Stub/TestService.php +++ b/tests/Stub/TestService.php @@ -34,6 +34,17 @@ public function Throw(ContextInterface $ctx, Message $in): Message $grpcException = new GRPCException("main exception message", 3, [$detailsMessage]); + throw $grpcException; + case "withDetailsAndHeaders": + $ctx->getValue(GRPC\ResponseHeaders::class)->set('foo', 'bar'); + $ctx->getValue(GRPC\ResponseTrailers::class)->set('baz', 'bar'); + + $detailsMessage = new DetailsMessageForException(); + $detailsMessage->setCode(1); + $detailsMessage->setMessage("details message"); + + $grpcException = new GRPCException("main exception message", 3, [$detailsMessage]); + throw $grpcException; case "regularException": { @@ -70,6 +81,7 @@ public function Info(ContextInterface $ctx, Message $in): Message } $ctx->getValue(GRPC\ResponseHeaders::class)->set('foo', 'bar'); + $ctx->getValue(GRPC\ResponseTrailers::class)->set('baz', 'bar'); return $out; }