feat(dry-run): implement dry-run
BREAKING CHANGE: runDBRead
This commit is contained in:
parent
e85c3f089f
commit
002775e192
10
load.sh
Executable file
10
load.sh
Executable file
@ -0,0 +1,10 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Options: see /test/Load.hs (Main)
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
[ "${FLOCKER}" != "$0" ] && exec env FLOCKER="$0" flock -en .stack-work.lock "$0" "$@" || :
|
||||||
|
|
||||||
|
stack build --fast --flag uniworx:-library-only --flag uniworx:dev
|
||||||
|
|
||||||
|
stack exec uniworxload -- $@
|
||||||
12
package.yaml
12
package.yaml
@ -266,6 +266,18 @@ executables:
|
|||||||
when:
|
when:
|
||||||
- condition: flag(library-only)
|
- condition: flag(library-only)
|
||||||
buildable: false
|
buildable: false
|
||||||
|
uniworxload:
|
||||||
|
main: Load.hs
|
||||||
|
ghc-options:
|
||||||
|
- -main-is Load
|
||||||
|
source-dirs: test
|
||||||
|
dependencies:
|
||||||
|
- uniworx
|
||||||
|
- normaldistribution
|
||||||
|
other-modules: []
|
||||||
|
when:
|
||||||
|
- condition: flag(library-only)
|
||||||
|
buildable: false
|
||||||
|
|
||||||
# Test suite
|
# Test suite
|
||||||
tests:
|
tests:
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
|
||||||
{-# LANGUAGE DuplicateRecordFields #-}
|
{-# LANGUAGE DuplicateRecordFields #-}
|
||||||
{-# LANGUAGE OverloadedLabels #-}
|
{-# LANGUAGE OverloadedLabels #-}
|
||||||
{-# OPTIONS_GHC -fno-warn-orphans -fno-warn-incomplete-uni-patterns #-} -- MonadCrypto
|
{-# OPTIONS_GHC -fno-warn-orphans -fno-warn-incomplete-uni-patterns -fno-warn-redundant-constraints #-} -- MonadCrypto
|
||||||
|
|
||||||
module Foundation
|
module Foundation
|
||||||
( module Foundation
|
( module Foundation
|
||||||
@ -64,6 +64,7 @@ import Control.Monad.Except (MonadError(..))
|
|||||||
import Control.Monad.Trans.State (execStateT)
|
import Control.Monad.Trans.State (execStateT)
|
||||||
import Control.Monad.Writer.Class (MonadWriter(..))
|
import Control.Monad.Writer.Class (MonadWriter(..))
|
||||||
import Control.Monad.Memo.Class (MonadMemo(..), for4)
|
import Control.Monad.Memo.Class (MonadMemo(..), for4)
|
||||||
|
import Control.Monad.Reader.Class (MonadReader(local))
|
||||||
import qualified Control.Monad.Catch as C
|
import qualified Control.Monad.Catch as C
|
||||||
|
|
||||||
import Handler.Utils.StudyFeatures
|
import Handler.Utils.StudyFeatures
|
||||||
@ -103,6 +104,9 @@ import qualified Web.ServerSession.Frontend.Yesod.Jwt as JwtSession
|
|||||||
|
|
||||||
import Web.Cookie
|
import Web.Cookie
|
||||||
|
|
||||||
|
import Yesod.Core.Types (GHState(..), HandlerData(handlerState, handlerEnv), RunHandlerEnv(rheSite, rheChild))
|
||||||
|
import Database.Persist.Sql (transactionUndo, SqlReadBackend(..))
|
||||||
|
|
||||||
-- | Convenient Type Synonyms:
|
-- | Convenient Type Synonyms:
|
||||||
type DB = YesodDB UniWorX
|
type DB = YesodDB UniWorX
|
||||||
type Form x = Html -> MForm (HandlerFor UniWorX) (FormResult x, Widget)
|
type Form x = Html -> MForm (HandlerFor UniWorX) (FormResult x, Widget)
|
||||||
@ -256,7 +260,7 @@ instance Exception InvalidAuthTag
|
|||||||
data AccessPredicate
|
data AccessPredicate
|
||||||
= APPure (Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> Reader MsgRenderer AuthResult)
|
= APPure (Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> Reader MsgRenderer AuthResult)
|
||||||
| APHandler (Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> Handler AuthResult)
|
| APHandler (Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> Handler AuthResult)
|
||||||
| APDB (Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> DB AuthResult)
|
| APDB (Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> ReaderT SqlReadBackend Handler AuthResult)
|
||||||
|
|
||||||
class (MonadHandler m, HandlerSite m ~ UniWorX) => MonadAP m where
|
class (MonadHandler m, HandlerSite m ~ UniWorX) => MonadAP m where
|
||||||
evalAccessPred :: AccessPredicate -> Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> m AuthResult
|
evalAccessPred :: AccessPredicate -> Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> m AuthResult
|
||||||
@ -265,10 +269,10 @@ instance {-# INCOHERENT #-} (MonadHandler m, HandlerSite m ~ UniWorX) => MonadAP
|
|||||||
evalAccessPred aPred aid r w = liftHandler $ case aPred of
|
evalAccessPred aPred aid r w = liftHandler $ case aPred of
|
||||||
(APPure p) -> runReader (p aid r w) <$> getMsgRenderer
|
(APPure p) -> runReader (p aid r w) <$> getMsgRenderer
|
||||||
(APHandler p) -> p aid r w
|
(APHandler p) -> p aid r w
|
||||||
(APDB p) -> runDB $ p aid r w
|
(APDB p) -> runDBRead $ p aid r w
|
||||||
|
|
||||||
instance (MonadHandler m, HandlerSite m ~ UniWorX, backend ~ YesodPersistBackend UniWorX) => MonadAP (ReaderT backend m) where
|
instance (MonadHandler m, HandlerSite m ~ UniWorX, BackendCompatible SqlBackend backend) => MonadAP (ReaderT backend m) where
|
||||||
evalAccessPred aPred aid r w = mapReaderT liftHandler $ case aPred of
|
evalAccessPred aPred aid r w = mapReaderT liftHandler . withReaderT (SqlReadBackend . projectBackend) $ case aPred of
|
||||||
(APPure p) -> lift $ runReader (p aid r w) <$> getMsgRenderer
|
(APPure p) -> lift $ runReader (p aid r w) <$> getMsgRenderer
|
||||||
(APHandler p) -> lift $ p aid r w
|
(APHandler p) -> lift $ p aid r w
|
||||||
(APDB p) -> p aid r w
|
(APDB p) -> p aid r w
|
||||||
@ -333,7 +337,7 @@ askBearerUnsafe :: forall m.
|
|||||||
=> ExceptT AuthResult m (BearerToken UniWorX)
|
=> ExceptT AuthResult m (BearerToken UniWorX)
|
||||||
-- | This performs /no/ meaningful validation of the `BearerToken`
|
-- | This performs /no/ meaningful validation of the `BearerToken`
|
||||||
--
|
--
|
||||||
-- Use `Handler.Utils.Tokens.requireBearerToken` or `Handler.Utils.Tokens.maybeBearerToken` instead
|
-- Use `requireBearerToken` or `maybeBearerToken` instead
|
||||||
askBearerUnsafe = $cachedHere $ do
|
askBearerUnsafe = $cachedHere $ do
|
||||||
bearer <- maybeMExceptT (unauthorizedI MsgUnauthorizedNoToken) askBearer
|
bearer <- maybeMExceptT (unauthorizedI MsgUnauthorizedNoToken) askBearer
|
||||||
catch (decodeBearer bearer) $ \case
|
catch (decodeBearer bearer) $ \case
|
||||||
@ -343,10 +347,10 @@ askBearerUnsafe = $cachedHere $ do
|
|||||||
$logWarnS "AuthToken" $ tshow other
|
$logWarnS "AuthToken" $ tshow other
|
||||||
throwError =<< unauthorizedI MsgUnauthorizedTokenInvalid
|
throwError =<< unauthorizedI MsgUnauthorizedTokenInvalid
|
||||||
|
|
||||||
validateBearer :: Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> BearerToken UniWorX -> DB AuthResult
|
validateBearer :: Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> BearerToken UniWorX -> ReaderT SqlReadBackend Handler AuthResult
|
||||||
validateBearer mAuthId' route' isWrite' token' = $runCachedMemoT $ for4 memo validateBearer' mAuthId' route' isWrite' token'
|
validateBearer mAuthId' route' isWrite' token' = $runCachedMemoT $ for4 memo validateBearer' mAuthId' route' isWrite' token'
|
||||||
where
|
where
|
||||||
validateBearer' :: _ -> _ -> _ -> _ -> CachedMemoT (Maybe (AuthId UniWorX), Route UniWorX, Bool, BearerToken UniWorX) AuthResult DB AuthResult
|
validateBearer' :: _ -> _ -> _ -> _ -> CachedMemoT (Maybe (AuthId UniWorX), Route UniWorX, Bool, BearerToken UniWorX) AuthResult (ReaderT SqlReadBackend Handler) AuthResult
|
||||||
validateBearer' mAuthId route isWrite BearerToken{..} = lift . exceptT return return $ do
|
validateBearer' mAuthId route isWrite BearerToken{..} = lift . exceptT return return $ do
|
||||||
guardMExceptT (maybe True (HashSet.member route) bearerRoutes) (unauthorizedI MsgUnauthorizedTokenInvalidRoute)
|
guardMExceptT (maybe True (HashSet.member route) bearerRoutes) (unauthorizedI MsgUnauthorizedTokenInvalidRoute)
|
||||||
|
|
||||||
@ -381,6 +385,79 @@ validateBearer mAuthId' route' isWrite' token' = $runCachedMemoT $ for4 memo val
|
|||||||
|
|
||||||
return Authorized
|
return Authorized
|
||||||
|
|
||||||
|
maybeBearerToken :: (MonadHandler m, HandlerSite m ~ UniWorX, MonadCatch m) => m (Maybe (BearerToken UniWorX))
|
||||||
|
maybeBearerToken = runMaybeT $ catchIfMaybeT cPred requireBearerToken
|
||||||
|
where
|
||||||
|
cPred err = any ($ err)
|
||||||
|
[ is $ _HCError . _PermissionDenied
|
||||||
|
, is $ _HCError . _NotAuthenticated
|
||||||
|
]
|
||||||
|
|
||||||
|
requireBearerToken :: (MonadHandler m, HandlerSite m ~ UniWorX) => m (BearerToken UniWorX)
|
||||||
|
requireBearerToken = liftHandler $ do
|
||||||
|
bearer <- exceptT (guardAuthResult >=> error "askToken should not throw `Authorized`") return askBearerUnsafe
|
||||||
|
mAuthId <- maybeAuthId
|
||||||
|
currentRoute <- maybe (permissionDeniedI MsgUnauthorizedToken404) return =<< getCurrentRoute
|
||||||
|
isWrite <- isWriteRequest currentRoute
|
||||||
|
guardAuthResult <=< runDBRead $ validateBearer mAuthId currentRoute isWrite bearer
|
||||||
|
return bearer
|
||||||
|
|
||||||
|
requireCurrentBearerRestrictions :: ( MonadHandler m
|
||||||
|
, HandlerSite m ~ UniWorX
|
||||||
|
, FromJSON a
|
||||||
|
, ToJSON a
|
||||||
|
)
|
||||||
|
=> m (Maybe a)
|
||||||
|
requireCurrentBearerRestrictions = runMaybeT $ do
|
||||||
|
bearer <- requireBearerToken
|
||||||
|
route <- MaybeT getCurrentRoute
|
||||||
|
hoistMaybe $ bearer ^? _bearerRestrictionIx route
|
||||||
|
|
||||||
|
maybeCurrentBearerRestrictions :: ( MonadHandler m
|
||||||
|
, HandlerSite m ~ UniWorX
|
||||||
|
, MonadCatch m
|
||||||
|
, FromJSON a
|
||||||
|
, ToJSON a
|
||||||
|
)
|
||||||
|
=> m (Maybe a)
|
||||||
|
maybeCurrentBearerRestrictions = runMaybeT $ do
|
||||||
|
bearer <- MaybeT maybeBearerToken
|
||||||
|
route <- MaybeT getCurrentRoute
|
||||||
|
hoistMaybe $ bearer ^? _bearerRestrictionIx route
|
||||||
|
|
||||||
|
isDryRun :: forall m.
|
||||||
|
( MonadHandler m
|
||||||
|
, HandlerSite m ~ UniWorX
|
||||||
|
, MonadCatch m
|
||||||
|
)
|
||||||
|
=> m Bool
|
||||||
|
isDryRun = $cachedHere $ orM
|
||||||
|
[ hasGlobalPostParam PostDryRun
|
||||||
|
, hasGlobalGetParam GetDryRun
|
||||||
|
, and2M bearerDryRun bearerRequired
|
||||||
|
]
|
||||||
|
where
|
||||||
|
bearerDryRun = maybeT (return False) $ MaybeT maybeCurrentBearerRestrictions >>= hoistMaybe . \case
|
||||||
|
JSON.Object hm -> Just $ HashMap.member "dry-run" hm
|
||||||
|
_other -> Nothing
|
||||||
|
bearerRequired = maybeT (return True) . catchIfMaybeT cPred . liftHandler $ do
|
||||||
|
mAuthId <- maybeAuthId
|
||||||
|
currentRoute <- maybe (permissionDeniedI MsgUnauthorizedToken404) return =<< getCurrentRoute
|
||||||
|
isWrite <- isWriteRequest currentRoute
|
||||||
|
|
||||||
|
let noTokenAuth :: AuthDNF -> AuthDNF
|
||||||
|
noTokenAuth = over _dnfTerms . Set.filter . noneOf (re _nullable . folded) $ (== AuthToken) . plVar
|
||||||
|
|
||||||
|
dnf <- either throwM return $ routeAuthTags currentRoute
|
||||||
|
guardAuthResult <=< fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ const True) (noTokenAuth dnf) mAuthId currentRoute isWrite
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
cPred err = any ($ err)
|
||||||
|
[ is $ _HCError . _PermissionDenied
|
||||||
|
, is $ _HCError . _NotAuthenticated
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
tagAccessPredicate :: AuthTag -> AccessPredicate
|
tagAccessPredicate :: AuthTag -> AccessPredicate
|
||||||
tagAccessPredicate AuthFree = trueAP
|
tagAccessPredicate AuthFree = trueAP
|
||||||
@ -1096,7 +1173,7 @@ tagAccessPredicate AuthParticipant = APDB $ \mAuthId route _ -> case route of
|
|||||||
where
|
where
|
||||||
isCourseParticipant tid ssh csh participant onlyActive = do
|
isCourseParticipant tid ssh csh participant onlyActive = do
|
||||||
let
|
let
|
||||||
authorizedIfExists :: E.From a => (a -> E.SqlQuery b) -> ExceptT AuthResult DB ()
|
authorizedIfExists :: E.From a => (a -> E.SqlQuery b) -> ExceptT AuthResult (ReaderT SqlReadBackend Handler) ()
|
||||||
authorizedIfExists = flip whenExceptT Authorized <=< lift . E.selectExists . E.from
|
authorizedIfExists = flip whenExceptT Authorized <=< lift . E.selectExists . E.from
|
||||||
-- participant is currently registered
|
-- participant is currently registered
|
||||||
mapExceptT ($cachedHereBinary (participant, tid, ssh, csh)) . authorizedIfExists $ \(course `E.InnerJoin` courseParticipant) -> do
|
mapExceptT ($cachedHereBinary (participant, tid, ssh, csh)) . authorizedIfExists $ \(course `E.InnerJoin` courseParticipant) -> do
|
||||||
@ -1357,8 +1434,12 @@ tagAccessPredicate AuthAuthentication = APDB $ \mAuthId route _ -> case route of
|
|||||||
guard $ not systemMessageAuthenticatedOnly || isAuthenticated
|
guard $ not systemMessageAuthenticatedOnly || isAuthenticated
|
||||||
return Authorized
|
return Authorized
|
||||||
r -> $unsupportedAuthPredicate AuthAuthentication r
|
r -> $unsupportedAuthPredicate AuthAuthentication r
|
||||||
tagAccessPredicate AuthRead = APHandler . const . const $ bool (return Authorized) (unauthorizedI MsgUnauthorizedWrite)
|
tagAccessPredicate AuthRead = APPure $ \_ _ isWrite -> do
|
||||||
tagAccessPredicate AuthWrite = APHandler . const . const $ bool (unauthorizedI MsgUnauthorized) (return Authorized)
|
MsgRenderer mr <- ask
|
||||||
|
return $ bool Authorized (Unauthorized $ mr MsgUnauthorizedWrite) isWrite
|
||||||
|
tagAccessPredicate AuthWrite = APPure $ \_ _ isWrite -> do
|
||||||
|
MsgRenderer mr <- ask
|
||||||
|
return $ bool (Unauthorized $ mr MsgUnauthorized) Authorized isWrite
|
||||||
|
|
||||||
|
|
||||||
authTagSpecificity :: AuthTag -> AuthTag -> Ordering
|
authTagSpecificity :: AuthTag -> AuthTag -> Ordering
|
||||||
@ -1430,7 +1511,7 @@ evalAuthTags AuthTagActive{..} (map (Set.toList . toNullable) . Set.toList . dnf
|
|||||||
evalDNF :: [[AuthLiteral]] -> WriterT (Set AuthTag) m AuthResult
|
evalDNF :: [[AuthLiteral]] -> WriterT (Set AuthTag) m AuthResult
|
||||||
evalDNF = foldr (\ats ar -> ar `orAR'` foldr (\aTag ar' -> ar' `andAR'` evalAuthLiteral aTag) (return $ trueAR mr) ats) (return $ falseAR mr)
|
evalDNF = foldr (\ats ar -> ar `orAR'` foldr (\aTag ar' -> ar' `andAR'` evalAuthLiteral aTag) (return $ trueAR mr) ats) (return $ falseAR mr)
|
||||||
|
|
||||||
$logDebugS "evalAuthTags" . tshow . (route, isWrite, )$ map (map $ id &&& authTagIsActive . plVar) authDNF
|
$logDebugS "evalAuthTags" . tshow . (route, isWrite, ) $ map (map $ id &&& authTagIsActive . plVar) authDNF
|
||||||
|
|
||||||
result <- evalDNF $ filter (all $ authTagIsActive . plVar) authDNF
|
result <- evalDNF $ filter (all $ authTagIsActive . plVar) authDNF
|
||||||
|
|
||||||
@ -1449,7 +1530,7 @@ evalAccessFor mAuthId route isWrite = do
|
|||||||
dnf <- either throwM return $ routeAuthTags route
|
dnf <- either throwM return $ routeAuthTags route
|
||||||
fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ const True) dnf mAuthId route isWrite
|
fmap fst . runWriterT $ evalAuthTags (AuthTagActive $ const True) dnf mAuthId route isWrite
|
||||||
|
|
||||||
evalAccessForDB :: (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX) => Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> ReaderT (YesodPersistBackend UniWorX) m AuthResult
|
evalAccessForDB :: (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX, BackendCompatible SqlBackend backend) => Maybe (AuthId UniWorX) -> Route UniWorX -> Bool -> ReaderT backend m AuthResult
|
||||||
evalAccessForDB = evalAccessFor
|
evalAccessForDB = evalAccessFor
|
||||||
|
|
||||||
evalAccess :: (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX) => Route UniWorX -> Bool -> m AuthResult
|
evalAccess :: (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX) => Route UniWorX -> Bool -> m AuthResult
|
||||||
@ -1460,7 +1541,7 @@ evalAccess route isWrite = do
|
|||||||
(result, deactivated) <- runWriterT $ evalAuthTags tagActive dnf mAuthId route isWrite
|
(result, deactivated) <- runWriterT $ evalAuthTags tagActive dnf mAuthId route isWrite
|
||||||
result <$ tellSessionJson SessionInactiveAuthTags deactivated
|
result <$ tellSessionJson SessionInactiveAuthTags deactivated
|
||||||
|
|
||||||
evalAccessDB :: (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX) => Route UniWorX -> Bool -> ReaderT (YesodPersistBackend UniWorX) m AuthResult
|
evalAccessDB :: (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX, BackendCompatible SqlBackend backend) => Route UniWorX -> Bool -> ReaderT backend m AuthResult
|
||||||
evalAccessDB = evalAccess
|
evalAccessDB = evalAccess
|
||||||
|
|
||||||
-- | Check whether the current user is authorized by `evalAccess` for the given route
|
-- | Check whether the current user is authorized by `evalAccess` for the given route
|
||||||
@ -1588,8 +1669,28 @@ instance Yesod UniWorX where
|
|||||||
-- b) Validates that incoming write requests include that token in either a header or POST parameter.
|
-- b) Validates that incoming write requests include that token in either a header or POST parameter.
|
||||||
-- To add it, chain it together with the defaultMiddleware: yesodMiddleware = defaultYesodMiddleware . defaultCsrfMiddleware
|
-- To add it, chain it together with the defaultMiddleware: yesodMiddleware = defaultYesodMiddleware . defaultCsrfMiddleware
|
||||||
-- For details, see the CSRF documentation in the Yesod.Core.Handler module of the yesod-core package.
|
-- For details, see the CSRF documentation in the Yesod.Core.Handler module of the yesod-core package.
|
||||||
yesodMiddleware = observeYesodCacheSizeMiddleware . languagesMiddleware appLanguages . headerMessagesMiddleware . defaultYesodMiddleware . normalizeRouteMiddleware . csrfMiddleware . updateFavouritesMiddleware . storeBearerMiddleware
|
yesodMiddleware = storeBearerMiddleware . csrfMiddleware . dryRunMiddleware . observeYesodCacheSizeMiddleware . languagesMiddleware appLanguages . headerMessagesMiddleware . defaultYesodMiddleware . normalizeRouteMiddleware . updateFavouritesMiddleware
|
||||||
where
|
where
|
||||||
|
dryRunMiddleware :: Handler a -> Handler a
|
||||||
|
dryRunMiddleware handler = do
|
||||||
|
dryRun <- isDryRun
|
||||||
|
if | dryRun -> do
|
||||||
|
hData <- ask
|
||||||
|
prevState <- readIORef (handlerState hData)
|
||||||
|
let
|
||||||
|
restoreSession =
|
||||||
|
modifyIORef (handlerState hData) $
|
||||||
|
\hst -> hst { ghsSession = ghsSession prevState
|
||||||
|
, ghsCache = ghsCache prevState
|
||||||
|
, ghsCacheBy = ghsCacheBy prevState
|
||||||
|
}
|
||||||
|
site' = (rheSite $ handlerEnv hData) { appMemcached = Nothing }
|
||||||
|
handler' = local (\hd -> hd { handlerEnv = (handlerEnv hd) { rheSite = site', rheChild = site' } }) handler
|
||||||
|
|
||||||
|
addCustomHeader HeaderDryRun ("1" :: Text)
|
||||||
|
|
||||||
|
handler' `finally` restoreSession
|
||||||
|
| otherwise -> handler
|
||||||
updateFavouritesMiddleware :: Handler a -> Handler a
|
updateFavouritesMiddleware :: Handler a -> Handler a
|
||||||
updateFavouritesMiddleware handler = (*> handler) . runMaybeT $ do
|
updateFavouritesMiddleware handler = (*> handler) . runMaybeT $ do
|
||||||
route <- MaybeT getCurrentRoute
|
route <- MaybeT getCurrentRoute
|
||||||
@ -1637,7 +1738,7 @@ instance Yesod UniWorX where
|
|||||||
storeBearerMiddleware handler = do
|
storeBearerMiddleware handler = do
|
||||||
askBearer >>= \case
|
askBearer >>= \case
|
||||||
Just (Jwt bs) -> setSessionBS (toPathPiece SessionBearer) bs
|
Just (Jwt bs) -> setSessionBS (toPathPiece SessionBearer) bs
|
||||||
Nothing -> return ()
|
Nothing -> return ()
|
||||||
|
|
||||||
handler
|
handler
|
||||||
|
|
||||||
@ -4458,16 +4559,34 @@ routeNormalizers =
|
|||||||
return newRoute
|
return newRoute
|
||||||
|
|
||||||
|
|
||||||
|
runDBRead :: ReaderT SqlReadBackend Handler a -> Handler a
|
||||||
|
runDBRead action = do
|
||||||
|
$logDebugS "YesodPersist" "runDBRead"
|
||||||
|
runSqlPool (withReaderT SqlReadBackend action) =<< appConnPool <$> getYesod
|
||||||
|
|
||||||
-- How to run database actions.
|
-- How to run database actions.
|
||||||
instance YesodPersist UniWorX where
|
instance YesodPersist UniWorX where
|
||||||
type YesodPersistBackend UniWorX = SqlBackend
|
type YesodPersistBackend UniWorX = SqlBackend
|
||||||
runDB action = do
|
runDB action = do
|
||||||
|
-- stack <- liftIO currentCallStack
|
||||||
|
-- $logDebugS "YesodPersist" . unlines $ "runDB" : map pack stack
|
||||||
$logDebugS "YesodPersist" "runDB"
|
$logDebugS "YesodPersist" "runDB"
|
||||||
runSqlPool action =<< appConnPool <$> getYesod
|
dryRun <- isDryRun
|
||||||
|
let action'
|
||||||
|
| dryRun = action <* transactionUndo
|
||||||
|
| otherwise = action
|
||||||
|
runSqlPool action' =<< appConnPool <$> getYesod
|
||||||
instance YesodPersistRunner UniWorX where
|
instance YesodPersistRunner UniWorX where
|
||||||
getDBRunner = do
|
getDBRunner = do
|
||||||
(DBRunner{..}, cleanup) <- defaultGetDBRunner appConnPool
|
(DBRunner{..}, cleanup) <- defaultGetDBRunner appConnPool
|
||||||
return . (, cleanup) $ DBRunner (\act -> $logDebugS "YesodPersist" "runDBRunner" >> runDBRunner act)
|
return . (, cleanup) $ DBRunner (\action -> do
|
||||||
|
dryRun <- isDryRun
|
||||||
|
let action'
|
||||||
|
| dryRun = action <* transactionUndo
|
||||||
|
| otherwise = action
|
||||||
|
$logDebugS "YesodPersist" "runDBRunner"
|
||||||
|
runDBRunner action'
|
||||||
|
)
|
||||||
|
|
||||||
data CampusUserConversionException
|
data CampusUserConversionException
|
||||||
= CampusUserInvalidIdent
|
= CampusUserInvalidIdent
|
||||||
@ -4952,7 +5071,8 @@ instance YesodAuth UniWorX where
|
|||||||
campusUserFailoverMode :: FailoverMode
|
campusUserFailoverMode :: FailoverMode
|
||||||
campusUserFailoverMode = FailoverUnlimited
|
campusUserFailoverMode = FailoverUnlimited
|
||||||
|
|
||||||
instance YesodAuthPersist UniWorX
|
instance YesodAuthPersist UniWorX where
|
||||||
|
getAuthEntity = liftHandler . runDBRead . get
|
||||||
|
|
||||||
|
|
||||||
unsafeHandler :: UniWorX -> Handler a -> IO a
|
unsafeHandler :: UniWorX -> Handler a -> IO a
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import Import
|
|||||||
|
|
||||||
import Handler.Utils
|
import Handler.Utils
|
||||||
import Handler.Utils.Profile
|
import Handler.Utils.Profile
|
||||||
import Handler.Utils.Tokens
|
|
||||||
|
|
||||||
-- import Colonnade hiding (fromMaybe, singleton)
|
-- import Colonnade hiding (fromMaybe, singleton)
|
||||||
-- import Yesod.Colonnade
|
-- import Yesod.Colonnade
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import Import
|
|||||||
import Jobs
|
import Jobs
|
||||||
-- import Data.Text
|
-- import Data.Text
|
||||||
import Handler.Utils
|
import Handler.Utils
|
||||||
import Handler.Utils.Tokens
|
|
||||||
import Handler.Utils.Users
|
import Handler.Utils.Users
|
||||||
import Handler.Utils.Invitations
|
import Handler.Utils.Invitations
|
||||||
|
|
||||||
|
|||||||
@ -21,8 +21,6 @@ import Import
|
|||||||
import Utils.Form
|
import Utils.Form
|
||||||
import Jobs.Queue
|
import Jobs.Queue
|
||||||
|
|
||||||
import Handler.Utils.Tokens
|
|
||||||
|
|
||||||
import Text.Hamlet
|
import Text.Hamlet
|
||||||
|
|
||||||
import qualified Data.Conduit.List as C
|
import qualified Data.Conduit.List as C
|
||||||
|
|||||||
@ -1,47 +0,0 @@
|
|||||||
module Handler.Utils.Tokens
|
|
||||||
( maybeBearerToken, requireBearerToken
|
|
||||||
, maybeCurrentBearerRestrictions, requireCurrentBearerRestrictions
|
|
||||||
) where
|
|
||||||
|
|
||||||
import Import
|
|
||||||
|
|
||||||
|
|
||||||
maybeBearerToken :: (MonadHandler m, HandlerSite m ~ UniWorX, MonadCatch m) => m (Maybe (BearerToken UniWorX))
|
|
||||||
maybeBearerToken = runMaybeT $ catchIfMaybeT cPred requireBearerToken
|
|
||||||
where
|
|
||||||
cPred err = any ($ err)
|
|
||||||
[ is $ _HCError . _PermissionDenied
|
|
||||||
, is $ _HCError . _NotAuthenticated
|
|
||||||
]
|
|
||||||
|
|
||||||
requireBearerToken :: (MonadHandler m, HandlerSite m ~ UniWorX) => m (BearerToken UniWorX)
|
|
||||||
requireBearerToken = liftHandler $ do
|
|
||||||
bearer <- exceptT (guardAuthResult >=> error "askToken should not throw `Authorized`") return askBearerUnsafe
|
|
||||||
mAuthId <- maybeAuthId
|
|
||||||
currentRoute <- maybe (permissionDeniedI MsgUnauthorizedToken404) return =<< getCurrentRoute
|
|
||||||
isWrite <- isWriteRequest currentRoute
|
|
||||||
guardAuthResult <=< runDB $ validateBearer mAuthId currentRoute isWrite bearer
|
|
||||||
return bearer
|
|
||||||
|
|
||||||
requireCurrentBearerRestrictions :: ( MonadHandler m
|
|
||||||
, HandlerSite m ~ UniWorX
|
|
||||||
, FromJSON a
|
|
||||||
, ToJSON a
|
|
||||||
)
|
|
||||||
=> m (Maybe a)
|
|
||||||
requireCurrentBearerRestrictions = runMaybeT $ do
|
|
||||||
bearer <- requireBearerToken
|
|
||||||
route <- MaybeT getCurrentRoute
|
|
||||||
hoistMaybe $ bearer ^? _bearerRestrictionIx route
|
|
||||||
|
|
||||||
maybeCurrentBearerRestrictions :: ( MonadHandler m
|
|
||||||
, HandlerSite m ~ UniWorX
|
|
||||||
, MonadCatch m
|
|
||||||
, FromJSON a
|
|
||||||
, ToJSON a
|
|
||||||
)
|
|
||||||
=> m (Maybe a)
|
|
||||||
maybeCurrentBearerRestrictions = runMaybeT $ do
|
|
||||||
bearer <- MaybeT maybeBearerToken
|
|
||||||
route <- MaybeT getCurrentRoute
|
|
||||||
hoistMaybe $ bearer ^? _bearerRestrictionIx route
|
|
||||||
@ -564,6 +564,9 @@ catchMaybeT _ act = catch (lift act) (const mzero :: e -> MaybeT m a)
|
|||||||
|
|
||||||
catchMPlus :: forall p m e a. (MonadPlus m, MonadCatch m, Exception e) => p e -> m a -> m a
|
catchMPlus :: forall p m e a. (MonadPlus m, MonadCatch m, Exception e) => p e -> m a -> m a
|
||||||
catchMPlus _ = handle (const mzero :: e -> m a)
|
catchMPlus _ = handle (const mzero :: e -> m a)
|
||||||
|
|
||||||
|
catchIfMPlus :: forall m e a. (MonadPlus m, MonadCatch m, Exception e) => (e -> Bool) -> m a -> m a
|
||||||
|
catchIfMPlus p act = catchIf p act (const mzero)
|
||||||
|
|
||||||
mcons :: Maybe a -> [a] -> [a]
|
mcons :: Maybe a -> [a] -> [a]
|
||||||
mcons Nothing xs = xs
|
mcons Nothing xs = xs
|
||||||
@ -841,7 +844,7 @@ choice = foldr (<|>) empty
|
|||||||
-- Custom HTTP Headers --
|
-- Custom HTTP Headers --
|
||||||
---------------------------------
|
---------------------------------
|
||||||
|
|
||||||
data CustomHeader = HeaderIsModal | HeaderDBTableShortcircuit | HeaderMassInputShortcircuit | HeaderAlerts | HeaderDBTableCanonicalURL
|
data CustomHeader = HeaderIsModal | HeaderDBTableShortcircuit | HeaderMassInputShortcircuit | HeaderAlerts | HeaderDBTableCanonicalURL | HeaderDryRun
|
||||||
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic)
|
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic)
|
||||||
|
|
||||||
instance Universe CustomHeader
|
instance Universe CustomHeader
|
||||||
|
|||||||
@ -21,7 +21,7 @@ import Data.Universe
|
|||||||
import Control.Monad.Trans.Maybe (MaybeT(..))
|
import Control.Monad.Trans.Maybe (MaybeT(..))
|
||||||
|
|
||||||
|
|
||||||
data GlobalGetParam = GetLang | GetReferer | GetBearer | GetRecipient | GetCsvExampleData
|
data GlobalGetParam = GetLang | GetReferer | GetBearer | GetRecipient | GetCsvExampleData | GetDryRun
|
||||||
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic)
|
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic)
|
||||||
deriving anyclass (Universe, Finite)
|
deriving anyclass (Universe, Finite)
|
||||||
|
|
||||||
@ -62,6 +62,7 @@ data GlobalPostParam = PostFormIdentifier
|
|||||||
| PostLoginDummy
|
| PostLoginDummy
|
||||||
| PostExamAutoOccurrencePrevious
|
| PostExamAutoOccurrencePrevious
|
||||||
| PostLanguage
|
| PostLanguage
|
||||||
|
| PostDryRun
|
||||||
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic)
|
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic)
|
||||||
deriving anyclass (Universe, Finite)
|
deriving anyclass (Universe, Finite)
|
||||||
|
|
||||||
|
|||||||
@ -106,5 +106,7 @@ extra-deps:
|
|||||||
|
|
||||||
- token-bucket-0.1.0.1
|
- token-bucket-0.1.0.1
|
||||||
|
|
||||||
|
- normaldistribution-1.1.0.3
|
||||||
|
|
||||||
resolver: lts-15.12
|
resolver: lts-15.12
|
||||||
allow-newer: true
|
allow-newer: true
|
||||||
|
|||||||
@ -288,6 +288,13 @@ packages:
|
|||||||
sha256: b0b4a08ea1bf76bd108310f64d7f80e0f30b61ddc3d71f6cab7bdce329d2c1fa
|
sha256: b0b4a08ea1bf76bd108310f64d7f80e0f30b61ddc3d71f6cab7bdce329d2c1fa
|
||||||
original:
|
original:
|
||||||
hackage: token-bucket-0.1.0.1
|
hackage: token-bucket-0.1.0.1
|
||||||
|
- completed:
|
||||||
|
hackage: normaldistribution-1.1.0.3@sha256:2615b784c4112cbf6ffa0e2b55b76790290a9b9dff18a05d8c89aa374b213477,2160
|
||||||
|
pantry-tree:
|
||||||
|
size: 269
|
||||||
|
sha256: 856818862d12df8b030fa9cfef2c4ffa604d06f0eb057498db245dfffcd60e3c
|
||||||
|
original:
|
||||||
|
hackage: normaldistribution-1.1.0.3
|
||||||
snapshots:
|
snapshots:
|
||||||
- completed:
|
- completed:
|
||||||
size: 494635
|
size: 494635
|
||||||
|
|||||||
181
test/Load.hs
Normal file
181
test/Load.hs
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
{-# OPTIONS_GHC -fno-warn-unused-top-binds #-}
|
||||||
|
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||||
|
|
||||||
|
module Load
|
||||||
|
( main
|
||||||
|
) where
|
||||||
|
|
||||||
|
import "uniworx" Import hiding (Option(..), Normal)
|
||||||
|
|
||||||
|
import System.Console.GetOpt
|
||||||
|
|
||||||
|
import qualified Data.Text as Text
|
||||||
|
|
||||||
|
import qualified Data.Map.Strict as Map
|
||||||
|
|
||||||
|
import Data.Random.Normal
|
||||||
|
import qualified Control.Monad.Random.Class as Random
|
||||||
|
import System.Random (RandomGen)
|
||||||
|
|
||||||
|
import System.Exit (exitWith, ExitCode(..))
|
||||||
|
import System.IO (hPutStrLn)
|
||||||
|
|
||||||
|
import UnliftIO.Concurrent (threadDelay)
|
||||||
|
|
||||||
|
import System.Clock (getTime, Clock(Monotonic))
|
||||||
|
import qualified System.Clock as Clock
|
||||||
|
|
||||||
|
|
||||||
|
data Normal k = Normal
|
||||||
|
{ dAvg :: k
|
||||||
|
, dRelDev :: Centi
|
||||||
|
} deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||||
|
|
||||||
|
sampleN :: (Random.MonadSplit g m, RandomGen g) => (k -> Centi -> k) -> Normal k -> m k
|
||||||
|
sampleN scale Normal{..}
|
||||||
|
| dRelDev == 0 = return dAvg
|
||||||
|
| otherwise = do
|
||||||
|
gen <- Random.getSplit
|
||||||
|
let (realToFrac -> r, _) = normal' (1, realToFrac dRelDev :: Double) gen
|
||||||
|
return $ dAvg `scale` r
|
||||||
|
|
||||||
|
instance PathPiece k => PathPiece (Normal k) where
|
||||||
|
toPathPiece Normal{dRelDev = MkFixed perc, dAvg}
|
||||||
|
| perc == 0 = toPathPiece dAvg
|
||||||
|
| otherwise = toPathPiece dAvg <> ";" <> toPathPiece perc <> "%"
|
||||||
|
fromPathPiece t
|
||||||
|
| (avg, relDev') <- Text.breakOn ";" t
|
||||||
|
, Just relDev <- Text.stripSuffix "%" =<< Text.stripPrefix ";" relDev'
|
||||||
|
= Normal <$> fromPathPiece avg <*> (MkFixed <$> fromPathPiece relDev)
|
||||||
|
| otherwise
|
||||||
|
= Normal <$> fromPathPiece t <*> pure 0
|
||||||
|
|
||||||
|
scaleDiffTime :: DiffTime -> Centi -> DiffTime
|
||||||
|
scaleDiffTime (diffTimeToPicoseconds -> ps) s = picosecondsToDiffTime . round $ s * fromIntegral ps
|
||||||
|
|
||||||
|
sampleNDiffTime :: (Random.MonadSplit g m, RandomGen g) => Normal DiffTime -> m DiffTime
|
||||||
|
sampleNDiffTime = sampleN scaleDiffTime
|
||||||
|
|
||||||
|
|
||||||
|
instance PathPiece DiffTime where
|
||||||
|
toPathPiece = toPathPiece . MkFixed @E12 . diffTimeToPicoseconds
|
||||||
|
fromPathPiece t = fromPathPiece t <&> \(MkFixed ps :: Pico) -> picosecondsToDiffTime ps
|
||||||
|
|
||||||
|
|
||||||
|
data LoadSimulation
|
||||||
|
= LoadSheetSubmission
|
||||||
|
deriving (Eq, Ord, Read, Show, Enum, Bounded, Generic, Typeable)
|
||||||
|
deriving anyclass (Universe, Finite)
|
||||||
|
|
||||||
|
nullaryPathPiece ''LoadSimulation $ camelToPathPiece' 1
|
||||||
|
|
||||||
|
data LoadOptions = LoadOptions
|
||||||
|
{ loadSimulations :: Map LoadSimulation SimulationOptions
|
||||||
|
} deriving (Eq, Ord, Show, Generic, Typeable)
|
||||||
|
|
||||||
|
instance Default LoadOptions where
|
||||||
|
def = LoadOptions
|
||||||
|
{ loadSimulations = Map.empty
|
||||||
|
}
|
||||||
|
|
||||||
|
data SimulationOptions = SimulationOptions
|
||||||
|
{ simParallel :: Natural
|
||||||
|
, simDelay, simDuration :: Normal DiffTime
|
||||||
|
} deriving (Eq, Ord, Show, Generic, Typeable)
|
||||||
|
|
||||||
|
instance Default SimulationOptions where
|
||||||
|
def = SimulationOptions
|
||||||
|
{ simParallel = 1
|
||||||
|
, simDelay = Normal 0 0
|
||||||
|
, simDuration = Normal 10 0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
data SimulationContext = SimulationContext
|
||||||
|
{ loadOptions :: LoadOptions
|
||||||
|
, simulationOptions :: SimulationOptions
|
||||||
|
, targetDuration :: DiffTime
|
||||||
|
, runtime :: forall m. MonadIO m => m DiffTime
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
makeLenses_ ''LoadOptions
|
||||||
|
makeLenses_ ''SimulationOptions
|
||||||
|
makeLenses_ ''SimulationContext
|
||||||
|
|
||||||
|
|
||||||
|
_MapF :: (Finite k, Ord k) => Iso' (Map k v) (k -> Maybe v)
|
||||||
|
_MapF = iso (flip Map.lookup) (\f -> Map.fromList $ mapMaybe (\k -> (k, ) <$> f k) universeF)
|
||||||
|
|
||||||
|
|
||||||
|
argsDescr :: [OptDescr (Endo LoadOptions)]
|
||||||
|
argsDescr
|
||||||
|
= [ Option ['n', 'p'] ["number", "parallel"] (ReqArg (\(splitArg -> (cloneIndexedTraversal -> f, arg)) -> Endo . over f $ set _simParallel arg) "NATURAL") "Number of simulations to run in parallel"
|
||||||
|
, Option ['r'] ["run"] (ReqArg (\(ppArg -> sim) -> Endo $ over (_loadSimulations . at sim) (<|> Just def)) "SIMULATION") "Run the given Simulation"
|
||||||
|
, Option ['d'] ["duration"] (ReqArg (\(splitArg -> (cloneIndexedTraversal -> f, arg)) -> Endo . over f $ set _simDuration arg) "DURATION") "Try to run each simulation to take up the given duration"
|
||||||
|
, Option ['w', 's'] ["wait", "delay", "stagger"] (ReqArg (\(splitArg -> (cloneIndexedTraversal -> f, arg)) -> Endo . over f $ set _simDelay arg) "DURATION") "Wait the given time before starting each simulation"
|
||||||
|
]
|
||||||
|
where
|
||||||
|
splitArg :: PathPiece p => String -> (AnIndexedTraversal' LoadSimulation LoadOptions SimulationOptions, p)
|
||||||
|
splitArg (Text.pack -> t)
|
||||||
|
| (ref, arg) <- Text.breakOn ":" t
|
||||||
|
, let refs = Text.splitOn "," ref
|
||||||
|
sArg = Text.stripPrefix ":" arg
|
||||||
|
, Just refs' <- if | is _Just sArg -> mapM fromPathPiece refs
|
||||||
|
| otherwise -> Just []
|
||||||
|
, Just arg' <- fromPathPiece $ fromMaybe ref sArg
|
||||||
|
= (, arg') $ if
|
||||||
|
| null refs' -> _loadSimulations . itraversed
|
||||||
|
| otherwise -> _loadSimulations . _MapF . itraversed . indices (`elem` refs') . iplens (fromMaybe def) (const Just)
|
||||||
|
| otherwise
|
||||||
|
= terror $ "Invalid option argument: " <> t
|
||||||
|
|
||||||
|
ppArg :: PathPiece p => String -> p
|
||||||
|
ppArg (Text.pack -> a) = fromMaybe (terror $ "Invalid option argument: " <> a) $ fromPathPiece a
|
||||||
|
|
||||||
|
main :: IO ()
|
||||||
|
main = do
|
||||||
|
args <- map unpack <$> getArgs
|
||||||
|
case over _1 (over _loadSimulations (Map.filter $ (> 0) . simParallel) . (`appEndo` def) . getDual . foldMap Dual) $ getOpt Permute argsDescr args of
|
||||||
|
(cfg, [], []) | not . Map.null $ loadSimulations cfg
|
||||||
|
-> imapM_ (\sim simOpts -> runReaderT (runSimulation sim) (cfg & _loadSimulations . at sim .~ Nothing, simOpts)) $ loadSimulations cfg
|
||||||
|
(_, _, errs) -> do
|
||||||
|
forM_ errs $ hPutStrLn stderr
|
||||||
|
hPutStrLn stderr $ usageInfo "uniworxload" argsDescr
|
||||||
|
exitWith $ ExitFailure 2
|
||||||
|
|
||||||
|
runSimulation :: LoadSimulation -> ReaderT (LoadOptions, SimulationOptions) IO ()
|
||||||
|
runSimulation sim = do
|
||||||
|
p <- view $ _2 . _simParallel
|
||||||
|
replicateConcurrently_ (fromIntegral p) $ do
|
||||||
|
d <- view $ _2 . _simDelay
|
||||||
|
d' <- sampleNDiffTime d
|
||||||
|
|
||||||
|
dur <- view $ _2 . _simDuration
|
||||||
|
tDuration <- sampleNDiffTime dur
|
||||||
|
|
||||||
|
let MkFixed us = realToFrac d' :: Micro
|
||||||
|
threadDelay $ fromInteger us
|
||||||
|
|
||||||
|
cTime <- liftIO $ getTime Monotonic
|
||||||
|
let running :: forall m. MonadIO m => m DiffTime
|
||||||
|
running = do
|
||||||
|
cTime' <- liftIO $ getTime Monotonic
|
||||||
|
let diff = MkFixed . Clock.toNanoSecs $ cTime' - cTime :: Nano
|
||||||
|
MkFixed ps = realToFrac diff :: Pico
|
||||||
|
return $ picosecondsToDiffTime ps
|
||||||
|
|
||||||
|
withReaderT (\(lO, sO) -> SimulationContext lO sO tDuration running) $ runSimulation' sim
|
||||||
|
|
||||||
|
|
||||||
|
delayRemaining :: (MonadReader SimulationContext m, MonadIO m, RealFrac r) => r -> m ()
|
||||||
|
delayRemaining p = do
|
||||||
|
total <- asks targetDuration
|
||||||
|
cTime <- join $ asks runtime
|
||||||
|
let remaining = MkFixed . diffTimeToPicoseconds $ total - cTime :: Pico
|
||||||
|
MkFixed us = realToFrac $ realToFrac remaining * p :: Micro
|
||||||
|
threadDelay $ fromInteger us
|
||||||
|
|
||||||
|
|
||||||
|
runSimulation' :: LoadSimulation -> ReaderT SimulationContext IO ()
|
||||||
|
runSimulation' = liftIO . print
|
||||||
Reference in New Issue
Block a user