packages feed

spire-grpc-0.1.0.0: src/Spire/Grpc/Reflection.hs

{-# LANGUAGE OverloadedStrings #-}
-- | gRPC server reflection service.
--
-- Allows tools like @grpcurl@ to discover services at runtime without
-- requiring a @.proto@ file. Implements a simplified text-based
-- reflection protocol at the standard reflection service path.
--
-- @
-- let services = grpcServiceMap [...]
-- let infos = [ ServiceInfo "myapp.Greeter" [...] "..." ]
-- let withRefl = withReflection infos services
-- runServerH2 (defaultH2Config 50051) (grpcServer withRefl)
-- @
module Spire.Grpc.Reflection
  ( -- * Reflection service
    reflectionService
  , ServiceInfo (..)
  , MethodInfo (..)
    -- * Adding reflection to a server
  , withReflection
  ) where

import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE

import Spire.Grpc.Server (GrpcServiceMap, GrpcHandler (..), GrpcRequest (..), GrpcResponse (..))
import Spire.Grpc.Status


-- | Description of a single RPC method.
data MethodInfo = MethodInfo
  { miName       :: !Text
  , miInputType  :: !Text
  , miOutputType :: !Text
  } deriving (Show, Eq)


-- | Description of a gRPC service for reflection.
data ServiceInfo = ServiceInfo
  { siFullName  :: !Text       -- ^ e.g. "myapp.UserService"
  , siMethods   :: ![MethodInfo]
  , siProtoFile :: !Text       -- ^ the .proto text (generated or provided)
  } deriving (Show, Eq)


-- | Build a 'GrpcServiceMap' containing the reflection service handlers.
reflectionService :: [ServiceInfo] -> GrpcServiceMap
reflectionService infos = reflectionHandlers infos


-- | Add reflection to an existing 'GrpcServiceMap'.
--
-- Registers the reflection service alongside your application services.
-- The reflection service itself is also listed in service discovery.
withReflection :: [ServiceInfo] -> GrpcServiceMap -> GrpcServiceMap
withReflection infos existing = Map.union (reflectionHandlers infos) existing


-- | Internal: build the handler map for the reflection service.
reflectionHandlers :: [ServiceInfo] -> GrpcServiceMap
reflectionHandlers infos =
  Map.fromList
    [ ( ("grpc.reflection.v1alpha.ServerReflection", "ServerReflectionInfo")
      , reflectionHandler infos
      )
    ]


-- | The reflection handler. Implements a simple text-based protocol:
--
-- * @"list_services"@ -> newline-separated list of service full names
-- * @"file_containing_symbol:<symbol>"@ -> .proto text for that service
-- * anything else -> NOT_FOUND
reflectionHandler :: [ServiceInfo] -> GrpcHandler
reflectionHandler infos = GrpcHandler $ \req -> do
  let payload = grpcBody req
      payloadText = TE.decodeUtf8 payload
  pure $ case parseReflectionRequest payloadText of
    ListServices ->
      let names = T.intercalate "\n" (map siFullName infos)
      in GrpcUnary (grpcOk "") (TE.encodeUtf8 names)

    FileContainingSymbol symbol ->
      case findServiceBySymbol infos symbol of
        Just info ->
          GrpcUnary (grpcOk "") (TE.encodeUtf8 (siProtoFile info))
        Nothing ->
          GrpcError (grpcNotFound ("Symbol not found: " <> symbol))

    UnknownRequest ->
      GrpcError (grpcNotFound "Unknown reflection request")


-- | Parsed reflection request types.
data ReflectionRequest
  = ListServices
  | FileContainingSymbol !Text
  | UnknownRequest


-- | Parse the text payload into a reflection request.
parseReflectionRequest :: Text -> ReflectionRequest
parseReflectionRequest txt
  | T.strip txt == "list_services" = ListServices
  | "file_containing_symbol:" `T.isPrefixOf` txt =
      let symbol = T.strip (T.drop (T.length "file_containing_symbol:") txt)
      in FileContainingSymbol symbol
  | otherwise = UnknownRequest


-- | Find a service whose full name matches the symbol, or whose methods
-- contain the symbol (as "ServiceName.MethodName").
findServiceBySymbol :: [ServiceInfo] -> Text -> Maybe ServiceInfo
findServiceBySymbol infos symbol =
  case filter (\si -> siFullName si == symbol) infos of
    (x:_) -> Just x
    []    ->
      -- Try matching as ServiceName.MethodName
      case filter (hasMethod symbol) infos of
        (x:_) -> Just x
        []    -> Nothing
  where
    hasMethod sym si =
      any (\mi -> siFullName si <> "." <> miName mi == sym) (siMethods si)