packages feed

launchdarkly-server-sdk-4.0.0: src/LaunchDarkly/Server/Integrations/TestData/FlagBuilder.hs

{-# LANGUAGE NamedFieldPuns #-}

module LaunchDarkly.Server.Integrations.TestData.FlagBuilder
    ( FlagBuilder (..)
    , VariationIndex
    , newFlagBuilder
    , booleanFlag
    , on
    , fallthroughVariation
    , offVariation
    , variationForAll
    , variationForAllUsers
    , valueForAll
    , valueForAllUsers
    , variationForKey
    , variationForUser
    , variations
    , buildFlag
    , ifMatch
    , ifMatchContext
    , ifNotMatch
    , ifNotMatchContext
    , FlagRuleBuilder
    , andMatch
    , andMatchContext
    , andNotMatch
    , andNotMatchContext
    , thenReturn
    , Variation
    )
where

import qualified Data.Aeson as Aeson
import Data.Function ((&))
import Data.HashSet (HashSet)
import qualified Data.HashSet as HS
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import GHC.Natural (Natural)
import qualified LaunchDarkly.Server.Features as F
import qualified LaunchDarkly.Server.Operators as Op
import LaunchDarkly.Server.Reference (makeReference)

type VariationIndex = Integer

trueVariationForBoolean, falseVariationForBoolean :: VariationIndex
trueVariationForBoolean = 0
falseVariationForBoolean = 1

variationForBoolean :: Bool -> VariationIndex
variationForBoolean True = trueVariationForBoolean
variationForBoolean False = falseVariationForBoolean

-- |
-- A builder for feature flag configurations to be used with
-- "LaunchDarkly.Server.Integrations.TestData".
--
-- see 'LaunchDarkly.Server.Integrations.TestData.flag' and
-- 'LaunchDarkly.Server.Integrations.TestData.update'
data FlagBuilder = FlagBuilder
    { fbKey :: Text
    , fbOffVariation :: Maybe VariationIndex
    , fbOn :: Bool
    , fbFallthroughVariation :: Maybe VariationIndex
    , fbVariations :: [Aeson.Value]
    , fbTargetMap :: Map Text (Map VariationIndex (HashSet Text))
    , fbRules :: [FlagRule]
    }
    deriving (Show)

fbTargets :: FlagBuilder -> ([F.Target], [F.Target])
fbTargets FlagBuilder {fbTargetMap = targetMap} =
    Map.foldlWithKey splitIntoTargets ([], []) targetMap
  where
    splitIntoTargets :: ([F.Target], [F.Target]) -> Text -> Map VariationIndex (HashSet Text) -> ([F.Target], [F.Target])
    splitIntoTargets acc "user" keyMap = Map.foldlWithKey foldForUserKind acc keyMap
    splitIntoTargets acc kind keyMap = Map.foldlWithKey (foldForOtherKind kind) acc keyMap

    -- When processing user kinds, we need to add a full target to the user targets, and a placeholder without the values in the context targets list
    foldForUserKind :: ([F.Target], [F.Target]) -> VariationIndex -> HashSet Text -> ([F.Target], [F.Target])
    foldForUserKind (userTargets, allTargets) variation values =
        ( F.Target {values, variation, contextKind = "user"} : userTargets
        , F.Target {values = mempty, variation, contextKind = "user"} : allTargets
        )

    foldForOtherKind :: Text -> ([F.Target], [F.Target]) -> VariationIndex -> HashSet Text -> ([F.Target], [F.Target])
    foldForOtherKind kind (userTargets, allTargets) variation values =
        ( userTargets
        , F.Target {values = values, variation, contextKind = kind} : allTargets
        )

buildFlag :: Natural -> FlagBuilder -> F.Flag
buildFlag version flagBuilder =
    F.Flag
        { F.key = fbKey flagBuilder
        , F.version = version
        , F.on = fbOn flagBuilder
        , F.trackEvents = False
        , F.trackEventsFallthrough = False
        , F.deleted = False
        , F.prerequisites = []
        , F.salt = "salt"
        , F.targets = userTargets
        , F.contextTargets = allTargets
        , F.rules = mapWithIndex convertFlagRule (fbRules flagBuilder)
        , F.fallthrough = F.VariationOrRollout (fbFallthroughVariation flagBuilder) Nothing
        , F.offVariation = fbOffVariation flagBuilder
        , F.variations = fbVariations flagBuilder
        , F.debugEventsUntilDate = Nothing
        , F.clientSideAvailability = F.ClientSideAvailability False False False
        }
  where
    (userTargets, allTargets) = fbTargets flagBuilder

mapWithIndex :: Integral num => (num -> a -> b) -> [a] -> [b]
mapWithIndex f l =
    fmap (uncurry f) (zip [0 ..] l)

newFlagBuilder :: Text -> FlagBuilder
newFlagBuilder key =
    FlagBuilder
        { fbKey = key
        , fbOffVariation = Nothing
        , fbOn = True
        , fbFallthroughVariation = Nothing
        , fbVariations = mempty
        , fbTargetMap = mempty
        , fbRules = mempty
        }

booleanFlagVariations :: [Aeson.Value]
booleanFlagVariations = [Aeson.Bool True, Aeson.Bool False]

isBooleanFlag :: FlagBuilder -> Bool
isBooleanFlag flagBuilder
    | booleanFlagVariations == fbVariations flagBuilder = True
    | otherwise = False

-- |
-- A shortcut for setting the flag to use the standard boolean configuration.
--
-- This is the default for all new flags created with
-- 'LaunchDarkly.Server.Integrations.TestData.flag'. The flag will have two
-- variations, @True@ and @False@ (in that order); it will return @False@
-- whenever targeting is off, and @True@ when targeting is on if no other
-- settings specify otherwise.
booleanFlag :: FlagBuilder -> FlagBuilder
booleanFlag flagBuilder
    | isBooleanFlag flagBuilder =
        flagBuilder
    | otherwise =
        flagBuilder
            & variations booleanFlagVariations
            & fallthroughVariation trueVariationForBoolean
            & offVariation falseVariationForBoolean

-- |
-- Sets targeting to be on or off for this flag.
--
-- The effect of this depends on the rest of the flag configuration, just as it
-- does on the real LaunchDarkly dashboard. In the default configuration that
-- you get from calling 'LaunchDarkly.Server.Integrations.TestData.flag' with a
-- new flag key, the flag will return @False@ whenever targeting is off, and
-- @True@ when targeting is on.
on ::
    -- | isOn @True@ if targeting should be on
    Bool ->
    FlagBuilder ->
    FlagBuilder
on isOn fb =
    fb {fbOn = isOn}

-- |
-- Removes any existing rules from the flag.
-- This undoes the effect of methods like 'ifMatch' or 'ifNotMatch'
clearRules :: FlagBuilder -> FlagBuilder
clearRules fb =
    fb {fbRules = mempty}

-- |
-- Removes any existing targets from the flag.
-- This undoes the effect of methods like 'variationForKey'
clearTargets :: FlagBuilder -> FlagBuilder
clearTargets fb =
    fb {fbTargetMap = mempty}

-- |
-- Sets the flag to always return the specified variation value for all
-- contexts.
--
-- The value may be of any type that implements 'Aeson.ToJSON'. This method
-- changes the flag to have only a single variation, which is this value, and
-- to return the same variation regardless of whether targeting is on or off.
-- Any existing targets or rules are removed.
valueForAll ::
    Aeson.ToJSON value =>
    value -> -- the desired value to be returned for all contexts
    FlagBuilder ->
    FlagBuilder
valueForAll val fb =
    fb
        & variations [Aeson.toJSON val]
        & variationForAll (0 :: VariationIndex)

{-# DEPRECATED valueForAllUsers "Use valueForAll instead" #-}

-- |
-- Sets the flag to always return the specified variation value for all users.
--
-- This function is an alias to 'valueForAll'.
--
-- The value may be of any type that implements 'Aeson.ToJSON'. This method
-- changes the flag to have only a single variation, which is this value, and
-- to return the same variation regardless of whether targeting is on or off.
-- Any existing targets or rules are removed.
valueForAllUsers ::
    Aeson.ToJSON value =>
    value -> -- the desired value to be returned for all users
    FlagBuilder ->
    FlagBuilder
valueForAllUsers = valueForAll

-- |
-- Changes the allowable variation values for the flag.
--
-- The value may be of any JSON type, as defined by 'Aeson.Value'. For
-- instance, a boolean flag normally has [toJSON True, toJSON False]; a
-- string-valued flag might have [toJSON "red", toJSON "green"]; etc.
variations ::
    -- | the desired variations
    [Aeson.Value] ->
    FlagBuilder ->
    FlagBuilder
variations values fb =
    fb {fbVariations = values}

-- Should this actually use overloaded function names?
class Variation val where
    -- |
    -- Specifies the fallthrough variation. The fallthrough is the value that
    -- is returned if targeting is on and the context was not matched by a more
    -- specific target or rule.
    --
    -- If the flag was previously configured with other variations and the
    -- variation specified is a boolean, this also changes it to a boolean
    -- flag.
    fallthroughVariation ::
        -- | @True@ or @False@ or the desired fallthrough variation index: 0 for the first, 1 for the second, etc.
        val ->
        FlagBuilder ->
        FlagBuilder

    -- |
    -- Specifies the off variation for a flag. This is the variation that is
    -- returned whenever targeting is off.
    --
    -- If the flag was previously configured with other variations and the
    -- variation specified is a boolean, this also changes it to a boolean
    -- flag.
    offVariation ::
        -- | @True@ or @False@ or the desired fallthrough variation index: 0 for the first, 1 for the second, etc.
        val ->
        FlagBuilder ->
        FlagBuilder

    -- |
    -- Sets the flag to always return the specified variation for all contexts.
    --
    -- The variation is specified, Targeting is switched on, and any existing
    -- targets or rules are removed. The fallthrough variation is set to the
    -- specified value. The off variation is left unchanged.
    --
    -- If the flag was previously configured with other variations and the
    -- variation specified is a boolean, this also changes it to a boolean
    -- flag.
    variationForAll ::
        -- | @True@ or @False@ or the desired fallthrough variation index: 0 for the first, 1 for the second, etc.
        val ->
        FlagBuilder ->
        FlagBuilder

    -- |
    -- Sets the flag to always return the specified variation for all users.
    --
    -- The variation is specified, Targeting is switched on, and any existing
    -- targets or rules are removed. The fallthrough variation is set to the
    -- specified value. The off variation is left unchanged.
    --
    -- If the flag was previously configured with other variations and the
    -- variation specified is a boolean, this also changes it to a boolean
    -- flag.
    variationForAllUsers ::
        -- | @True@ or @False@ or the desired fallthrough variation index: 0 for the first, 1 for the second, etc.
        val ->
        FlagBuilder ->
        FlagBuilder

    -- |
    -- Sets the flag to return the specified variation for a specific context
    -- kind and key when targeting is on.
    --
    -- This has no effect when targeting is turned off for the flag.
    --
    -- If the flag was previously configured with other variations and the
    -- variation specified is a boolean, this also changes it to a boolean
    -- flag.
    variationForKey ::
        -- | The context kind to match against
        Text ->
        -- | a key to target
        Text ->
        -- | @True@ or @False@ or the desired fallthrough variation index: 0 for the first, 1 for the second, etc.
        val ->
        FlagBuilder ->
        FlagBuilder

    -- |
    -- Sets the flag to return the specified variation for a specific user key
    -- when targeting is on.
    --
    -- This has no effect when targeting is turned off for the flag.
    --
    -- If the flag was previously configured with other variations and the
    -- variation specified is a boolean, this also changes it to a boolean
    -- flag.
    variationForUser ::
        -- | a user key to target
        Text ->
        -- | @True@ or @False@ or the desired fallthrough variation index: 0 for the first, 1 for the second, etc.
        val ->
        FlagBuilder ->
        FlagBuilder

    -- |
    -- Finishes defining the rule, specifying the result as either a boolean or
    -- a variation index.
    --
    -- If the flag was previously configured with other variations and the
    -- variation specified is a boolean, this also changes it to a boolean
    -- flag.
    thenReturn ::
        -- | @True@ or @False@ or the desired fallthrough variation index: 0 for the first, 1 for the second, etc.
        val ->
        FlagRuleBuilder ->
        FlagBuilder

instance Variation Integer where
    fallthroughVariation variationIndex fb =
        fb {fbFallthroughVariation = Just variationIndex}

    offVariation variationIndex fb =
        fb {fbOffVariation = Just variationIndex}

    variationForAll variationIndex fb =
        fb
            & on True
            & clearRules
            & clearTargets
            & fallthroughVariation variationIndex

    variationForAllUsers = variationForAll

    variationForKey kind key variationIndex fb@(FlagBuilder {fbTargetMap = targetMap}) =
        case Map.lookup kind targetMap of
            Nothing -> fb {fbTargetMap = Map.insert kind (Map.singleton variationIndex $ HS.singleton key) targetMap}
            Just m ->
                case Map.lookup variationIndex m of
                    Nothing -> fb {fbTargetMap = Map.insert kind (Map.insert variationIndex (HS.singleton key) m) targetMap}
                    Just keys -> fb {fbTargetMap = Map.insert kind (Map.insert variationIndex (HS.insert key keys) m) targetMap}

    variationForUser = variationForKey "user"

    thenReturn variationIndex ruleBuilder =
        let fb = frbBaseBuilder ruleBuilder
         in fb {fbRules = FlagRule (frbClauses ruleBuilder) variationIndex : fbRules fb}

instance Variation Bool where
    fallthroughVariation value fb =
        fb
            & booleanFlag
            & fallthroughVariation (variationForBoolean value)
    offVariation value fb =
        fb
            & booleanFlag
            & offVariation (variationForBoolean value)
    variationForAll value fb =
        fb
            & booleanFlag
            & variationForAll (variationForBoolean value)
    variationForAllUsers = variationForAll
    variationForKey kind key value fb =
        fb
            & booleanFlag
            & variationForKey kind key (variationForBoolean value)
    variationForUser userKey value fb =
        fb
            & booleanFlag
            & variationForUser userKey (variationForBoolean value)
    thenReturn value ruleBuilder =
        ruleBuilder {frbBaseBuilder = booleanFlag $ frbBaseBuilder ruleBuilder}
            & thenReturn (variationForBoolean value)

-- |
-- Starts defining a flag rule, using the "is one of" operator.
--
-- For example, this creates a rule that returns @True@ if the name is
-- \"Patsy\" or \"Edina\":
--
-- @
-- testData
--     & flag "flag"
--     & ifMatchContext "user" "name" [toJSON \"Patsy\", toJSON \"Edina\"]
--     & thenReturn True
-- @
ifMatchContext ::
    -- | the context kind to match again
    Text ->
    -- | the context attribute to match against
    Text ->
    -- | values to compare to
    [Aeson.Value] ->
    FlagBuilder ->
    -- | call 'thenReturn' to finish the rule, or add more tests with 'andMatch' or 'andNotMatch'
    FlagRuleBuilder
ifMatchContext kind attribute values fb =
    newFlagRuleBuilder fb
        & andMatchContext kind attribute values

{-# DEPRECATED ifMatch "Use ifMatchContext instead" #-}

-- |
-- Starts defining a flag rule, using the "is one of" operator.
--
-- This is a shortcut for calling 'ifMatch' with a context kind of "user".
--
-- For example, this creates a rule that returns @True@ if the name is
-- \"Patsy\" or \"Edina\":
--
-- @
-- testData
--     & flag "flag"
--     & ifMatch "name" [toJSON \"Patsy\", toJSON \"Edina\"]
--     & thenReturn True
-- @
ifMatch ::
    -- | the context attribute to match against
    Text ->
    -- | values to compare to
    [Aeson.Value] ->
    FlagBuilder ->
    -- | call 'thenReturn' to finish the rule, or add more tests with 'andMatch' or 'andNotMatch'
    FlagRuleBuilder
ifMatch = ifMatchContext "user"

-- |
-- Starts defining a flag rule, using the "is not one of" operator.
--
-- For example, this creates a rule that returns @True@ if the name is neither
-- \"Saffron\" nor \"Bubble\"
--
-- @
-- testData
--     & flag "flag"
--     & ifNotMatchContext "user" "name" [toJSON \"Saffron\", toJSON \"Bubble\"]
--     & thenReturn True
-- @
ifNotMatchContext ::
    -- | context kind to match again
    Text ->
    -- | attribute to match against
    Text ->
    -- | values to compare to
    [Aeson.Value] ->
    FlagBuilder ->
    -- | call 'thenReturn' to finish the rule, or add more tests with 'andMatch' or 'andNotMatch'
    FlagRuleBuilder
ifNotMatchContext kind attibute values fb =
    newFlagRuleBuilder fb
        & andNotMatchContext kind attibute values

{-# DEPRECATED ifNotMatch "Use ifNotMatchContext instead" #-}

-- |
-- Starts defining a flag rule, using the "is not one of" operator.
--
-- This is a shortcut for calling 'ifNotMatchContext' with a context kind of
-- "user".
--
-- For example, this creates a rule that returns @True@ if the name is neither
-- \"Saffron\" nor \"Bubble\"
--
-- @
-- testData
--     & flag "flag"
--     & ifNotMatch "name" [toJSON \"Saffron\", toJSON \"Bubble\"]
--     & thenReturn True
-- @
ifNotMatch ::
    -- | attribute to match against
    Text ->
    -- | values to compare to
    [Aeson.Value] ->
    FlagBuilder ->
    -- | call 'thenReturn' to finish the rule, or add more tests with 'andMatch' or 'andNotMatch'
    FlagRuleBuilder
ifNotMatch = ifNotMatchContext "user"

data Clause = Clause
    { clauseAttribute :: Text
    , contextKind :: Text
    , clauseValues :: [Aeson.Value]
    , clauseNegate :: Bool
    }
    deriving (Show)

data FlagRule = FlagRule
    { frClauses :: [Clause]
    , frVariation :: VariationIndex
    }
    deriving (Show)

convertFlagRule :: Integer -> FlagRule -> F.Rule
convertFlagRule idx flagRule =
    F.Rule
        { F.id = T.pack $ "rule" <> show idx
        , F.variationOrRollout = F.VariationOrRollout (Just $ frVariation flagRule) Nothing
        , F.clauses = fmap convertClause (frClauses flagRule)
        , F.trackEvents = False
        }

convertClause :: Clause -> F.Clause
convertClause clause =
    F.Clause
        { F.attribute = makeReference $ clauseAttribute clause
        , F.contextKind = contextKind clause
        , F.negate = clauseNegate clause
        , F.values = clauseValues clause
        , F.op = Op.OpIn
        }

-- |
-- A builder for feature flag rules to be used with 'FlagBuilder'.
--
-- In the LaunchDarkly model, a flag can have any number of rules, and a rule
-- can have any number of clauses. A clause is an individual test such as
-- \"name is \'X\'\". A rule matches a context if all of the rule's clauses
-- match the context.
--
-- To start defining a rule, use one of the matching functions such as
-- 'ifMatch' or 'ifNotMatch'. This defines the first clause for the rule.
--
-- Optionally, you may add more clauses with the rule builder functions such as
-- 'andMatch' and 'andNotMatch'.
--
-- Finally, call 'thenReturn' to finish defining the rule.
data FlagRuleBuilder = FlagRuleBuilder
    { frbClauses :: [Clause]
    , frbBaseBuilder :: FlagBuilder
    }
    deriving (Show)

newFlagRuleBuilder :: FlagBuilder -> FlagRuleBuilder
newFlagRuleBuilder baseBuilder =
    FlagRuleBuilder
        { frbClauses = mempty
        , frbBaseBuilder = baseBuilder
        }

-- |
-- Adds another clause, using the "is one of" operator.
--
-- For example, this creates a rule that returns @True@ if the name is
-- \"Patsy\" and the country is \"gb\":
--
-- @
-- testData
--     & flag "flag"
--     & ifMatch "name" [toJSON \"Patsy\"]
--     & andMatch "country" [toJSON \"gb\"]
--     & thenReturn True
-- @
andMatchContext ::
    -- | the context kind to match again
    Text ->
    -- | the context attribute to match against
    Text ->
    -- | values to compare to
    [Aeson.Value] ->
    FlagRuleBuilder ->
    FlagRuleBuilder
andMatchContext kind attribute values ruleBuilder =
    ruleBuilder {frbClauses = Clause attribute kind values False : frbClauses ruleBuilder}

{-# DEPRECATED andMatch "Use andMatchContext instead" #-}

-- |
-- Adds another clause, using the "is one of" operator.
--
-- This is a shortcut for calling 'andMatchContext' with a context kind of
-- "user".
--
-- For example, this creates a rule that returns @True@ if the name is
-- \"Patsy\" and the country is \"gb\":
--
-- @
-- testData
--     & flag "flag"
--     & ifMatch "name" [toJSON \"Patsy\"]
--     & andMatch "country" [toJSON \"gb\"]
--     & thenReturn True
-- @
andMatch ::
    -- | the context attribute to match against
    Text ->
    -- | values to compare to
    [Aeson.Value] ->
    FlagRuleBuilder ->
    FlagRuleBuilder
andMatch = andMatchContext "user"

-- |
-- Adds another clause, using the "is not one of" operator.
--
-- For example, this creates a rule that returns @True@ if the name is
-- \"Patsy\" and the country is not \"gb\":
--
-- @
-- testData
--     & flag "flag"
--     & ifMatchContext "user" "name" [toJSON \"Patsy\"]
--     & andNotMatchContext "user" "country" [toJSON \"gb\"]
--     & thenReturn True
-- @
andNotMatchContext ::
    -- | the context kind to match against
    Text ->
    -- | the context attribute to match against
    Text ->
    -- | values to compare to
    [Aeson.Value] ->
    FlagRuleBuilder ->
    FlagRuleBuilder
andNotMatchContext kind attribute values ruleBuilder =
    ruleBuilder {frbClauses = Clause attribute kind values True : frbClauses ruleBuilder}

{-# DEPRECATED andNotMatch "Use andNotMatchContext instead" #-}

-- |
-- Adds another clause, using the "is not one of" operator.
--
-- This is a shortcut for calling 'andNotMatchContext' with a context kind of
-- "user".
--
-- For example, this creates a rule that returns @True@ if the name is
-- \"Patsy\" and the country is not \"gb\":
--
-- @
-- testData
--     & flag "flag"
--     & ifMatch "name" [toJSON \"Patsy\"]
--     & andNotMatch "country" [toJSON \"gb\"]
--     & thenReturn True
-- @
andNotMatch ::
    -- | the context attribute to match against
    Text ->
    -- | values to compare to
    [Aeson.Value] ->
    FlagRuleBuilder ->
    FlagRuleBuilder
andNotMatch = andNotMatchContext "user"

{-# DEPRECATED variationForAllUsers "Use variationForAll instead" #-}
{-# DEPRECATED variationForUser "Use variationForKey instead" #-}