packages feed

grapesy-1.0.0: src/Network/GRPC/Server/RequestHandler.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Request handler
--
-- This is not part of the library's public API.
--
-- Intended for unqualified import.
module Network.GRPC.Server.RequestHandler (
    -- * Definition
    RequestHandler
  , requestHandlerToServer
    -- * Construction
  , requestHandler
  ) where

import Control.Concurrent
import Control.Concurrent.Thread.Delay qualified as UnboundedDelays
import Control.Exception (evaluate)
import Control.Monad.Catch
import Data.Bifunctor
import Data.ByteString.Builder qualified as Builder
import Data.ByteString.Char8 qualified as BS.Char8
import Data.ByteString.UTF8 qualified as BS.UTF8
import Data.Maybe (fromMaybe)
import Data.Proxy
import Data.Text qualified as Text
import Network.HTTP.Types qualified as HTTP
import Network.HTTP2.Server qualified as HTTP2

import Network.GRPC.Server.Call
import Network.GRPC.Server.Context (ServerContext (..), ServerParams(..))
import Network.GRPC.Server.Handler
import Network.GRPC.Server.HandlerMap (HandlerMap)
import Network.GRPC.Server.HandlerMap qualified as HandlerMap
import Network.GRPC.Server.RequestHandler.API
import Network.GRPC.Server.Session (CallSetupFailure(..))
import Network.GRPC.Spec
import Network.GRPC.Spec.Serialization
import Network.GRPC.Util.GHC
import Network.GRPC.Util.Session.Server

{-------------------------------------------------------------------------------
  Construct request handler
-------------------------------------------------------------------------------}

-- | Construct request handler
requestHandler :: HandlerMap IO -> ServerContext -> RequestHandler ()
requestHandler handlers ctxt unmask request respond = do
    labelThisThread "grapesy:requestHandler"

    SomeRpcHandler (_ :: Proxy rpc) handler <-
      findHandler handlers request      `catch` setupFailure params respond
    (call :: Call rpc, mTimeout :: Maybe Timeout) <-
      setupCall connectionToClient ctxt `catch` setupFailure params respond

    imposeTimeout mTimeout $
      runHandler unmask call handler
  where
    ServerContext{serverParams = params} = ctxt

    connectionToClient :: ConnectionToClient
    connectionToClient = ConnectionToClient{request, respond}

    timeoutException :: GrpcException
    timeoutException = GrpcException {
          grpcError         = GrpcDeadlineExceeded
        , grpcErrorMessage  = Nothing
        , grpcErrorDetails  = Nothing
        , grpcErrorMetadata = []
        }

    imposeTimeout :: Maybe Timeout -> IO () -> IO ()
    imposeTimeout Nothing  = id
    imposeTimeout (Just t) = timeoutWith timeoutException (timeoutToMicro t)

-- | Find handler (based on the path)
--
-- Throws 'CallSetupFailure' if no handler could be found.
findHandler ::
     HandlerMap IO
  -> HTTP2.Request
  -> IO (SomeRpcHandler IO)
findHandler handlers req = do
    -- TODO: <https://github.com/well-typed/grapesy/issues/131>
    -- We should do some request logging.

    resourceHeaders <-
      either throwM return . first CallSetupInvalidResourceHeaders $
        parseResourceHeaders rawHeaders
    let path = resourcePath resourceHeaders

    -- We have to be careful looking up the handler; there might be pure
    -- exceptions in the list of handlers (most commonly @undefined@).
    mHandler <- try $ evaluate $ HandlerMap.lookup path handlers
    case mHandler of
      Right (Just h) -> return h
      Right Nothing  -> throwM $ CallSetupUnimplementedMethod path
      Left err       -> throwM $ CallSetupHandlerLookupException err
  where
    rawHeaders :: RawResourceHeaders
    rawHeaders = RawResourceHeaders {
          rawPath   = fromMaybe "" $ HTTP2.requestPath   req
        , rawMethod = fromMaybe "" $ HTTP2.requestMethod req
        }

-- | Call setup failure
--
-- Something went wrong during call setup. No response has been sent to the
-- client at all yet. We try to tell the client what happened, but ignore any
-- exceptions that might arise from doing so.
setupFailure ::
     ServerParams
  -> (HTTP2.Response -> IO ())
  -> CallSetupFailure
  -> IO a
setupFailure params sendResponse failure = do
    response <- mkFailureResponse params failure
    _ :: Either SomeException () <- try $ sendResponse response
    throwM failure

{-------------------------------------------------------------------------------
  Failures
-------------------------------------------------------------------------------}

-- | Turn setup failure into response to the client
--
-- The gRPC spec mandates that we /always/ return HTTP 200 OK, but it does not
-- explicitly say what to do when the request is malformed (not conform the
-- gRPC specification). We choose to return HTTP errors in this case.
--
-- See <https://datatracker.ietf.org/doc/html/rfc7231#section-6.5> for
-- a discussion of the HTTP error codes, specifically
--
-- * 400 Bad Request
--   <https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.1>
-- * 405 Method Not Allowed
--   <https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.5>
--   <https://datatracker.ietf.org/doc/html/rfc7231#section-7.4.1>
--
-- Testing out-of-spec errors can be bit awkward. One option is @curl@:
--
-- > curl --verbose --http2 --http2-prior-knowledge http://127.0.0.1:50051/
mkFailureResponse :: ServerParams -> CallSetupFailure -> IO HTTP2.Response
mkFailureResponse params = \case
    CallSetupInvalidResourceHeaders (InvalidMethod method) ->
      return $
        HTTP2.responseBuilder
          HTTP.methodNotAllowed405
          [("Allow", "POST")]
          (Builder.byteString . mconcat $ [
              "Unexpected :method " <> method <> ".\n"
            , "The only method supported by gRPC is POST.\n"
            ])
    CallSetupInvalidResourceHeaders (InvalidPath path) ->
      return $
        HTTP2.responseBuilder HTTP.badRequest400 [] . Builder.byteString $
          "Invalid path " <> path
    CallSetupInvalidRequestHeaders invalid ->
      return $
        HTTP2.responseBuilder (statusInvalidHeaders invalid) [] $
          prettyInvalidHeaders invalid
    CallSetupUnsupportedCompression cid ->
      return $
        HTTP2.responseBuilder HTTP.badRequest400 [] . Builder.byteString $
          "Unsupported compression: " <> BS.UTF8.fromString (show cid)
    CallSetupUnimplementedMethod path -> do
      let trailersOnly :: TrailersOnly
          trailersOnly = properTrailersToTrailersOnly (
              grpcExceptionToTrailers $ grpcUnimplemented path
            , serverContentType
            )
      return $
        HTTP2.responseNoBody HTTP.ok200 $
          buildTrailersOnly contentTypeForUnknown trailersOnly
    CallSetupHandlerLookupException err -> do
      msg <- serverExceptionToClient err
      let trailersOnly :: TrailersOnly
          trailersOnly = properTrailersToTrailersOnly (
              grpcExceptionToTrailers $ GrpcException {
                  grpcError         = GrpcUnknown
                , grpcErrorMessage  = msg
                , grpcErrorDetails  = Nothing
                , grpcErrorMetadata = []
                }
            , serverContentType
            )
      return $
        HTTP2.responseNoBody HTTP.ok200 $
          buildTrailersOnly contentTypeForUnknown trailersOnly
  where
    ServerParams{
        serverExceptionToClient
      , serverContentType
      } = params

-- | Variation on 'chooseContentType' that can be used when the RPC is unknown
contentTypeForUnknown :: ContentType -> Maybe BS.UTF8.ByteString
contentTypeForUnknown ContentTypeDefault       = Nothing
contentTypeForUnknown (ContentTypeOverride ct) = Just ct

grpcUnimplemented :: Path -> GrpcException
grpcUnimplemented path = GrpcException {
      grpcError         = GrpcUnimplemented
    , grpcErrorMessage  = Just . Text.pack . BS.Char8.unpack $ mconcat [
                                "Method "
                              , pathService path
                              , "."
                              , pathMethod path
                              , " not implemented"
                              ]
    , grpcErrorDetails  = Nothing
    , grpcErrorMetadata = []
    }

{-------------------------------------------------------------------------------
  Auxiliary
-------------------------------------------------------------------------------}

-- | Timeout with a specific exception
timeoutWith :: Exception e => e -> Integer -> IO a -> IO a
timeoutWith e t io = do
    me <- myThreadId

    let timer :: IO ()
        timer = do
            UnboundedDelays.delay t
            throwTo me e

    bracket (forkIO timer) killThread $ \_ -> io