Skip to content

Commit 8aeebe8

Browse files
committed
cardano-rpc: Add gRPC reflection support
1 parent 2713d42 commit 8aeebe8

10 files changed

Lines changed: 781 additions & 6 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
project: cardano-rpc
2+
pr: 1334
3+
kind:
4+
- feature
5+
description: |
6+
The cardano-rpc gRPC server now implements the gRPC Server Reflection Protocol (grpc.reflection.v1 and grpc.reflection.v1alpha), so tools such as grpcurl, Postman and buf can list and describe the server's available services and message schemas without needing local .proto files. Queries for proto2 extensions are not supported and return NOT_FOUND, since none of the server's protocol buffers use them.

cardano-rpc/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ Use a dedicated chain indexing service for those.
4949
|--------|--------|
5050
| [WatchTx](https://utxorpc.org/watch/spec/#watchservice) | ⬜ Not supported |
5151

52+
## Other gRPC services
53+
54+
Besides the UTxO RPC spec above, `cardano-rpc` implements the standard [gRPC Server Reflection Protocol](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md), so tools such as `grpcurl` can list and describe the server's services without needing local `.proto` files.
55+
56+
| Method | Status |
57+
|--------|--------|
58+
| [grpc.reflection.v1.ServerReflection/ServerReflectionInfo](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md) | ✅ Supported |
59+
| [grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo](https://github.com/grpc/grpc/blob/master/doc/server-reflection.md) | ✅ Supported |
60+
5261
## Building
5362

5463
You need the following dependencies installed on your system:

cardano-rpc/cardano-rpc.cabal

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ library
4949
exposed-modules:
5050
Cardano.Rpc.Client
5151
Cardano.Rpc.Proto.Api.Node
52+
Cardano.Rpc.Proto.Api.Reflection.V1
53+
Cardano.Rpc.Proto.Api.Reflection.V1alpha
5254
Cardano.Rpc.Proto.Api.UtxoRpc.Query
5355
Cardano.Rpc.Proto.Api.UtxoRpc.Submit
5456
Cardano.Rpc.Proto.Api.UtxoRpc.Sync
@@ -58,6 +60,8 @@ library
5860
Cardano.Rpc.Server.Internal.Error
5961
Cardano.Rpc.Server.Internal.Monad
6062
Cardano.Rpc.Server.Internal.Node
63+
Cardano.Rpc.Server.Internal.Reflection
64+
Cardano.Rpc.Server.Internal.Reflection.DescriptorTable
6165
Cardano.Rpc.Server.Internal.TimedCache
6266
Cardano.Rpc.Server.Internal.Tracing
6367
Cardano.Rpc.Server.Internal.UtxoRpc.Eval
@@ -147,6 +151,10 @@ library gen
147151
exposed-modules:
148152
Proto.Cardano.Rpc.Node
149153
Proto.Cardano.Rpc.Node_Fields
154+
Proto.Grpc.Reflection.V1.Reflection
155+
Proto.Grpc.Reflection.V1.Reflection_Fields
156+
Proto.Grpc.Reflection.V1alpha.Reflection
157+
Proto.Grpc.Reflection.V1alpha.Reflection_Fields
150158
Proto.Utxorpc.V1beta.Cardano.Cardano
151159
Proto.Utxorpc.V1beta.Cardano.Cardano_Fields
152160
Proto.Utxorpc.V1beta.Query.Query
@@ -200,6 +208,7 @@ test-suite cardano-rpc-test
200208
ouroboros-consensus,
201209
ouroboros-consensus:cardano,
202210
proto-lens,
211+
proto-lens-protobuf-types,
203212
rio,
204213
scientific,
205214
sop-extras,
@@ -226,6 +235,8 @@ test-suite cardano-rpc-test
226235
Test.Cardano.Rpc.Pagination
227236
Test.Cardano.Rpc.Predicate
228237
Test.Cardano.Rpc.ProtocolParameters
238+
Test.Cardano.Rpc.Reflection
239+
Test.Cardano.Rpc.Reflection.DescriptorTable
229240
Test.Cardano.Rpc.Script
230241
Test.Cardano.Rpc.TxOutput
231242
Test.Cardano.Rpc.Type
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{-# LANGUAGE TypeFamilies #-}
2+
{-# OPTIONS_GHC -Wno-orphans #-}
3+
4+
module Cardano.Rpc.Proto.Api.Reflection.V1
5+
( module Proto.Grpc.Reflection.V1.Reflection
6+
, module Proto.Grpc.Reflection.V1.Reflection_Fields
7+
)
8+
where
9+
10+
import Network.GRPC.Common
11+
import Network.GRPC.Common.Protobuf
12+
13+
import Proto.Grpc.Reflection.V1.Reflection
14+
import Proto.Grpc.Reflection.V1.Reflection_Fields
15+
16+
type instance RequestMetadata (Protobuf ServerReflection meth) = NoMetadata
17+
18+
type instance ResponseInitialMetadata (Protobuf ServerReflection meth) = NoMetadata
19+
20+
type instance ResponseTrailingMetadata (Protobuf ServerReflection meth) = NoMetadata
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{-# LANGUAGE TypeFamilies #-}
2+
{-# OPTIONS_GHC -Wno-orphans #-}
3+
4+
module Cardano.Rpc.Proto.Api.Reflection.V1alpha
5+
( module Proto.Grpc.Reflection.V1alpha.Reflection
6+
, module Proto.Grpc.Reflection.V1alpha.Reflection_Fields
7+
)
8+
where
9+
10+
import Network.GRPC.Common
11+
import Network.GRPC.Common.Protobuf
12+
13+
import Proto.Grpc.Reflection.V1alpha.Reflection
14+
import Proto.Grpc.Reflection.V1alpha.Reflection_Fields
15+
16+
type instance RequestMetadata (Protobuf ServerReflection meth) = NoMetadata
17+
18+
type instance ResponseInitialMetadata (Protobuf ServerReflection meth) = NoMetadata
19+
20+
type instance ResponseTrailingMetadata (Protobuf ServerReflection meth) = NoMetadata

cardano-rpc/src/Cardano/Rpc/Server.hs

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@
77
{-# LANGUAGE QuantifiedConstraints #-}
88
{-# LANGUAGE RankNTypes #-}
99
{-# LANGUAGE ScopedTypeVariables #-}
10+
{-# LANGUAGE TypeApplications #-}
1011

1112
module Cardano.Rpc.Server
1213
( runRpcServer
1314
, NodeKernelAccess
1415
, mkNodeKernelAccess
16+
, registeredServiceNames
1517

1618
-- * Traces
1719
, TraceRpc (..)
@@ -25,6 +27,8 @@ where
2527

2628
import Cardano.Api
2729
import Cardano.Rpc.Proto.Api.Node qualified as Rpc
30+
import Cardano.Rpc.Proto.Api.Reflection.V1 qualified as ReflectionV1
31+
import Cardano.Rpc.Proto.Api.Reflection.V1alpha qualified as ReflectionV1alpha
2832
import Cardano.Rpc.Proto.Api.UtxoRpc.Query qualified as UtxoRpc
2933
import Cardano.Rpc.Proto.Api.UtxoRpc.Submit qualified as UtxoRpc
3034
import Cardano.Rpc.Proto.Api.UtxoRpc.Sync qualified as UtxoRpc
@@ -34,6 +38,11 @@ import Cardano.Rpc.Server.Internal.Error (renderRpcExceptionForClient)
3438
import Cardano.Rpc.Server.Internal.Monad
3539
import Cardano.Rpc.Server.Internal.Node
3640
import Cardano.Rpc.Server.Internal.Orphans ()
41+
import Cardano.Rpc.Server.Internal.Reflection
42+
( qualifiedServiceName
43+
, serverReflectionInfoMethodV1
44+
, serverReflectionInfoMethodV1alpha
45+
)
3746
import Cardano.Rpc.Server.Internal.Tracing
3847
import Cardano.Rpc.Server.Internal.UtxoRpc.Eval
3948
import Cardano.Rpc.Server.Internal.UtxoRpc.Query
@@ -107,6 +116,42 @@ methodsSyncRpc =
107116
. Method (mkNonStreaming $ wrapInSpan TraceRpcReadTipSpan . readTipMethod)
108117
$ NoMoreMethods
109118

119+
-- | gRPC method table for the Server Reflection API's @v1@ service.
120+
methodsReflectionV1
121+
:: MonadIO m
122+
=> Methods m (ProtobufMethodsOf ReflectionV1.ServerReflection)
123+
methodsReflectionV1 =
124+
Method (mkBiDiStreaming $ serverReflectionInfoMethodV1 registeredServiceNames) $
125+
NoMoreMethods
126+
127+
-- | gRPC method table for the Server Reflection API's legacy @v1alpha@ service.
128+
methodsReflectionV1alpha
129+
:: MonadIO m
130+
=> Methods m (ProtobufMethodsOf ReflectionV1alpha.ServerReflection)
131+
methodsReflectionV1alpha =
132+
Method (mkBiDiStreaming $ serverReflectionInfoMethodV1alpha registeredServiceNames) $
133+
NoMoreMethods
134+
135+
-- | Every service this server registers, paired with its handler methods
136+
-- in one list, so the name registered with grapesy and the name advertised
137+
-- by the Server Reflection API's @list_services@ ('registeredServiceNames')
138+
-- can never drift apart - unlike two hand-maintained lists, this cannot go
139+
-- out of sync by construction.
140+
registeredServices :: [(Text, [SomeRpcHandler (RIO RpcEnv)])]
141+
registeredServices =
142+
[ (qualifiedServiceName @Rpc.Node, fromMethods methodsNodeRpc)
143+
, (qualifiedServiceName @UtxoRpc.QueryService, fromMethods methodsUtxoRpc)
144+
, (qualifiedServiceName @UtxoRpc.SubmitService, fromMethods methodsUtxoRpcSubmit)
145+
, (qualifiedServiceName @UtxoRpc.SyncService, fromMethods methodsSyncRpc)
146+
, (qualifiedServiceName @ReflectionV1.ServerReflection, fromMethods methodsReflectionV1)
147+
, (qualifiedServiceName @ReflectionV1alpha.ServerReflection, fromMethods methodsReflectionV1alpha)
148+
]
149+
150+
-- | Fully qualified names of every service this server registers, for the
151+
-- Server Reflection API's @list_services@.
152+
registeredServiceNames :: [Text]
153+
registeredServiceNames = map fst registeredServices
154+
110155
-- | Start the gRPC server, registering all RPC service handlers.
111156
-- Does nothing when the RPC server is disabled in configuration.
112157
runRpcServer
@@ -169,12 +214,7 @@ runRpcServer tracer rpcConfig networkMagic nodeKernelAccessRef = handleFatalExce
169214
runRIO rpcEnv $
170215
withRunInIO $ \runInIO ->
171216
runServer http2Settings config <=< mkGrpcServer serverParams . fmap (hoistSomeRpcHandler runInIO) $
172-
mconcat
173-
[ fromMethods methodsNodeRpc
174-
, fromMethods methodsUtxoRpc
175-
, fromMethods methodsUtxoRpcSubmit
176-
, fromMethods methodsSyncRpc
177-
]
217+
mconcat (map snd registeredServices)
178218
where
179219
serverParams :: ServerParams
180220
serverParams =
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
{-# LANGUAGE AllowAmbiguousTypes #-}
2+
{-# LANGUAGE LambdaCase #-}
3+
{-# LANGUAGE ScopedTypeVariables #-}
4+
{-# LANGUAGE TypeApplications #-}
5+
6+
-- | Handler for the gRPC Server Reflection API
7+
-- (<https://github.com/grpc/grpc/blob/master/doc/server-reflection.md>),
8+
-- @grpc.reflection.v1@ and the older @grpc.reflection.v1alpha@. This lets
9+
-- generic clients (e.g. @grpcurl@) discover and decode this server's proto
10+
-- services without a local copy of the @.proto@ files.
11+
module Cardano.Rpc.Server.Internal.Reflection
12+
( serverReflectionInfoMethodV1
13+
, serverReflectionInfoMethodV1alpha
14+
, answerReflectionRequest
15+
, qualifiedServiceName
16+
)
17+
where
18+
19+
import Cardano.Rpc.Proto.Api.Reflection.V1 qualified as V1
20+
import Cardano.Rpc.Proto.Api.Reflection.V1alpha qualified as V1alpha
21+
import Cardano.Rpc.Server.Internal.Error (throwGrpcErrorWithMessage)
22+
import Cardano.Rpc.Server.Internal.Reflection.DescriptorTable
23+
24+
import RIO
25+
26+
import Data.ProtoLens (Message, decodeMessage, defMessage, encodeMessage)
27+
import Data.ProtoLens.Service.Types (Service, ServiceName, ServicePackage)
28+
import Data.Text qualified as Text
29+
import GHC.TypeLits (symbolVal)
30+
import Network.GRPC.Spec
31+
( GrpcError (GrpcInternal, GrpcInvalidArgument, GrpcNotFound)
32+
, NextElem (NextElem, NoNextElem)
33+
, Proto (Proto)
34+
, fromGrpcError
35+
)
36+
37+
-- | Handle the @ServerReflectionInfo@ bidirectional stream for
38+
-- @grpc.reflection.v1@: answer every request on the incoming stream in
39+
-- turn, then forward the client's own terminal marker. A bidi handler that
40+
-- returns without sending 'NoNextElem' itself has its stream cancelled
41+
-- instead of closed with trailers, the same requirement as for
42+
-- server-streaming handlers (both go through grapesy's identical
43+
-- @sendOutput call . fromNextElem call@ path).
44+
serverReflectionInfoMethodV1
45+
:: MonadIO m
46+
=> [Text]
47+
-- ^ Fully qualified names of every service registered with this server,
48+
-- answered verbatim for @list_services@
49+
-> IO (NextElem (Proto V1.ServerReflectionRequest))
50+
-> (NextElem (Proto V1.ServerReflectionResponse) -> IO ())
51+
-> m ()
52+
serverReflectionInfoMethodV1 serviceNames recv send = liftIO loop
53+
where
54+
loop =
55+
recv >>= \case
56+
NoNextElem -> send NoNextElem
57+
NextElem request -> do
58+
send . NextElem $ answerReflectionRequest descriptorTable serviceNames request
59+
loop
60+
61+
-- | Handle the same stream for the legacy @grpc.reflection.v1alpha@, by
62+
-- bridging each message to and from @v1@ and answering with the one core
63+
-- 'answerReflectionRequest'.
64+
serverReflectionInfoMethodV1alpha
65+
:: MonadIO m
66+
=> [Text]
67+
-- ^ Fully qualified names of every service registered with this server,
68+
-- answered verbatim for @list_services@
69+
-> IO (NextElem (Proto V1alpha.ServerReflectionRequest))
70+
-> (NextElem (Proto V1alpha.ServerReflectionResponse) -> IO ())
71+
-> m ()
72+
serverReflectionInfoMethodV1alpha serviceNames recv send = liftIO loop
73+
where
74+
loop =
75+
recv >>= \case
76+
NoNextElem -> send NoNextElem
77+
NextElem request -> do
78+
v1Request <- bridgeMessage request
79+
v1alphaResponse <- bridgeMessage (answerReflectionRequest descriptorTable serviceNames v1Request)
80+
send $ NextElem v1alphaResponse
81+
loop
82+
83+
-- | Answer one @ServerReflectionRequest@, dispatching on its
84+
-- @message_request@ oneof.
85+
--
86+
-- Lookup failures ('V1.FileByFilename', 'V1.FileContainingSymbol') are
87+
-- reported in-stream as an @ErrorResponse@ with @NOT_FOUND@, never as a
88+
-- gRPC error: the RPC itself stays OK for the life of the stream.
89+
-- @file_containing_extension@ and @all_extension_numbers_of_type@ also
90+
-- answer @NOT_FOUND@, since none of the proto files served here declare
91+
-- proto2 extensions.
92+
answerReflectionRequest
93+
:: DescriptorTable
94+
-> [Text]
95+
-> Proto V1.ServerReflectionRequest
96+
-> Proto V1.ServerReflectionResponse
97+
answerReflectionRequest table serviceNames request =
98+
defMessage
99+
& V1.validHost .~ (request ^. V1.host)
100+
& V1.originalRequest .~ request
101+
& answer
102+
where
103+
answer :: Proto V1.ServerReflectionResponse -> Proto V1.ServerReflectionResponse
104+
answer = case request ^. V1.maybe'messageRequest of
105+
-- proto3 leaves message_request entirely unset when malformed by the
106+
-- client; there is no lookup to fail here, so this is INVALID_ARGUMENT
107+
-- rather than NOT_FOUND. Answering in-stream here matches grpc's
108+
-- canonical C++ implementation; Go instead terminates the RPC.
109+
Nothing ->
110+
V1.errorResponse .~ mkErrorResponse GrpcInvalidArgument "no message_request set"
111+
Just (Proto messageRequest) -> case messageRequest of
112+
V1.ServerReflectionRequest'FileByFilename fileName ->
113+
fileDescriptorAnswer fileName
114+
V1.ServerReflectionRequest'FileContainingSymbol symbolName ->
115+
case lookupSymbol table symbolName of
116+
Nothing -> V1.errorResponse .~ mkErrorResponse GrpcNotFound ("symbol not found: " <> symbolName)
117+
Just fileName -> fileDescriptorAnswer fileName
118+
V1.ServerReflectionRequest'FileContainingExtension extensionRequest ->
119+
V1.errorResponse
120+
.~ mkErrorResponse
121+
GrpcNotFound
122+
( "no extensions are declared by this server (requested for type: "
123+
<> (Proto extensionRequest ^. V1.containingType)
124+
<> ")"
125+
)
126+
V1.ServerReflectionRequest'AllExtensionNumbersOfType typeName ->
127+
V1.errorResponse
128+
.~ mkErrorResponse GrpcNotFound ("no extensions are declared of type: " <> typeName)
129+
V1.ServerReflectionRequest'ListServices _ ->
130+
V1.listServicesResponse
131+
.~ (defMessage & V1.service .~ map (\serviceName -> defMessage & V1.name .~ serviceName) serviceNames)
132+
133+
fileDescriptorAnswer
134+
:: Text -> Proto V1.ServerReflectionResponse -> Proto V1.ServerReflectionResponse
135+
fileDescriptorAnswer fileName = case transitiveClosure table fileName of
136+
Nothing -> V1.errorResponse .~ mkErrorResponse GrpcNotFound ("file not found: " <> fileName)
137+
Just entries ->
138+
V1.fileDescriptorResponse .~ (defMessage & V1.fileDescriptorProto .~ map fileEntryBytes entries)
139+
140+
mkErrorResponse :: GrpcError -> Text -> Proto V1.ErrorResponse
141+
mkErrorResponse grpcError message =
142+
defMessage
143+
& V1.errorCode .~ fromIntegral (fromGrpcError grpcError)
144+
& V1.errorMessage .~ message
145+
146+
-- | Re-encode a message as a wire-compatible message with different
147+
-- generated Haskell types. Safe between schemas that agree on every field
148+
-- number and wire type, which @v1@ and @v1alpha@ of the reflection protos
149+
-- do (@v1alpha@ is @v1@ under its original package name); a future schema
150+
-- divergence is reported as an @INTERNAL@ gRPC error rather than a panic.
151+
bridgeMessage :: (Message a, Message b, MonadIO m) => Proto a -> m (Proto b)
152+
bridgeMessage message =
153+
either (throwGrpcErrorWithMessage GrpcInternal . ("bridgeMessage: " <>) . Text.pack) pure $
154+
decodeMessage (encodeMessage message)
155+
156+
-- | The fully qualified name of a proto service, @\<package\>.\<Service\>@,
157+
-- read off its own compiled-in descriptor via proto-lens's 'Service' class.
158+
-- Deriving it this way, rather than writing out the string, means the name
159+
-- paired with each service's handler in "Cardano.Rpc.Server" and the name
160+
-- 'answerReflectionRequest' (above) advertises for @list_services@ can
161+
-- never drift apart.
162+
qualifiedServiceName :: forall s. Service s => Text
163+
qualifiedServiceName =
164+
Text.pack (symbolVal (Proxy @(ServicePackage s)))
165+
<> "."
166+
<> Text.pack (symbolVal (Proxy @(ServiceName s)))

0 commit comments

Comments
 (0)