happstack-dlg-0.1: src/Happstack/Server/Dialogs.hs
module Happstack.Server.Dialogs (
Dlg,
Page,
perform,
showPage,
DialogManager,
makeDialogManager,
closeDialogManager,
dialog
)
where
import Happstack.Server
import Control.Monad.Trans
import Control.Monad.Reader
import Control.Concurrent
import Control.Concurrent.MVar
import Data.Maybe
import Data.Char
import Data.Time
import System.Random
import Data.Map (Map)
import qualified Data.Map as M
{-|
A value of a 'Dlg' type represents a dialog between the user and the application,
after which the application builds a value of type 'a'. The trivial case is that
the value is already known. Alternatively, it may be that there is some action to
be performed, or else that the user needs to be asked or told something.
-}
data Dlg m a = Done a
| Action (ServerPartT m (Dlg m a))
| Step (Page m) (Dlg m a)
{-|
A value of 'Page' type represents a way of rendering a page, given a unique ID
that should be included in responses in order to reassociate the response with the
current dialog.
-}
type Page m = Int -> ServerPartT m Response
{-
Dlg is a monad in the obvious way: return represents a dialog that has no
steps; and (>>=) combines dialogs by doing the first part of the first
dialog, and then continuing with the rest.
-}
instance Monad m => Monad (Dlg m) where
return = Done
Done x >>= y = y x
Action x >>= y = Action (x >>= return . (>>= y))
Step p f >>= y = Step p (f >>= y)
instance MonadTrans Dlg where
lift = Action . lift . (>>= return . Done)
instance MonadIO m => MonadIO (Dlg m) where
liftIO = lift . liftIO
{-
Converts a 'ServerPartT' into a Dlg. This is essentially a mechanism for
escaping the confines of the dialog mechanism and performing your own
processing with the request.
-}
perform :: Monad m => ServerPartT m a -> Dlg m a
perform x = Action (x >>= return . Done)
{-|
Converts methods for rendering and parsing the result of a page into a
'Dlg' step.
-}
showPage :: Monad m => Page m -> ServerPartT m a -> Dlg m a
showPage p r = Step p (Action (fmap Done r))
{-|
A 'DialogSession' represents a single user's active dialogs, which are retained
for an entire session. A reaper thread clears up sessions that have not been
touched for some session timeout, so each session also stores the last time it
was touched. In addition, each session is associated with a fixed client address
and cannot be used from a different client, which avoid hijacking.
-}
data DialogSession m = DialogSession {
client :: String,
lastTouched :: MVar UTCTime,
dialogs :: MVar (Map Int (Dlg m ()))
}
{-|
A 'DialogManager' is responsible for maintaining the state for 'Dlg' sequences
for all users. To do this, it keeps for each user a session object encapsulating
their dialogs, and associates each user with a dialog using cookies.
-}
data DialogManager m = DialogManager {
sessions :: MVar (Map Int (DialogSession m)),
open :: MVar Bool
}
{-|
Determine whether a session is still valid or not.
-}
goodSession :: NominalDiffTime -> (Int, DialogSession m) -> IO Bool
goodSession timeout (_, DialogSession _ tref _) = do
st <- readMVar tref
ct <- getCurrentTime
return (diffUTCTime ct st <= timeout)
{-|
Monadic while statement, for convenience.
-}
whileM :: Monad m => m Bool -> m a -> m ()
whileM cond action = do
b <- cond
if b then action >> whileM cond action else return ()
{-|
Create a new 'DialogManager' to manage a set of dialogs in the web application.
This also spawns the session reaper, which cleans up sessions that haven't been
touched for a given time period.
-}
makeDialogManager :: NominalDiffTime -> IO (DialogManager m)
makeDialogManager timeout = do
oref <- newMVar True
sref <- newMVar M.empty
forkIO $ whileM (readMVar oref) $ do
threadDelay 5000
sessionMap <- takeMVar sref
goodSessions <- filterM (goodSession timeout) (M.assocs sessionMap)
putMVar sref (M.fromList goodSessions)
return (DialogManager { sessions = sref, open = oref })
{-|
Closes a DialogManager, which will cause it to cease accepting any incoming
requests, and also to terminate the session reaper thread.
-}
closeDialogManager :: DialogManager m -> IO ()
closeDialogManager (DialogManager _ oref) = swapMVar oref False >> return ()
{-|
Given a 'Map' with a key type that can be randomly chosen, returns a key
that is not currently in the map.
-}
uniqueKey :: (Random k, Ord k) => Map k a -> IO k
uniqueKey m = do k <- randomIO
if M.member k m then uniqueKey m else return k
{-|
Adds a 'DialogSession' and associated cookie. This always sets a new blank
session, so should only be used when there is no session already.
-}
addDialogSession :: MonadIO m => DialogManager m -> ServerPartT m (DialogSession m)
addDialogSession (DialogManager sref _) = do
rq <- askRq
(k, session) <- liftIO $ do
smap <- takeMVar sref
k <- uniqueKey smap
let (c, _) = rqPeer rq
ct <- getCurrentTime
tref <- newMVar ct
dref <- newMVar M.empty
let session = DialogSession c tref dref
putMVar sref (M.insert k session smap)
return (k, session)
addCookie (-1) (mkCookie "dlg-sid" (show k))
return session
{-|
Ensures that there is a 'DialogSession' for the current user, and returns it.
Adds a blank one if necessary. This also updates the last touched time for the
session, preventing it from being removed by the reaper thread for a while.
-}
getDialogSession :: MonadIO m => DialogManager m -> ServerPartT m (DialogSession m)
getDialogSession dmgr@(DialogManager sref oref) = do
open <- liftIO $ readMVar oref
unless open mzero
rq <- askRq
msid <- getDataFn (lookCookieValue "dlg-sid")
case msid of
Nothing -> addDialogSession dmgr
Just sid -> do smap <- liftIO $ readMVar sref
case M.lookup (read sid) smap of
Nothing -> addDialogSession dmgr
Just s@(DialogSession { lastTouched = tref }) ->
if fst (rqPeer rq) /= client s
then addDialogSession dmgr
else liftIO $ do ct <- getCurrentTime
swapMVar tref ct
return s
{-|
The 'dialog' function builds a 'ServerPartT' that handles a given dialog. In
general, it can be combined in normal ways with guards and such, so long as changes
in the request parameters won't cause it to be missed when future requests are made in
the same dialog.
-}
dialog :: MonadIO m => DialogManager m -> Dlg m () -> ServerPartT m Response
dialog dmgr@(DialogManager sref oref) dlg = do
(DialogSession _ _ dref) <- getDialogSession dmgr
cont dref `mplus` this dref dlg
where cont dref = withDataFn (lookRead "dlg-dlgid") $ \dlgid -> do
dlgs <- liftIO $ readMVar dref
rq <- askRq
case M.lookup dlgid dlgs of
Nothing -> mzero
Just d -> this dref d
this dref (Done _) = mzero
this dref (Action a) = a >>= this dref
this dref (Step p f) = do
dlgs <- liftIO $ takeMVar dref
k <- liftIO $ uniqueKey dlgs
liftIO $ putMVar dref (M.insert k f dlgs)
p k