{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ViewPatterns #-}
-- |
-- Reference is an attribute name or path expression identifying a value within
-- a Context.
--
-- This type is mainly intended to be used internally by LaunchDarkly SDK and
-- service code, where efficiency is a major concern so it's desirable to do
-- any parsing or preprocessing just once. Applications are unlikely to need to
-- use the Reference type directly.
--
-- It can be used to retrieve a value with
-- 'LaunchDarkly.Server.Context.getValueForReference' or to identify an
-- attribute or nested value that should be considered private.
--
-- Parsing and validation are done at the time that the Reference is
-- constructed. If a Reference instance was created from an invalid string, it
-- is considered invalid. The error can be inspected with 'getError'.
--
-- == Syntax
--
-- The string representation of an attribute reference in LaunchDarkly JSON
-- data uses the following syntax:
--
-- If the first character is not a slash, the string is interpreted literally
-- as an attribute name. An attribute name can contain any characters, but must
-- not be empty.
--
-- If the first character is a slash, the string is interpreted as a
-- slash-delimited path where the first path component is an attribute name,
-- and each subsequent path component is the name of a property in a JSON
-- object. Any instances of the characters "/" or "~" in a path component are
-- escaped as "~1" or "~0" respectively. This syntax deliberately resembles
-- JSON Pointer, but no JSON Pointer behaviors other than those mentioned here
-- are supported.
--
-- == Examples
--
-- Suppose there is a context whose JSON implementation looks like this:
--
-- {
-- "kind": "user",
-- "key": "value1",
-- "address": {
-- "street": {
-- "line1": "value2",
-- "line2": "value3"
-- },
-- "city": "value4"
-- },
-- "good/bad": "value5"
-- }
--
-- The attribute references "key" and "/key" would both point to "value1".
--
-- The attribute reference "/address/street/line1" would point to "value2".
--
-- The attribute references "good/bad" and "/good~1bad" would both point to
-- "value5".
module LaunchDarkly.Server.Reference
( Reference
, makeReference
, makeLiteral
, isValid
, getError
, getComponents
, getRawPath
)
where
import Data.Aeson (ToJSON, Value (String), toJSON)
import Data.Text (Text)
import qualified Data.Text as T
-- | data record for the Reference type.
data Reference
= Valid {rawPath :: !Text, components :: ![Text]}
| Invalid {rawPath :: !Text, error :: !Text}
deriving (Show, Eq)
instance Ord Reference where
compare (Invalid _ _) (Valid _ _) = LT
compare (Valid _ _) (Invalid _ _) = GT
compare (Valid lhsRaw lhsComponents) (Valid rhsRaw rhsComponents)
| lhsComponents == rhsComponents = compare lhsRaw rhsRaw
| otherwise = compare lhsComponents rhsComponents
compare (Invalid lhsRaw lhsError) (Invalid rhsRaw rhsError)
| lhsRaw == rhsRaw = compare lhsError rhsError
| otherwise = compare lhsRaw rhsRaw
instance ToJSON Reference where
toJSON = String . rawPath
-- |
-- Creates a Reference from a string. For the supported syntax and examples,
-- see comments on the "LaunchDarkly.Server.Reference" module.
--
-- This function always returns a Reference that preserves the original string,
-- even if validation fails, so that accessing 'getRawPath' (or serializing the
-- Reference to JSON) will produce the original string. If validation fails,
-- 'getError' will return an error and any SDK method that takes this Reference
-- as a parameter will consider it invalid.
makeReference :: Text -> Reference
makeReference "" = Invalid {rawPath = "", error = "empty reference"}
makeReference "/" = Invalid {rawPath = "/", error = "empty reference"}
makeReference value@(T.stripPrefix "/" -> Nothing) = Valid {rawPath = value, components = [value]}
makeReference value@(T.stripSuffix "/" -> Just _) = Invalid {rawPath = value, error = "trailing slash"}
makeReference value = foldr addComponentToReference (Valid {rawPath = value, components = []}) (T.splitOn "/" $ T.drop 1 value)
-- |
-- makeLiteral is similar to 'makeReference' except that it always interprets
-- the string as a literal attribute name, never as a slash-delimited path
-- expression. There is no escaping or unescaping, even if the name contains
-- literal '/' or '~' characters. Since an attribute name can contain any
-- characters, this method always returns a valid Reference unless the name is
-- empty.
--
-- For example: @makeLiteral "name"@ is exactly equivalent to @makeReference
-- "name"@. @makeLiteral "a/b"@ is exactly equivalent to @makeReference "a/b"@
-- (since the syntax used by 'makeReference' treats the whole string as a
-- literal as long as it does not start with a slash), or to @makeReference
-- "/a~1b"@.
makeLiteral :: Text -> Reference
makeLiteral "" = Invalid {rawPath = "", error = "empty reference"}
makeLiteral value@(T.stripPrefix "/" -> Nothing) = Valid {rawPath = value, components = [value]}
makeLiteral value = Valid {rawPath = "/" <> (T.replace "/" "~1" $ T.replace "~" "~0" value), components = [value]}
-- |
-- Returns True for a valid Reference; False otherwise.
--
-- A Reference is invalid if the input string is empty, or starts with a slash
-- but is not a valid slash-delimited path, or starts with a slash and contains
-- an invalid escape sequence.
--
-- Otherwise, the Reference is valid, but that does not guarantee that such an
-- attribute exists in any given Context. For instance, @makeReference "name"@
-- is a valid Reference, but a specific Context might or might not have a name.
--
-- See comments on the "LaunchDarkly.Server.Reference" module for more details
-- of the attribute reference syntax.
isValid :: Reference -> Bool
isValid (Invalid _ _) = False
isValid _ = True
-- |
-- Returns an empty string for a valid Reference, or a Text error description
-- for an invalid Reference.
--
-- See comments on the "LaunchDarkly.Server.Reference" module for more details
-- of the attribute reference syntax.
getError :: Reference -> Text
getError (Invalid {error = e}) = e
getError _ = ""
-- |
-- Retrieves path components from the attribute reference.
--
-- Invalid references will return an empty list.
--
-- > makeReference "" & getComponents -- returns []
-- > makeReference "a" & getComponents -- returns ["a"]
-- > makeReference "/a/b" & getComponents -- returns ["a", "b"]
getComponents :: Reference -> [Text]
getComponents (Valid {components}) = components
getComponents _ = []
-- |
-- Returns the attribute reference as a string, in the same format provided
-- to 'makeReference'.
--
-- If the Reference was created with 'makeReference', this value is identical
-- to the original string. If it was created with 'makeLiteral', the value may
-- be different due to unescaping (for instance, an attribute whose name is
-- "/a" would be represented as "~1a").
getRawPath :: Reference -> Text
getRawPath = rawPath
-- Method intended to be used with a foldr. If you do not use this with a
-- foldr, the components will be in the wrong order as this method does
-- prepending.
--
-- This function helps assist in the construction of a Valid reference by
-- incrementally adding a new component to the Reference. If the component
-- cannot be added, or if the Reference is already invalid, we return an
-- Invalid reference with the appropriate error description.
addComponentToReference :: Text -> Reference -> Reference
addComponentToReference _ r@(Invalid _ _) = r
addComponentToReference "" (Valid {rawPath}) = Invalid {rawPath, error = "double slash"}
addComponentToReference component (Valid {rawPath, components}) = case unescapePath component of
Left c -> Valid {rawPath, components = (c : components)}
Right e -> Invalid {rawPath, error = e}
-- Performs unescaping of attribute reference path components:
--
-- "~1" becomes "/"
-- "~0" becomes "~"
-- "~" followed by any character other than "0" or "1" is invalid
--
-- This method returns an Either. Left Text is the path if unescaping was
-- valid; otherwise, Right Text will be a description error message.
unescapePath :: Text -> Either Text Text
unescapePath value@(T.isInfixOf "~" -> False) = Left value
unescapePath (T.stripSuffix "~" -> Just _) = Right "invalid escape sequence"
unescapePath value =
let component = T.foldl unescapeComponent (ComponentState {acc = [], valid = True, inEscape = False}) value
in case component of
ComponentState {acc = acc, valid = True} -> Left $ T.pack $ reverse acc
_ -> Right "invalid escape sequence"
-- Component state is a helper record to assist with unescaping a string.
--
-- When we are processing a string, we have to ensure that ~ is followed by 0
-- or 1. Any other value is invalid. To track this, we update this component
-- state through a fold operation.
data ComponentState = ComponentState
{ acc :: ![Char] -- Container to hold the piece of the input that has been successfully parsed.
, valid :: !Bool -- Is the state currently valid?
, inEscape :: !Bool -- Was the last character seen a tilde?
}
-- Intended to be used in a foldl operation to apply unescaping rules as
-- defined in 'unescapePath'.
--
-- Note that the 'ComponentState.acc' will be built backwards. This is because
-- prepending is faster in Haskell. Calling functions should reverse
-- accordingly.
unescapeComponent :: ComponentState -> Char -> ComponentState
-- Short circuit if we are already invalid
unescapeComponent component@(ComponentState {valid = False}) _ = component
-- Escape mode with a 0 or 1 means a valid escape sequence. We can append this
-- to the state's accumulator.
unescapeComponent component@(ComponentState {acc, inEscape = True}) '0' = component {acc = '~' : acc, valid = True, inEscape = False}
unescapeComponent component@(ComponentState {acc, inEscape = True}) '1' = component {acc = '/' : acc, valid = True, inEscape = False}
-- Any other character during an escape sequence isn't valid
unescapeComponent component@(ComponentState {inEscape = True}) _ = component {valid = False}
-- ~ means we should start escaping
unescapeComponent component '~' = component {inEscape = True}
-- Regular characters can be added without issue
unescapeComponent component@(ComponentState {acc}) c = component {acc = c : acc}