goggles-0.2: src/Network/Goggles/Cloud.hs
{-# language OverloadedStrings, TypeFamilies, GeneralizedNewtypeDeriving #-}
{-# language FlexibleContexts, MultiParamTypeClasses, DeriveDataTypeable #-}
{-# language UndecidableInstances #-}
module Network.Goggles.Cloud (
WebApiM(..)
, evalWebApiIO
, liftWebApiIO
, HasCredentials(..)
, Token(..)
, accessToken
, refreshToken
, Handle(..)
, createHandle
) where
import Control.Applicative (Alternative(..))
import Control.Monad.IO.Class
import Control.Monad.Catch
import Control.Monad.Reader
import qualified Control.Monad.Trans.Reader as RT (ask, local)
import Crypto.Random.Types (MonadRandom(..))
import Crypto.Random.Entropy (getEntropy)
import Control.Exception (AsyncException, fromException)
import Control.Concurrent.STM
import Data.Time
import Network.Goggles.Control.Exceptions
-- | This class
class HasCredentials c where
type Credentials c
type Options c
type TokenContent c
tokenFetch :: WebApiM c (Token c)
-- | An authentication 'Token' with an expiry date
data Token c = Token {
tToken :: TokenContent c
, tTime :: UTCTime
}
-- | A 'Handle' contains all information necessary to communicating with a cloud API provider:
--
-- * Authentication credentials (e.g. username/password)
-- * Authentication token (used to authenticate every API call)
-- * Options (e.g. GCP authentication scopes)
data Handle c = Handle {
credentials :: Credentials c
, token :: TVar (Maybe (Token c))
, options :: Options c
}
-- | The main type of the library. It can easily be re-used in libraries that interface with more than one cloud API provider because its type parameter `c` lets us be declare distinct behaviours for each.
newtype WebApiM c a = WebApiM {
runWebApiM :: ReaderT (Handle c) IO a
} deriving (Functor, Applicative, Monad)
instance HasCredentials c => Alternative (WebApiM c) where
empty = throwM $ UnknownError "empty"
a1 <|> a2 = do
ra <- try a1
case ra of
Right x -> pure x
Left e -> case (fromException e) :: Maybe CloudException of
Just _ -> a2
Nothing -> throwM (UnknownError "Uncaught exception (not a CloudException)")
-- -- | NB : this works similarly to <|> in the Alternative instance; it must discard information on which exception case occurred
-- tryOrElse :: MonadCatch m => m b -> m b -> m b
-- tryOrElse a1 a2 = do
-- ra <- try a1
-- case ra of
-- Right x -> pure x
-- Left e -> case (e :: CloudException) of _ -> a2
-- | Lift an `IO a` action into the 'WebApiM' monad
liftWebApiIO_ :: IO a -> WebApiM c a
liftWebApiIO_ m = WebApiM $ ReaderT (const m)
-- | Lift an `IO a` action into the 'WebApiM' monad, and catch synchronous exceptions, while rethrowing the asynchronous ones to IO
liftWebApiIO :: HasCredentials c => IO a -> WebApiM c a
liftWebApiIO m = do
liftWebApiIO_ m `catch` \e -> case fromException e of
Just asy -> throwM (asy :: AsyncException)
Nothing -> throwM $ IOError (show e)
-- | Evaluate a 'WebApiM' action, given a 'Handle'.
--
-- NB : Assumes all exceptions are handled by `throwM`
evalWebApiIO :: Handle c -> WebApiM c a -> IO a
evalWebApiIO r (WebApiM b) = runReaderT b r `catch` \e -> case (e :: CloudException) of
ex -> throwM ex
instance HasCredentials c => MonadIO (WebApiM c) where
liftIO = liftWebApiIO
instance HasCredentials c => MonadThrow (WebApiM c) where
throwM = liftIO . throwM
instance HasCredentials c => MonadCatch (WebApiM c) where
catch (WebApiM (ReaderT m)) c =
WebApiM $ ReaderT $ \r -> m r `catch` \e -> runReaderT (runWebApiM $ c e) r
{- | the whole point of this parametrization is to have a distinct MonadHttp for each API provider/DSP
instance HasCredentials c => MonadHttp (Boo c) where
handleHttpException = throwM
-}
instance HasCredentials c => MonadRandom (WebApiM c) where
getRandomBytes = liftIO . getEntropy
instance HasCredentials c => MonadReader (Handle c) (WebApiM c) where
ask = WebApiM RT.ask
local f m = WebApiM $ RT.local f (runWebApiM m)
-- | `cacheToken tok hdl` : Overwrite the token TVar `tv` containing a token if `tok` carries a more recent timestamp.
cacheToken ::
HasCredentials c => Token c -> WebApiM c (Token c)
cacheToken tok = do
tv <- asks token
liftWebApiIO $ atomically $ do
current <- readTVar tv
let new = case current of
Nothing -> tok
Just t -> if tTime t > tTime tok then t else tok
writeTVar tv (Just new)
return new
refreshToken :: HasCredentials c => WebApiM c (Token c)
refreshToken = tokenFetch >>= cacheToken
-- | Extract the token content (needed to authenticate subsequent requests). The token will be valid for at least 60 seconds
accessToken :: HasCredentials c => WebApiM c (TokenContent c)
accessToken = do
tokenTVar <- asks token
mbToken <- liftWebApiIO $ atomically $ readTVar tokenTVar
tToken <$> case mbToken of
Nothing -> refreshToken
Just t -> do
now <- liftWebApiIO $ getCurrentTime
if now > addUTCTime (- 60) (tTime t)
then refreshToken
else return t
-- | Create a 'Handle' with an empty token
createHandle :: HasCredentials c => Credentials c -> Options c -> IO (Handle c)
createHandle sa opts = Handle <$> pure sa <*> newTVarIO Nothing <*> pure opts