hyperbole-0.6.0: src/Web/Hyperbole/Data/Param.hs
{-# LANGUAGE DefaultSignatures #-}
module Web.Hyperbole.Data.Param where
import Data.Aeson (FromJSON, GFromJSON, GToJSON, Options (..), SumEncoding (..), ToJSON, Value (..), Zero, defaultOptions, genericParseJSON, genericToJSON)
import Data.Aeson qualified as A
import Data.Aeson.Types qualified as A
import Data.Bifunctor (first)
import Data.String (IsString (..))
import Data.String.Conversions (cs)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Data.Word
import GHC.Exts (IsList (..))
import GHC.Generics
import Text.Read (readMaybe)
import Web.HttpApiData (FromHttpApiData, ToHttpApiData)
import Web.HttpApiData qualified as HttpApiData
import Web.Hyperbole.Data.URI (URI (..), parseURIReference, uriToText)
newtype Param = Param {text :: Text}
deriving newtype (Show, Eq, Ord, IsString)
-- | Encode arbitrarily complex data into url form encoded data
newtype ParamValue = ParamValue {value :: Text}
deriving newtype (Ord, Eq)
deriving (Show, Generic)
instance IsString ParamValue where
fromString s = ParamValue (cs s)
{- | 'session's, 'form's, and 'query's all encode data as query strings. ToParam and FromParam control how a datatype is encoded to a parameter.
-
This is equivalent to Web.HttpApiData, which is missing some instances and has some strange defaults
@
#EMBED Example.Docs.Sessions data AppColor
@
-}
class ToParam a where
toParam :: a -> ParamValue
default toParam :: (Generic a, GToJSON Zero (Rep a)) => a -> ParamValue
toParam = genericToParam
instance ToParam ParamValue where
toParam = id
instance ToParam Int where
toParam = jsonParam
instance ToParam Integer where
toParam = jsonParam
instance ToParam Text where
toParam = toQueryParam
instance {-# OVERLAPS #-} ToParam String where
toParam = toQueryParam
instance ToParam Float where
toParam = jsonParam
instance ToParam Double where
toParam = jsonParam
instance ToParam Word where
toParam = jsonParam
instance ToParam Word8 where
toParam = jsonParam
instance ToParam Word16 where
toParam = jsonParam
instance ToParam Word32 where
toParam = jsonParam
instance ToParam Word64 where
toParam = jsonParam
instance ToParam Bool where
toParam = jsonParam
instance ToParam Char where
toParam = toQueryParam
instance ToParam UTCTime where
toParam = toQueryParam
instance ToParam URI where
toParam = toParam . uriToText
instance ToParam Value where
toParam = jsonParam
instance (ToParam a, ToParam b) => ToParam (a, b) where
toParam (a, b) = toParam [toParam a, toParam b]
{- | Decode data from a 'query', 'session', or 'form' parameter value
@
#EMBED Example.Docs.Sessions data AppColor
@
-}
class FromParam a where
parseParam :: ParamValue -> Either String a
default parseParam :: (Generic a, GFromJSON Zero (Rep a)) => ParamValue -> Either String a
parseParam = genericParseParam
decodeFormValue :: Maybe Text -> Either String a
decodeFormValue mval = do
case mval of
Nothing -> Left "missing form field value"
Just t -> do
parseParam $ ParamValue t
-- decodeParamValue :: Text -> Either String a
-- decodeParamValue = parseParam . decodeParam
-- Permissive instances. Some of these come directly from forms!
instance FromParam ParamValue where
parseParam = pure
instance FromParam Int where
parseParam "" = pure 0
parseParam p = jsonParse p
instance FromParam Integer where
parseParam "" = pure 0
parseParam p = jsonParse p
instance FromParam Float where
parseParam "" = pure 0
parseParam p = jsonParse p
instance FromParam Double where
parseParam "" = pure 0
parseParam p = jsonParse p
instance FromParam Text where
parseParam = parseQueryParam
instance (FromParam a, FromParam b) => FromParam (a, b) where
parseParam p = do
ps <- parseParam @[ParamValue] p
case ps of
[pa, pb] -> (,) <$> parseParam pa <*> parseParam pb
_ -> Left $ "Expected [a,b] but got: " <> cs p.value
-- -- we don't need to desanitize the text
-- parseFormField [inp] = do
-- parseParam $ ParamValue inp (String inp)
instance {-# OVERLAPS #-} FromParam String where
parseParam p = cs <$> parseQueryParam @Text p
-- parseFormField sel f = do
-- inp :: Text <- first cs $ FE.parseUnique @Text (cs sel) f
-- parseParam $ ParamValue inp (String inp)
instance FromParam Word where
parseParam = jsonParse
instance FromParam Word8 where
parseParam = jsonParse
instance FromParam Word16 where
parseParam = jsonParse
instance FromParam Word32 where
parseParam = jsonParse
instance FromParam Word64 where
parseParam = jsonParse
instance FromParam Bool where
parseParam (ParamValue t) =
case t of
"on" -> pure True
"off" -> pure False
"" -> pure False
"false" -> pure False
"true" -> pure True
other -> Left $ "Could not parse bool param: " <> cs other
decodeFormValue Nothing = pure False
decodeFormValue (Just t) =
parseParam $ ParamValue t
instance FromParam Char where
parseParam = parseQueryParam
instance FromParam UTCTime where
parseParam = parseQueryParam
instance FromParam Value where
parseParam = jsonParse
instance FromParam URI where
parseParam (ParamValue t) = do
case parseURIReference (cs t) of
Nothing -> Left $ "Invalid URI: " <> cs t
Just u -> pure u
instance {-# OVERLAPPABLE #-} (ToParam a) => ToParam [a] where
toParam as =
-- JSON encode the individual params
let ps :: [ParamValue] = fmap toParam as
in toParam $ Array $ fromList $ fmap (String . (.value)) ps
instance {-# OVERLAPPABLE #-} (FromParam a) => FromParam [a] where
parseParam p = do
ts <- jsonParse @[Text] p
mapM (parseParam . ParamValue) ts
instance (ToParam a) => ToParam (Maybe a) where
toParam Nothing = ParamValue "~"
toParam (Just a) = toParam a
instance {-# OVERLAPPABLE #-} (FromParam a) => FromParam (Maybe a) where
parseParam (ParamValue "") = pure Nothing
parseParam (ParamValue "~") = pure Nothing
parseParam t = Just <$> parseParam @a t
decodeFormValue Nothing = pure Nothing
decodeFormValue (Just t) = do
parseParam @(Maybe a) (ParamValue t)
instance {-# OVERLAPS #-} FromParam (Maybe Text) where
parseParam (ParamValue "~") = pure Nothing
-- keep empty strings, the default instance discards them
parseParam (ParamValue "") = pure (Just "")
parseParam t = Just <$> parseParam @Text t
decodeFormValue Nothing = pure Nothing
decodeFormValue (Just t) = do
parseParam @(Maybe Text) (ParamValue t)
instance (ToParam a, ToParam b) => ToParam (Either a b) where
toParam (Left a) = toParam a
toParam (Right b) = toParam b
instance (FromParam a, FromParam b) => FromParam (Either a b) where
parseParam val =
case parseParam @a val of
Right a -> pure $ Left a
Left _ -> do
case parseParam @b val of
Left _ -> Left $ "Could not parse Either param: " <> show val
Right b -> pure $ Right b
parseQueryParam :: (FromHttpApiData a) => ParamValue -> Either String a
parseQueryParam (ParamValue t) =
first cs $ HttpApiData.parseQueryParam t
toQueryParam :: (ToHttpApiData a) => a -> ParamValue
toQueryParam a =
ParamValue $ HttpApiData.toQueryParam a
-- | Encode a Show as a query param
showParam :: (Show a) => a -> ParamValue
showParam a = toQueryParam $ show a
-- | Decode a Read as a query param
readParam :: (Read a) => ParamValue -> Either String a
readParam p = do
str <- parseQueryParam p
case readMaybe str of
Nothing -> Left $ cs $ "Could not read query param: " <> str
Just a -> pure a
genericToParam :: (Generic a, GToJSON Zero (Rep a)) => a -> ParamValue
genericToParam a =
case genericToJSON jsonOptions a of
String t -> ParamValue t
other -> jsonParam other
genericParseParam :: (Generic a, GFromJSON Zero (Rep a)) => ParamValue -> Either String a
genericParseParam (ParamValue t) = do
val <- maybe (pure $ String t) pure $ A.decode (cs t)
A.parseEither (genericParseJSON jsonOptions) val
-- Encoding ------------------------------------------------------------
jsonOptions :: A.Options
jsonOptions = defaultOptions{sumEncoding = TwoElemArray}
jsonParam :: (ToJSON a) => a -> ParamValue
jsonParam a = ParamValue (cs $ A.encode a)
jsonParse :: (FromJSON a) => ParamValue -> Either String a
jsonParse (ParamValue t) = do
A.eitherDecode (cs t)