packages feed

acolyte-grpc-0.1.0.0: src/Acolyte/Grpc/Server.hs

-- | Build a gRPC server from a acolyte API type.
--
-- The same API type that drives REST routing also drives gRPC:
--
-- @
-- type API = '[ Get HealthPath Text
--             , Post UsersPath (Json CreateUser) (Json User)
--             ]
--
-- -- REST server (existing):
-- restServer = mkServer @API restHandlers
--
-- -- gRPC server (new):
-- grpcSvc = grpcServer (mkGrpcServiceMap @API "mypackage" "MyService" grpcHandlers)
-- @
module Acolyte.Grpc.Server
  ( -- * Service map construction
    mkGrpcServiceMap
    -- * Handler types
  , GrpcHandlerFn
    -- * Type class for building
  , BuildGrpcHandlers (..)
  ) where

import Data.ByteString (ByteString)
import Data.Kind (Type)
import Data.Text (Text)
import qualified Data.Text as T
import GHC.TypeLits (KnownSymbol, symbolVal)
import Data.Proxy (Proxy (..))

import Acolyte.Core.Method (KnownMethod, methodVal, Method(..))
import Acolyte.Core.Path (PathSegment(..))
import Acolyte.Core.Endpoint (Endpoint, NoBody)
import Acolyte.Core.Effect (Requires)
import Acolyte.Core.Wrapper
  ( Describe, WithParams, WithHeaders
  , ServerStream, ClientStream, BidiStream, RespondsWith
  )

import Spire.Grpc (GrpcServiceMap, GrpcHandler(..), GrpcRequest(..), GrpcResponse(..), grpcServiceMap, grpcOk, grpcInternal, unaryHandler)
import Spire.Grpc.Codec (encodeMessage, decodeMessage, GrpcMessage(..))

import Acolyte.Grpc.Proto (GrpcCodec(..))


-- | A gRPC handler function: takes raw request bytes, returns raw response bytes.
type GrpcHandlerFn = ByteString -> IO (Either Text ByteString)


-- | Build a gRPC service map from an API type.
--
-- @
-- let services = mkGrpcServiceMap @MyAPI "pkg" "MySvc"
--       (handler1, handler2, handler3)
-- @
--
-- Each handler in the tuple is a 'GrpcHandlerFn'. The API type
-- determines method names from endpoint paths.
mkGrpcServiceMap
  :: forall api handlers
   . BuildGrpcHandlers api handlers
  => Text     -- ^ package name (e.g. "myapp")
  -> Text     -- ^ service name (e.g. "UserService")
  -> handlers -- ^ tuple of GrpcHandlerFn
  -> GrpcServiceMap
mkGrpcServiceMap pkg svc handlers =
  let serviceName = pkg <> "." <> svc
      entries = buildGrpcHandlers @api handlers
  in grpcServiceMap
    [ (serviceName, methodName, mkHandler fn)
    | (methodName, fn) <- entries
    ]
  where
    mkHandler fn = unaryHandler $ \reqBytes -> do
      result <- fn reqBytes
      case result of
        Right resp -> pure (Right resp)
        Left err   -> pure (Left (grpcInternal err))


-- | Type class to walk the API and extract method names + handlers.
class BuildGrpcHandlers (api :: [Type]) handlers where
  buildGrpcHandlers :: handlers -> [(Text, GrpcHandlerFn)]


-- Arity 1
instance EndpointMethodName e => BuildGrpcHandlers '[e] GrpcHandlerFn where
  buildGrpcHandlers h = [(endpointMethodName @e, h)]

-- Arity 2
instance (EndpointMethodName e1, EndpointMethodName e2)
  => BuildGrpcHandlers '[e1, e2] (GrpcHandlerFn, GrpcHandlerFn) where
  buildGrpcHandlers (h1, h2) =
    [ (endpointMethodName @e1, h1)
    , (endpointMethodName @e2, h2)
    ]

-- Arity 3
instance (EndpointMethodName e1, EndpointMethodName e2, EndpointMethodName e3)
  => BuildGrpcHandlers '[e1, e2, e3] (GrpcHandlerFn, GrpcHandlerFn, GrpcHandlerFn) where
  buildGrpcHandlers (h1, h2, h3) =
    [ (endpointMethodName @e1, h1)
    , (endpointMethodName @e2, h2)
    , (endpointMethodName @e3, h3)
    ]

-- Arity 4
instance (EndpointMethodName e1, EndpointMethodName e2, EndpointMethodName e3, EndpointMethodName e4)
  => BuildGrpcHandlers '[e1, e2, e3, e4] (GrpcHandlerFn, GrpcHandlerFn, GrpcHandlerFn, GrpcHandlerFn) where
  buildGrpcHandlers (h1, h2, h3, h4) =
    [ (endpointMethodName @e1, h1), (endpointMethodName @e2, h2)
    , (endpointMethodName @e3, h3), (endpointMethodName @e4, h4)
    ]

-- Arity 5
instance (EndpointMethodName e1, EndpointMethodName e2, EndpointMethodName e3, EndpointMethodName e4, EndpointMethodName e5)
  => BuildGrpcHandlers '[e1, e2, e3, e4, e5] (GrpcHandlerFn, GrpcHandlerFn, GrpcHandlerFn, GrpcHandlerFn, GrpcHandlerFn) where
  buildGrpcHandlers (h1, h2, h3, h4, h5) =
    [ (endpointMethodName @e1, h1), (endpointMethodName @e2, h2)
    , (endpointMethodName @e3, h3), (endpointMethodName @e4, h4)
    , (endpointMethodName @e5, h5)
    ]


-- | Extract a gRPC method name from an endpoint type.
--
-- Uses the first path literal as the method name.
-- E.g., @Get '[ 'Lit "health" ] Text@ -> "Health"
-- E.g., @Post '[ 'Lit "users" ] req resp@ -> "Users"
class EndpointMethodName (e :: Type) where
  endpointMethodName :: Text

instance EndpointMethodName inner => EndpointMethodName (Requires e inner) where
  endpointMethodName = endpointMethodName @inner

instance EndpointMethodName inner => EndpointMethodName (Describe desc inner) where
  endpointMethodName = endpointMethodName @inner

instance EndpointMethodName inner => EndpointMethodName (WithParams ps inner) where
  endpointMethodName = endpointMethodName @inner

instance EndpointMethodName inner => EndpointMethodName (WithHeaders hs inner) where
  endpointMethodName = endpointMethodName @inner

instance EndpointMethodName inner => EndpointMethodName (ServerStream inner) where
  endpointMethodName = endpointMethodName @inner

instance EndpointMethodName inner => EndpointMethodName (ClientStream inner) where
  endpointMethodName = endpointMethodName @inner

instance EndpointMethodName inner => EndpointMethodName (BidiStream inner) where
  endpointMethodName = endpointMethodName @inner

instance EndpointMethodName inner => EndpointMethodName (RespondsWith s inner) where
  endpointMethodName = endpointMethodName @inner

instance PathToMethodName path => EndpointMethodName (Endpoint m path req resp) where
  endpointMethodName = pathToMethodName @path


-- | Convert a type-level path to a PascalCase gRPC method name.
class PathToMethodName (path :: [PathSegment]) where
  pathToMethodName :: Text

instance PathToMethodName '[] where
  pathToMethodName = "Unknown"

instance (KnownSymbol s, PathToMethodName rest) => PathToMethodName ('Lit s ': rest) where
  pathToMethodName = capitalize (T.pack (symbolVal (Proxy @s)))

instance PathToMethodName rest => PathToMethodName ('Capture t ': rest) where
  pathToMethodName = pathToMethodName @rest


-- | Capitalize the first letter of a text value.
capitalize :: Text -> Text
capitalize t = case T.uncons t of
  Just (c, rest) -> T.cons (toUpper c) rest
  Nothing        -> t
  where
    toUpper c
      | c >= 'a' && c <= 'z' = toEnum (fromEnum c - 32)
      | otherwise             = c