packages feed

servant-util-0.3: src/Servant/Util/Combinators/Logging.hs

{-# LANGUAGE PolyKinds #-}

-- | Allows to enable logging of requests and responses.
module Servant.Util.Combinators.Logging
    ( -- * Automatic requests logging
      LoggingApi
    , LoggingApiRec
    , LoggingMod
    , LoggingLevel
    , LoggingRequestsEnabled
    , LoggingRequestsDisabled
    , LoggingResponsesEnabled
    , LoggingResponsesDisabled
    , LoggingDisabled
    , LogContext (..)
    , HasLoggingServer (..)
    , ServantLogConfig (..)
    , ForResponseLog (..)
    , BuildableForResponseIfNecessary
    , buildListForResponse
    , buildForResponse
    , ApiHasArgClass (..)
    , ApiCanLogArg (..)
    , addParamLogInfo
    , setInPrefix
    , serverWithLogging
    ) where

import Universum

import Control.Monad.Error.Class (catchError, throwError)
import Data.Constraint (Dict (..))
import Data.Default (Default (..))
import Data.OpenApi (OpenApi)
import Data.Reflection (Reifies (..), reify)
import Data.Swagger (Swagger)
import Data.Time.Clock.POSIX (getPOSIXTime)
import Fmt (Buildable (..), Builder, blockListF, pretty, (+|), (|+), (||+))
import GHC.IO.Unsafe (unsafePerformIO)
import GHC.TypeLits (KnownSymbol, symbolVal)
import Servant.API (Capture, Description, NoContent, NoContentVerb, QueryFlag, QueryParam', Raw,
                    ReflectMethod (..), ReqBody, SBoolI, Summary, Verb, (:<|>) (..), (:>))
import Servant.API.Modifiers (FoldRequired, foldRequiredArgument)
import Servant.Client (HasClient (..))
import Servant.OpenApi (HasOpenApi (..))
import Servant.Server (Handler (..), HasServer (..), Server, ServerError (..))
import Servant.Swagger (HasSwagger (..))
import Servant.Swagger.UI.Core (SwaggerUiHtml)
import System.Console.Pretty (Color (..), Style (..), color, style)

import qualified Data.Text as T
import qualified Servant.Server.Internal as SI

import Servant.Util.Common

-- | Enables logging for server which serves given api.
--
-- `config` is a type at which you have to specify 'ServantLogConfig' via
-- reflection. This way was chosen because the least thing we need in
-- config is 'LoggerName', and we want to have '<>' on 'LoggerName's thus
-- 'KnownSymbol' is not enough.
--
-- This logging will report
--
-- * Request parameters, including request bodies
-- * If execution failed with error, it will be displayed
-- * Details like request method and endpoint execution time
--
-- If user makes request which is not defined it won't be logged. However,
-- I don't find it a great problem, it may impede only in development or on
-- getting acknowledged with api.
data LoggingApi config api

-- | Helper to traverse servant api and apply logging.
data LoggingApiRec config (lcontext :: LoggingContext) api

-- | Logging context at type-level, accumulates all the 'LoggingMod' modifiers.
data LoggingContext = LoggingContext
  (Maybe Nat)  -- ^ Recommended logging level.
  Bool  -- ^ Whether requests are logged.
  Bool  -- ^ Whether responses are logged.

type family EmptyLoggingContext :: LoggingContext where
  EmptyLoggingContext = 'LoggingContext 'Nothing 'True 'True

type family LcResponsesEnabled (lcontext :: LoggingContext) :: Bool where
  LcResponsesEnabled ('LoggingContext _ _ flag) = flag

-- | Require 'Buildable' for the response type, but only if logging context
-- assumes that the response will indeed be built.
type BuildableForResponseIfNecessary lcontext resp =
  ( If (LcResponsesEnabled lcontext)
      (Buildable (ForResponseLog resp))
      (() :: Constraint)
  , Demote (LcResponsesEnabled lcontext)
  )

-- | Servant combinator that changes how the logs will be printed for the
-- affected endpoints.
--
-- This is an internal thing, we export aliases.
data LoggingMod (mod :: LoggingModKind)

-- | How to change the logging of the endpoints.
data LoggingModKind
  = LMLoggingLevel Nat
  | LMRequestsLogged Bool
  | LMResponsesLogged Bool
  | LMLoggingDisabled

-- | Combinator to set the logging level within the endpoints.
type LoggingLevel lvl = LoggingMod ('LMLoggingLevel lvl)

-- | Combinator to disable logging of requests.
type LoggingRequestsDisabled = LoggingMod ('LMRequestsLogged 'False)

-- | Combinator to enable logging of requests back for a narrower
-- set of entrypoints.
type LoggingRequestsEnabled = LoggingMod ('LMRequestsLogged 'True)

-- | Combinator to disable logging of responses.
type LoggingResponsesDisabled = LoggingMod ('LMResponsesLogged 'False)

-- | Combinator to enable logging of responses.
type LoggingResponsesEnabled = LoggingMod ('LMResponsesLogged 'True)

-- | Combinator to disable all the logging.
--
-- This works similarly to other similar combinators and can be partially
-- or fully reverted with 'LoggingRequestsDisabled' or 'LoggingResponsesDisabled'.
type LoggingDisabled = LoggingMod 'LMLoggingDisabled

-- | Full context of the logging action.
data LogFullContext = LogFullContext
  { lcRecommendedLevel :: Maybe Natural
    -- ^ Logging level specified by 'LoggingLevel' combinator.
    -- Will be provided to the user.
  , lcRequestsEnabled  :: Bool
    -- ^ Whether to log requests.
    -- Accounted automatically.
  , lcResponsesEnabled :: Bool
    -- ^ Whether to log responses.
    -- Accounted automatically.
  } deriving (Show)

type instance Demoted LoggingContext = LogFullContext
instance ( ctx ~ 'LoggingContext lvl req resp
         , Demote lvl
         , Demote req
         , Demote resp
         ) => Demote ctx where
  demote _ = LogFullContext
    { lcRecommendedLevel = demote (Proxy @lvl)
    , lcRequestsEnabled = demote (Proxy @req)
    , lcResponsesEnabled = demote (Proxy @resp)
    }

type family ApplyLoggingMod (lcontext :: LoggingContext) (mod :: LoggingModKind) where
  ApplyLoggingMod ('LoggingContext _ req resp) ('LMLoggingLevel lvl) =
    'LoggingContext ('Just lvl) req resp
  ApplyLoggingMod ('LoggingContext mlvl _ resp) ('LMRequestsLogged req) =
    'LoggingContext mlvl req resp
  ApplyLoggingMod ('LoggingContext mlvl req _) ('LMResponsesLogged resp) =
    'LoggingContext mlvl req resp
  ApplyLoggingMod ('LoggingContext mlvl _ _) 'LMLoggingDisabled =
    'LoggingContext mlvl 'False 'False

-- | Logging context that will be supplied to the user.
newtype LogContext = LogContext
  { lecRecommendedLevel :: Maybe Natural
    -- ^ Logging level specified by 'LoggingLevel' combinator.
  } deriving (Show, Eq)

-- | Logging configuration specified at server start.
newtype ServantLogConfig = ServantLogConfig
    { clcLog :: LogContext -> Text -> IO ()
    }

dullColor :: Color -> Text -> Text
dullColor c = style Faint . color c

gray :: Text -> Text
gray = dullColor White

-- | Used to incrementally collect info about passed parameters.
data ApiParamsLogInfo
      -- | Parameters gathered at current stage.
      -- The first field tells whether have we met '(:<|>)',
      -- the second is path prefix itself,
      -- the third field is the remaining part.
    = ApiParamsLogInfo Bool [Text] [Text]
      -- | Parameters collection failed with reason
      --   (e.g. decoding error)
    | ApiNoParamsLogInfo Text

instance Default ApiParamsLogInfo where
    def = ApiParamsLogInfo False [] []

addParamLogInfo :: Text -> ApiParamsLogInfo -> ApiParamsLogInfo
addParamLogInfo _ failed@ApiNoParamsLogInfo{} = failed
addParamLogInfo paramInfo (ApiParamsLogInfo False path []) =
    ApiParamsLogInfo False (paramInfo : path) []
addParamLogInfo paramInfo (ApiParamsLogInfo inPrefix path infos) =
    ApiParamsLogInfo inPrefix path (paramInfo : infos)

setInPrefix :: ApiParamsLogInfo -> ApiParamsLogInfo
setInPrefix failed@ApiNoParamsLogInfo{}     = failed
setInPrefix infos@(ApiParamsLogInfo _ [] _) = infos
setInPrefix (ApiParamsLogInfo _ path info)  = ApiParamsLogInfo True path info

-- | When it comes to logging responses, returned data may be very large.
-- Log space is valuable (already in testnet we got truncated logs),
-- so we have to care about printing only whose data which may be useful.
newtype ForResponseLog a = ForResponseLog { unForResponseLog :: a }

buildListForResponse
    :: Buildable (ForResponseLog x)
    => (forall a. [a] -> [a]) -> ForResponseLog [x] -> Builder
buildListForResponse truncList (ForResponseLog l) =
    let startNf = if null l then "" else "\n"
        lt = truncList l
        diff = length l - length lt
        mMore | diff == 0 = ""
              | otherwise = "\n    and " +| diff |+ " entries more..."
    in  startNf +| blockListF (map ForResponseLog lt) |+ mMore

buildForResponse :: Buildable a => ForResponseLog a -> Builder
buildForResponse = build . unForResponseLog

instance ( HasServer (LoggingApiRec config EmptyLoggingContext api) ctx
         , HasServer api ctx
         ) =>
         HasServer (LoggingApi config api) ctx where
    type ServerT (LoggingApi config api) m = ServerT api m

    route = inRouteServer @(LoggingApiRec config EmptyLoggingContext api) route
            (def, )

    hoistServerWithContext _ = hoistServerWithContext (Proxy :: Proxy api)

-- | Version of 'HasServer' which is assumed to perform logging.
-- It's helpful because 'ServerT (LoggingApi ...)' is already defined for us
-- in actual 'HasServer' instance once and forever.
class HasServer api ctx => HasLoggingServer config (lcontext :: LoggingContext) api ctx where
    routeWithLog
        :: Proxy (LoggingApiRec config lcontext api)
        -> SI.Context ctx
        -> SI.Delayed env (Server (LoggingApiRec config lcontext api))
        -> SI.Router env

instance HasLoggingServer config lcontext api ctx =>
         HasServer (LoggingApiRec config lcontext api) ctx where
    type ServerT (LoggingApiRec config lcontext api) m =
         (ApiParamsLogInfo, ServerT api m)

    route = routeWithLog

    hoistServerWithContext _ pc nt s =
        hoistServerWithContext (Proxy :: Proxy api) pc nt <$> s

instance ( HasLoggingServer config lcontext api1 ctx
         , HasLoggingServer config lcontext api2 ctx
         ) =>
         HasLoggingServer config lcontext (api1 :<|> api2) ctx where
    routeWithLog =
        inRouteServer
            @(LoggingApiRec config lcontext api1 :<|> LoggingApiRec config lcontext api2)
            route $
            \(paramsInfo, f1 :<|> f2) ->
                let paramsInfo' = setInPrefix paramsInfo
                in (paramsInfo', f1) :<|> (paramsInfo', f2)

instance ( KnownSymbol path
         , HasLoggingServer config lcontext res ctx
         ) =>
         HasLoggingServer config lcontext (path :> res) ctx where
    routeWithLog =
        inRouteServer @(path :> LoggingApiRec config lcontext res) route $
        first updateParamsInfo
      where
        updateParamsInfo =
            let path = toText . symbolVal $ Proxy @path
            in  addParamLogInfo path

-- | Describes a way to log a single parameter.
class ApiHasArgClass subApi =>
      ApiCanLogArg subApi where
    type ApiArgToLog subApi :: Type
    type ApiArgToLog subApi = ApiArg subApi

    toLogParamInfo
        :: Buildable (ApiArgToLog subApi)
        => Proxy subApi -> ApiArg subApi -> Text
    default toLogParamInfo
        :: Buildable (ApiArg subApi)
        => Proxy subApi -> ApiArg subApi -> Text
    toLogParamInfo _ param = pretty param

instance KnownSymbol s => ApiCanLogArg (Capture s a)

instance ApiCanLogArg (ReqBody ct a)

instance ( Buildable a
         , KnownSymbol cs
         , SBoolI (FoldRequired mods)
         ) =>
         ApiCanLogArg (QueryParam' mods cs a) where
    type ApiArgToLog (QueryParam' mods cs a) = a
    toLogParamInfo _ mparam = foldRequiredArgument (Proxy :: Proxy mods) (\(a :: a) -> pretty a)
      (\case
        Just a  -> pretty a
        Nothing -> noEntry) mparam
      where
        noEntry = gray "-"

instance KnownSymbol cs => ApiCanLogArg (QueryFlag cs) where
    type ApiArgToLog (QueryFlag cs) = Bool

paramRouteWithLog
    :: forall config lcontext api subApi res ctx env.
       ( api ~ (subApi :> res)
       , HasServer (subApi :> LoggingApiRec config lcontext res) ctx
       , ApiHasArg subApi res
       , ApiHasArg subApi (LoggingApiRec config lcontext res)
       , ApiCanLogArg subApi
       , Buildable (ApiArgToLog subApi)
       )
    => Proxy (LoggingApiRec config lcontext api)
    -> SI.Context ctx
    -> SI.Delayed env (Server (LoggingApiRec config lcontext api))
    -> SI.Router env
paramRouteWithLog =
    inRouteServer @(subApi :> LoggingApiRec config lcontext res) route $
        \(paramsInfo, f) a -> (a `updateParamsInfo` paramsInfo, f a)
  where
    updateParamsInfo a =
        let paramVal = toLogParamInfo (Proxy @subApi) a
            paramName = apiArgName $ Proxy @subApi
            paramInfo = paramName |+ ": " +| paramVal |+ ""
        in addParamLogInfo paramInfo . setInPrefix

instance ( HasServer (subApi :> res) ctx
         , HasServer (subApi :> LoggingApiRec config lcontext res) ctx
         , ApiHasArg subApi res
         , ApiHasArg subApi (LoggingApiRec config lcontext res)
         , ApiCanLogArg subApi
         , Buildable (ApiArgToLog subApi)
         , subApi ~ apiType (a :: Type)
         ) =>
         HasLoggingServer config lcontext (apiType a :> res) ctx where
    routeWithLog = paramRouteWithLog

instance ( HasLoggingServer config lcontext res ctx
         , KnownSymbol s
         ) =>
         HasLoggingServer config lcontext (QueryFlag s :> res) ctx where
    routeWithLog = paramRouteWithLog

instance HasLoggingServer config lcontext res ctx =>
         HasLoggingServer config lcontext (Summary s :> res) ctx where
    routeWithLog = inRouteServer @(Summary s :> LoggingApiRec config lcontext res) route id

instance HasLoggingServer config lcontext res ctx =>
         HasLoggingServer config lcontext (Description d :> res) ctx where
    routeWithLog = inRouteServer @(Description d :> LoggingApiRec config lcontext res) route id

instance ( HasLoggingServer config (ApplyLoggingMod lcontext mod) res ctx
         , HasServer res ctx
         ) =>
         HasLoggingServer config lcontext (LoggingMod mod :> res) ctx where
    routeWithLog = inRouteServer @(LoggingApiRec config (ApplyLoggingMod lcontext mod) res) route id

-- | Unique identifier for request-response pair.
newtype RequestId = RequestId Integer

instance Buildable RequestId where
    build (RequestId i) = "#" +| i |+ ""

-- | We want all servant servers to have non-overlapping ids,
-- so using singleton counter here.
requestsCounter :: TVar Integer
requestsCounter = unsafePerformIO $ newTVarIO 0
{-# NOINLINE requestsCounter #-}

nextRequestId :: MonadIO m => m RequestId
nextRequestId = atomically $ do
    modifyTVar' requestsCounter (+1)
    RequestId <$> readTVar requestsCounter

-- | Modify an action so that it performs all the required logging.
applyServantLogging
    :: ( Reifies config ServantLogConfig
       , Demote (lcontext :: LoggingContext)
       , Demote (LcResponsesEnabled lcontext)
       , ReflectMethod (method :: k)
       )
    => Proxy config
    -> Proxy lcontext
    -> Proxy method
    -> ApiParamsLogInfo
    -> If (LcResponsesEnabled lcontext) (a -> Text) ()
    -> Handler a
    -> Handler a
applyServantLogging configP (contextP :: Proxy lcontext) methodP paramsInfo showResponse action = do
    timer <- mkTimer
    reqId <- nextRequestId
    catchErrors reqId timer $ do
        reportRequest reqId
        res <- action
        reportResponse reqId timer res
        return res
  where
    method = decodeUtf8 $ reflectMethod methodP :: Text
    cmethod =
        let c = case method of
              "GET"    -> Cyan
              "POST"   -> Yellow
              "PUT"    -> Green
              "DELETE" -> Red
              _        -> Magenta
        in style Faint $ color c method
    mkTimer :: MonadIO m => m (m Text)
    mkTimer = do
        startTime <- liftIO getPOSIXTime
        return $ do
            endTime <- liftIO getPOSIXTime
            return . show $ endTime - startTime
    logContext = demote contextP
    logEntryContext = LogContext
      { lecRecommendedLevel = lcRecommendedLevel logContext
      }
    log :: Text -> Handler ()
    log txt = liftIO $ clcLog (reflect configP) logEntryContext txt
    eParamLogs :: Either Text Text
    eParamLogs = case paramsInfo of
        ApiParamsLogInfo _ path infos -> Right $
            let pathPart =
                    "    " <> gray ":>" <> " " <>
                    T.intercalate (gray "/") (reverse path)
                infoPart = reverse infos <&> \info ->
                    "    " +| gray ":>"
                    |+ " " +| info |+ ""
            in T.intercalate "\n" (pathPart : infoPart)
        ApiNoParamsLogInfo why -> Left why
    reportRequest :: RequestId -> Handler ()
    reportRequest reqId =
      when (lcRequestsEnabled logContext) $
        case eParamLogs of
            Left e ->
                log $ "\n" +| dullColor Red "Unexecuted request due to error"
                    |+ " " +| e
                    |+ ""
            Right paramLogs -> do
                log $  "\n" +| cmethod
                    |+ " "  +| gray ("Request " <> pretty reqId)
                    |+ "\n" +| paramLogs |+ ""
    responseTag reqId = "Response " <> pretty reqId
    reportResponse reqId timer resp =
      case tyBoolCase (Proxy @(LcResponsesEnabled lcontext)) of
        Left _ -> pass
        Right Dict -> do
          durationText <- timer
          log $
              "\n    " +| gray (responseTag reqId)
                |+ " " +| dullColor Green "OK"
                |+ " " +| durationText
                |+ " " +| gray ">"
                |+ " " +| showResponse resp
                |+ ""
    catchErrors reqId st =
        flip catchError (servantErrHandler reqId st) .
        handleAny (exceptionsHandler reqId st)
    servantErrHandler reqId timer err@ServerError{..} = do
        durationText <- timer
        let errMsg = errHTTPCode |+ " "  +| errReasonPhrase |+ ":"
        log $
            "\n    " +| gray (responseTag reqId)
              |+ " " +| durationText
              |+ " " +| dullColor Red errMsg
              |+ " " +| decodeUtf8 @Text errBody
              |+ ""
        throwError err
    exceptionsHandler reqId timer e = do
        durationText <- timer
        log $
            "\n    " +| dullColor Red (responseTag reqId)
              |+ " " +| e
             ||+ " " +| durationText
              |+ ""
        throwM e

applyLoggingToHandler
    :: forall k config lcontext method a.
       ( Reifies config ServantLogConfig
       , Demote lcontext
       , BuildableForResponseIfNecessary lcontext a
       , Demote lcontext
       , ReflectMethod method
       )
    => Proxy config -> Proxy (lcontext :: LoggingContext) -> Proxy (method :: k)
    -> (ApiParamsLogInfo, Handler a) -> Handler a
applyLoggingToHandler configP contextP methodP (paramsInfo, handler) = do
    let apply format =
          applyServantLogging configP contextP methodP paramsInfo format handler
    case tyBoolCase $ Proxy @(LcResponsesEnabled lcontext) of
        Right Dict -> apply (pretty . ForResponseLog)
        Left Dict  -> apply ()

skipLogging :: (ApiParamsLogInfo, action) -> action
skipLogging = snd

instance ( HasServer (Verb mt st ct a) ctx
         , Reifies config ServantLogConfig
         , Demote lcontext
         , ReflectMethod mt
         , BuildableForResponseIfNecessary lcontext a
         ) =>
         HasLoggingServer config lcontext (Verb (mt :: k) (st :: Nat) (ct :: [Type]) a) ctx where
    routeWithLog =
        inRouteServer @(Verb mt st ct a) route $
        applyLoggingToHandler (Proxy @config) (Proxy @lcontext) (Proxy @mt)

instance ( HasServer (NoContentVerb mt) ctx
         , Reifies config ServantLogConfig
         , Demote lcontext
         , ReflectMethod mt
         , BuildableForResponseIfNecessary lcontext NoContent
         ) =>
         HasLoggingServer config lcontext (NoContentVerb (mt :: k)) ctx where
    routeWithLog =
        inRouteServer @(NoContentVerb mt) route $
        applyLoggingToHandler (Proxy @config) (Proxy @lcontext) (Proxy @mt)

instance HasLoggingServer config lcontext Raw ctx where
    routeWithLog = inRouteServer @Raw route skipLogging

instance Buildable (ForResponseLog NoContent) where
    build _ = "<no response>"

instance Buildable (ForResponseLog ()) where
    build _ = "<no response>"

instance Buildable (ForResponseLog Integer) where
    build = buildForResponse

instance Buildable (ForResponseLog Swagger) where
    build _ = "Swagger specification"

instance Buildable (ForResponseLog OpenApi) where
    build _ = "OpenApi specification"

instance Buildable (ForResponseLog (SwaggerUiHtml dir api)) where
    build _ = "Accessed documentation UI"

instance HasServer api ctx =>
         HasServer (LoggingMod mod :> api) ctx where
    type ServerT (LoggingMod mod :> api) m = ServerT api m
    route = inRouteServer @api route id
    hoistServerWithContext _ = hoistServerWithContext (Proxy @api)

instance HasClient m api =>
         HasClient m (LoggingMod mod :> api) where
    type Client m (LoggingMod mod :> api) = Client m api
    clientWithRoute mp _ = clientWithRoute mp (Proxy @api)
    hoistClientMonad mp _ = hoistClientMonad mp (Proxy @api)

instance HasSwagger api =>
         HasSwagger (LoggingMod mod :> api) where
    toSwagger _ = toSwagger (Proxy @api)

instance HasOpenApi api =>
         HasOpenApi (LoggingMod mod :> api) where
    toOpenApi _ = toOpenApi (Proxy @api)

-- | Apply logging to the given server.
serverWithLogging
    :: forall api a.
       ServantLogConfig
    -> Proxy api
    -> (forall (config :: Type). Reifies config ServantLogConfig =>
        Proxy (LoggingApi config api) -> a)
    -> a
serverWithLogging config _ f =
    reify config $ \(Proxy :: Proxy config) -> f (Proxy @(LoggingApi config api))