packages feed

launchdarkly-server-sdk-3.0.1: stores/launchdarkly-server-sdk-redis/src/LaunchDarkly/Server/Store/Redis/Internal.hs

-- | The public interface for the LaunchDarkly Haskell Redis integration

module LaunchDarkly.Server.Store.Redis.Internal
    ( RedisStoreConfig
    , makeRedisStoreConfig
    , redisConfigSetNamespace
    , makeRedisStore
    , redisUpsertInternal
    ) where

import           Data.Maybe                 (isJust)
import           Control.Monad              (forM_, void)
import           Control.Monad.Catch        (MonadCatch, Exception, catches, Handler(..))
import           Control.Exception          (throwIO)
import           Control.Monad.IO.Class     (MonadIO, liftIO)
import           Data.Aeson                 (FromJSON, ToJSON, decode, encode)
import           Data.ByteString            (ByteString)
import           Data.ByteString.Lazy       (toStrict, fromStrict)
import           Data.Text                  (Text)
import qualified Data.Text as               T
import           Data.Text.Encoding         (decodeUtf8, encodeUtf8)
import           Data.Typeable              (Typeable)
import           Data.Generics.Product      (getField, setField)
import           Database.Redis             (ConnectionLostException, Reply, multiExec, runRedis, del, get
                                            , set, hget, hgetall, hset, watch, Redis, Connection, TxResult(..))
import           GHC.Natural                (Natural)
import           GHC.Generics               (Generic)

import           LaunchDarkly.Server.Store  (StoreInterface(..), RawFeature(..), StoreResult(..))
import           LaunchDarkly.AesonCompat   (KeyMap, mapValues, toList, fromList, objectKeys)

data MinimalFeature = MinimalFeature
    { key     :: Text
    , version :: Natural
    , deleted :: Bool
    } deriving (Generic, ToJSON, FromJSON)

-- | Opaque type used to configure the Redis store integration.
data RedisStoreConfig = RedisStoreConfig
    { namespace  :: Text
    , connection :: Connection
    }

-- | Create a default config from a given connection pool.
makeRedisStoreConfig :: Connection -> RedisStoreConfig
makeRedisStoreConfig connection = RedisStoreConfig
    { namespace  = "LaunchDarkly"
    , connection = connection
    }

-- | Configure the Redis key prefix. All keys are prefixed by default before
-- being inserted into Redis. The default prefix is "LaunchDarkly".
redisConfigSetNamespace :: Text -> RedisStoreConfig -> RedisStoreConfig
redisConfigSetNamespace namespace' config = config { namespace = namespace' }

-- | Construct a `StoreInterface` that can then be used during SDK
-- configuration.
makeRedisStore :: RedisStoreConfig -> IO StoreInterface
makeRedisStore config = pure StoreInterface
    { storeInterfaceUpsertFeature = redisUpsert        config
    , storeInterfaceGetFeature    = redisGetFeature    config
    , storeInterfaceInitialize    = redisInitialize    config
    , storeInterfaceIsInitialized = redisIsInitialized config
    , storeInterfaceAllFeatures   = redisGetAll        config
    }

data RedisError = RedisError Text deriving (Typeable, Show, Exception)

makeKey :: RedisStoreConfig -> Text -> ByteString
makeKey config key = encodeUtf8 $ T.concat [namespace config, ":", key]

exceptOnReply :: (MonadIO m) => Either Reply a -> m a
exceptOnReply =  \case
    Left err -> liftIO $ throwIO $ RedisError $ T.pack $ show err
    Right x  -> pure x

run :: RedisStoreConfig -> Redis a -> StoreResult a
run config action = catches (runRedis (connection config) action >>= pure . pure)
    [ Handler $ \(e :: ConnectionLostException) -> pure $ Left $ T.pack $ show e
    , Handler $ \(RedisError err) -> pure $ Left err
    ]

decodeMinimal :: ByteString -> Maybe MinimalFeature
decodeMinimal = decode . fromStrict

rawToOpaque :: ByteString -> RawFeature
rawToOpaque raw = case decodeMinimal raw of
    Nothing      -> RawFeature Nothing 0
    Just decoded -> RawFeature (if getField @"deleted" decoded then Nothing else pure raw)
        (getField @"version" decoded)

opaqueToRep :: Text -> RawFeature -> ByteString
opaqueToRep key opaque = case rawFeatureBuffer opaque of
    Just buffer -> buffer
    Nothing     -> toStrict $ encode $ MinimalFeature key (rawFeatureVersion opaque) True

redisInitialize :: RedisStoreConfig -> KeyMap (KeyMap RawFeature) -> StoreResult ()
redisInitialize config values = run config $ do
    del (map (makeKey config) $ objectKeys values) >>= void . exceptOnReply
    forM_ (toList values) $ \(kind, features) -> forM_ (toList features) $ \(key, feature) ->
        (hset (makeKey config kind) (encodeUtf8 key) $ opaqueToRep key feature) >>= void . exceptOnReply
    set (makeKey config "$inited") "" >>= void . exceptOnReply

redisUpsert :: RedisStoreConfig -> Text -> Text -> RawFeature -> StoreResult Bool
redisUpsert = redisUpsertInternal (pure ())

redisUpsertInternal :: IO () -> RedisStoreConfig -> Text -> Text -> RawFeature -> StoreResult Bool
redisUpsertInternal hook config kind key opaque = run config tryUpsert where
    tryUpsert = watch [space] >>= void . exceptOnReply >>
        hget space (encodeUtf8 key) >>= exceptOnReply >>= \x -> (liftIO hook) >> case x of
            Nothing    -> doInsert
            (Just raw) -> case decodeMinimal raw of
                Nothing      -> pure True
                Just decoded -> if getField @"version" decoded >= rawFeatureVersion opaque
                    then pure False else doInsert
    space     = makeKey config kind
    doInsert  = multiExec (hset space (encodeUtf8 key) (opaqueToRep key opaque)) >>= \case
        TxSuccess _ -> pure True
        TxError err -> liftIO $ throwIO $ RedisError $ T.pack $ show err
        TxAborted   -> tryUpsert

redisGetFeature :: RedisStoreConfig -> Text -> Text -> StoreResult RawFeature
redisGetFeature config kind key = run config $ hget (makeKey config kind) (encodeUtf8 key)
    >>= exceptOnReply >>= \case
        Nothing    -> pure $ RawFeature Nothing 0
        (Just raw) -> pure $ rawToOpaque raw

redisIsInitialized :: RedisStoreConfig -> StoreResult Bool
redisIsInitialized config = run config $ get (makeKey config "$inited")
    >>= exceptOnReply >>= pure . isJust

redisGetAll :: RedisStoreConfig -> Text -> StoreResult (KeyMap RawFeature)
redisGetAll config kind = run config $ hgetall (makeKey config kind)
    >>= exceptOnReply >>= pure . mapValues rawToOpaque . fromList . map (\(k, v) -> (decodeUtf8 k, v))