├── 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 | [](https://packagist.org/packages/spiral/roadrunner-grpc)
11 | [](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 |
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 |