packages feed

pdf-slave-server-0.1.1.0: src/Text/PDF/Slave/Server/Monad.hs

{-# LANGUAGE TemplateHaskell #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}
module Text.PDF.Slave.Server.Monad(
  -- * Monad
    ServerM
  , runServerM
  , serverMtoHandler
  -- ** Monad environment
  , ServerEnv(..)
  , newServerEnv
  -- * Auth monad
  , AuthM
  , runAuth
  , authToServerM
  -- * Utilities
  , getConfig
  , emitRenderItem
  , module Text.PDF.Slave.Server.Util
  ) where

import Control.Concurrent (forkIO)
import Control.Concurrent.STM.TChan
import Control.Concurrent.Thread.Delay
import Control.Monad
import Control.Monad.Base
import Control.Monad.Error.Class
import Control.Monad.Except
import Control.Monad.Logger
import Control.Monad.Reader
import Control.Monad.STM
import Control.Monad.Trans.Control
import Data.Monoid
import Data.Text (pack, Text)
import Data.Time (getCurrentTime, diffUTCTime, addUTCTime)
import Data.Yaml (encode, ToJSON)
import Network.HTTP.Client
import Network.HTTP.Client.TLS
import Servant.Server
import Servant.Server.Auth.Token.Config
import Servant.Server.Auth.Token.Model
import Text.PDF.Slave.Server.DB.Model

import Text.PDF.Slave
import Text.PDF.Slave.Server.API (APINotificationBody(..), toAPIRenderId)
import Text.PDF.Slave.Server.Config
import Text.PDF.Slave.Server.DB
import Text.PDF.Slave.Server.Notification
import Text.PDF.Slave.Server.Util

import qualified Servant.Server.Auth.Token.Acid as A
import qualified Control.Immortal as Immortal
import qualified Shelly as Sh

-- | Server private environment
data ServerEnv = ServerEnv {
  envConfig      :: ServerConfig    -- ^ Configuration used to create the server
, envDB          :: AcidState Model -- ^ Server DB
-- | Coordination channel that is pushed when new item arrived. Workers wait for
-- new value in the channel when they finished all available work from rendering
-- queue. Thats helps to avoid polling when there is no work to do.
, envRenderChan  :: TChan ()
-- | Coordination channel that is pushed when new item arrived. Workers wait for
-- new value in the channel when they finished all available work from notification
-- queue. Thats helps to avoid polling when there is no work to do.
, envNotificationChan  :: TChan ()
-- | Connection manager for sending notifications
, envManager :: Manager
}

-- Derive HasStorage for 'AcidBackendT' with 'Model'.
-- It is important that it is come before the below newtype
A.deriveAcidHasStorage ''Model

-- | Special monad for authorisation actions
newtype AuthM a = AuthM { unAuthM :: A.AcidBackendT Model IO a }
  deriving (Functor, Applicative, Monad, MonadIO, MonadError ServantErr, HasAuthConfig, HasStorage)

-- | Execution of authorisation actions that require 'AuthHandler' context
runAuth :: AuthM a -> ServerM a
runAuth m = do
  cfg <- asks (serverAuthConfig . envConfig)
  db <- asks envDB
  liftHandler $ Handler . ExceptT $ A.runAcidBackendT cfg db $ unAuthM m

-- | Transformation from 'AuthM' monad to 'ServerM'
authToServerM :: AuthM :~> ServerM
authToServerM = NT runAuth

-- | Create new server environment
newServerEnv :: (MonadIO m, MonadBaseControl IO m)
  => ServerConfig -> m ServerEnv
newServerEnv cfg = do
  acid <- createDB (serverDatabaseConf cfg)
  renderChan <- liftIO newTChanIO
  notificChan <- liftIO newTChanIO
  mng <- liftIO $ newManager tlsManagerSettings
  let env = ServerEnv {
        envConfig = cfg
      , envDB = acid
      , envRenderChan = renderChan
      , envNotificationChan = notificChan
      , envManager = mng
      }
  liftIO . runServerMIO env $ do
    runAuth $ ensureAdmin (passwordsStrength . serverAuthConfig $ cfg) "admin" (serverAdminPassword cfg) "admin@localhost"
    replicateM_ (serverRenderWorkers cfg) spawnRendererWorker
    replicateM_ (serverNotificationWorkers cfg) spawnNotificationWorker
  return env

-- | Server monad that holds internal environment
newtype ServerM a = ServerM { unServerM :: ReaderT ServerEnv (LoggingT Handler) a }
  deriving (Functor, Applicative, Monad, MonadIO, MonadBase IO, MonadReader ServerEnv
    , MonadLogger, MonadLoggerIO, MonadError ServantErr)

newtype StMServerM a = StMServerM { unStMServerM :: StM (ReaderT ServerEnv (LoggingT Handler)) a }

instance MonadBaseControl IO ServerM where
    type StM ServerM a = StMServerM a
    liftBaseWith f = ServerM $ liftBaseWith $ \q -> f (fmap StMServerM . q . unServerM)
    restoreM = ServerM . restoreM . unStMServerM

instance HasAcidState Model ServerM where
  getAcidState = asks envDB

-- | Lift servant monad to server monad
liftHandler :: Handler a -> ServerM a
liftHandler = ServerM . lift . lift

-- | Execution of 'ServerM'
runServerM :: ServerEnv -> ServerM a -> Handler a
runServerM e = runStdoutLoggingT . flip runReaderT e . unServerM

-- | Execution of 'ServerM' in IO monad
runServerMIO :: ServerEnv -> ServerM a -> IO a
runServerMIO env m = do
  ea <- runHandler $ runServerM env m
  case ea of
    Left e -> fail $ "runServerMIO: " <> show e
    Right a -> return a

-- | Transformation to Servant 'Handler'
serverMtoHandler :: ServerEnv -> ServerM :~> Handler
serverMtoHandler e = NT (runServerM e)

-- | Getting server configuration
getConfig :: ServerM ServerConfig
getConfig = asks envConfig

-- | Awake sleeping workers when we have a work to do
emitRenderItem :: ServerM ()
emitRenderItem = do
  chan <- asks envRenderChan
  liftIO . atomically $ writeTChan chan ()

-- | Awake sleeping workers when we have a work to do
emitNotificationItem :: ServerM ()
emitNotificationItem = do
  chan <- asks envNotificationChan
  liftIO . atomically $ writeTChan chan ()

-- | Spawn a worker that reads next available render task from queue and executes it
spawnRendererWorker :: ServerM ()
spawnRendererWorker = void . Immortal.createWithLabel "rendererWorker" $ const $ do
  $logDebug "Spawning renderer worker"
  work
  where
    work = do
      hasWork <- runQuery CheckNextRenderItem
      if hasWork then do
          mitem <- runUpdate FetchRenderItem
          case mitem of
            Nothing -> work
            Just item -> do
              let ri = renderId item
              $logInfo $ "Start rendering of " <> showt ri
              renderRes <- renderDocument item
              $logInfo $ "Finished rendering of " <> showt ri
              case renderRes of
                Left er -> $logWarn $ "Rendering failed with: " <> er
                Right _ -> $logInfo $ "Rendering finished successfully"
              registerNotification item renderRes
              work
        else sleep

    sleep = do
      chan <- asks envRenderChan
      _ <- liftIO . atomically $ readTChan chan
      work

-- | Write down YAML file
writeYaml :: ToJSON a => Sh.FilePath -> a -> Sh.Sh ()
writeYaml path a = Sh.writeBinary path $ encode a

-- | Render given item
renderDocument :: RenderItem -> ServerM (Either Text PDFContent)
renderDocument RenderItem{..} = do
  let template = maybe renderTemplate (\i -> renderTemplate { templateInput = Just i }) renderInput
  Sh.shelly $ Sh.handleany_sh (return . Left . pack . show) $ Sh.withTmpDir $ \tempDir -> Sh.chdir tempDir $ do
    let templateFilename = tempDir <> "bundle.yaml"
        outputFilename = tempDir <> "output.pdf"
    writeYaml templateFilename template
    _ <- Sh.bash "stack" [ "exec"
      , "--package=aeson"      -- TODO: add packages dependencies to API
      , "--package=bytestring"
      , "--package=HaTeX"
      , "--"
      , "pdf-slave"
      , "pdf"
      , "--template=" <> Sh.toTextIgnore templateFilename
      , "--output=" <> Sh.toTextIgnore outputFilename ]
    Right <$> Sh.readBinary outputFilename

-- | Convert rendering result to notification and save it
registerNotification :: RenderItem -> Either Text PDFContent -> ServerM ()
registerNotification RenderItem{..} res = do
  $logInfo $ "Registering notification for " <> showt renderId
  t <- liftIO getCurrentTime
  let notification = Notification {
          notifTarget = renderUrl
        , notifRenderId = renderId
        , notifDocument = res
        , notifTries = 0
        , notifLastError = Nothing
        , notifNextTry = t
        , notifToken = renderToken
        }
  runUpdate . AddNotification $ notification
  emitNotificationItem -- awake notification workers

-- | Spawn a worker that waits for notifications and tries to deliver them.
spawnNotificationWorker :: ServerM ()
spawnNotificationWorker = void . Immortal.createWithLabel "rendererWorker" $ const $ do
  $logDebug "Spawning notification worker"
  work
  where
    work = do
      $logDebug "Check pending notifications"
      t <- liftIO getCurrentTime
      hasWork <- runQuery $ CheckNextNotification t
      if hasWork then do
          $logDebug $ "Detected unprocessed notifications..."
          mitem <- runUpdate $ FetchNotification t
          case mitem of
            Nothing -> do
              $logDebug $ "But there is no work actually"
              work
            Just notification -> do
              mnotification <- deliverNotification notification
              whenJust mnotification $ runUpdate . AddNotification
              work
        else do
          $logDebug "No work for notification worker, sleep"
          sleep

    sleep = do
      chan <- asks envNotificationChan
      -- run thread that will awake the worker when next notification is ready
      mt <- runQuery GetNotificationNextTime
      whenJust mt $ \t -> do
        curTime <- liftIO getCurrentTime
        let dt = toMicroseconds $ diffUTCTime t curTime
        $logDebug $ "Next notification in " <> showt dt <> " ms"
        void . liftIO . forkIO $ do
          delay dt
          atomically $ writeTChan chan ()
      -- wait for new notifications
      _ <- liftIO . atomically $ readTChan chan
      work

-- | Try to send notification to client, if failed, delay notification for additional
-- try. If maximum count of tries is hit, the notification is deleted.
deliverNotification :: Notification -> ServerM (Maybe Notification)
deliverNotification n@Notification{..} = do
  $logInfo $ "Trying to deliver notification for " <> showt notifRenderId
  let body = APINotificationBody {
          apiNotificationId = toAPIRenderId . unRenderId $ notifRenderId
        , apiNotificationError = either Just (const Nothing) notifDocument
        , apiNotificationDocument = either (const Nothing) Just notifDocument
        }
  mng <- asks envManager
  res <- postNotification mng notifTarget body notifToken
  case res of
    Left (BadBaseUrl e) -> do
      $logError $ "Notification for " <> showt notifRenderId
        <> " is failed, wrong url " <> notifTarget <> ": " <> showt e
      return Nothing
    Left HasRedirectError -> do
      $logError $ "Notification for " <> showt notifRenderId
        <> " is failed, forbidden redirections"
      return Nothing
    Left (WrongSuccessStatus s) -> do
      $logWarn $ "Notification for " <> showt notifRenderId
        <> " is succeded, but returned strage status " <> showt s
      return Nothing
    Left (NotificationFail e) -> do
      $logWarn $ "Notification for " <> showt notifRenderId
        <> " is failed, reason: " <> showt e <> ", will retry later."
      ServerConfig{..} <- asks envConfig
      case (notifTries >=) <$> serverMaxNotificationTries of
        Just True -> do
          $logError $ "Notification for " <> showt notifRenderId
            <> " is not delivered! Maximum count of tries is hit."
          return Nothing
        _ -> do
          t <- liftIO getCurrentTime
          let n' = n {
                  notifTries = notifTries + 1
                , notifLastError = Just $ showt e
                , notifNextTry = serverNotificationDelay `addUTCTime` t
                }
          return $ Just n'
    Right () -> do
      $logInfo $ "Notification for " <> showt notifRenderId
        <> " is succeded!"
      return Nothing