{-#LANGUAGE NoImplicitPrelude #-} {-#LANGUAGE OverloadedStrings #-} {-#LANGUAGE TypeFamilies #-} {-#LANGUAGE MultiParamTypeClasses #-} {-#LANGUAGE FlexibleInstances #-} {-#LANGUAGE FlexibleContexts #-} {-#LANGUAGE LambdaCase #-} {-#LANGUAGE DeriveGeneric #-} {-#LANGUAGE DeriveFunctor #-} {-#LANGUAGE TypeApplications #-} -- | Types for and operations on backend data. module Web.Sprinkles.Backends.Data ( BackendData (..) , BackendMeta (..) , BackendSource (..) , Verification (..) , RawBytes (..) , toBackendData , Items (..) , reduceItems , addBackendDataChildren , rawToLBS , rawFromLBS , serializeBackendSource , deserializeBackendSource ) where import Web.Sprinkles.Prelude import Text.Ginger (ToGVal (..), GVal, Run (..), dict, (~>)) import qualified Text.Ginger as Ginger import Data.Aeson as JSON import Data.Aeson.TH as JSON import Data.Yaml as YAML import qualified Data.Serialize as Cereal import Data.Serialize (Serialize) import Foreign.C.Types (CTime (..)) import Network.Mime (MimeType) import Data.Default (Default (..)) import Data.Time.Clock.POSIX (POSIXTime, posixSecondsToUTCTime) import Data.Time (UTCTime, LocalTime, utc, utcToLocalTime) import Data.Scientific (Scientific) import qualified Data.ByteString.Char8 as BS8 import qualified Data.ByteString.Lazy.Char8 as LBS8 import Text.Printf (printf) import Data.Foldable (Foldable (foldMap)) import Web.Sprinkles.Backends.Spec -- | Extract raw integer value from a 'CTime' unCTime :: CTime -> Int unCTime (CTime i) = fromIntegral i -- | The shapes of data that can be returned from a backend query. data Items a = NotFound -- ^ Nothing was found | SingleItem a -- ^ A single item was requested, and this is it | MultiItem [a] -- ^ Multiple items were requested, here they are deriving (Functor) instance Foldable Items where foldMap f NotFound = mempty foldMap f (SingleItem x) = f x foldMap f (MultiItem xs) = mconcat $ map f xs -- | Transform a raw list of results into an 'Items' value. This allows us -- later to distinguish between Nothing Found vs. Empty List, and between -- Single Item Requested And Found vs. Many Items Requested, One Found. This -- is needed such that when a single item is requested, it gets converted to -- 'GVal' and JSON as a scalar, while when we request many items and receive -- one, it becomes a singleton list. reduceItems :: FetchMode -> [a] -> Items a reduceItems FetchOne [] = NotFound reduceItems FetchOne (x:_) = SingleItem x reduceItems FetchAll xs = MultiItem xs reduceItems (FetchN n) xs = MultiItem $ take n xs instance ToGVal m a => ToGVal m (Items a) where toGVal NotFound = def toGVal (SingleItem x) = toGVal x toGVal (MultiItem xs) = toGVal xs instance ToJSON a => ToJSON (Items a) where toJSON NotFound = Null toJSON (SingleItem x) = toJSON x toJSON (MultiItem xs) = toJSON xs data RawBytes = RawBytes { rbLength :: IO Integer , rbGetRange :: Integer -> Integer -> IO LByteString } rawFromLBS :: LByteString -> RawBytes rawFromLBS b = RawBytes { rbLength = return . fromIntegral $ length b , rbGetRange = \start len -> return . take (fromIntegral len) . drop (fromIntegral start) $ b } rawToLBS :: RawBytes -> IO LByteString rawToLBS r = do len <- rbLength r rbGetRange r 0 len rawToGVal :: MonadIO m => RawBytes -> GVal (Run p m h) rawToGVal raw = dict [ ("length", Ginger.fromFunction (gfnLength raw)) , ("read", Ginger.fromFunction (gfnRead raw)) , ("store", Ginger.fromFunction (gfnStore raw)) ] where gfnLength :: MonadIO m => RawBytes -> [(Maybe Text, GVal (Run p m h))] -> Run p m h (GVal (Run p m h)) gfnLength raw args = do length <- liftIO (rbLength raw) return . toGVal $ length gfnRead :: MonadIO m => RawBytes -> [(Maybe Text, GVal (Run p m h))] -> Run p m h (GVal (Run p m h)) gfnRead raw args = do inputLength <- liftIO (rbLength raw) let extracted = Ginger.extractArgsDefL [ ("start", def) , ("length", toGVal inputLength) ] args case extracted of Right [startG, lengthG] -> do let start = fromMaybe 0 $ asInteger startG length = fromMaybe inputLength $ asInteger lengthG bytes <- liftIO (rbGetRange raw start length) return . toGVal . LBS8.unpack $ bytes _ -> fail "Invalid arguments to RawBytes.read" gfnStore :: MonadIO m => RawBytes -> [(Maybe Text, GVal (Run p m h))] -> Run p m h (GVal (Run p m h)) gfnStore raw args = do let extracted = Ginger.extractArgsDefL [ ("filename", "stored") ] args case extracted of Right [filenameG] -> liftIO $ do let filename = unpack . Ginger.asText $ filenameG len <- rbLength raw bytes <- rbGetRange raw 0 len LBS8.writeFile filename bytes return . toGVal $ True _ -> fail "Invalid arguments to RawBytes.store" asInteger :: GVal m -> Maybe Integer asInteger = fmap round . Ginger.asNumber instance MonadIO m => ToGVal (Run p m h) RawBytes where toGVal = rawToGVal -- | A parsed record from a query result. data BackendData p m h = BackendData { bdJSON :: JSON.Value -- ^ Result body as JSON , bdGVal :: GVal (Run p m h) -- ^ Result body as GVal , bdRaw :: RawBytes -- ^ Raw result body source , bdMeta :: BackendMeta -- ^ Meta-information , bdChildren :: HashMap Text (BackendData p m h) -- ^ Child documents , bdVerification :: Verification } data Verification = Trusted | VerifyCSRF deriving (Show, Eq, Enum, Ord, Bounded) -- | A raw (unparsed) record from a query result. data BackendSource = BackendSource { bsMeta :: BackendMeta , bsSource :: RawBytes , bsVerification :: Verification } deriving (Generic) data SerializableBackendSource = SerializableBackendSource { sbsMeta :: BackendMeta , sbsSource :: LByteString } deriving (Generic) instance Serialize SerializableBackendSource where serializeBackendSource :: BackendSource -> IO SerializableBackendSource serializeBackendSource bs = do srcBytes <- rawToLBS . bsSource $ bs return $ SerializableBackendSource (bsMeta bs) srcBytes deserializeBackendSource :: SerializableBackendSource -> BackendSource deserializeBackendSource sbs = BackendSource (sbsMeta sbs) (rawFromLBS $ sbsSource sbs) Trusted -- | Wrap a parsed backend value in a 'BackendData' structure. The original -- raw 'BackendSource' value is needed alongside the parsed value, because the -- resulting structure contains both the 'BackendMeta' and the raw (unparsed) -- data from it. toBackendData :: (ToJSON a, ToGVal (Run p m h) a) => BackendSource -> a -> BackendData p m h toBackendData src val = BackendData { bdJSON = toJSON val , bdGVal = toGVal val , bdRaw = bsSource src , bdMeta = bsMeta src , bdChildren = mapFromList [] , bdVerification = bsVerification src } addBackendDataChildren :: HashMap Text (BackendData p m h) -> BackendData p m h -> BackendData p m h addBackendDataChildren children bd = bd { bdChildren = children <> bdChildren bd } instance ToJSON (BackendData p m h) where toJSON = bdJSON instance MonadIO m => ToGVal (Run p m h) (BackendData p m h) where toGVal bd = let baseVal = bdGVal bd baseLookup = fromMaybe (const def) $ Ginger.asLookup baseVal baseDictItems = Ginger.asDictItems baseVal children = bdChildren bd childrenG = toGVal children in baseVal { Ginger.asLookup = Just $ \case "props" -> return . toGVal . bdMeta $ bd "children" -> return childrenG "bytes" -> return . toGVal . bdRaw $ bd k -> baseLookup k , Ginger.asDictItems = (("props" ~> bdMeta bd):) . (("bytes" ~> bdRaw bd):) . (("children", childrenG):) <$> baseDictItems } -- | Metadata for a backend query result. data BackendMeta = BackendMeta { bmMimeType :: MimeType , bmMTime :: Maybe POSIXTime -- ^ Last modification time, if available , bmName :: Text -- ^ Human-friendly name , bmPath :: Text -- ^ Path, according to the semantics of the backend (file path or URI) , bmSize :: Maybe Integer -- ^ Size of the raw source, in bytes, if available } deriving (Show, Generic) instance Serialize BackendMeta where put bm = do Cereal.put $ bmMimeType bm Cereal.put . fmap fromEnum $ bmMTime bm Cereal.put . encodeUtf8 $ bmName bm Cereal.put . encodeUtf8 $ bmPath bm Cereal.put $ bmSize bm get = BackendMeta <$> Cereal.get <*> (fmap toEnum <$> Cereal.get) <*> (decodeUtf8 <$> Cereal.get) <*> (decodeUtf8 <$> Cereal.get) <*> Cereal.get mtimeFlavors :: BackendMeta -> (Maybe POSIXTime, Maybe Scientific, Maybe LocalTime) mtimeFlavors bm = let mtime = bmMTime bm in ( mtime , realToFrac <$> mtime :: Maybe Scientific , utcToLocalTime utc . posixSecondsToUTCTime <$> mtime ) instance ToJSON BackendMeta where toJSON bm = let (mtime, mtimeSci, mtimeUTC) = mtimeFlavors bm in JSON.object [ "mimeType" .= decodeUtf8 @Text (bmMimeType bm) , "mtime" .= mtimeSci , "mtimeUTC" .= mtimeUTC , "name" .= bmName bm , "path" .= bmPath bm , "size" .= bmSize bm ] instance ToGVal m BackendMeta where toGVal bm = let (mtime, mtimeSci, mtimeUTC) = mtimeFlavors bm in Ginger.dict [ "type" ~> decodeUtf8 @Text (bmMimeType bm) , "mtime" ~> mtimeSci , "mtimeUTC" ~> mtimeUTC , "name" ~> bmName bm , "path" ~> bmPath bm , "size" ~> bmSize bm ]