packages feed

freckle-app 1.0.2.10 → 1.0.3.0

raw patch · 15 files changed

+697/−5 lines, 15 filesdep +memcachedep +scientistdep +unliftio-corePVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependencies added: memcache, scientist, unliftio-core

API changes (from Hackage documentation)

+ Freckle.App.Memcached: caching :: (MonadUnliftIO m, MonadLogger m, MonadReader env m, HasMemcachedClient env, Cachable a) => CacheKey -> CacheTTL -> m a -> m a
+ Freckle.App.Memcached: cachingAs :: (MonadUnliftIO m, MonadLogger m, MonadReader env m, HasMemcachedClient env) => (ByteString -> Either String a) -> (a -> ByteString) -> CacheKey -> CacheTTL -> m a -> m a
+ Freckle.App.Memcached: cachingAsJSON :: (MonadUnliftIO m, MonadLogger m, MonadReader env m, HasMemcachedClient env, FromJSON a, ToJSON a) => CacheKey -> CacheTTL -> m a -> m a
+ Freckle.App.Memcached: class Cachable a
+ Freckle.App.Memcached: class HasMemcachedClient env
+ Freckle.App.Memcached: fromCachable :: Cachable a => ByteString -> Either String a
+ Freckle.App.Memcached: instance Freckle.App.Memcached.Cachable Data.ByteString.Internal.ByteString
+ Freckle.App.Memcached: instance Freckle.App.Memcached.Cachable Data.ByteString.Lazy.Internal.ByteString
+ Freckle.App.Memcached: instance Freckle.App.Memcached.Cachable Data.Text.Internal.Text
+ Freckle.App.Memcached: memcachedClientL :: HasMemcachedClient env => Lens' env MemcachedClient
+ Freckle.App.Memcached: toCachable :: Cachable a => a -> ByteString
+ Freckle.App.Memcached.CacheKey: cacheKey :: Text -> Either String CacheKey
+ Freckle.App.Memcached.CacheKey: cacheKeyThrow :: (HasCallStack, MonadIO m) => Text -> m CacheKey
+ Freckle.App.Memcached.CacheKey: data CacheKey
+ Freckle.App.Memcached.CacheKey: fromCacheKey :: CacheKey -> Key
+ Freckle.App.Memcached.CacheKey: instance Data.Hashable.Class.Hashable Freckle.App.Memcached.CacheKey.CacheKey
+ Freckle.App.Memcached.CacheKey: instance GHC.Classes.Eq Freckle.App.Memcached.CacheKey.CacheKey
+ Freckle.App.Memcached.CacheKey: instance GHC.Show.Show Freckle.App.Memcached.CacheKey.CacheKey
+ Freckle.App.Memcached.CacheTTL: cacheTTL :: Int -> CacheTTL
+ Freckle.App.Memcached.CacheTTL: data CacheTTL
+ Freckle.App.Memcached.CacheTTL: fromCacheTTL :: CacheTTL -> Expiration
+ Freckle.App.Memcached.CacheTTL: instance GHC.Classes.Eq Freckle.App.Memcached.CacheTTL.CacheTTL
+ Freckle.App.Memcached.CacheTTL: instance GHC.Classes.Ord Freckle.App.Memcached.CacheTTL.CacheTTL
+ Freckle.App.Memcached.CacheTTL: instance GHC.Enum.Enum Freckle.App.Memcached.CacheTTL.CacheTTL
+ Freckle.App.Memcached.CacheTTL: instance GHC.Num.Num Freckle.App.Memcached.CacheTTL.CacheTTL
+ Freckle.App.Memcached.CacheTTL: instance GHC.Real.Integral Freckle.App.Memcached.CacheTTL.CacheTTL
+ Freckle.App.Memcached.CacheTTL: instance GHC.Real.Real Freckle.App.Memcached.CacheTTL.CacheTTL
+ Freckle.App.Memcached.CacheTTL: instance GHC.Show.Show Freckle.App.Memcached.CacheTTL.CacheTTL
+ Freckle.App.Memcached.Client: class HasMemcachedClient env
+ Freckle.App.Memcached.Client: data MemcachedClient
+ Freckle.App.Memcached.Client: get :: (MonadIO m, MonadReader env m, HasMemcachedClient env) => CacheKey -> m (Maybe Value)
+ Freckle.App.Memcached.Client: instance Freckle.App.Memcached.Client.HasMemcachedClient Freckle.App.Memcached.Client.MemcachedClient
+ Freckle.App.Memcached.Client: instance Freckle.App.Memcached.Client.HasMemcachedClient site => Freckle.App.Memcached.Client.HasMemcachedClient (Yesod.Core.Types.HandlerData child site)
+ Freckle.App.Memcached.Client: memcachedClientDisabled :: MemcachedClient
+ Freckle.App.Memcached.Client: memcachedClientL :: HasMemcachedClient env => Lens' env MemcachedClient
+ Freckle.App.Memcached.Client: newMemcachedClient :: MonadIO m => MemcachedServers -> m MemcachedClient
+ Freckle.App.Memcached.Client: set :: (MonadIO m, MonadReader env m, HasMemcachedClient env) => CacheKey -> Value -> CacheTTL -> m ()
+ Freckle.App.Memcached.Servers: MemcachedServers :: [MemcachedServer] -> MemcachedServers
+ Freckle.App.Memcached.Servers: [unMemcachedServers] :: MemcachedServers -> [MemcachedServer]
+ Freckle.App.Memcached.Servers: defaultMemcachedServers :: MemcachedServers
+ Freckle.App.Memcached.Servers: emptyMemcachedServers :: MemcachedServers
+ Freckle.App.Memcached.Servers: newtype MemcachedServers
+ Freckle.App.Memcached.Servers: readMemcachedServers :: String -> Either String MemcachedServers
+ Freckle.App.Memcached.Servers: toServerSpecs :: MemcachedServers -> [ServerSpec]
+ Freckle.App.Scientist: experimentPublishDatadog :: (MonadReader env m, MonadUnliftIO m, HasDogStatsClient env, HasDogStatsTags env) => Result c a b -> m ()
+ Freckle.App.Test.Logging: runCapturedLoggingT :: MonadUnliftIO m => LoggingT m a -> m (a, [Text])
+ Yesod.Core.Lens: envL :: Lens' (HandlerData child site) (RunHandlerEnv child site)
+ Yesod.Core.Lens: siteL :: Lens' (RunHandlerEnv child site) site
- Freckle.App.Test: beforeAll :: IO a -> SpecWith a -> Spec
+ Freckle.App.Test: beforeAll :: HasCallStack => IO a -> SpecWith a -> Spec

Files

CHANGELOG.md view
@@ -1,6 +1,11 @@ ## [_Unreleased_](https://github.com/freckle/freckle-app/compare/v1.0.2.10...main) -- None+## [v1.0.3.0](https://github.com/freckle/freckle-app/compare/v1.0.2.10...v1.0.3.0)++- Add `Freckle.App.Memcache` for using memcached in Apps+- Add `Freckle.App.Scientist` for using [scientist][] in Apps++  [scientist]: https://github.com/freckle/scientist-hs#readme  ## [v1.0.2.10](https://github.com/freckle/freckle-app/compare/v1.0.2.9...v1.0.2.10) 
freckle-app.cabal view
@@ -1,6 +1,6 @@ cabal-version:      1.18 name:               freckle-app-version:            1.0.2.10+version:            1.0.3.0 license:            MIT license-file:       LICENSE maintainer:         Freckle Education@@ -38,16 +38,24 @@         Freckle.App.Http.Paginate         Freckle.App.Http.Retry         Freckle.App.Logging+        Freckle.App.Memcached+        Freckle.App.Memcached.CacheKey+        Freckle.App.Memcached.CacheTTL+        Freckle.App.Memcached.Client+        Freckle.App.Memcached.Servers         Freckle.App.Prelude         Freckle.App.RIO+        Freckle.App.Scientist         Freckle.App.Test         Freckle.App.Test.DocTest         Freckle.App.Test.Hspec.Runner+        Freckle.App.Test.Logging         Freckle.App.Version         Freckle.App.Wai         Freckle.App.Yesod         Freckle.App.Yesod.Routes         Network.HTTP.Link.Compat+        Yesod.Core.Lens      hs-source-dirs:     library     other-modules:      Paths_freckle_app@@ -92,6 +100,7 @@         iproute >=1.7.7,         lens >=4.16.1,         load-env >=0.2.0.2,+        memcache >=0.2.0.1,         monad-control >=1.0.2.3,         monad-logger >=0.3.31,         mtl >=2.2.2,@@ -106,6 +115,7 @@         retry >=0.8.1.0,         rio >=0.1.7.0,         safe >=0.3.17,+        scientist >=0.0.0.0,         semigroupoids >=5.2.2,         template-haskell >=2.13.0.0,         text >=1.2.3.1,@@ -181,6 +191,8 @@     other-modules:         Freckle.App.Env.InternalSpec         Freckle.App.HttpSpec+        Freckle.App.Memcached.ServersSpec+        Freckle.App.MemcachedSpec         Freckle.App.WaiSpec         Spec         Paths_freckle_app@@ -200,10 +212,15 @@         aeson >=1.3.1.1,         base >=4.11.1.0 && <5,         bytestring >=0.10.8.2,+        errors >=2.3.0,         freckle-app -any,         hspec >=2.8.1,         http-types >=0.12.2,         lens >=4.16.1,         lens-aeson >=1.0.2,+        memcache >=0.2.0.1,+        monad-logger >=0.3.31,+        mtl >=2.2.2,+        unliftio-core >=0.1.2.0,         wai >=3.2.1.2,         wai-extra >=3.0.24.3
gittest/Spec.hs view
@@ -1,1 +1,1 @@-{-# OPTIONS_GHC -F -pgmF hspec-discover -optF --module-name=Spec -optF --no-main #-}+{-# OPTIONS_GHC -F -pgmF hspec-discover -optF --module-name=Spec #-}
+ library/Freckle/App/Memcached.hs view
@@ -0,0 +1,125 @@+-- | App-level caching backed by Memcached+--+-- Usage:+--+-- 1. Have a Reader-like monad stack over some @App@+-- 2. Set up that @App@ with 'HasMemcachedClient'+-- 3. Give the value to cache a 'Cachable' instance+-- 4. Use 'caching'+--+-- To avoid 'Cachable', see 'cachingAs' and 'cachingAsJSON'.+--+module Freckle.App.Memcached+  ( Cachable(..)+  , caching+  , cachingAs+  , cachingAsJSON++  -- * Re-exports+  , module Freckle.App.Memcached.Client+  , module Freckle.App.Memcached.CacheKey+  , module Freckle.App.Memcached.CacheTTL+  ) where++import Freckle.App.Prelude++import Control.Monad.Logger (MonadLogger, logErrorN)+import Data.Aeson+import Data.ByteString (ByteString)+import qualified Data.ByteString.Lazy as BSL+import Data.Text.Encoding (decodeUtf8With)+import Data.Text.Encoding.Error (lenientDecode)+import Freckle.App.Memcached.CacheKey+import Freckle.App.Memcached.CacheTTL+import Freckle.App.Memcached.Client (HasMemcachedClient(..))+import qualified Freckle.App.Memcached.Client as Memcached+import UnliftIO.Exception (Exception(..), handleAny)++class Cachable a where+  toCachable :: a -> ByteString+  fromCachable :: ByteString -> Either String a++instance Cachable ByteString where+  toCachable = id+  fromCachable = Right++instance Cachable BSL.ByteString where+  toCachable = BSL.toStrict+  fromCachable = Right . BSL.fromStrict++instance Cachable Text where+  toCachable = encodeUtf8+  fromCachable = Right . decodeUtf8With lenientDecode++data Cached a+  = CacheFound a+  | CacheNotFound+  | CacheError Text++-- | Memoize an action using Memcached and 'Cachable'+caching+  :: ( MonadUnliftIO m+     , MonadLogger m+     , MonadReader env m+     , HasMemcachedClient env+     , Cachable a+     )+  => CacheKey+  -> CacheTTL+  -> m a+  -> m a+caching = cachingAs fromCachable toCachable++-- | Like 'caching', but with explicit conversion functions+cachingAs+  :: (MonadUnliftIO m, MonadLogger m, MonadReader env m, HasMemcachedClient env)+  => (ByteString -> Either String a)+  -> (a -> ByteString)+  -> CacheKey+  -> CacheTTL+  -> m a+  -> m a+cachingAs from to key ttl f = do+  result <-+    fmap (maybe CacheNotFound (either (CacheError . pack) CacheFound . from))+    $ handleCachingError Nothing "getting"+    $ Memcached.get key++  case result of+    CacheFound a -> pure a+    CacheNotFound -> store+    CacheError e -> do+      logCachingError "deserializing" e+      store+ where+  store = do+    a <- f+    a <$ handleCachingError () "setting" (Memcached.set key (to a) ttl)++-- | Like 'caching', but de/serializing the value as JSON+cachingAsJSON+  :: ( MonadUnliftIO m+     , MonadLogger m+     , MonadReader env m+     , HasMemcachedClient env+     , FromJSON a+     , ToJSON a+     )+  => CacheKey+  -> CacheTTL+  -> m a+  -> m a+cachingAsJSON = cachingAs eitherDecodeStrict encodeStrict++handleCachingError+  :: (MonadUnliftIO m, MonadLogger m) => a -> Text -> m a -> m a+handleCachingError value action = handleAny $ \ex -> do+  logCachingError action $ pack $ displayException ex+  pure value++logCachingError :: MonadLogger m => Text -> Text -> m ()+logCachingError action message =+  logErrorN $ "[Caching] error " <> action <> ": " <> message++encodeStrict :: ToJSON a => a -> ByteString+encodeStrict = BSL.toStrict . encode
+ library/Freckle/App/Memcached/CacheKey.hs view
@@ -0,0 +1,48 @@+module Freckle.App.Memcached.CacheKey+  ( CacheKey+  , cacheKey+  , cacheKeyThrow+  , fromCacheKey+  ) where++import Freckle.App.Prelude++import Data.Char (isControl, isSpace)+import qualified Data.Text as T+import Database.Memcache.Types (Key)+import GHC.Stack (HasCallStack)+import UnliftIO.Exception (throwString)++newtype CacheKey = CacheKey Text+  deriving stock Show+  deriving newtype (Eq, Hashable)++unCacheKey :: CacheKey -> Text+unCacheKey (CacheKey x) = x++-- | Build a 'CacheKey', ensuring it's valid for Memcached+--+-- <https://github.com/memcached/memcached/blob/master/doc/protocol.txt#L41>+--+-- @+-- Currently the length limit of a key is set at 250 characters (of course,+-- normally clients wouldn't need to use such long keys); the key must not+-- include control characters or whitespace.+-- @+--+cacheKey :: Text -> Either String CacheKey+cacheKey t+  | T.length t > 250 = invalid "Must be fewer than 250 characters"+  | T.any isControl t = invalid "Cannot contain control characters"+  | T.any isSpace t = invalid "Cannot container whitespace"+  | otherwise = Right $ CacheKey t+ where+  invalid msg =+    Left $ "Not a valid memcached key:\n  " <> unpack t <> "\n\n" <> msg++-- | Build a 'CacheKey' and throw if invalid+cacheKeyThrow :: (HasCallStack, MonadIO m) => Text -> m CacheKey+cacheKeyThrow = either throwString pure . cacheKey++fromCacheKey :: CacheKey -> Key+fromCacheKey = encodeUtf8 . unCacheKey
+ library/Freckle/App/Memcached/CacheTTL.hs view
@@ -0,0 +1,29 @@+module Freckle.App.Memcached.CacheTTL+  ( CacheTTL+  , cacheTTL+  , fromCacheTTL+  ) where++import Freckle.App.Prelude++import Data.Word (Word32)+import Database.Memcache.Types (Expiration)++newtype CacheTTL = CacheTTL Int+  deriving stock Show+  deriving newtype (Eq, Ord, Enum, Num, Real, Integral)++cacheTTL :: Int -> CacheTTL+cacheTTL = CacheTTL++fromCacheTTL :: CacheTTL -> Expiration+fromCacheTTL (CacheTTL i)+  | i < fromIntegral minWord = minWord+  | i > fromIntegral maxWord = maxWord+  | otherwise = fromIntegral i+ where+  minWord :: Word32+  minWord = minBound++  maxWord :: Word32+  maxWord = maxBound
+ library/Freckle/App/Memcached/Client.hs view
@@ -0,0 +1,72 @@+module Freckle.App.Memcached.Client+  ( MemcachedClient+  , newMemcachedClient+  , memcachedClientDisabled+  , HasMemcachedClient(..)+  , get+  , set+  ) where++import Freckle.App.Prelude++import Control.Lens (Lens', _1, view)+import qualified Database.Memcache.Client as Memcache+import Database.Memcache.Types (Value)+import Freckle.App.Memcached.CacheKey+import Freckle.App.Memcached.CacheTTL+import Freckle.App.Memcached.Servers+import Yesod.Core.Lens+import Yesod.Core.Types (HandlerData)++data MemcachedClient+  = MemcachedClient Memcache.Client+  | MemcachedClientDisabled++class HasMemcachedClient env where+  memcachedClientL :: Lens' env MemcachedClient++instance HasMemcachedClient MemcachedClient where+  memcachedClientL = id++instance HasMemcachedClient site => HasMemcachedClient (HandlerData child site) where+  memcachedClientL = envL . siteL . memcachedClientL++newMemcachedClient :: MonadIO m => MemcachedServers -> m MemcachedClient+newMemcachedClient servers = case toServerSpecs servers of+  [] -> pure memcachedClientDisabled+  specs -> liftIO $ MemcachedClient <$> Memcache.newClient specs Memcache.def++memcachedClientDisabled :: MemcachedClient+memcachedClientDisabled = MemcachedClientDisabled++get+  :: (MonadIO m, MonadReader env m, HasMemcachedClient env)+  => CacheKey+  -> m (Maybe Value)+get k = with $ \case+  MemcachedClient mc -> liftIO $ view _1 <$$> Memcache.get mc (fromCacheKey k)+  MemcachedClientDisabled -> pure Nothing++-- | Set a value to expire in the given seconds+--+-- Pass @0@ to set a value that never expires.+--+set+  :: (MonadIO m, MonadReader env m, HasMemcachedClient env)+  => CacheKey+  -> Value+  -> CacheTTL+  -> m ()+set k v expiration = with $ \case+  MemcachedClient mc ->+    void $ liftIO $ Memcache.set mc (fromCacheKey k) v 0 $ fromCacheTTL+      expiration+  MemcachedClientDisabled -> pure ()++with+  :: (MonadReader env m, HasMemcachedClient env)+  => (MemcachedClient -> m a)+  -> m a+with f = do+  c <- view memcachedClientL+  f c
+ library/Freckle/App/Memcached/Servers.hs view
@@ -0,0 +1,119 @@+{-# LANGUAGE CPP #-}++-- | Read a Memcached Servers value, to support ENV-based configuration+--+-- Format:+--+-- @+-- memcached://[user[:password]@]host][:port],...+-- @+--+-- Usage with "Freckle.App.Env":+--+-- @+-- -- Required+-- Env.var (Env.eitherReader readMemcachedServers) "MEMCACHED_SERVERS" Env.nonEmpty+--+-- -- Default to localhost:11211+-- Env.var (Env.eitherReader readMemcachedServers) "MEMCACHED_SERVERS" (Env.def defaultMemcachedServers)+--+-- -- Default to disabled+-- Env.var (Env.eitherReader readMemcachedServers) "MEMCACHED_SERVERS" (Env.def emptyMemcachedServers)+-- @+--+module Freckle.App.Memcached.Servers+  ( MemcachedServers(..)+  , defaultMemcachedServers+  , emptyMemcachedServers+  , readMemcachedServers+  , toServerSpecs+  ) where++import Freckle.App.Prelude++import Control.Error.Util (note)+import qualified Data.Text as T+import qualified Database.Memcache.Client as Memcache+import Network.URI (URI(..), URIAuth(..), parseAbsoluteURI)++newtype MemcachedServers = MemcachedServers+  { unMemcachedServers :: [MemcachedServer]+  }++defaultMemcachedServers :: MemcachedServers+defaultMemcachedServers = MemcachedServers [defaultMemcachedServer]++emptyMemcachedServers :: MemcachedServers+emptyMemcachedServers = MemcachedServers []++readMemcachedServers :: String -> Either String MemcachedServers+readMemcachedServers =+  fmap MemcachedServers+    . traverse (readMemcachedServer . unpack)+    . filter (not . T.null)+    . map T.strip+    . T.splitOn ","+    . pack++toServerSpecs :: MemcachedServers -> [Memcache.ServerSpec]+toServerSpecs = map unMemcachedServer . unMemcachedServers++newtype MemcachedServer = MemcachedServer+  { unMemcachedServer :: Memcache.ServerSpec+  }++defaultMemcachedServer :: MemcachedServer+defaultMemcachedServer = MemcachedServer Memcache.def++readMemcachedServer :: String -> Either String MemcachedServer+readMemcachedServer s = do+  uri <- note ("Not a valid URI: " <> s) $ parseAbsoluteURI s+  note "Must begin memcached://" $ guard $ uriScheme uri == "memcached:"++  let mAuth = uriAuthority uri++  pure+    . MemcachedServer+    . maybe id setHost mAuth+    . maybe id setPort mAuth+    . maybe id setAuth (readAuthentication . uriUserInfo =<< mAuth)+    $ Memcache.def++readAuthentication :: String -> Maybe Memcache.Authentication+readAuthentication = go . pack+ where+  go a = do+    (u, p) <- second (T.drop 1) . T.breakOn ":" <$> T.stripSuffix "@" a++    guard $ not $ T.null u+    guard $ not $ T.null p++    pure Memcache.Auth+      { Memcache.username = encodeUtf8 u+      , Memcache.password = encodeUtf8 p+      }++setHost :: URIAuth -> Memcache.ServerSpec -> Memcache.ServerSpec+setHost auth ss = case uriRegName auth of+  "" -> ss+  rn -> ss { Memcache.ssHost = rn }++setPort :: URIAuth -> Memcache.ServerSpec -> Memcache.ServerSpec+setPort auth ss = fromMaybe ss $ do+  p <- case uriPort auth of+    "" -> Nothing+    (':' : p) -> fromPort p+    p -> fromPort p+  pure $ ss { Memcache.ssPort = p }+ where+#if MIN_VERSION_memcache(0,3,0)+  -- ssPort is a ServiceName, which is a String+  fromPort = Just+#else+  -- ssPort is a PortNumber, which we need to Read+  fromPort = readMay+#endif++setAuth+  :: Memcache.Authentication -> Memcache.ServerSpec -> Memcache.ServerSpec+setAuth auth ss = ss { Memcache.ssAuth = auth }
+ library/Freckle/App/Scientist.hs view
@@ -0,0 +1,36 @@+module Freckle.App.Scientist+  ( experimentPublishDatadog+  ) where++import Freckle.App.Prelude++import Data.List.NonEmpty as NE+import qualified Freckle.App.Datadog as Datadog+import Scientist+import Scientist.Duration++-- | Publish experiment durations to Datadog+--+-- * Experiments are labeled "science.${name}"+-- * Control results are tagged with "variant:control"+-- * Candidates are tagged with "variant:control-${i}"+--+experimentPublishDatadog+  :: ( MonadReader env m+     , MonadUnliftIO m+     , Datadog.HasDogStatsClient env+     , Datadog.HasDogStatsTags env+     )+  => Result c a b+  -> m ()+experimentPublishDatadog result = for_ (resultDetails result) $ \details -> do+  let statName = "science." <> resultDetailsExperimentName details+  Datadog.gauge statName [("variant", "control")]+    $ durationToSeconds+    $ resultControlDuration+    $ resultDetailsControl details+  for_ (NE.zip (resultDetailsCandidates details) (NE.fromList [1 ..]))+    $ \(candidate, i :: Int) -> do+        Datadog.gauge statName [("variant", "candidate-" <> tshow i)]+          $ durationToSeconds+          $ resultCandidateDuration candidate
+ library/Freckle/App/Test/Logging.hs view
@@ -0,0 +1,30 @@+module Freckle.App.Test.Logging+  ( runCapturedLoggingT+  ) where++import Freckle.App.Prelude++import Control.Concurrent.Chan+import Control.Monad (forever)+import Control.Monad.Logger+import UnliftIO.Async+import UnliftIO.IORef++-- | Run a 'LoggingT', capturing and returning any logged messages alongside+--+-- I do not know why 'runChanLoggingT' exists presumably for this purpose, but+-- requires so much more effort to ultimately accomplish.+--+runCapturedLoggingT :: MonadUnliftIO m => LoggingT m a -> m (a, [Text])+runCapturedLoggingT f = do+  chan <- liftIO newChan+  ref <- newIORef []+  x <- async $ forever $ do+    (_, _, _, str) <- liftIO $ readChan chan+    modifyIORef' ref (<> [decodeUtf8 $ fromLogStr str])++  a <- runChanLoggingT chan f++  cancel x+  msgs <- readIORef ref+  pure (a, msgs)
+ library/Yesod/Core/Lens.hs view
@@ -0,0 +1,15 @@+module Yesod.Core.Lens+  ( envL+  , siteL+  ) where++import Freckle.App.Prelude++import Control.Lens (Lens', lens)+import Yesod.Core.Types (HandlerData, RunHandlerEnv, handlerEnv, rheSite)++envL :: Lens' (HandlerData child site) (RunHandlerEnv child site)+envL = lens handlerEnv $ \x y -> x { handlerEnv = y }++siteL :: Lens' (RunHandlerEnv child site) site+siteL = lens rheSite $ \x y -> x { rheSite = y }
package.yaml view
@@ -1,6 +1,6 @@ --- name: freckle-app-version: 1.0.2.10+version: 1.0.3.0 maintainer: Freckle Education category: Utils github: freckle/freckle-app@@ -81,6 +81,7 @@     - iproute     - lens     - load-env+    - memcache     - monad-control     - monad-logger >= 0.3.31     - mtl@@ -95,6 +96,7 @@     - retry >= 0.8.1.0     - rio     - safe+    - scientist     - semigroupoids     - template-haskell     - text@@ -118,11 +120,16 @@     dependencies:       - aeson       - bytestring+      - errors       - freckle-app       - hspec       - http-types       - lens       - lens-aeson+      - memcache+      - monad-logger+      - mtl+      - unliftio-core       - wai       - wai-extra @@ -131,6 +138,7 @@     source-dirs: doctest     dependencies:       - freckle-app+   gittest:     main: Main.hs     source-dirs: gittest
+ tests/Freckle/App/Memcached/ServersSpec.hs view
@@ -0,0 +1,94 @@+{-# LANGUAGE CPP #-}++module Freckle.App.Memcached.ServersSpec+  ( spec+  ) where++import Freckle.App.Prelude++import Control.Error.Util (hush)+import Data.Either (isLeft, isRight)+import qualified Database.Memcache.Client as Memcache+import Freckle.App.Memcached.Servers+import Freckle.App.Test++spec :: Spec+spec = do+  describe "readMemcachedServers" $ do+    it "requires the correct prefix" $ example $ do+      void (readMemcachedServers "http://") `shouldSatisfy` isLeft+      void (readMemcachedServers "memcached://") `shouldSatisfy` isRight++    it "treats an empty value as none" $ example $ do+      readServerSpecs "" `shouldBe` Just []++    it "treats an empty prefixed value as default" $ example $ do+      readServerSpecs "memcached://" `shouldBe` Just [Memcache.def]++    it "can set host" $ example $ do+      let mServer = readServerSpec "memcached://my-host"++      fmap Memcache.ssHost mServer `shouldBe` Just "my-host"+      fmap (portServiceName . Memcache.ssPort) mServer `shouldBe` Just "11211"+      fmap Memcache.ssAuth mServer `shouldBe` Just Memcache.NoAuth++    it "can set port" $ example $ do+      let mServer = readServerSpec "memcached://:11212"++      fmap Memcache.ssHost mServer `shouldBe` Just defaultHost+      fmap (portServiceName . Memcache.ssPort) mServer `shouldBe` Just "11212"+      fmap Memcache.ssAuth mServer `shouldBe` Just Memcache.NoAuth++    it "can set auth" $ example $ do+      let mServer = readServerSpec "memcached://user:password@"++      fmap Memcache.ssHost mServer `shouldBe` Just defaultHost+      fmap (portServiceName . Memcache.ssPort) mServer `shouldBe` Just "11211"+      fmap Memcache.ssAuth mServer+        `shouldBe` Just (Memcache.Auth "user" "password")++    it "refuses user-less or password-less auth" $ example $ do+      let+        mAuth1 = Memcache.ssAuth <$> readServerSpec "memcached://user:@"+        mAuth2 = Memcache.ssAuth <$> readServerSpec "memcached://:password@"++      mAuth1 `shouldBe` Just Memcache.NoAuth+      mAuth2 `shouldBe` Just Memcache.NoAuth++    it "can set lots at once" $ example $ do+      let mServer = readServerSpec "memcached://user:password@my-host:11212"++      fmap Memcache.ssHost mServer `shouldBe` Just "my-host"+      fmap (portServiceName . Memcache.ssPort) mServer `shouldBe` Just "11212"+      fmap Memcache.ssAuth mServer+        `shouldBe` Just (Memcache.Auth "user" "password")++    it "can do all of this for a list of servers" $ example $ do+      let+        mServerSpecs = readServerSpecs+          "memcached://a-host,memcached://b-host:11212,memcached://u:p@:11213"++      fmap (map Memcache.ssHost) mServerSpecs+        `shouldBe` Just ["a-host", "b-host", defaultHost]+      fmap (map (portServiceName . Memcache.ssPort)) mServerSpecs+        `shouldBe` Just ["11211", "11212", "11213"]+      fmap (map Memcache.ssAuth) mServerSpecs+        `shouldBe` Just+                     [Memcache.NoAuth, Memcache.NoAuth, Memcache.Auth "u" "p"]++readServerSpec :: String -> Maybe Memcache.ServerSpec+readServerSpec = headMay <=< readServerSpecs++readServerSpecs :: String -> Maybe [Memcache.ServerSpec]+readServerSpecs = fmap toServerSpecs . hush . readMemcachedServers++defaultHost :: String+defaultHost = Memcache.ssHost Memcache.def++#if MIN_VERSION_memcache(0,3,0)+portServiceName :: String -> String+portServiceName = id+#else+portServiceName :: Show a => a -> String+portServiceName = show+#endif
+ tests/Freckle/App/MemcachedSpec.hs view
@@ -0,0 +1,94 @@+module Freckle.App.MemcachedSpec+  ( spec+  ) where++import Freckle.App.Prelude++import Control.Monad.IO.Unlift (MonadUnliftIO(..))+import Control.Monad.Logger+import Control.Monad.Reader+import qualified Data.List.NonEmpty as NE+import qualified Freckle.App.Env as Env+import Freckle.App.Memcached+import Freckle.App.Memcached.Client (MemcachedClient, newMemcachedClient)+import qualified Freckle.App.Memcached.Client as Memcached+import Freckle.App.Memcached.Servers+import Freckle.App.Test.Logging+import Test.Hspec++data ExampleValue+  = A+  | B+  | C+  deriving stock (Eq, Show)++instance Cachable ExampleValue where+  toCachable = \case+    A -> "A"+    B -> "Broken"+    C -> "C"++  fromCachable = \case+    "A" -> Right A+    "B" -> Right B+    "C" -> Right C+    x -> Left $ "invalid: " <> show x++newtype TestAppT m a = TestAppT+  { unTestAppT :: ReaderT MemcachedClient (LoggingT m) a+  }+  deriving newtype+    ( Functor+    , Applicative+    , Monad+    , MonadReader MemcachedClient+    , MonadIO+    , MonadLogger+    )++-- We could derive this in newer versions of unliftio-core, but defining it by+-- hand supports a few resolvers back, without CPP. This is just a copy of the+-- ReaderT instance,+--+-- https://hackage.haskell.org/package/unliftio-core-0.2.0.1/docs/src/Control.Monad.IO.Unlift.html#line-64+--+instance MonadUnliftIO m => MonadUnliftIO (TestAppT m) where+  {-# INLINE withRunInIO #-}+  withRunInIO inner = TestAppT $ withRunInIO $ \run -> inner (run . unTestAppT)++runTestAppT :: MonadUnliftIO m => TestAppT m a -> m (a, [Text])+runTestAppT f = do+  servers <- liftIO $ Env.parse $ Env.var+    (Env.eitherReader readMemcachedServers)+    "MEMCACHED_SERVERS"+    (Env.def defaultMemcachedServers)+  mc <- newMemcachedClient servers+  runCapturedLoggingT $ runReaderT (unTestAppT f) mc++spec :: Spec+spec = do+  describe "caching" $ do+    it "caches the given action by key using Cachable" $ example $ do+      void $ runTestAppT $ do+        key <- cacheKeyThrow "A"++        val <- caching key (cacheTTL 5) $ pure A+        mbs <- Memcached.get key++        liftIO $ val `shouldBe` A+        liftIO $ mbs `shouldBe` Just "A"++    it "logs, but doesn't fail, on deserialization errors" $ example $ do+      (_, msgs) <- runTestAppT $ do+        key <- cacheKeyThrow "B"++        val0 <- caching key (cacheTTL 5) $ pure B -- set+        val1 <- caching key (cacheTTL 5) $ pure B -- get will fail+        mbs <- Memcached.get key++        liftIO $ val0 `shouldBe` B+        liftIO $ val1 `shouldBe` B+        liftIO $ mbs `shouldBe` Just "Broken"++      fmap NE.last (NE.nonEmpty msgs)+        `shouldBe` Just "[Caching] error deserializing: invalid: \"Broken\""
tests/Spec.hs view
@@ -1,1 +1,1 @@-{-# OPTIONS_GHC -F -pgmF hspec-discover -optF --module-name=Spec -optF --no-main #-}+{-# OPTIONS_GHC -F -pgmF hspec-discover -optF --module-name=Spec #-}