mirror of
https://github.com/qfpl/applied-fp-course.git
synced 2024-11-27 01:23:00 +03:00
Added more description around the ReaderT / AppM
More work needed to explain things a little better, not sure I've made a good case for motivating the use of ReaderT or actually explained it that well.
This commit is contained in:
parent
e06eae6695
commit
767256ebd4
@ -4,19 +4,55 @@ module FirstApp.AppM where
|
||||
import Control.Monad.IO.Class (MonadIO)
|
||||
import Control.Monad.Reader (MonadReader, ReaderT, runReaderT)
|
||||
|
||||
import Data.Text (Text)
|
||||
import Data.Text (Text)
|
||||
|
||||
import FirstApp.Conf (Conf)
|
||||
import FirstApp.DB (FirstAppDB)
|
||||
|
||||
-- One motivation for using ReaderT is that there exists some information in
|
||||
-- your application that is used almost everywhere and it is tiresome to have to
|
||||
-- weave it through everywhere. We also don't allow "global variables" as they
|
||||
-- are error prone, fragile, and prevent you from properly being able to reason
|
||||
-- about the operation of your program.
|
||||
--
|
||||
-- a ReaderT is a function from some 'r' to some 'm a' : (r -> m a). Where by
|
||||
-- the 'r' is accessible to all functions that run in the context of that 'm'.
|
||||
--
|
||||
-- This means that if you use the 'r' everywhere or simply enough throughout
|
||||
-- your application, you no longer have to constantly weave the extra 'r' as an
|
||||
-- argument to everything that might need it.
|
||||
-- Since by definition:
|
||||
-- foo :: ReaderT r m a
|
||||
-- When run, becomes:
|
||||
-- foo :: r -> m a
|
||||
--
|
||||
-- First, let's clean up our (Conf,FirstAppDB) with an application Env type. We
|
||||
-- will add a general purpose logging function, since we're not limited to
|
||||
-- just values!
|
||||
data Env = Env
|
||||
-- Add the type signature of a very general "logging" function.
|
||||
{ loggingRick :: Text -> AppM ()
|
||||
, envConfig :: Conf
|
||||
, envDb :: FirstAppDB
|
||||
}
|
||||
|
||||
-- Lets crack on and define a newtype wrapper for our ReaderT, this will save us
|
||||
-- having to write out the full ReaderT definition for every function that uses it.
|
||||
newtype AppM a = AppM
|
||||
-- Our ReaderT will only contain the Env, and our base monad will be IO, leave
|
||||
-- the return type polymorphic so that it will work regardless of what is
|
||||
-- being returned from the functions that will use it. Using a newtype (in
|
||||
-- addition to the useful type system) means that it is harder to use a
|
||||
-- different ReaderT when we meant to use our own, or vice versa. In such a
|
||||
-- situation it is extremely unlikely the application would compile at all,
|
||||
-- but the name differences alone make the confusion a little less likely.
|
||||
{ unAppM :: ReaderT Env IO a }
|
||||
-- Because we're using a newtype, all of the instance definitions for ReaderT
|
||||
-- would normally no apply. However, because we've done nothing but create a
|
||||
-- convenience wrapper for our ReaderT, there is an extension for Haskell that
|
||||
-- allows it to simply extend all the existing instances to work without AppM.
|
||||
-- Add the GeneralizedNewtypeDeriving pragma to the top of the file and these
|
||||
-- all work without any extra effort.
|
||||
deriving ( Functor
|
||||
, Applicative
|
||||
, Monad
|
||||
@ -24,6 +60,25 @@ newtype AppM a = AppM
|
||||
, MonadIO
|
||||
)
|
||||
|
||||
-- This a helper function that will take the requirements for our ReaderT, an
|
||||
-- Env, and the (AppM a) that is the context/action to be run with the given Env.
|
||||
--
|
||||
-- First step is to unwrap our AppM, the newtype definition we wrote gives us
|
||||
-- that function:
|
||||
-- unAppM :: AppM a -> ReaderT Env IO a
|
||||
--
|
||||
-- Then we run the ReaderT, which itself is just a newtype to get access to the
|
||||
-- action we're going to evaluate:
|
||||
-- runReaderT :: ReaderT r m a -> r -> m a
|
||||
-- ~
|
||||
-- runReaderT :: ReaderT Env IO a -> Env -> IO a
|
||||
--
|
||||
-- Combining them (runReaderT . unAppM) we are left with:
|
||||
-- Env -> IO a
|
||||
--
|
||||
-- We have an Env so that leaves us with the:
|
||||
-- IO a
|
||||
-- and we're done.
|
||||
runAppM
|
||||
:: Env
|
||||
-> AppM a
|
||||
|
@ -89,8 +89,15 @@ app
|
||||
app env rq cb =
|
||||
requestToResponse >>= cb
|
||||
where
|
||||
requestToResponse = runAppM env $
|
||||
mkRequest rq >>= handleRErr >>= handleRespErr
|
||||
-- Now that our request handling and response creating functions operate
|
||||
-- within our AppM context, we need to run the AppM to get our IO action out
|
||||
-- to be run and handed off to the callback function. We've already written
|
||||
-- the function for this so include the 'runAppM' with the Env.
|
||||
requestToResponse = runAppM env $ do
|
||||
-- Exercise: Rewrite this function to remove the need for the intermediate values.
|
||||
rq' <- mkRequest rq
|
||||
er' <- handleRErr rq'
|
||||
handleRespErr er'
|
||||
|
||||
handleRespErr =
|
||||
either mkErrorResponse pure
|
||||
@ -102,14 +109,31 @@ handleRequest
|
||||
:: RqType
|
||||
-> AppM (Either Error Response)
|
||||
handleRequest rqType = do
|
||||
-- Now that we're operating within the context of our AppM, which is a
|
||||
-- ReaderT, we're able to access the values stored in the Env.
|
||||
--
|
||||
-- Two functions that allow us to access the data stored in our ReaderT are:
|
||||
-- ask :: MonadReader r m => m r
|
||||
-- &
|
||||
-- asks :: MonadReader r m => (r -> a) -> m a
|
||||
--
|
||||
-- We will use asks here as we're only after the FirstAppDB, so...
|
||||
-- > envDb :: Env -> FirstAppDB
|
||||
-- > AppM :: ReaderT Env IO a
|
||||
-- > asks :: (Env -> a) -> AppM a
|
||||
-- > asks envDb :: AppM FirstAppDB
|
||||
db <- asks envDb
|
||||
liftIO $ case rqType of
|
||||
-- Exercise: Could this be generalised to clean up the repetition ?
|
||||
AddRq t c -> fmap (const ( Res.resp200 "Success" )) <$> DB.addCommentToTopic db t c
|
||||
ViewRq t -> fmap Res.resp200Json <$> DB.getComments db t
|
||||
ListRq -> fmap Res.resp200Json <$> DB.getTopics db
|
||||
|
||||
mkRequest
|
||||
:: Request
|
||||
-- We change this to be in our AppM context as well because when we're
|
||||
-- constructing our RqType we might want to call on settings or other such
|
||||
-- things, maybe.
|
||||
-> AppM ( Either Error RqType )
|
||||
mkRequest rq =
|
||||
case ( pathInfo rq, requestMethod rq ) of
|
||||
@ -153,6 +177,9 @@ mkErrorResponse UnknownRoute = pure $ Res.resp404 "Unknown Route"
|
||||
mkErrorResponse EmptyCommentText = pure $ Res.resp400 "Empty Comment"
|
||||
mkErrorResponse EmptyTopic = pure $ Res.resp400 "Empty Topic"
|
||||
mkErrorResponse ( DBError e ) = do
|
||||
-- As with our request for the FirstAppDB, we use the asks function from
|
||||
-- Control.Monad.Reader and pass the field accessor from the Env record.
|
||||
rick <- asks loggingRick
|
||||
rick . Text.pack $ show e
|
||||
-- Be a sensible developer and don't leak your DB errors over the interwebs.
|
||||
pure $ Res.resp500 "OH NOES"
|
||||
|
@ -17,6 +17,7 @@ main :: IO ()
|
||||
main = do
|
||||
let dieWith m = print m >> Exit.exitFailure
|
||||
|
||||
-- Keeping everything in sync with out larger application changes.
|
||||
reqsE <- Main.prepareAppReqs
|
||||
case reqsE of
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user