dynamic-0.1.0: src/Dynamic.hs
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# OPTIONS_GHC -Wall #-}
-- | Support dynamic typing.
module Dynamic
( Dynamic(..)
-- * Accessors
, (!)
, set
, modify
, del
-- * Input
, fromJson
, fromCsv
, fromCsvNamed
, fromJsonFile
, fromCsvFile
, fromCsvFileNamed
, fromList
, fromDict
-- * Ouput
, toJson
, toCsv
, toCsvNamed
, toJsonFile
, toCsvFile
, toDouble
, toInt
, toBool
, toList
, toKeys
, toElems
-- * Web requests
, get
, post
, getJson
, postJson
) where
import Control.Arrow ((***))
import Control.Exception
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Encode.Pretty as Aeson
import Data.Bifunctor
import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy as L
import qualified Data.Csv as Csv
import Data.Data
import Data.HashMap.Strict (HashMap)
import qualified Data.HashMap.Strict as HM
import Data.List
import Data.Maybe
import Data.String
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Text.IO as T
import qualified Data.Text.Read as T
import Data.Vector (Vector)
import qualified Data.Vector as V
import GHC.Generics
import Network.HTTP.Simple
-- | A dynamic error.
data DynamicException
= DynamicTypeError Text
| ParseError Text
| NoSuchKey Text
| NoSuchIndex Int
deriving (Show, Typeable)
instance Exception DynamicException
-- | The dynamic type.
data Dynamic
= Dictionary !(HashMap Text Dynamic)
| Array !(Vector Dynamic)
| String !Text
| Double !Double
| Bool !Bool
| Null
deriving (Eq, Typeable, Data, Generic, Ord)
--------------------------------------------------------------------------------
-- Class instances
-- | Dumps it to JSON.
instance Show Dynamic where
show = T.unpack . toJson
-- | Converts everything to a double.
instance Num Dynamic where
(toDouble -> x) + (toDouble -> y) = Double (x + y)
(toDouble -> x) * (toDouble -> y) = Double (x * y)
abs = Double . abs . toDouble
signum = Double . signum . toDouble
fromInteger = Double . fromInteger
negate = Double . negate . toDouble
-- | Treats the dynamic as a double.
instance Enum Dynamic where
toEnum = Double . fromIntegral
fromEnum = fromEnum . toDouble
-- | Implemented via 'toDouble'.
instance Real Dynamic where
toRational = toRational . toDouble
instance Fractional Dynamic where
fromRational = Double . fromRational
recip = Double . recip . toDouble
-- | Implemented via 'Double'.
instance Integral Dynamic where
toInteger = toInteger . toInt
quotRem x y =
(Double . fromIntegral *** Double . fromIntegral)
(quotRem (toInt x) (toInt y))
-- | Makes a 'String'.
instance IsString Dynamic where
fromString = String . T.pack
-- | Does what you'd expect.
instance Aeson.FromJSON Dynamic where
parseJSON =
\case
Aeson.Array a -> Array <$> traverse Aeson.parseJSON a
Aeson.Number sci -> pure (Double (realToFrac sci))
Aeson.Bool v -> pure (Bool v)
Aeson.Null -> pure Null
Aeson.Object hm -> fmap Dictionary (Aeson.parseJSON (Aeson.Object hm))
Aeson.String s -> pure (String s)
-- | Pretty much a 1:1 correspondance.
instance Aeson.ToJSON Dynamic where
toJSON =
\case
Dictionary v -> Aeson.toJSON v
Array v -> Aeson.toJSON v
String t -> Aeson.toJSON t
Double t -> Aeson.toJSON t
Bool t -> Aeson.toJSON t
Null -> Aeson.toJSON Aeson.Null
-- | Produces an array representing a row of columns.
instance Csv.FromRecord Dynamic where
parseRecord xs = Array <$> traverse Csv.parseField xs
-- | Produces a dictionary representing a row of columns.
instance Csv.FromNamedRecord Dynamic where
parseNamedRecord xs =
Dictionary . HM.fromList . map (first T.decodeUtf8) . HM.toList <$>
traverse Csv.parseField xs
-- | Tries to figure out decimals, coerce true/false into 'Bool', and
-- null into 'Null'.
instance Csv.FromField Dynamic where
parseField bs =
case T.decimal text of
Left {} ->
case T.toLower (T.strip text) of
"true" -> pure (Bool True)
"false" -> pure (Bool False)
"null" -> pure Null
_ -> asString
Right (v, _) -> pure v
where
text = T.decodeUtf8 bs
asString = pure (String (T.decodeUtf8 bs))
-- | Renders the elements of containers, or else a singleton.
instance Csv.ToRecord Dynamic where
toRecord =
\case
Dictionary hm -> V.map Csv.toField (V.fromList (HM.elems hm))
Array vs -> V.map Csv.toField vs
String s -> V.singleton (T.encodeUtf8 s)
Double d -> V.singleton (Csv.toField d)
Bool d -> V.singleton (Csv.toField (Bool d))
Null -> mempty
-- | Just works on dictionaries.
instance Csv.ToNamedRecord Dynamic where
toNamedRecord =
\case
Dictionary hm ->
HM.fromList (map (bimap T.encodeUtf8 Csv.toField) (HM.toList hm))
_ -> throw (TypeError "Can't make a CSV row out of a non-dictionary")
-- | Identity for strings, else JSON output.
instance Csv.ToField Dynamic where
toField =
\case
String i -> T.encodeUtf8 i
other -> L.toStrict (Aeson.encode other)
-- | Nulls are identity, arrays/dicts join, string + double/bool
-- append everything else is @toText x <> toText y@.
instance Semigroup Dynamic where
Null <> x = x
x <> Null = x
Array xs <> Array ys = Array (xs <> ys)
Dictionary x <> Dictionary y = Dictionary (x <> y)
String x <> String y = String (x <> y)
String x <> Double y = String (x <> toText (Double y))
Double x <> String y = String (toText (Double x) <> y)
String x <> Bool y = String (x <> toText (Bool y))
Bool x <> String y = String (toText (Bool x) <> y)
-- Everything else
x <> y = String (toText x <> toText y)
--------------------------------------------------------------------------------
-- Accessors
-- | @object ! key@ to access the field at key.
(!) :: Dynamic -> Dynamic -> Dynamic
(!) obj k =
case obj of
Dictionary mp ->
case HM.lookup (toText k) mp of
Nothing -> Null
Just v -> v
Array v ->
case v V.!? toInt k of
Nothing -> Null
Just el -> el
String str -> String (T.take 1 (T.drop (toInt k) str))
_ -> throw (DynamicTypeError "Can't index this type of value.")
infixl 9 !
-- | @set key value object@ -- set the field's value.
set :: Dynamic -> Dynamic -> Dynamic -> Dynamic
set k v obj =
case obj of
Dictionary mp -> Dictionary (HM.insert (toText k) v mp)
_ -> throw (DynamicTypeError "Not an object!")
-- | @modify k f obj@ -- modify the value at key.
modify :: Dynamic -> (Dynamic -> Dynamic) -> Dynamic -> Dynamic
modify k f obj =
case obj of
Dictionary mp -> Dictionary (HM.adjust f (toText k) mp)
_ -> throw (DynamicTypeError "Not an object!")
-- | @del k obj@ -- delete the key k in obj.
del :: Dynamic -> Dynamic -> Dynamic
del k obj =
case obj of
Dictionary mp -> Dictionary (HM.delete (toText k) mp)
_ -> throw (DynamicTypeError "Not an object!")
--------------------------------------------------------------------------------
-- Output
toString :: Dynamic -> String
toString = T.unpack . toText
toByteString :: Dynamic -> ByteString
toByteString = T.encodeUtf8 . toText
-- | Convert to string if string, or else JSON encoding.
toText :: Dynamic -> Text
toText =
\case
String s -> s
orelse -> toJson orelse
-- | Convert a dynamic value to a Double.
toDouble :: Dynamic -> Double
toDouble =
\case
String t ->
case T.double t of
Left {} ->
throw (DynamicTypeError ("Couldn't treat string as number: " <> t))
Right (v, _) -> v
Double d -> d
Bool {} -> throw (DynamicTypeError "Can't treat bool as number.")
Null -> 0
Dictionary {} ->
throw (DynamicTypeError "Can't treat dictionary as number.")
Array {} -> throw (DynamicTypeError "Can't treat array as number.")
-- | Convert a dynamic value to an Int.
toInt :: Dynamic -> Int
toInt = floor . toDouble
-- | Produces a JSON representation of the string.
toJson :: Dynamic -> Text
toJson = T.decodeUtf8 . L.toStrict . Aeson.encodePretty
-- | Produces a JSON representation of the string.
toJsonFile :: FilePath -> Dynamic -> IO ()
toJsonFile fp = L.writeFile fp . Aeson.encodePretty
-- | Produces a JSON representation of the string.
toCsv :: [Dynamic] -> Text
toCsv = T.decodeUtf8 . L.toStrict . Csv.encode
-- | Produces a JSON representation of the string.
toCsvFile :: FilePath -> [Dynamic] -> IO ()
toCsvFile fp = L.writeFile fp . Csv.encode
-- | Produces a JSON representation of the string.
toCsvNamed :: [Dynamic] -> Text
toCsvNamed xs = rows xs
where
rows = T.decodeUtf8 . L.toStrict . Csv.encodeByName (makeHeader xs)
makeHeader rs =
case rs of
(Dictionary hds:_) -> V.fromList (map T.encodeUtf8 (HM.keys hds))
_ -> mempty
-- | Convert to a boolean.
toBool :: Dynamic -> Bool
toBool =
\case
Dictionary m -> not (HM.null m)
Array v -> not (V.null v)
Bool b -> b
Double 0 -> False
Double {} -> True
Null -> False
String text ->
case T.toLower (T.strip text) of
"true" -> True
"false" -> False
_ -> not (T.null text)
-- | Convert to a list.
toList :: Dynamic -> [Dynamic]
toList =
\case
Array v -> V.toList v
Dictionary kvs ->
map
(\(k, v) -> Dictionary (HM.fromList [("key", String k), ("value", v)]))
(HM.toList kvs)
rest -> [rest]
-- | Get all the keys.
toKeys :: Dynamic -> [Dynamic]
toKeys =
\case
Array v -> V.toList v
Dictionary kvs -> map String (HM.keys kvs)
rest -> [rest]
-- | Get all the elems.
toElems :: Dynamic -> [Dynamic]
toElems =
\case
Array v -> V.toList v
Dictionary kvs -> HM.elems kvs
rest -> [rest]
--------------------------------------------------------------------------------
-- Input
-- | Read JSON into a Dynamic.
fromJson :: Text -> Dynamic
fromJson =
fromMaybe (throw (ParseError "Unable to parse JSON.")) .
Aeson.decode . L.fromStrict . T.encodeUtf8
-- | Read CSV into a list of rows with columns (don't use column names).
fromCsv :: Text -> [[Dynamic]]
fromCsv =
V.toList .
either (const (throw (ParseError "Unable to parse CSV."))) id .
Csv.decode Csv.NoHeader . L.fromStrict . T.encodeUtf8
-- | Read CSV into a list of rows (use column names).
fromCsvNamed :: Text -> [Dynamic]
fromCsvNamed =
V.toList .
either (const (throw (ParseError "Unable to parse CSV."))) snd .
Csv.decodeByName . L.fromStrict . T.encodeUtf8
-- | Same as 'fromJson' but from a file.
fromJsonFile :: FilePath -> IO Dynamic
fromJsonFile = fmap fromJson . T.readFile
-- | Same as 'fromCsv' but from a file.
fromCsvFile :: FilePath -> IO [[Dynamic]]
fromCsvFile = fmap fromCsv . T.readFile
-- | Same as 'fromCsvFileNamed' but from a file.
fromCsvFileNamed :: FilePath -> IO [Dynamic]
fromCsvFileNamed = fmap fromCsvNamed . T.readFile
-- | Convert a list of dynamics to a dynamic list.
fromList :: [Dynamic] -> Dynamic
fromList = Array . V.fromList
-- | Convert a list of key/pairs to a dynamic dictionary.
fromDict :: [(Dynamic, Dynamic)] -> Dynamic
fromDict hm = Dictionary (HM.fromList (map (bimap toText id) hm))
--------------------------------------------------------------------------------
-- Web helpers
-- | HTTP GET request for text content.
get ::
Dynamic
-> [(Dynamic, Dynamic)] -- ^ Headers.
-> IO Text
get url headers = do
response <-
httpBS
(foldl'
(\r (k, v) ->
addRequestHeader (fromString (toString k)) (toByteString v) r)
(addRequestHeader
"User-Agent"
"haskell-dynamic"
(fromString (toString url)))
headers)
pure (T.decodeUtf8 (getResponseBody response))
-- | HTTP GET request for text content.
getJson ::
Dynamic
-> [(Dynamic, Dynamic)] -- ^ Headers.
-> IO Dynamic
getJson url headers = fmap fromJson (get url headers)
-- | HTTP POST request for text content.
post ::
Dynamic -- ^ URL.
-> [(Dynamic, Dynamic)] -- ^ Headers.
-> Dynamic -- ^ Body.
-> IO Text
post url headers body = do
response <-
httpBS
(foldl'
(\r (k, v) ->
addRequestHeader (fromString (toString k)) (toByteString v) r)
(addRequestHeader
"User-Agent"
"haskell-dynamic"
(setRequestMethod
"POST"
(setRequestBodyJSON body (fromString (toString url)))))
headers)
pure (T.decodeUtf8 (getResponseBody response))
-- | HTTP POST request for JSON content.
postJson ::
Dynamic -- ^ URL.
-> [(Dynamic, Dynamic)] -- ^ Headers.
-> Dynamic -- ^ Body.
-> IO Dynamic
postJson url headers body = fmap fromJson (post url headers body)