diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
new file mode 100644
index 0000000..7e8ead3
--- /dev/null
+++ b/.github/workflows/codeql-analysis.yml
@@ -0,0 +1,71 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+name: "CodeQL"
+
+on:
+ push:
+ branches: [master]
+ pull_request:
+ # The branches below must be a subset of the branches above
+ branches: [master]
+ schedule:
+ - cron: '0 12 * * 6'
+
+jobs:
+ analyze:
+ name: Analyze
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ # Override automatic language detection by changing the below list
+ # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
+ language: ['go']
+ # Learn more...
+ # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v2
+ with:
+ # We must fetch at least the immediate parents so that if this is
+ # a pull request then we can checkout the head.
+ fetch-depth: 2
+
+ # If this run was triggered by a pull request event, then checkout
+ # the head of the pull request instead of the merge commit.
+ - run: git checkout HEAD^2
+ if: ${{ github.event_name == 'pull_request' }}
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v1
+ with:
+ languages: ${{ matrix.language }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+ # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+ # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
+ # If this step fails, then you should remove it and run the build manually (see below)
+ - name: Autobuild
+ uses: github/codeql-action/autobuild@v1
+
+ # âšī¸ Command-line programs to run using the OS shell.
+ # đ https://git.io/JvXDl
+
+ # âī¸ If the Autobuild fails above, remove it and uncomment the following three lines
+ # and modify them (or add more) to build your code if your project
+ # uses a compiled language
+
+ #- run: |
+ # make bootstrap
+ # make release
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v1
diff --git a/.gitignore b/.gitignore
index 42dc51b..bd558f8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,5 @@ vendor/
clover.xml
example/vendor/
go.sum
-builds/
\ No newline at end of file
+builds/
+.phpunit.result.cache
diff --git a/.travis.yml b/.travis.yml
index d948824..b63f434 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -15,14 +15,13 @@ install:
- popd
- export GO111MODULE=on
- go mod download
- - composer install --no-interaction --prefer-source --ignore-platform-reqs
+ - composer install --no-interaction --prefer-source
- go get
script:
- go test -race -v -coverprofile=grpc.txt -covermode=atomic
- go test ./parser -race -v -coverprofile=parser.txt -covermode=atomic
- go test -race -v -coverprofile=protoc-gen-php-grpc.txt ./cmd/protoc-gen-php-grpc -covermode=atomic
- - composer require phpunit/phpunit:~7.0 --ignore-platform-reqs
- vendor/bin/spiral-cs check src tests/GRPC
- vendor/bin/phpunit --coverage-clover=coverage.xml
- go build cmd/protoc-gen-php-grpc/main.go
@@ -62,3 +61,12 @@ jobs:
- sudo cp `which php7.4` `which php`
- php -v
- composer self-update
+ - stage: Test
+ env: "PHP=8.0"
+ before_install:
+ - sudo add-apt-repository -y ppa:ondrej/php
+ - sudo apt-get update
+ - sudo apt-get install -y php8.0-cli php8.0-xml php8.0-xdebug
+ - sudo cp `which php8.0` `which php`
+ - php -v
+ - composer self-update
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87f720f..0a3e96e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,12 @@
CHANGELOG
=========
+v1.4.1 (13.10.2020)
+-------------------
+- RoadRunner version update to 1.8.3
+- Golang version in go.mod bump to 1.15
+- Add server configuration options (debug) (@aldump)
+
v1.4.0 (1.08.2020)
-------------------
- Add all major gRPC configuration options. [Docs](https://github.com/spiral/docs/blob/master/grpc/configuration.md#application-server)
diff --git a/build.sh b/build.sh
index fa12e35..f7bed77 100755
--- a/build.sh
+++ b/build.sh
@@ -2,7 +2,7 @@
cd $(dirname "${BASH_SOURCE[0]}")
OD="$(pwd)"
# Pushes application version into the build information.
-RR_VERSION=1.4.0
+RR_VERSION=1.4.1
# Hardcode some values to the core package
LDFLAGS="$LDFLAGS -X github.com/spiral/roadrunner/cmd/rr/cmd.Version=${RR_VERSION}"
diff --git a/cmd/rr-grpc/grpc/metrics.go b/cmd/rr-grpc/grpc/metrics.go
index 51de6cb..a5510dd 100644
--- a/cmd/rr-grpc/grpc/metrics.go
+++ b/cmd/rr-grpc/grpc/metrics.go
@@ -73,14 +73,14 @@ func newCollector() *metricCollector {
Name: "rr_grpc_call_total",
Help: "Total number of handled grpc requests after server restart.",
},
- []string{"code"},
+ []string{"code", "method"},
),
callDuration: prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "rr_grpc_call_duration_seconds",
Help: "GRPC call duration.",
},
- []string{"code"},
+ []string{"code", "method"},
),
workersMemory: prometheus.NewGauge(
prometheus.GaugeOpts{
@@ -103,8 +103,12 @@ func (c *metricCollector) listener(event int, ctx interface{}) {
code = st.Code().String()
}
- c.callDuration.With(prometheus.Labels{"code": code}).Observe(uc.Elapsed().Seconds())
- c.callCounter.With(prometheus.Labels{"code": code}).Inc()
+ method := "Unknown"
+ if uc.Info != nil {
+ method = uc.Info.FullMethod
+ }
+ c.callDuration.With(prometheus.Labels{"code": code, "method": method}).Observe(uc.Elapsed().Seconds())
+ c.callCounter.With(prometheus.Labels{"code": code, "method": method}).Inc()
}
}
diff --git a/composer.json b/composer.json
index 0da593d..cb350cd 100644
--- a/composer.json
+++ b/composer.json
@@ -1,33 +1,55 @@
{
- "name": "spiral/php-grpc",
- "type": "server",
- "description": "High-Performance GRPC server for PHP applications",
- "license": "MIT",
- "authors": [
- {
- "name": "Anton Titov / Wolfy-J",
- "email": "wolfy.jd@gmail.com"
- }
- ],
- "require": {
- "php": "^7.2",
- "ext-json": "*",
- "google/protobuf": "^3.7",
- "spiral/roadrunner": "^1.8"
- },
- "require-dev": {
- "phpunit/phpunit": "~7.0",
- "spiral/code-style": "^1.0"
- },
- "autoload": {
- "psr-4": {
- "Spiral\\GRPC\\": "src/"
- }
- },
- "autoload-dev": {
- "psr-4": {
- "": "tests/src/",
- "Spiral\\GRPC\\Tests\\": "tests/GRPC/"
- }
- }
+ "name": "spiral/php-grpc",
+ "type": "library",
+ "description": "High-Performance GRPC server for PHP applications",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Anton Titov / Wolfy-J",
+ "email": "wolfy.jd@gmail.com"
+ }
+ ],
+ "require": {
+ "php": ">=7.2",
+ "ext-json": "*",
+ "symfony/polyfill-php80": "^1.22",
+ "symfony/polyfill-php73": "^1.22",
+ "google/protobuf": "^3.7",
+ "spiral/roadrunner": "^1.8"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^8.5|^9.0",
+ "spiral/code-style": "^1.0",
+ "jetbrains/phpstorm-attributes": "^1.0",
+ "symfony/var-dumper": ">=4.4",
+ "vimeo/psalm": "^4.6"
+ },
+ "autoload": {
+ "psr-4": {
+ "Spiral\\GRPC\\": "src"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "": "tests/src",
+ "Spiral\\GRPC\\Tests\\": "tests/GRPC"
+ }
+ },
+ "scripts": {
+ "test": [
+ "spiral-cs check src tests/GRPC",
+ "psalm --no-cache",
+ "phpunit"
+ ]
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true
}
diff --git a/example/server/composer.json b/example/server/composer.json
index 71825a7..b630329 100644
--- a/example/server/composer.json
+++ b/example/server/composer.json
@@ -1,10 +1,23 @@
{
- "require": {
- "spiral/php-grpc": "^1.0"
- },
- "autoload": {
- "psr-4": {
- "": "src/"
- }
- }
+ "name": "app/example-grpc-server",
+ "description": "Example GRPC Server",
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../.."
+ }
+ ],
+ "require": {
+ "spiral/php-grpc": "*"
+ },
+ "require-dev": {
+ "grpc/grpc": "^1.36"
+ },
+ "autoload": {
+ "psr-4": {
+ "": "src"
+ }
+ },
+ "minimum-stability": "dev",
+ "prefer-stable": true
}
diff --git a/example/server/worker.php b/example/server/worker.php
index 9a13fcd..683a234 100644
--- a/example/server/worker.php
+++ b/example/server/worker.php
@@ -3,14 +3,24 @@
* Sample GRPC PHP server.
*/
-use Spiral\Goridge;
-use Spiral\RoadRunner;
+use Service\EchoInterface;
+use Spiral\Goridge\StreamRelay;
+use Spiral\GRPC\Server;
+use Spiral\RoadRunner\Worker;
-ini_set('display_errors', 'stderr');
-require "vendor/autoload.php";
+require __DIR__ . '/vendor/autoload.php';
-$server = new \Spiral\GRPC\Server();
-$server->registerService(\Service\EchoInterface::class, new EchoService());
+$server = new Server(null, [
+ 'debug' => false, // optional (default: false)
+]);
-$w = new RoadRunner\Worker(new Goridge\StreamRelay(STDIN, STDOUT));
-$server->serve($w);
\ No newline at end of file
+$server->registerService(EchoInterface::class, new EchoService());
+
+$worker = \method_exists(Worker::class, 'create')
+ // RoadRunner >= 2.x
+ ? Worker::create()
+ // RoadRunner 1.x
+ : new Worker(new StreamRelay(STDIN, STDOUT))
+;
+
+$server->serve($worker);
diff --git a/go.mod b/go.mod
index 657ba32..55cd06a 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/spiral/php-grpc
-go 1.13
+go 1.15
require (
github.com/buger/goterm v0.0.0-20200322175922-2f3e71b85129
@@ -10,7 +10,7 @@ require (
github.com/prometheus/client_golang v1.6.0
github.com/sirupsen/logrus v1.6.0
github.com/spf13/cobra v1.0.0
- github.com/spiral/roadrunner v1.8.2
+ github.com/spiral/roadrunner v1.8.3
github.com/stretchr/testify v1.5.1
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2
google.golang.org/grpc v1.29.1
diff --git a/phpunit.xml b/phpunit.xml
index 563ac8f..384daa4 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -10,17 +10,19 @@
processIsolation="false"
stopOnFailure="false"
stopOnError="false"
->
-
+ stderr="true">
-
- ./tests/
+
+ tests
-
src
-
\ No newline at end of file
+
+
+
+
+
diff --git a/psalm.xml b/psalm.xml
new file mode 100644
index 0000000..9cb3de6
--- /dev/null
+++ b/psalm.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Context.php b/src/Context.php
index ec224bf..3c9dab7 100644
--- a/src/Context.php
+++ b/src/Context.php
@@ -1,23 +1,29 @@
+ * @template-implements \ArrayAccess
+ */
+final class Context implements ContextInterface, \IteratorAggregate, \Countable, \ArrayAccess
{
- /** @var array */
+ /**
+ * @var array
+ */
private $values;
/**
- * @param array $values
+ * @param array $values
*/
public function __construct(array $values)
{
@@ -25,7 +31,7 @@ public function __construct(array $values)
}
/**
- * @inheritdoc
+ * {@inheritDoc}
*/
public function withValue(string $key, $value): ContextInterface
{
@@ -36,18 +42,82 @@ public function withValue(string $key, $value): ContextInterface
}
/**
- * @inheritdoc
+ * {@inheritDoc}
+ * @param mixed|null $default
*/
- public function getValue(string $key)
+ public function getValue(string $key, $default = null)
{
- return $this->values[$key] ?? null;
+ return $this->values[$key] ?? $default;
}
/**
- * @inheritdoc
+ * {@inheritDoc}
*/
public function getValues(): array
{
return $this->values;
}
+
+ /**
+ * {@inheritDoc}
+ */
+ public function offsetExists($offset): bool
+ {
+ assert(\is_string($offset), 'Offset argument must be a type of string');
+
+ /**
+ * Note: PHP Opcode optimisation
+ * @see https://www.php.net/manual/pt_BR/internals2.opcodes.isset-isempty-var.php
+ *
+ * Priority use `ZEND_ISSET_ISEMPTY_VAR !0` opcode instead of `DO_FCALL 'array_key_exists'`.
+ */
+ return isset($this->values[$offset]) || \array_key_exists($offset, $this->values);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function offsetGet($offset)
+ {
+ assert(\is_string($offset), 'Offset argument must be a type of string');
+
+ return $this->values[$offset] ?? null;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function offsetSet($offset, $value): void
+ {
+ assert(\is_string($offset), 'Offset argument must be a type of string');
+
+ $this->values[$offset] = $value;
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function offsetUnset($offset): void
+ {
+ assert(\is_string($offset), 'Offset argument must be a type of string');
+
+ unset($this->values[$offset]);
+ }
+
+
+ /**
+ * {@inheritDoc}
+ */
+ public function getIterator(): \Traversable
+ {
+ return new \ArrayIterator($this->values);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function count(): int
+ {
+ return \count($this->values);
+ }
}
diff --git a/src/ContextInterface.php b/src/ContextInterface.php
index 9fa7558..2d53297 100644
--- a/src/ContextInterface.php
+++ b/src/ContextInterface.php
@@ -1,10 +1,10 @@
*/
public function getValues(): array;
}
diff --git a/src/Exception/GRPCException.php b/src/Exception/GRPCException.php
index 23ef36e..b146129 100644
--- a/src/Exception/GRPCException.php
+++ b/src/Exception/GRPCException.php
@@ -1,10 +1,10 @@
*/
private $details;
/**
- * @param string $message
- * @param int $code
- * @param array $details
- * @param Throwable|null $previous
+ * @param string $message
+ * @param StatusCodeType|null $code
+ * @param array $details
+ * @param \Throwable|null $previous
*/
- public function __construct(string $message = '', int $code = 0, $details = [], Throwable $previous = null)
- {
- if ($code == 0) {
- $code = static::CODE;
- }
-
- parent::__construct($message, $code, $previous);
+ final public function __construct(
+ string $message = '',
+ #[ExpectedValues(valuesFromClass: StatusCode::class)]
+ int $code = null,
+ array $details = [],
+ \Throwable $previous = null
+ ) {
+ parent::__construct($message, (int)($code ?? static::CODE), $previous);
$this->details = $details;
}
/**
- * @return array
+ * @param string $message
+ * @param StatusCodeType|null $code
+ * @param array $details
+ * @param \Throwable|null $previous
+ * @return static
+ */
+ public static function create(
+ string $message,
+ #[ExpectedValues(valuesFromClass: StatusCode::class)]
+ int $code = null,
+ \Throwable $previous = null,
+ array $details = []
+ ): self {
+ return new static($message, $code, $details, $previous);
+ }
+
+ /**
+ * {@inheritDoc}
*/
public function getDetails(): array
{
@@ -55,22 +83,32 @@ public function getDetails(): array
}
/**
- * @param array $details
+ * {@inheritDoc}
*/
public function setDetails(array $details): void
{
$this->details = $details;
}
+ /**
+ * {@inheritDoc}
+ */
+ public function addDetails(Message $message): void
+ {
+ $this->details[] = $message;
+ }
+
/**
* Push details message to the exception.
*
- * @param $details
- *
+ * @param Message $details
* @return $this
+ * @deprecated Please use {@see GRPCException::addDetails()} method instead.
*/
- public function withDetails($details)
- {
+ #[Deprecated('Please use GRPCException::addDetails() instead', '%class%::addDetails(%parameter0%)')]
+ public function withDetails(
+ $details
+ ): self {
$this->details[] = $details;
return $this;
diff --git a/src/Exception/GRPCExceptionInterface.php b/src/Exception/GRPCExceptionInterface.php
new file mode 100644
index 0000000..75c1894
--- /dev/null
+++ b/src/Exception/GRPCExceptionInterface.php
@@ -0,0 +1,37 @@
+
+ */
+ public function getDetails(): array;
+}
diff --git a/src/Exception/InvokeException.php b/src/Exception/InvokeException.php
index a19ba72..ee18751 100644
--- a/src/Exception/InvokeException.php
+++ b/src/Exception/InvokeException.php
@@ -1,10 +1,10 @@
$details
+ */
+ public function setDetails(array $details): void;
+
+ /**
+ * Appends details message to the GRPC Exception.
+ *
+ * @param Message $message
+ */
+ public function addDetails(Message $message): void;
+}
diff --git a/src/Exception/NotFoundException.php b/src/Exception/NotFoundException.php
index 384704a..8ce0a7c 100644
--- a/src/Exception/NotFoundException.php
+++ b/src/Exception/NotFoundException.php
@@ -1,10 +1,10 @@
getName()],
- [&$context,$this->makeInput($method, $input)]
- );
+ private const ERROR_METHOD_RETURN =
+ 'Method %s must return an object that instance of %s, ' .
+ 'but the result provides type of %s';
+
+ /**
+ * @var string
+ */
+ private const ERROR_METHOD_IN_TYPE =
+ 'Method %s input type must be an instance of %s, ' .
+ 'but the input is type of %s'
+ ;
+
+ /**
+ * {@inheritDoc}
+ */
+ public function invoke(ServiceInterface $service, Method $method, ContextInterface $ctx, ?string $input): string
+ {
+ /** @var callable $callable */
+ $callable = [$service, $method->getName()];
+
+ /** @var Message $message */
+ $message = $callable($ctx, $this->makeInput($method, $input));
+
+ // Note: This validation will only work if the
+ // assertions option ("zend.assertions") is enabled.
+ assert($this->assertResultType($method, $message));
try {
- return $out->serializeToString();
+ return $message->serializeToString();
} catch (\Throwable $e) {
- throw new InvokeException($e->getMessage(), StatusCode::INTERNAL, [], $e);
+ throw InvokeException::create($e->getMessage(), StatusCode::INTERNAL, $e);
}
}
/**
+ * Checks that the result from the GRPC service method returns the
+ * Message object.
+ *
* @param Method $method
- * @param string $body
+ * @param mixed $result
+ * @return bool
+ * @throws \BadFunctionCallException
+ */
+ private function assertResultType(Method $method, $result): bool
+ {
+ if (! $result instanceof Message) {
+ $type = \is_object($result) ? \get_class($result) : \get_debug_type($result);
+
+ throw new \BadFunctionCallException(
+ \sprintf(self::ERROR_METHOD_RETURN, $method->getName(), Message::class, $type)
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * @param Method $method
+ * @param string|null $body
* @return Message
- *
* @throws InvokeException
*/
private function makeInput(Method $method, ?string $body): Message
@@ -49,15 +87,40 @@ private function makeInput(Method $method, ?string $body): Message
try {
$class = $method->getInputType();
- /** @var Message $in */
+ // Note: This validation will only work if the
+ // assertions option ("zend.assertions") is enabled.
+ assert($this->assertInputType($method, $class));
+
+ /** @psalm-suppress UnsafeInstantiation */
$in = new $class();
+
if ($body !== null) {
$in->mergeFromString($body);
}
return $in;
} catch (\Throwable $e) {
- throw new InvokeException($e->getMessage(), StatusCode::INTERNAL, [], $e);
+ throw InvokeException::create($e->getMessage(), StatusCode::INTERNAL, $e);
}
}
+
+ /**
+ * Checks that the input of the GRPC service method contains the
+ * Message object.
+ *
+ * @param Method $method
+ * @param string $class
+ * @return bool
+ * @throws \InvalidArgumentException
+ */
+ private function assertInputType(Method $method, string $class): bool
+ {
+ if (! \is_subclass_of($class, Message::class)) {
+ throw new \InvalidArgumentException(
+ \sprintf(self::ERROR_METHOD_IN_TYPE, $method->getName(), Message::class, $class)
+ );
+ }
+
+ return true;
+ }
}
diff --git a/src/InvokerInterface.php b/src/InvokerInterface.php
index 8b10f97..80e2afe 100644
--- a/src/InvokerInterface.php
+++ b/src/InvokerInterface.php
@@ -1,10 +1,10 @@
+ */
private $input;
- /** @var string */
+ /**
+ * @var class-string
+ */
private $output;
+ /**
+ * @param string $name
+ * @param class-string $input
+ * @param class-string $output
+ */
+ private function __construct(string $name, string $input, string $output)
+ {
+ $this->name = $name;
+ $this->input = $input;
+ $this->output = $output;
+ }
+
/**
* @return string
*/
@@ -38,7 +101,7 @@ public function getName(): string
}
/**
- * @return string
+ * @return class-string
*/
public function getInputType(): string
{
@@ -46,13 +109,28 @@ public function getInputType(): string
}
/**
- * @return string
+ * @return class-string
*/
public function getOutputType(): string
{
return $this->output;
}
+ /**
+ * @param \ReflectionType|null $type
+ * @return \ReflectionClass|null
+ * @throws \ReflectionException
+ */
+ private static function getReflectionClassByType(?\ReflectionType $type): ?\ReflectionClass
+ {
+ if ($type instanceof \ReflectionNamedType && ! $type->isBuiltin()) {
+ /** @psalm-suppress ArgumentTypeCoercion */
+ return new \ReflectionClass($type->getName());
+ }
+
+ return null;
+ }
+
/**
* Returns true if method signature matches.
*
@@ -61,58 +139,188 @@ public function getOutputType(): string
*/
public static function match(\ReflectionMethod $method): bool
{
- if ($method->getNumberOfParameters() != 2) {
+ try {
+ self::assertMethodSignature($method);
+ } catch (\Throwable $e) {
return false;
}
- $ctx = $method->getParameters()[0]->getClass();
- $in = $method->getParameters()[1]->getClass();
+ return true;
+ }
- if (empty($ctx) || !$ctx->implementsInterface(ContextInterface::class)) {
- return false;
+ /**
+ * @param \ReflectionMethod $method
+ * @param \ReflectionParameter $context
+ * @throws \ReflectionException
+ */
+ private static function assertContextParameter(\ReflectionMethod $method, \ReflectionParameter $context): void
+ {
+ $type = $context->getType();
+
+ // When the type is not specified, it means that it is declared as
+ // a "mixed" type, which is a valid case
+ if ($type !== null) {
+ if (! $type instanceof \ReflectionNamedType) {
+ $message = \sprintf(self::ERROR_PARAM_UNION_TYPE, $context->getName(), $method->getName());
+ throw new \DomainException($message, 0x02);
+ }
+
+ // If the type is not declared as a generic "mixed" or "object",
+ // then it can only be a type that implements ContextInterface.
+ if (! \in_array($type->getName(), ['mixed', 'object'], true)) {
+ /** @psalm-suppress ArgumentTypeCoercion */
+ $isContextImplementedType = ! $type->isBuiltin()
+ && (new \ReflectionClass($type->getName()))
+ ->implementsInterface(ContextInterface::class)
+ ;
+
+ // Checking that the signature can accept the context.
+ //
+ // TODO If the type is any other implementation of the Spiral\GRPC\ContextInterface other than
+ // class Spiral\GRPC\Context, it may cause an error.
+ // It might make sense to check for such cases?
+ if (! $isContextImplementedType) {
+ $message = \vsprintf(self::ERROR_PARAM_CONTEXT_TYPE, [
+ $context->getName(),
+ $method->getName(),
+ ContextInterface::class
+ ]);
+
+ throw new \DomainException($message, 0x03);
+ }
+ }
}
+ }
- if (empty($in) || !$in->isSubclassOf(Message::class)) {
- return false;
+ /**
+ * @param \ReflectionMethod $method
+ * @param \ReflectionParameter $input
+ * @throws \ReflectionException
+ */
+ private static function assertInputParameter(\ReflectionMethod $method, \ReflectionParameter $input): void
+ {
+ $type = $input->getType();
+
+ // Parameter type cannot be omitted ("mixed")
+ if ($type === null) {
+ $message = \vsprintf(self::ERROR_PARAM_INPUT_TYPE, [
+ $input->getName(),
+ $method->getName(),
+ Message::class,
+ 'mixed'
+ ]);
+
+ throw new \DomainException($message, 0x04);
}
- if (empty($method->getReturnType())) {
- return false;
+ // Parameter type cannot be declared as singular non-named type
+ if (! $type instanceof \ReflectionNamedType) {
+ $message = \sprintf(self::ERROR_PARAM_UNION_TYPE, $input->getName(), $method->getName());
+ throw new \DomainException($message, 0x05);
}
- try {
- $return = $method->getReturnType()->getName();
- if (!class_exists($return)) {
- return false;
- }
- $return = new \ReflectionClass($return);
- } catch (\ReflectionException $e) {
- return false;
+ /** @psalm-suppress ArgumentTypeCoercion */
+ $isProtobufMessageType = ! $type->isBuiltin()
+ && (new \ReflectionClass($type->getName()))
+ ->isSubclassOf(Message::class)
+ ;
+
+ if (! $isProtobufMessageType) {
+ $message = \vsprintf(self::ERROR_PARAM_INPUT_TYPE, [
+ $input->getName(),
+ $method->getName(),
+ Message::class,
+ $type->getName(),
+ ]);
+ throw new \DomainException($message, 0x06);
}
+ }
- return $return->isSubclassOf(Message::class);
+ /**
+ * @param \ReflectionMethod $method
+ * @throws \ReflectionException
+ */
+ private static function assertOutputReturnType(\ReflectionMethod $method): void
+ {
+ $type = $method->getReturnType();
+
+ // Return type cannot be omitted ("mixed")
+ if ($type === null) {
+ $message = \sprintf(self::ERROR_RETURN_TYPE, $method->getName(), Message::class, 'mixed');
+ throw new \DomainException($message, 0x07);
+ }
+
+ // Return type cannot be declared as singular non-named type
+ if (! $type instanceof \ReflectionNamedType) {
+ $message = \sprintf(self::ERROR_RETURN_UNION_TYPE, $method->getName());
+ throw new \DomainException($message, 0x08);
+ }
+
+ /** @psalm-suppress ArgumentTypeCoercion */
+ $isProtobufMessageType = ! $type->isBuiltin()
+ && (new \ReflectionClass($type->getName()))
+ ->isSubclassOf(Message::class)
+ ;
+
+ if (! $isProtobufMessageType) {
+ $message = \sprintf(self::ERROR_RETURN_TYPE, $method->getName(), Message::class, $type->getName());
+ throw new \DomainException($message, 0x09);
+ }
}
/**
- * Returns true if method signature matches.
+ * @param \ReflectionMethod $method
+ * @throws \ReflectionException
+ * @throws \DomainException
+ */
+ private static function assertMethodSignature(\ReflectionMethod $method): void
+ {
+ // Check that there are only two parameters
+ if ($method->getNumberOfParameters() !== 2) {
+ $message = \sprintf(self::ERROR_PARAMS_COUNT, $method->getName(), $method->getNumberOfParameters());
+ throw new \DomainException($message, 0x01);
+ }
+
+ /**
+ * @var \ReflectionParameter $context
+ * @var \ReflectionParameter $input
+ */
+ [$context, $input] = $method->getParameters();
+
+ // The first parameter can only take a context object
+ self::assertContextParameter($method, $context);
+
+ // The second argument can only be a subtype of the Google\Protobuf\Internal\Message class
+ self::assertInputParameter($method, $input);
+
+ // The return type must be declared as a Google\Protobuf\Internal\Message class
+ self::assertOutputReturnType($method);
+ }
+
+ /**
+ * Creates a new {@see Method} object from a {@see \ReflectionMethod} object.
*
* @param \ReflectionMethod $method
* @return Method
*/
public static function parse(\ReflectionMethod $method): Method
{
- if (!self::match($method)) {
- throw new GRPCException(
- "Method `{$method->getName()}` is not valid GRPC method.",
- StatusCode::INTERNAL
- );
+ try {
+ self::assertMethodSignature($method);
+ } catch (\Throwable $e) {
+ $message = \sprintf(self::ERROR_INVALID_GRPC_METHOD, $method->getName());
+ throw GRPCException::create($message, StatusCode::INTERNAL, $e);
}
- $m = new self();
- $m->name = $method->getName();
- $m->input = $method->getParameters()[1]->getClass()->getName();
- $m->output = $method->getReturnType()->getName();
+ [,$input] = $method->getParameters();
+
+ /** @var \ReflectionNamedType $inputType */
+ $inputType = $input->getType();
+
+ /** @var \ReflectionNamedType $returnType */
+ $returnType = $method->getReturnType();
- return $m;
+ /** @psalm-suppress ArgumentTypeCoercion */
+ return new self($method->getName(), $inputType->getName(), $returnType->getName());
}
}
diff --git a/src/ResponseHeaders.php b/src/ResponseHeaders.php
index 71d6455..c1ee345 100644
--- a/src/ResponseHeaders.php
+++ b/src/ResponseHeaders.php
@@ -1,27 +1,36 @@
+ */
+final class ResponseHeaders implements \IteratorAggregate, \Countable
{
- /** @var array */
- private $headers;
+ /**
+ * @var array
+ */
+ private $headers = [];
/**
- * @param array $headers
+ * @param iterable $headers
*/
- public function __construct(array $headers = [])
+ public function __construct(iterable $headers = [])
{
- $this->headers = $headers;
+ foreach ($headers as $key => $value) {
+ $this->set($key, $value);
+ }
}
/**
@@ -35,8 +44,8 @@ public function set(string $key, string $value): void
/**
* @param string $key
- * @param string $default $default
- * @return string $default|null
+ * @param string|null $default
+ * @return string|null
*/
public function get(string $key, string $default = null): ?string
{
@@ -44,22 +53,33 @@ public function get(string $key, string $default = null): ?string
}
/**
- * @return array
+ * {@inheritDoc}
+ */
+ public function getIterator(): \Traversable
+ {
+ return new \ArrayIterator($this->headers);
+ }
+
+ /**
+ * @return int
*/
- public function getIterator(): array
+ public function count(): int
{
- return $this->headers;
+ return \count($this->headers);
}
/**
* @return string
+ * @throws \JsonException
*/
public function packHeaders(): string
{
+ // If an empty array is serialized, it is cast to the string "[]"
+ // instead of object string "{}"
if ($this->headers === []) {
return '{}';
}
- return json_encode($this->headers);
+ return Json::encode($this->headers);
}
}
diff --git a/src/Server.php b/src/Server.php
index d56ba50..90886d3 100644
--- a/src/Server.php
+++ b/src/Server.php
@@ -1,10 +1,10 @@
>
+ * }
*/
final class Server
{
- /** @var InvokerInterface */
+ /**
+ * @var InvokerInterface
+ */
private $invoker;
- /** @var ServiceWrapper[] */
+ /**
+ * @var ServiceWrapper[]
+ */
private $services = [];
+ /**
+ * @var ServerOptions
+ */
+ private $options;
+
/**
* @param InvokerInterface|null $invoker
+ * @param ServerOptions $options
*/
- public function __construct(InvokerInterface $invoker = null)
+ public function __construct(InvokerInterface $invoker = null, array $options = [])
{
$this->invoker = $invoker ?? new Invoker();
+ $this->options = $options;
}
/**
* Register new GRPC service.
*
- * Example: $server->registerService(EchoServiceInterface::class, new EchoService());
+ * For example:
+ *
+ * $server->registerService(EchoServiceInterface::class, new EchoService());
+ *
*
- * @param string $interface Generated service interface.
- * @param ServiceInterface $service Must implement interface.
+ * @template T of ServiceInterface
*
+ * @param class-string $interface Generated service interface.
+ * @param T $service Must implement interface.
* @throws ServiceException
*/
public function registerService(string $interface, ServiceInterface $service): void
{
$service = new ServiceWrapper($this->invoker, $interface, $service);
+
$this->services[$service->getName()] = $service;
}
+ /**
+ * @param string $body
+ * @param ContextResponse $data
+ * @return array{ 0: string, 1: string }
+ * @throws \JsonException
+ * @throws \Throwable
+ */
+ private function tick(string $body, array $data): array
+ {
+ $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];
+ }
+
+ /**
+ * @param Worker $worker
+ * @param string $body
+ * @param string $headers
+ * @psalm-suppress InaccessibleMethod
+ */
+ private function workerSend(Worker $worker, string $body, string $headers): void
+ {
+ // RoadRunner 1.x
+ if (\method_exists($worker, 'send')) {
+ $worker->send($body, $headers);
+
+ return;
+ }
+
+ // RoadRunner 2.x
+ $worker->respond(new Payload($body, $headers));
+ }
+
+ /**
+ * @param Worker $worker
+ * @param string $message
+ */
+ private function workerError(Worker $worker, string $message): void
+ {
+ $worker->error($message);
+ }
+
+ /**
+ * @param Worker $worker
+ * @return array { 0: string, 1: string } | null
+ *
+ * @psalm-suppress UndefinedMethod
+ * @psalm-suppress PossiblyUndefinedVariable
+ */
+ private function workerReceive(Worker $worker): ?array
+ {
+ /** @var string|\Stringable $body */
+ $body = $worker->receive($ctx);
+
+ if (empty($body) && empty($ctx)) {
+ return null;
+ }
+
+ return [(string)$body, (string)$ctx];
+ }
+
/**
* Serve GRPC over given RoadRunner worker.
*
- * @param Worker $worker
+ * @param Worker $worker
* @param callable|null $finalize
*/
public function serve(Worker $worker, callable $finalize = null): void
{
while (true) {
- $body = $worker->receive($ctx);
- if (empty($body) && empty($ctx)) {
+ $request = $this->workerReceive($worker);
+
+ if (! $request) {
return;
}
+ [$body, $headers] = $request;
+
try {
- $ctx = json_decode($ctx, true);
- $grpcCtx = new Context(
- $ctx['context'] + [ResponseHeaders::class => new ResponseHeaders([])]
- );
-
- $resp = $this->invoke(
- $ctx['service'],
- $ctx['method'],
- $grpcCtx,
- $body
- );
-
- /** @var ResponseHeaders|null $responseHeaders */
- $responseHeaders = $grpcCtx->getValue(ResponseHeaders::class);
- $worker->send($resp, $responseHeaders ? $responseHeaders->packHeaders() : '{}');
- } catch (GRPCException $e) {
- $worker->error($this->packError($e));
+ /** @var ContextResponse $context */
+ $context = Json::decode((string)$headers);
+
+ [$answerBody, $answerHeaders] = $this->tick((string)$body, $context);
+
+ $this->workerSend($worker, $answerBody, $answerHeaders);
+ } catch (GRPCExceptionInterface $e) {
+ $this->workerError($worker, $this->packError($e));
} catch (\Throwable $e) {
- $worker->error((string)$e);
+ $this->workerError($worker, $this->isDebugMode() ? (string)$e : $e->getMessage());
} finally {
if ($finalize !== null) {
- call_user_func($finalize, $e ?? null);
+ isset($e) ? $finalize($e) : $finalize();
}
}
}
@@ -98,23 +189,17 @@ public function serve(Worker $worker, callable $finalize = null): void
/**
* Invoke service method with binary payload and return the response.
*
- * @param string $service
- * @param string $method
- * @param Context $context
- * @param string $body
+ * @param string $service
+ * @param string $method
+ * @param ContextInterface $context
+ * @param string $body
* @return string
- *
* @throws GRPCException
- * @throws \Throwable
*/
- protected function invoke(
- string $service,
- string $method,
- Context $context,
- ?string $body
- ): string {
- if (!isset($this->services[$service])) {
- throw new NotFoundException("Service `{$service}` not found.", StatusCode::NOT_FOUND);
+ protected function invoke(string $service, string $method, ContextInterface $context, string $body): string
+ {
+ if (! isset($this->services[$service])) {
+ throw NotFoundException::create("Service `{$service}` not found.", StatusCode::NOT_FOUND);
}
return $this->services[$service]->invoke($method, $context, $body);
@@ -125,20 +210,17 @@ protected function invoke(
*
* Internal agreement:
*
- * Details will be sent as serialized google.protobuf.Any messages after code and exception message
- * separated with |:| delimeter.
+ * Details will be sent as serialized google.protobuf.Any messages after
+ * code and exception message separated with |:| delimiter.
*
- * @param GRPCException $e
+ * @param GRPCExceptionInterface $e
* @return string
*/
- private function packError(GRPCException $e): string
+ private function packError(GRPCExceptionInterface $e): string
{
$data = [$e->getCode(), $e->getMessage()];
foreach ($e->getDetails() as $detail) {
- /**
- * @var Message $detail
- */
$anyMessage = new Any();
$anyMessage->pack($detail);
@@ -146,6 +228,22 @@ private function packError(GRPCException $e): string
$data[] = $anyMessage->serializeToString();
}
- return implode('|:|', $data);
+ return \implode('|:|', $data);
+ }
+
+ /**
+ * If server runs in debug mode
+ *
+ * @return bool
+ */
+ private function isDebugMode(): bool
+ {
+ $debug = false;
+
+ if (isset($this->options['debug'])) {
+ $debug = \filter_var($this->options['debug'], \FILTER_VALIDATE_BOOLEAN);
+ }
+
+ return $debug;
}
}
diff --git a/src/ServiceInterface.php b/src/ServiceInterface.php
index 074aefe..991ae1f 100644
--- a/src/ServiceInterface.php
+++ b/src/ServiceInterface.php
@@ -1,10 +1,10 @@
invoker = $invoker;
+
$this->configure($interface, $service);
}
@@ -73,53 +70,54 @@ public function getMethods(): array
}
/**
- * @param string $method
+ * @param string $method
* @param ContextInterface $context
- * @param string $input
- * @param array $metadata
+ * @param string|null $input
* @return string
- *
* @throws NotFoundException
* @throws InvokeException
*/
- public function invoke(string $method, ContextInterface $context, ?string $input, array &$metadata = []): string
+ public function invoke(string $method, ContextInterface $context, ?string $input): string
{
- if (!isset($this->methods[$method])) {
- throw new NotFoundException("Method `{$method}` not found in service `{$this->name}`.");
+ if (! isset($this->methods[$method])) {
+ throw NotFoundException::create("Method `{$method}` not found in service `{$this->name}`.");
}
- return $this->invoker->invoke($this->service, $this->methods[$method], $context, $input, $metadata);
+ return $this->invoker->invoke($this->service, $this->methods[$method], $context, $input);
}
/**
* Configure service name and methods.
*
- * @param string $interface
+ * @param class-string $interface
* @param ServiceInterface $service
- *
* @throws ServiceException
*/
protected function configure(string $interface, ServiceInterface $service): void
{
try {
- $r = new \ReflectionClass($interface);
- if (!$r->hasConstant('NAME')) {
- throw new ServiceException(
- "Invalid service interface `{$interface}`, constant `NAME` not found."
- );
+ $reflection = new \ReflectionClass($interface);
+
+ if (! $reflection->hasConstant('NAME')) {
+ $message = "Invalid service interface `{$interface}`, constant `NAME` not found.";
+ throw ServiceException::create($message);
}
- $this->name = $r->getConstant('NAME');
+
+ $name = $reflection->getConstant('NAME');
+
+ if (! \is_string($name)) {
+ $message = "Constant `NAME` of service interface `{$interface}` must be a type of string";
+ throw ServiceException::create($message);
+ }
+
+ $this->name = $name;
} catch (\ReflectionException $e) {
- throw new ServiceException(
- "Invalid service interface `{$interface}`.",
- StatusCode::INTERNAL,
- [],
- $e
- );
+ $message = "Invalid service interface `{$interface}`.";
+ throw ServiceException::create($message, StatusCode::INTERNAL, $e);
}
- if (!$service instanceof $interface) {
- throw new ServiceException("Service handler does not implement `{$interface}`.");
+ if (! $service instanceof $interface) {
+ throw ServiceException::create("Service handler does not implement `{$interface}`.");
}
$this->service = $service;
@@ -130,7 +128,7 @@ protected function configure(string $interface, ServiceInterface $service): void
/**
* @param ServiceInterface $service
- * @return array
+ * @return array
*/
protected function fetchMethods(ServiceInterface $service): array
{
diff --git a/src/StatusCode.php b/src/StatusCode.php
index 7c0f7fd..5ff9c30 100644
--- a/src/StatusCode.php
+++ b/src/StatusCode.php
@@ -1,10 +1,10 @@
invoke(
- $s,
- $m,
- new Context([]),
- $this->packMessage('hello')
- );
+ $out = $i->invoke($s, $m, new Context([]), $this->packMessage('hello'));
$m = new Message();
$m->mergeFromString($out);
@@ -40,22 +35,16 @@ public function testInvoke(): void
$this->assertSame('hello', $m->getMsg());
}
- /**
- * @expectedException \Spiral\GRPC\Exception\InvokeException
- */
public function testInvokeError(): void
{
+ $this->expectException(\Spiral\GRPC\Exception\InvokeException::class);
+
$s = new TestService();
$m = Method::parse(new \ReflectionMethod($s, 'Echo'));
$i = new Invoker();
- $i->invoke(
- $s,
- $m,
- new Context([]),
- 'invalid-message'
- );
+ $i->invoke($s, $m, new Context([]), 'invalid-message');
}
private function packMessage(string $message): string
diff --git a/tests/GRPC/MethodTest.php b/tests/GRPC/MethodTest.php
index 6a782d4..7491e84 100644
--- a/tests/GRPC/MethodTest.php
+++ b/tests/GRPC/MethodTest.php
@@ -19,11 +19,10 @@
class MethodTest extends TestCase
{
- /**
- * @expectedException \Spiral\GRPC\Exception\GRPCException
- */
public function testInvalidParse(): void
{
+ $this->expectException(\Spiral\GRPC\Exception\GRPCException::class);
+
Method::parse(new \ReflectionMethod($this, 'testInvalidParse'));
}
diff --git a/tests/GRPC/ServerTest.php b/tests/GRPC/ServerTest.php
index 8409269..205399e 100644
--- a/tests/GRPC/ServerTest.php
+++ b/tests/GRPC/ServerTest.php
@@ -85,6 +85,28 @@ public function testNotFound2(): void
$this->assertTrue($w->done());
}
+ public function testServerDebugModeNotEnabled(): void
+ {
+ $s = new Server();
+ $s->registerService(TestInterface::class, new TestService());
+
+ $w = new TestWorker($this, [
+ [
+ 'ctx' => [
+ 'service' => 'service.Test',
+ 'method' => 'Throw',
+ 'context' => [],
+ ],
+ 'send' => $this->packMessage('regularException'),
+ 'error' => 'Just another exception'
+ ]
+ ]);
+
+ $s->serve($w);
+
+ $this->assertTrue($w->done());
+ }
+
private function packMessage(string $message): string
{
$m = new Message();
diff --git a/tests/GRPC/ServiceWrapperTest.php b/tests/GRPC/ServiceWrapperTest.php
index 5032a51..bae551b 100644
--- a/tests/GRPC/ServiceWrapperTest.php
+++ b/tests/GRPC/ServiceWrapperTest.php
@@ -55,11 +55,10 @@ public function testMethods(): void
$this->assertCount(5, $w->getMethods());
}
- /**
- * @expectedException \Spiral\GRPC\Exception\NotFoundException
- */
public function testInvokeNotFound(): void
{
+ $this->expectException(\Spiral\GRPC\Exception\NotFoundException::class);
+
$w = new ServiceWrapper(
new Invoker(),
TestInterface::class,
@@ -85,11 +84,10 @@ public function testInvoke(): void
$this->assertSame('hello world', $m->getMsg());
}
- /**
- * @expectedException \Spiral\GRPC\Exception\ServiceException
- */
public function testNotImplemented(): void
{
+ $this->expectException(\Spiral\GRPC\Exception\ServiceException::class);
+
$w = new ServiceWrapper(
new Invoker(),
TestInterface::class,
@@ -97,11 +95,10 @@ public function testNotImplemented(): void
);
}
- /**
- * @expectedException \Spiral\GRPC\Exception\ServiceException
- */
public function testInvalidInterface(): void
{
+ $this->expectException(\Spiral\GRPC\Exception\ServiceException::class);
+
$w = new ServiceWrapper(
new Invoker(),
InvalidInterface::class,
@@ -109,11 +106,10 @@ public function testInvalidInterface(): void
);
}
- /**
- * @expectedException \Spiral\GRPC\Exception\ServiceException
- */
public function testInvalidInterface2(): void
{
+ $this->expectException(\Spiral\GRPC\Exception\ServiceException::class);
+
$w = new ServiceWrapper(
new Invoker(),
'NotFound',
diff --git a/tests/src/Test/TestService.php b/tests/src/Test/TestService.php
index 6d4a385..12b9479 100644
--- a/tests/src/Test/TestService.php
+++ b/tests/src/Test/TestService.php
@@ -33,6 +33,9 @@ public function Throw(ContextInterface $ctx, Message $in): Message
$grpcException = new GRPCException("main exception message", 3, [$detailsMessage]);
throw $grpcException;
+ case "regularException": {
+ throw new \Exception("Just another exception");
+ }
}
return $out;
diff --git a/tests/worker.php b/tests/worker.php
index e8210bb..7a80416 100644
--- a/tests/worker.php
+++ b/tests/worker.php
@@ -7,10 +7,10 @@
use Spiral\RoadRunner;
ini_set('display_errors', 'stderr');
-require "vendor/autoload.php";
+require __DIR__ . '/../vendor/autoload.php';
$server = new \Spiral\GRPC\Server();
$server->registerService(\Service\TestInterface::class, new \Test\TestService());
$w = new RoadRunner\Worker(new Goridge\StreamRelay(STDIN, STDOUT));
-$server->serve($w);
\ No newline at end of file
+$server->serve($w);