Skip to content

Commit c15e3df

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

10 files changed

Lines changed: 704 additions & 0 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: 0 # PLACEHOLDER: update to the real PR number before merging
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 custom options 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: 41 additions & 0 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,36 @@ 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+
-- | Fully qualified names of every service this server registers, read off
136+
-- each service's own compiled-in descriptor so the Server Reflection API's
137+
-- @list_services@ answer can never drift from what is actually registered
138+
-- below.
139+
registeredServiceNames :: [Text]
140+
registeredServiceNames =
141+
[ qualifiedServiceName @Rpc.Node
142+
, qualifiedServiceName @UtxoRpc.QueryService
143+
, qualifiedServiceName @UtxoRpc.SubmitService
144+
, qualifiedServiceName @UtxoRpc.SyncService
145+
, qualifiedServiceName @ReflectionV1.ServerReflection
146+
, qualifiedServiceName @ReflectionV1alpha.ServerReflection
147+
]
148+
110149
-- | Start the gRPC server, registering all RPC service handlers.
111150
-- Does nothing when the RPC server is disabled in configuration.
112151
runRpcServer
@@ -174,6 +213,8 @@ runRpcServer tracer rpcConfig networkMagic nodeKernelAccessRef = handleFatalExce
174213
, fromMethods methodsUtxoRpc
175214
, fromMethods methodsUtxoRpcSubmit
176215
, fromMethods methodsSyncRpc
216+
, fromMethods methodsReflectionV1
217+
, fromMethods methodsReflectionV1alpha
177218
]
178219
where
179220
serverParams :: ServerParams
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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.Reflection.DescriptorTable
22+
23+
import RIO
24+
25+
import Data.ProtoLens (Message, decodeMessageOrDie, defMessage, encodeMessage)
26+
import Data.ProtoLens.Service.Types (Service, ServiceName, ServicePackage)
27+
import Data.Text qualified as Text
28+
import GHC.TypeLits (symbolVal)
29+
import Network.GRPC.Spec
30+
( GrpcError (GrpcInvalidArgument, GrpcNotFound)
31+
, NextElem (NextElem, NoNextElem)
32+
, Proto (Proto)
33+
, fromGrpcError
34+
)
35+
36+
-- | The fully qualified name of a proto service, @\<package\>.\<Service\>@,
37+
-- read off its own compiled-in descriptor via proto-lens's 'Service' class.
38+
-- Deriving it this way, rather than writing out the string, means the name
39+
-- registered with grapesy ("Server.hs") and the name advertised by
40+
-- @list_services@ (below) can never drift apart.
41+
qualifiedServiceName :: forall s. Service s => Text
42+
qualifiedServiceName =
43+
Text.pack (symbolVal (Proxy @(ServicePackage s)))
44+
<> "."
45+
<> Text.pack (symbolVal (Proxy @(ServiceName s)))
46+
47+
-- | Handle the @ServerReflectionInfo@ bidirectional stream for
48+
-- @grpc.reflection.v1@: answer every request on the incoming stream in
49+
-- turn, then forward the client's own terminal marker. A bidi handler that
50+
-- returns without sending 'NoNextElem' itself has its stream cancelled
51+
-- instead of closed with trailers, the same requirement as for
52+
-- server-streaming handlers (both go through grapesy's identical
53+
-- @sendOutput call . fromNextElem call@ path).
54+
serverReflectionInfoMethodV1
55+
:: MonadIO m
56+
=> [Text]
57+
-- ^ Fully qualified names of every service registered with this server,
58+
-- answered verbatim for @list_services@
59+
-> IO (NextElem (Proto V1.ServerReflectionRequest))
60+
-> (NextElem (Proto V1.ServerReflectionResponse) -> IO ())
61+
-> m ()
62+
serverReflectionInfoMethodV1 serviceNames recv send = liftIO loop
63+
where
64+
loop =
65+
recv >>= \case
66+
NoNextElem -> send NoNextElem
67+
NextElem request -> do
68+
send . NextElem $ answerReflectionRequest descriptorTable serviceNames request
69+
loop
70+
71+
-- | Handle the same stream for the legacy @grpc.reflection.v1alpha@, by
72+
-- bridging each message to and from @v1@ and answering with the one core
73+
-- 'answerReflectionRequest'.
74+
serverReflectionInfoMethodV1alpha
75+
:: MonadIO m
76+
=> [Text]
77+
-> IO (NextElem (Proto V1alpha.ServerReflectionRequest))
78+
-> (NextElem (Proto V1alpha.ServerReflectionResponse) -> IO ())
79+
-> m ()
80+
serverReflectionInfoMethodV1alpha serviceNames recv send = liftIO loop
81+
where
82+
loop =
83+
recv >>= \case
84+
NoNextElem -> send NoNextElem
85+
NextElem request -> do
86+
send . NextElem . bridgeMessage $
87+
answerReflectionRequest descriptorTable serviceNames (bridgeMessage request)
88+
loop
89+
90+
-- | Re-encode a message as a wire-compatible message with different
91+
-- generated Haskell types. Safe only between schemas that agree on every
92+
-- field number and wire type, which @v1@ and @v1alpha@ of the reflection
93+
-- protos do (@v1alpha@ is @v1@ under its original package name).
94+
bridgeMessage :: (Message a, Message b) => Proto a -> Proto b
95+
bridgeMessage = decodeMessageOrDie . encodeMessage
96+
97+
-- | Answer one @ServerReflectionRequest@, dispatching on its
98+
-- @message_request@ oneof.
99+
--
100+
-- Lookup failures ('V1.FileByFilename', 'V1.FileContainingSymbol') are
101+
-- reported in-stream as an @ErrorResponse@ with @NOT_FOUND@, never as a
102+
-- gRPC error: the RPC itself stays OK for the life of the stream.
103+
-- @file_containing_extension@ and @all_extension_numbers_of_type@ also
104+
-- answer @NOT_FOUND@, since none of the proto files served here declare
105+
-- proto2 extensions.
106+
answerReflectionRequest
107+
:: DescriptorTable
108+
-> [Text]
109+
-> Proto V1.ServerReflectionRequest
110+
-> Proto V1.ServerReflectionResponse
111+
answerReflectionRequest table serviceNames request =
112+
defMessage
113+
& V1.validHost .~ (request ^. V1.host)
114+
& V1.originalRequest .~ request
115+
& answer
116+
where
117+
answer :: Proto V1.ServerReflectionResponse -> Proto V1.ServerReflectionResponse
118+
answer = case request ^. V1.maybe'messageRequest of
119+
-- proto3 leaves message_request entirely unset when malformed by the
120+
-- client; there is no lookup to fail here, so this is INVALID_ARGUMENT
121+
-- rather than NOT_FOUND.
122+
Nothing ->
123+
V1.errorResponse .~ mkErrorResponse GrpcInvalidArgument "no message_request set"
124+
Just (Proto messageRequest) -> case messageRequest of
125+
V1.ServerReflectionRequest'FileByFilename fileName ->
126+
fileDescriptorAnswer fileName
127+
V1.ServerReflectionRequest'FileContainingSymbol symbolName ->
128+
case lookupSymbol table symbolName of
129+
Nothing -> V1.errorResponse .~ mkErrorResponse GrpcNotFound ("symbol not found: " <> symbolName)
130+
Just fileName -> fileDescriptorAnswer fileName
131+
V1.ServerReflectionRequest'FileContainingExtension extensionRequest ->
132+
V1.errorResponse
133+
.~ mkErrorResponse
134+
GrpcNotFound
135+
( "no extensions are declared by this server (requested for type: "
136+
<> (Proto extensionRequest ^. V1.containingType)
137+
<> ")"
138+
)
139+
V1.ServerReflectionRequest'AllExtensionNumbersOfType typeName ->
140+
V1.errorResponse
141+
.~ mkErrorResponse GrpcNotFound ("no extensions are declared of type: " <> typeName)
142+
V1.ServerReflectionRequest'ListServices _ ->
143+
V1.listServicesResponse
144+
.~ (defMessage & V1.service .~ map (\serviceName -> defMessage & V1.name .~ serviceName) serviceNames)
145+
146+
fileDescriptorAnswer
147+
:: Text -> Proto V1.ServerReflectionResponse -> Proto V1.ServerReflectionResponse
148+
fileDescriptorAnswer fileName = case transitiveClosure table fileName of
149+
Nothing -> V1.errorResponse .~ mkErrorResponse GrpcNotFound ("file not found: " <> fileName)
150+
Just entries ->
151+
V1.fileDescriptorResponse .~ (defMessage & V1.fileDescriptorProto .~ map fileEntryBytes entries)
152+
153+
mkErrorResponse :: GrpcError -> Text -> Proto V1.ErrorResponse
154+
mkErrorResponse grpcError message =
155+
defMessage
156+
& V1.errorCode .~ fromIntegral (fromGrpcError grpcError)
157+
& V1.errorMessage .~ message

0 commit comments

Comments
 (0)