Skip to content

Instantly share code, notes, and snippets.

@butschster
Last active August 16, 2023 10:18
Show Gist options
  • Save butschster/e957e10cf01bc39f4cceebe69200396f to your computer and use it in GitHub Desktop.
Save butschster/e957e10cf01bc39f4cceebe69200396f to your computer and use it in GitHub Desktop.
GrpcExceptionMapper
<?php
declare(strict_types=1);
namespace Shared\gRPC\Service\Client;
use Shared\gRPC\Attribute\ServiceClient;
use Shared\gRPC\Service\ServiceClientTrait;
use Shared\gRPC\Services\Auth\v1\AuthServiceInterface;
use Shared\gRPC\Services\Auth\v1\Request\ChangePasswordRequest;
use Shared\gRPC\Services\Auth\v1\Request\ForgotPasswordRequest;
use Shared\gRPC\Services\Auth\v1\Request\GetUserByTokenRequest;
use Shared\gRPC\Services\Auth\v1\Request\LoginRequest;
use Shared\gRPC\Services\Auth\v1\Request\MeRequest;
use Shared\gRPC\Services\Auth\v1\Request\RegisterRequest;
use Shared\gRPC\Services\Auth\v1\Response\ChangePasswordResponse;
use Shared\gRPC\Services\Auth\v1\Response\ForgotPasswordResponse;
use Shared\gRPC\Services\Auth\v1\Response\GetUserByTokenResponse;
use Shared\gRPC\Services\Auth\v1\Response\LoginResponse;
use Shared\gRPC\Services\Auth\v1\Response\MeResponse;
use Shared\gRPC\Services\Auth\v1\Response\RegisterResponse;
use Spiral\RoadRunner\GRPC\ContextInterface;
#[ServiceClient(name: "auth.v1.AuthService")]
final class AuthServiceClient implements AuthServiceInterface
{
use ServiceClientTrait;
public function Auth(ContextInterface $ctx, LoginRequest $in): LoginResponse
{
return $this->callAction(__FUNCTION__, $ctx, $in);
}
public function Me(ContextInterface $ctx, MeRequest $in): MeResponse
{
return $this->callAction(__FUNCTION__, $ctx, $in);
}
public function Register(ContextInterface $ctx, RegisterRequest $in): RegisterResponse
{
return $this->callAction(__FUNCTION__, $ctx, $in);
}
public function ForgotPassword(ContextInterface $ctx, ForgotPasswordRequest $in): ForgotPasswordResponse
{
return $this->callAction(__FUNCTION__, $ctx, $in);
}
public function ChangePassword(ContextInterface $ctx, ChangePasswordRequest $in): ChangePasswordResponse
{
return $this->callAction(__FUNCTION__, $ctx, $in);
}
public function GetUserByToken(ContextInterface $ctx, GetUserByTokenRequest $in): GetUserByTokenResponse
{
return $this->callAction(__FUNCTION__, $ctx, $in);
}
}
<?php
declare(strict_types=1);
namespace Shared\gRPC\Interceptor\Incoming;
use Shared\gRPC\Exception\GrpcExceptionMapper;
use Spiral\Core\CoreInterceptorInterface;
use Spiral\Core\CoreInterface;
use Spiral\Exceptions\ExceptionHandlerInterface;
use Spiral\RoadRunner\GRPC\Exception\GRPCExceptionInterface;
final readonly class ExceptionHandlerInterceptor implements CoreInterceptorInterface
{
public function __construct(
private ExceptionHandlerInterface $errorHandler,
private GrpcExceptionMapper $mapper,
) {
}
/**
* Handle exceptions.
* @throws GRPCExceptionInterface
*/
public function process(string $controller, string $action, array $parameters, CoreInterface $core): mixed
{
try {
return $core->callAction($controller, $action, $parameters);
} catch (\Throwable $e) {
if (!$e instanceof \DomainException) {
$this->errorHandler->report($e);
}
throw $this->mapper->toGrpcException($e);
}
}
}
<?php
declare(strict_types=1);
namespace Shared\gRPC\Exception;
use Google\Rpc\Status;
use Shared\gRPC\Attribute\ErrorMapper;
use Shared\gRPC\Services\Common\v1\DTO\Exception;
use Shared\gRPC\Services\Common\v1\DTO\ValidationException;
use Spiral\Attributes\AttributeReader;
use Spiral\Attributes\ReaderInterface;
use Spiral\Core\Attribute\Singleton;
use Spiral\Core\Container;
use Spiral\Core\FactoryInterface;
use Spiral\RoadRunner\GRPC\Exception\GRPCException;
use Spiral\RoadRunner\GRPC\Exception\GRPCExceptionInterface;
use Spiral\Tokenizer\Attribute\TargetAttribute;
use Spiral\Tokenizer\TokenizationListenerInterface;
#[TargetAttribute(attribute: ErrorMapper::class)]
#[Singleton]
final class GrpcExceptionMapper implements TokenizationListenerInterface
{
/** @var array<class-string, class-string<MapperInterface>> */
private array $mappers = [];
/** @var array<class-string, MapperInterface> */
private array $resolvedMappers = [];
public function __construct(
private readonly ReaderInterface $reader = new AttributeReader(),
private readonly FactoryInterface $factory = new Container(),
) {
new Exception();
new ValidationException();
}
public function listen(\ReflectionClass $class): void
{
/** @var ErrorMapper $mapper */
$mapper = $this->reader->firstClassMetadata($class, ErrorMapper::class);
$this->mappers[$mapper->type] = $class->getName();
}
public function finalize(): void
{
// do nothing
}
public function toGrpcException(\Throwable $e): GRPCExceptionInterface
{
$type = $this->getExceptionKey($e);
if (isset($this->mappers[$type])) {
$mapper = $this->resolvedMappers[$type] ??= $this->factory->make($this->mappers[$type]);
return $mapper->toGrpcException($e);
}
$info = $this->makeExceptionMessageObject($e);
$previous = $e->getPrevious();
while ($previous !== null) {
$info->setPrevious($this->makeExceptionMessageObject($previous));
$previous = $previous->getPrevious();
}
return new GRPCException(
message: $e->getMessage(),
code: $e->getCode(),
details: [$info],
previous: $e
);
}
public function fromError(object $error): \Throwable
{
if (!isset($error->metadata['grpc-status-details-bin'])) {
return ResponseException::createFromStatus($error);
}
$exception = $this->parseException($error);
if (!isset($this->mappers[$exception->getType()])) {
return ResponseException::createFromStatus($error);
}
$mapper = $this->resolvedMappers[$exception->getType()] ??= $this->factory->make(
$this->mappers[$exception->getType()],
);
return $mapper->fromError($exception);
}
private function makeExceptionMessageObject(\Throwable $e): Exception
{
return match (true) {
default => new Exception([
'type' => $this->getExceptionKey($e),
'message' => $e->getMessage(),
'code' => $e->getCode(),
])
};
}
private function parseException(object $status): Exception|ValidationException
{
$status = \array_map(
function (string $info) {
$status = new Status();
$status->mergeFromString($info);
return $status;
},
$status->metadata['grpc-status-details-bin'],
)[0];
return $status->getDetails()[0]->unpack();
}
private function getExceptionKey(\Throwable $e): string
{
$className = (new \ReflectionClass($e))->getShortName();
return \strtolower(\preg_replace('/(?<!^)[A-Z]/', '_$0', $className));
}
}
<?php
declare(strict_types=1);
namespace Shared\gRPC\Service;
use Google\Protobuf\Internal\Message;
use Shared\gRPC\Exception\GrpcExceptionMapper;
use Spiral\Core\CoreInterface;
use Spiral\RoadRunner\GRPC\ContextInterface;
use Spiral\RoadRunner\GRPC\StatusCode;
use Spiral\RoadRunner\GRPC\ServiceInterface;
trait ServiceClientTrait
{
private readonly array $registeredServices;
public function __construct(
private readonly CoreInterface $core,
private readonly GrpcExceptionMapper $mapper,
ServiceLocatorInterface $locator = new NullServiceLocator(),
) {
$this->registeredServices = \array_map(
static fn (ServiceInterface $service): string => $service::NAME,
$locator->getServices(),
);
}
private function isRegistered(): bool
{
return \in_array($this::NAME, $this->registeredServices, true);
}
/**
* @throws \ReflectionException
* @throws \Throwable
*/
private function callAction(string $action, ContextInterface $ctx, Message $in): Message
{
if ($this->isRegistered()) {
throw new \LogicException(\sprintf(
'Infinite call of action "%s/%s" detected: Service is attempting to call itself, leading to a potential infinite loop.',
$this::NAME,
$action
));
}
$method = new \ReflectionMethod($this, $action);
$returnType = $method->getReturnType()->getName();
$uri = '/' . $this::NAME . '/' . $action;
[$response, $status] = $this->core->callAction($this::class, $uri, [
'in' => $in,
'ctx' => $ctx,
'responseClass' => $returnType,
]);
$code = $status->code ?? StatusCode::UNKNOWN;
if ($code !== StatusCode::OK) {
throw $this->mapper->fromError($status);
}
\assert($response instanceof $returnType);
return $response;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment