├── LICENSE ├── README.md ├── composer.json ├── resources └── .phpstorm.meta.php └── src ├── Context.php ├── ContextInterface.php ├── Exception ├── GRPCException.php ├── GRPCExceptionInterface.php ├── InvokeException.php ├── MutableGRPCExceptionInterface.php ├── NotFoundException.php ├── ServiceException.php ├── UnauthenticatedException.php └── UnimplementedException.php ├── Internal ├── CallContext.php └── Json.php ├── Invoker.php ├── InvokerInterface.php ├── Method.php ├── ResponseHeaders.php ├── ResponseTrailers.php ├── Server.php ├── ServiceInterface.php ├── ServiceWrapper.php └── StatusCode.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Spiral Scout 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | # RoadRunner GRPC Plugin 9 | 10 | [![Latest Stable Version](https://poser.pugx.org/spiral/roadrunner-grpc/version)](https://packagist.org/packages/spiral/roadrunner-grpc) 11 | [![Codecov](https://codecov.io/gh/roadrunner-php/grpc/branch/3.x/graph/badge.svg)](https://codecov.io/gh/roadrunner-php/grpc/) 12 | 13 | RoadRunner GRPC is an open-source (MIT) high-performance PHP [GRPC](https://grpc.io/) server build on top 14 | of [RoadRunner](https://github.com/roadrunner-server/roadrunner). Server support both PHP and Golang services running within one 15 | application. 16 | 17 | ## Features 18 | 19 | - native Golang GRPC implementation compliant 20 | - minimal configuration, plug-and-play model 21 | - very fast, low footprint proxy 22 | - simple TLS configuration 23 | - debug tools included 24 | - Prometheus metrics 25 | - middleware and server customization support 26 | - code generation using `protoc` plugin (Plugin can be downloaded from the 27 | roadrunner [releases page](https://github.com/roadrunner-server/roadrunner/releases)) 28 | - transport, message, worker error management 29 | - response error codes over php exceptions 30 | - works on Windows 31 | 32 | ## Documentation 33 | 34 | You can find more information about RoadRunner GRPC plugin in the [official documentation](https://docs.roadrunner.dev/plugins/grpc). 35 | 36 | ## Example 37 | 38 | You can find example of GRPC application in [example](./example/echo) directory. 39 | 40 | 41 | try Spiral Framework 42 | 43 | 44 | License: 45 | -------- 46 | MIT License (MIT). Please see [`LICENSE`](./LICENSE) for more information. Maintained 47 | by [SpiralScout](https://spiralscout.com). 48 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spiral/roadrunner-grpc", 3 | "type": "library", 4 | "description": "High-Performance GRPC server for PHP applications", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Anton Titov (wolfy-j)", 9 | "email": "wolfy-j@spiralscout.com" 10 | }, 11 | { 12 | "name": "Pavel Buchnev (butschster)", 13 | "email": "pavel.buchnev@spiralscout.com" 14 | }, 15 | { 16 | "name": "Aleksei Gagarin (roxblnfk)", 17 | "email": "alexey.gagarin@spiralscout.com" 18 | }, 19 | { 20 | "name": "Maksim Smakouz (msmakouz)", 21 | "email": "maksim.smakouz@spiralscout.com" 22 | }, 23 | { 24 | "name": "RoadRunner Community", 25 | "homepage": "https://github.com/spiral/roadrunner/graphs/contributors" 26 | } 27 | ], 28 | "homepage": "https://roadrunner.dev/", 29 | "support": { 30 | "docs": "https://docs.roadrunner.dev", 31 | "issues": "https://github.com/roadrunner-server/roadrunner/issues", 32 | "forum": "https://forum.roadrunner.dev/", 33 | "chat": "https://discord.gg/V6EK4he" 34 | }, 35 | "funding": [ 36 | { 37 | "type": "github", 38 | "url": "https://github.com/sponsors/roadrunner-server" 39 | } 40 | ], 41 | "require": { 42 | "php": ">=8.1", 43 | "ext-json": "*", 44 | "google/common-protos": "^3.1|^4.0", 45 | "google/protobuf": "^3.7 || ^4.0", 46 | "spiral/roadrunner-worker": "^3.0", 47 | "spiral/goridge": "^4.0", 48 | "spiral/roadrunner": "^2024.3 || ^2025.1", 49 | "symfony/polyfill-php83": "*" 50 | }, 51 | "require-dev": { 52 | "jetbrains/phpstorm-attributes": "^1.0", 53 | "mockery/mockery": "^1.4", 54 | "phpunit/phpunit": "^10.0", 55 | "spiral/code-style": "^2.2", 56 | "spiral/dumper": "^3.3", 57 | "vimeo/psalm": ">=5.8" 58 | }, 59 | "autoload": { 60 | "psr-4": { 61 | "Spiral\\RoadRunner\\GRPC\\": "src" 62 | } 63 | }, 64 | "autoload-dev": { 65 | "psr-4": { 66 | "GPBMetadata\\": "tests/generated/GPBMetadata", 67 | "Service\\": "tests/generated/Service", 68 | "Spiral\\RoadRunner\\GRPC\\Tests\\": "tests" 69 | } 70 | }, 71 | "scripts": { 72 | "cs:diff": "php-cs-fixer fix --dry-run -v --diff --show-progress dots", 73 | "cs:fix": "php-cs-fixer fix -v", 74 | "psalm": "psalm", 75 | "psalm:baseline": "psalm --set-baseline=psalm-baseline.xml", 76 | "tests": "phpunit" 77 | }, 78 | "config": { 79 | "sort-packages": true 80 | }, 81 | "minimum-stability": "dev", 82 | "prefer-stable": true 83 | } 84 | -------------------------------------------------------------------------------- /resources/.phpstorm.meta.php: -------------------------------------------------------------------------------- 1 | 10 | * @implements \ArrayAccess 11 | */ 12 | final class Context implements ContextInterface, \IteratorAggregate, \Countable, \ArrayAccess 13 | { 14 | /** 15 | * @param TValues $values 16 | */ 17 | public function __construct( 18 | private array $values, 19 | ) {} 20 | 21 | #[\Override] 22 | public function withValue(string $key, mixed $value): ContextInterface 23 | { 24 | $ctx = clone $this; 25 | $ctx->values[$key] = $value; 26 | 27 | return $ctx; 28 | } 29 | 30 | #[\Override] 31 | public function getValue(string $key, mixed $default = null): mixed 32 | { 33 | return $this->values[$key] ?? $default; 34 | } 35 | 36 | #[\Override] 37 | public function getValues(): array 38 | { 39 | return $this->values; 40 | } 41 | 42 | #[\Override] 43 | public function offsetExists(mixed $offset): bool 44 | { 45 | \assert(\is_string($offset), 'Offset argument must be a type of string'); 46 | 47 | /** 48 | * Note: PHP Opcode optimisation 49 | * @see https://www.php.net/manual/pt_BR/internals2.opcodes.isset-isempty-var.php 50 | * 51 | * Priority use `ZEND_ISSET_ISEMPTY_VAR !0` opcode instead of `DO_FCALL 'array_key_exists'`. 52 | */ 53 | return isset($this->values[$offset]) || \array_key_exists($offset, $this->values); 54 | } 55 | 56 | #[\Override] 57 | public function offsetGet(mixed $offset): mixed 58 | { 59 | \assert(\is_string($offset), 'Offset argument must be a type of string'); 60 | 61 | return $this->values[$offset] ?? null; 62 | } 63 | 64 | #[\Override] 65 | public function offsetSet(mixed $offset, mixed $value): void 66 | { 67 | \assert(\is_string($offset), 'Offset argument must be a type of string'); 68 | 69 | $this->values[$offset] = $value; 70 | } 71 | 72 | #[\Override] 73 | public function offsetUnset(mixed $offset): void 74 | { 75 | \assert(\is_string($offset), 'Offset argument must be a type of string'); 76 | 77 | unset($this->values[$offset]); 78 | } 79 | 80 | #[\Override] 81 | public function getIterator(): \Traversable 82 | { 83 | return new \ArrayIterator($this->values); 84 | } 85 | 86 | #[\Override] 87 | public function count(): int 88 | { 89 | return \count($this->values); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/ContextInterface.php: -------------------------------------------------------------------------------- 1 | 11 | */ 12 | interface ContextInterface 13 | { 14 | /** 15 | * Create context with new value. 16 | * 17 | * @param non-empty-string $key 18 | * @return $this 19 | */ 20 | public function withValue(string $key, mixed $value): self; 21 | 22 | /** 23 | * Get context value or return null. 24 | * 25 | * @param non-empty-string $key 26 | */ 27 | public function getValue(string $key): mixed; 28 | 29 | /** 30 | * Return all context values. 31 | * 32 | * @return TValues 33 | */ 34 | public function getValues(): array; 35 | } 36 | -------------------------------------------------------------------------------- /src/Exception/GRPCException.php: -------------------------------------------------------------------------------- 1 | details; 60 | } 61 | 62 | /** 63 | * @param Message[] $details 64 | */ 65 | #[\Override] 66 | public function setDetails(array $details): void 67 | { 68 | $this->details = $details; 69 | } 70 | 71 | #[\Override] 72 | public function addDetails(Message $message): void 73 | { 74 | $this->details[] = $message; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Exception/GRPCExceptionInterface.php: -------------------------------------------------------------------------------- 1 | $service 17 | * @param non-empty-string $method 18 | * @param array> $context 19 | */ 20 | public function __construct( 21 | public readonly string $service, 22 | public readonly string $method, 23 | public readonly array $context, 24 | ) {} 25 | 26 | /** 27 | * @throws \JsonException 28 | */ 29 | public static function decode(string $payload): self 30 | { 31 | /** 32 | * @psalm-var array{ 33 | * service: class-string, 34 | * method: non-empty-string, 35 | * context: array> 36 | * } $data 37 | */ 38 | $data = Json::decode($payload); 39 | 40 | return new self( 41 | service: $data['service'], 42 | method: $data['method'], 43 | context: $data['context'], 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Internal/Json.php: -------------------------------------------------------------------------------- 1 | name]; 28 | 29 | $input = $input instanceof Message ? $input : $this->makeInput($method, $input); 30 | 31 | /** @var Message $message */ 32 | $message = $callable($ctx, $input); 33 | 34 | \assert($this->assertResultType($method, $message)); 35 | 36 | try { 37 | return $message->serializeToString(); 38 | } catch (\Throwable $e) { 39 | throw InvokeException::create($e->getMessage(), StatusCode::INTERNAL, $e); 40 | } 41 | } 42 | 43 | /** 44 | * Checks that the result from the GRPC service method returns the Message object. 45 | * 46 | * @throws \BadFunctionCallException 47 | */ 48 | private function assertResultType(Method $method, mixed $result): bool 49 | { 50 | if (!$result instanceof Message) { 51 | $type = \get_debug_type($result); 52 | 53 | throw new \BadFunctionCallException( 54 | \sprintf(self::ERROR_METHOD_RETURN, $method->name, Message::class, $type), 55 | ); 56 | } 57 | 58 | return true; 59 | } 60 | 61 | /** 62 | * Converts the input from the GRPC service method to the Message object. 63 | * @throws InvokeException 64 | */ 65 | private function makeInput(Method $method, ?string $body): Message 66 | { 67 | try { 68 | $class = $method->inputType; 69 | \assert($this->assertInputType($method, $class)); 70 | 71 | /** @psalm-suppress UnsafeInstantiation */ 72 | $in = new $class(); 73 | 74 | if ($body !== null) { 75 | $in->mergeFromString($body); 76 | } 77 | 78 | return $in; 79 | } catch (\Throwable $e) { 80 | throw InvokeException::create($e->getMessage(), StatusCode::INTERNAL, $e); 81 | } 82 | } 83 | 84 | /** 85 | * Checks that the input of the GRPC service method contains the 86 | * Message object. 87 | * 88 | * @param class-string $class 89 | * @throws \InvalidArgumentException 90 | */ 91 | private function assertInputType(Method $method, string $class): bool 92 | { 93 | if (!\is_subclass_of($class, Message::class)) { 94 | throw new \InvalidArgumentException( 95 | \sprintf(self::ERROR_METHOD_IN_TYPE, $method->name, Message::class, $class), 96 | ); 97 | } 98 | 99 | return true; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/InvokerInterface.php: -------------------------------------------------------------------------------- 1 | $inputType 36 | * @param class-string $outputType 37 | */ 38 | private function __construct( 39 | public readonly string $name, 40 | public readonly string $inputType, 41 | public readonly string $outputType, 42 | ) {} 43 | 44 | /** 45 | * Returns true if method signature matches. 46 | */ 47 | public static function match(\ReflectionMethod $method): bool 48 | { 49 | try { 50 | self::assertMethodSignature($method); 51 | } catch (\Throwable) { 52 | return false; 53 | } 54 | 55 | return true; 56 | } 57 | 58 | /** 59 | * Creates a new {@see Method} object from a {@see \ReflectionMethod} object. 60 | */ 61 | public static function parse(\ReflectionMethod $method): Method 62 | { 63 | try { 64 | self::assertMethodSignature($method); 65 | } catch (\Throwable $e) { 66 | $message = \sprintf(self::ERROR_INVALID_GRPC_METHOD, $method->getName()); 67 | throw GRPCException::create($message, StatusCode::INTERNAL, $e); 68 | } 69 | 70 | [, $input] = $method->getParameters(); 71 | 72 | /** @var \ReflectionNamedType $inputType */ 73 | $inputType = $input->getType(); 74 | 75 | /** @var \ReflectionNamedType $returnType */ 76 | $returnType = $method->getReturnType(); 77 | 78 | /** @psalm-suppress ArgumentTypeCoercion */ 79 | return new self($method->getName(), $inputType->getName(), $returnType->getName()); 80 | } 81 | 82 | /** 83 | * @deprecated Use {Method->name} property instead. 84 | * @return non-empty-string 85 | */ 86 | public function getName(): string 87 | { 88 | return $this->name; 89 | } 90 | 91 | /** 92 | * @deprecated Use {Method->inputType} property instead. 93 | * @return class-string 94 | */ 95 | public function getInputType(): string 96 | { 97 | return $this->inputType; 98 | } 99 | 100 | /** 101 | * @deprecated Use {Method->outputType} property instead. 102 | * @return class-string 103 | */ 104 | public function getOutputType(): string 105 | { 106 | return $this->outputType; 107 | } 108 | 109 | /** 110 | * @throws \ReflectionException 111 | */ 112 | private static function assertContextParameter(\ReflectionMethod $method, \ReflectionParameter $context): void 113 | { 114 | $type = $context->getType(); 115 | 116 | // When the type is not specified, it means that it is declared as 117 | // a "mixed" type, which is a valid case 118 | if ($type !== null) { 119 | if (!$type instanceof \ReflectionNamedType) { 120 | $message = \sprintf(self::ERROR_PARAM_UNION_TYPE, $context->getName(), $method->getName()); 121 | throw new \DomainException($message, 0x02); 122 | } 123 | 124 | // If the type is not declared as a generic "mixed" or "object", 125 | // then it can only be a type that implements ContextInterface. 126 | if (!\in_array($type->getName(), ['mixed', 'object'], true)) { 127 | /** @psalm-suppress ArgumentTypeCoercion */ 128 | $isContextImplementedType = !$type->isBuiltin() 129 | && (new \ReflectionClass($type->getName()))->implementsInterface(ContextInterface::class); 130 | 131 | // Checking that the signature can accept the context. 132 | // 133 | // TODO If the type is any other implementation of the 134 | // Spiral\RoadRunner\GRPC\ContextInterface other than 135 | // class Spiral\RoadRunner\GRPC\Context, it may cause an error. 136 | // It might make sense to check for such cases? 137 | if (!$isContextImplementedType) { 138 | $message = \vsprintf(self::ERROR_PARAM_CONTEXT_TYPE, [ 139 | $context->getName(), 140 | $method->getName(), 141 | ContextInterface::class, 142 | ]); 143 | 144 | throw new \DomainException($message, 0x03); 145 | } 146 | } 147 | } 148 | } 149 | 150 | /** 151 | * @throws \ReflectionException 152 | */ 153 | private static function assertInputParameter(\ReflectionMethod $method, \ReflectionParameter $input): void 154 | { 155 | $type = $input->getType(); 156 | 157 | // Parameter type cannot be omitted ("mixed") 158 | if ($type === null) { 159 | $message = \vsprintf(self::ERROR_PARAM_INPUT_TYPE, [ 160 | $input->getName(), 161 | $method->getName(), 162 | Message::class, 163 | 'mixed', 164 | ]); 165 | 166 | throw new \DomainException($message, 0x04); 167 | } 168 | 169 | // Parameter type cannot be declared as singular non-named type 170 | if (!$type instanceof \ReflectionNamedType) { 171 | $message = \sprintf(self::ERROR_PARAM_UNION_TYPE, $input->getName(), $method->getName()); 172 | throw new \DomainException($message, 0x05); 173 | } 174 | 175 | /** @psalm-suppress ArgumentTypeCoercion */ 176 | $isProtobufMessageType = !$type->isBuiltin() 177 | && (new \ReflectionClass($type->getName())) 178 | ->isSubclassOf(Message::class); 179 | 180 | if (!$isProtobufMessageType) { 181 | $message = \vsprintf(self::ERROR_PARAM_INPUT_TYPE, [ 182 | $input->getName(), 183 | $method->getName(), 184 | Message::class, 185 | $type->getName(), 186 | ]); 187 | throw new \DomainException($message, 0x06); 188 | } 189 | } 190 | 191 | /** 192 | * @throws \ReflectionException 193 | */ 194 | private static function assertOutputReturnType(\ReflectionMethod $method): void 195 | { 196 | $type = $method->getReturnType(); 197 | 198 | // Return type cannot be omitted ("mixed") 199 | if ($type === null) { 200 | $message = \sprintf(self::ERROR_RETURN_TYPE, $method->getName(), Message::class, 'mixed'); 201 | throw new \DomainException($message, 0x07); 202 | } 203 | 204 | // Return type cannot be declared as singular non-named type 205 | if (!$type instanceof \ReflectionNamedType) { 206 | $message = \sprintf(self::ERROR_RETURN_UNION_TYPE, $method->getName()); 207 | throw new \DomainException($message, 0x08); 208 | } 209 | 210 | /** @psalm-suppress ArgumentTypeCoercion */ 211 | $isProtobufMessageType = !$type->isBuiltin() 212 | && (new \ReflectionClass($type->getName()))->isSubclassOf(Message::class); 213 | 214 | if (!$isProtobufMessageType) { 215 | $message = \sprintf(self::ERROR_RETURN_TYPE, $method->getName(), Message::class, $type->getName()); 216 | throw new \DomainException($message, 0x09); 217 | } 218 | } 219 | 220 | /** 221 | * @throws \ReflectionException 222 | * @throws \DomainException 223 | */ 224 | private static function assertMethodSignature(\ReflectionMethod $method): void 225 | { 226 | // Check that there are only two parameters 227 | if ($method->getNumberOfParameters() !== 2) { 228 | $message = \sprintf(self::ERROR_PARAMS_COUNT, $method->getName(), $method->getNumberOfParameters()); 229 | throw new \DomainException($message, 0x01); 230 | } 231 | 232 | /** 233 | * @var array{ 234 | * 0: \ReflectionParameter, 235 | * 1: \ReflectionParameter 236 | * } $params 237 | */ 238 | $params = $method->getParameters(); 239 | 240 | [$context, $input] = $params; 241 | 242 | // The first parameter can only take a context object 243 | self::assertContextParameter($method, $context); 244 | 245 | // The second argument can only be a subtype of the Google\Protobuf\Internal\Message class 246 | self::assertInputParameter($method, $input); 247 | 248 | // The return type must be declared as a Google\Protobuf\Internal\Message class 249 | self::assertOutputReturnType($method); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/ResponseHeaders.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class ResponseHeaders implements \IteratorAggregate, \Countable 15 | { 16 | /** 17 | * @var array 18 | */ 19 | private array $headers = []; 20 | 21 | /** 22 | * @param iterable $headers 23 | */ 24 | public function __construct(iterable $headers = []) 25 | { 26 | foreach ($headers as $key => $value) { 27 | $this->set($key, $value); 28 | } 29 | } 30 | 31 | /** 32 | * @param THeaderKey $key 33 | * @param THeaderValue $value 34 | */ 35 | public function set(string $key, string $value): void 36 | { 37 | $this->headers[$key] = $value; 38 | } 39 | 40 | /** 41 | * @param THeaderKey $key 42 | * @return THeaderValue|null 43 | */ 44 | public function get(string $key, ?string $default = null): ?string 45 | { 46 | return $this->headers[$key] ?? $default; 47 | } 48 | 49 | #[\Override] 50 | public function getIterator(): \Traversable 51 | { 52 | return new \ArrayIterator($this->headers); 53 | } 54 | 55 | #[\Override] 56 | public function count(): int 57 | { 58 | return \count($this->headers); 59 | } 60 | 61 | /** 62 | * @throws \JsonException 63 | */ 64 | public function packHeaders(): string 65 | { 66 | // If an empty array is serialized, it is cast to the string "[]" 67 | // instead of object string "{}" 68 | if ($this->headers === []) { 69 | return '{}'; 70 | } 71 | 72 | return Json::encode($this->headers); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/ResponseTrailers.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | final class ResponseTrailers implements \IteratorAggregate, \Countable 15 | { 16 | /** 17 | * @var array 18 | */ 19 | private array $trailers = []; 20 | 21 | /** 22 | * @param iterable $trailers 23 | */ 24 | public function __construct(iterable $trailers = []) 25 | { 26 | foreach ($trailers as $key => $value) { 27 | $this->set($key, $value); 28 | } 29 | } 30 | 31 | /** 32 | * @param THeaderKey $key 33 | * @param THeaderValue $value 34 | */ 35 | public function set(string $key, string $value): void 36 | { 37 | $this->trailers[$key] = $value; 38 | } 39 | 40 | /** 41 | * @param THeaderKey $key 42 | * @return THeaderValue|null 43 | */ 44 | public function get(string $key, ?string $default = null): ?string 45 | { 46 | return $this->trailers[$key] ?? $default; 47 | } 48 | 49 | #[\Override] 50 | public function getIterator(): \Traversable 51 | { 52 | return new \ArrayIterator($this->trailers); 53 | } 54 | 55 | #[\Override] 56 | public function count(): int 57 | { 58 | return \count($this->trailers); 59 | } 60 | 61 | /** 62 | * @throws \JsonException 63 | */ 64 | public function packTrailers(): string 65 | { 66 | // If an empty array is serialized, it is cast to the string "[]" 67 | // instead of object string "{}" 68 | if ($this->trailers === []) { 69 | return '{}'; 70 | } 71 | 72 | return Json::encode($this->trailers); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Server.php: -------------------------------------------------------------------------------- 1 | 44 | * $server->registerService(EchoServiceInterface::class, new EchoService()); 45 | * 46 | * 47 | * @template T of ServiceInterface 48 | * 49 | * @param class-string $interface Generated service interface. 50 | * @param T $service Must implement interface. 51 | * @throws ServiceException 52 | */ 53 | public function registerService(string $interface, ServiceInterface $service): void 54 | { 55 | $service = new ServiceWrapper($this->invoker, $interface, $service); 56 | 57 | $this->services[$service->getName()] = $service; 58 | } 59 | 60 | /** 61 | * Serve GRPC over given RoadRunner worker. 62 | */ 63 | public function serve(?WorkerInterface $worker = null, ?callable $finalize = null): void 64 | { 65 | $worker ??= Worker::create(); 66 | 67 | while (true) { 68 | $e = null; 69 | $request = $worker->waitPayload(); 70 | 71 | if ($request === null) { 72 | return; 73 | } 74 | 75 | $responseHeaders = new ResponseHeaders(); 76 | $responseTrailers = new ResponseTrailers(); 77 | 78 | try { 79 | $call = CallContext::decode($request->header); 80 | 81 | $context = new Context(\array_merge( 82 | $call->context, 83 | [ 84 | ResponseHeaders::class => $responseHeaders, 85 | ResponseTrailers::class => $responseTrailers, 86 | ], 87 | )); 88 | 89 | $response = $this->invoke($call->service, $call->method, $context, $request->body); 90 | 91 | $headers = []; 92 | $responseHeaders->count() === 0 or $headers['headers'] = $responseHeaders->packHeaders(); 93 | $responseTrailers->count() === 0 or $headers['trailers'] = $responseTrailers->packTrailers(); 94 | 95 | $this->workerSend( 96 | worker: $worker, 97 | body: $response, 98 | headers: $headers === [] ? '{}' : Json::encode($headers), 99 | ); 100 | } catch (GRPCExceptionInterface $e) { 101 | $headers = [ 102 | 'error' => $this->createGrpcError($e), 103 | ]; 104 | $responseHeaders->count() === 0 or $headers['headers'] = $responseHeaders->packHeaders(); 105 | $responseTrailers->count() === 0 or $headers['trailers'] = $responseTrailers->packTrailers(); 106 | 107 | $this->workerSend( 108 | worker: $worker, 109 | body: '', 110 | headers: Json::encode($headers), 111 | ); 112 | } catch (\Throwable $e) { 113 | $this->workerError($worker, $this->isDebugMode() ? (string) $e : $e->getMessage()); 114 | } finally { 115 | if ($finalize !== null) { 116 | isset($e) ? $finalize($e) : $finalize(); 117 | } 118 | } 119 | } 120 | } 121 | 122 | /** 123 | * Invoke service method with binary payload and return the response. 124 | * 125 | * @param class-string $service 126 | * @param non-empty-string $method 127 | * @throws GRPCException 128 | */ 129 | protected function invoke(string $service, string $method, ContextInterface $context, string $body): string 130 | { 131 | if (!isset($this->services[$service])) { 132 | throw NotFoundException::create("Service `{$service}` not found.", StatusCode::NOT_FOUND); 133 | } 134 | 135 | return $this->services[$service]->invoke($method, $context, $body); 136 | } 137 | 138 | private function workerError(WorkerInterface $worker, string $message): void 139 | { 140 | $worker->error($message); 141 | } 142 | 143 | /** 144 | * @psalm-suppress InaccessibleMethod 145 | */ 146 | private function workerSend(WorkerInterface $worker, string $body, string $headers): void 147 | { 148 | $worker->respond(new Payload($body, $headers)); 149 | } 150 | 151 | private function createGrpcError(GRPCExceptionInterface $e): string 152 | { 153 | $status = new Status([ 154 | 'code' => $e->getCode(), 155 | 'message' => $e->getMessage(), 156 | 'details' => \array_map( 157 | static function ($detail) { 158 | $message = new Any(); 159 | $message->pack($detail); 160 | 161 | return $message; 162 | }, 163 | $e->getDetails(), 164 | ), 165 | ]); 166 | 167 | return \base64_encode($status->serializeToString()); 168 | } 169 | 170 | /** 171 | * Checks if debug mode is enabled. 172 | */ 173 | private function isDebugMode(): bool 174 | { 175 | $debug = false; 176 | 177 | if (isset($this->options['debug'])) { 178 | $debug = \filter_var($this->options['debug'], \FILTER_VALIDATE_BOOLEAN); 179 | } 180 | 181 | return $debug; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/ServiceInterface.php: -------------------------------------------------------------------------------- 1 | */ 22 | private array $methods; 23 | 24 | /** 25 | * @template T of ServiceInterface 26 | * 27 | * @param class-string $interface Generated service interface. 28 | * @param T $service Must implement interface. 29 | */ 30 | public function __construct( 31 | private readonly InvokerInterface $invoker, 32 | string $interface, 33 | ServiceInterface $service, 34 | ) { 35 | $this->configure($interface, $service); 36 | } 37 | 38 | /** 39 | * Get service name from class const `NAME` 40 | * @return non-empty-string 41 | */ 42 | public function getName(): string 43 | { 44 | return $this->name; 45 | } 46 | 47 | public function getService(): ServiceInterface 48 | { 49 | return $this->service; 50 | } 51 | 52 | /** 53 | * Get available service methods. 54 | * 55 | * @return Method[] 56 | */ 57 | public function getMethods(): array 58 | { 59 | return \array_values($this->methods); 60 | } 61 | 62 | /** 63 | * Invoke given service method. 64 | * 65 | * @throws NotFoundException 66 | * @throws InvokeException 67 | */ 68 | public function invoke(string $method, ContextInterface $context, ?string $input): string 69 | { 70 | if (! isset($this->methods[$method])) { 71 | throw NotFoundException::create("Method `{$method}` not found in service `{$this->name}`."); 72 | } 73 | 74 | return $this->invoker->invoke($this->service, $this->methods[$method], $context, $input); 75 | } 76 | 77 | /** 78 | * Configure service name and methods. 79 | * 80 | * @template T of ServiceInterface 81 | * 82 | * @param class-string $interface Generated service interface. 83 | * @param T $service Must implement interface. 84 | * 85 | * @throws ServiceException 86 | */ 87 | protected function configure(string $interface, ServiceInterface $service): void 88 | { 89 | try { 90 | $reflection = new \ReflectionClass($interface); 91 | 92 | if (! $reflection->hasConstant('NAME')) { 93 | $message = "Invalid service interface `{$interface}`, constant `NAME` not found."; 94 | throw ServiceException::create($message); 95 | } 96 | 97 | /** @var non-empty-string $name */ 98 | $name = $reflection->getConstant('NAME'); 99 | 100 | if (! \is_string($name)) { 101 | $message = "Constant `NAME` of service interface `{$interface}` must be a type of string"; 102 | throw ServiceException::create($message); 103 | } 104 | 105 | $this->name = $name; 106 | } catch (\ReflectionException $e) { 107 | $message = "Invalid service interface `{$interface}`."; 108 | throw ServiceException::create($message, StatusCode::INTERNAL, $e); 109 | } 110 | 111 | if (! $service instanceof $interface) { 112 | throw ServiceException::create("Service handler does not implement `{$interface}`."); 113 | } 114 | 115 | $this->service = $service; 116 | 117 | // list of all available methods and their object types 118 | $this->methods = $this->fetchMethods($service); 119 | } 120 | 121 | /** 122 | * @return array 123 | */ 124 | protected function fetchMethods(ServiceInterface $service): array 125 | { 126 | $reflection = new \ReflectionObject($service); 127 | 128 | $methods = []; 129 | foreach ($reflection->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { 130 | if (Method::match($method)) { 131 | $methods[$method->getName()] = Method::parse($method); 132 | } 133 | } 134 | 135 | return $methods; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/StatusCode.php: -------------------------------------------------------------------------------- 1 |