{-# 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)