Factor ICAFocus into modules, change schema to include fetchedAt

main
xenia 2023-08-11 18:54:04 +02:00
parent d5b58e8df3
commit da52df1e8f
4 changed files with 123 additions and 43 deletions

View File

@ -1,46 +1,40 @@
{-# LANGUAGE OverloadedStrings #-}
{- TODO: refactor code into multiple modules -}
module ICAFocus where
import qualified Network.HTTP.Client as H
import qualified Network.HTTP.Client.TLS as H
import Network.HTTP.Types.Status
import Data.Aeson
import Data.Aeson.Types
import Data.Aeson.KeyMap
import Data.Text
import Text.Read
import Data.Int (Int32)
import Data.ByteString.Lazy
import Control.Monad.Except
import Misc
newtype ImageLink = ImageLink { getLink :: String }
deriving (Show)
newtype ImageData = ImageData { getData :: ByteString }
type ProductID = Int32
instance Show ImageData where
show _ = "ImageData { ... }"
data ICAFocusItem a = ICAFocusItem
{ name :: Text
, costPerKg :: Float
, costPerUnit :: Float
, image :: a
, pricePerUnit :: Float
, pricePerKg :: Float
}
deriving (Show, Functor)
type ICAFocusData = ICAFocusItem ImageLink
type ICAFocusProduct = ICAFocusItem ImageData
unpackEither :: MonadFail m => Either String a -> m a
unpackEither (Left s) = fail s
unpackEither (Right a) = pure a
apiEndpoint :: Int -> String
apiEndpoint :: ProductID -> String
apiEndpoint i = "https://handlaprivatkund.ica.se/stores/1004247/api/v4/products/bop?retailerProductId=" ++ show i
(.::) :: FromJSON a => Parser Object -> Key -> Parser a
v .:: k = v >>= (.: k)
instance FromJSON ICAFocusData where
parseJSON (Object v) = do
productMap <- v .: "entities" .:: "product"
@ -51,26 +45,7 @@ instance FromJSON ICAFocusData where
ICAFocusItem
<$> product .: "name"
<*> (ImageLink <$> product .: "image" .:: "src")
<*> (unpackEither . readEither =<< product .: "price" .:: "unit" .:: "current" .:: "amount")
<*> (unpackEither . readEither =<< product .: "price" .:: "current" .:: "amount")
<*> (unpackEither . readEither =<< product .: "price" .:: "unit" .:: "current" .:: "amount")
<*> (ImageLink <$> product .: "image" .:: "src")
loadProductData :: Int -> ExceptT String IO ICAFocusData
loadProductData product_id = do
req <- H.parseRequest $ apiEndpoint product_id
resp <- liftIO $ H.httpLbs req =<< H.newManager H.tlsManagerSettings
unless (H.responseStatus resp == status200) (throwError "Error status code")
liftEither . eitherDecode $ H.responseBody resp
loadProductImage :: ICAFocusData -> ExceptT String IO ICAFocusProduct
loadProductImage x = do
req <- H.parseRequest $ (getLink . image) x
resp <- liftIO $ H.httpLbs req =<< H.newManager H.tlsManagerSettings
unless (H.responseStatus resp == status200) (throwError "Error status code")
pure $ const (ImageData (H.responseBody resp)) <$> x
loadProduct :: Int -> ExceptT String IO ICAFocusProduct
loadProduct = loadProductData >=> loadProductImage

View File

@ -0,0 +1,96 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE LambdaCase #-}
module Get where
import qualified Network.HTTP.Client as H
import qualified Network.HTTP.Client.TLS as H
import Network.HTTP.Types.Status
import Control.Monad.Except
import qualified Database.PostgreSQL.Typed as PG
import Data.Aeson
import Data.Text
import Text.Read
import Data.Int (Int32)
import Data.ByteString.Lazy
import Data.Bifunctor
import ICAFocus
import DBConfig
PG.useTPGDatabase myPGDatabase
data GetError
= ICAFocusAPIError (H.Response ByteString)
| DecodeError String
| ProductNotCached
| OutdatedEntry
| UniqueKeyViolation -- ^ should never happen
instance Show GetError where
show (ICAFocusAPIError r) = "ICAFocusAPIError { responseStatus = " ++ show (H.responseStatus r) ++ " }"
show (DecodeError e) = "DecodeError: " ++ e
show ProductNotCached = "ProductNotCached"
show OutdatedEntry = "OutdatedEntry"
show UniqueKeyViolation = "UniqueKeyViolation (should not be possible (what is postgres bro doing))"
type GetM = ExceptT GetError IO
loadProductData :: ProductID -> GetM ICAFocusData
loadProductData productId = do
req <- H.parseRequest $ apiEndpoint productId
resp <- liftIO $ H.httpLbs req =<< H.newManager H.tlsManagerSettings
unless (H.responseStatus resp == status200) (throwError $ ICAFocusAPIError resp)
liftEither . first DecodeError . eitherDecode $ H.responseBody resp
-- Fetches the image of a product, returning the product with the image data
finalizeProductImage :: ICAFocusData -> GetM ICAFocusProduct
finalizeProductImage x = do
req <- H.parseRequest $ (getLink . image) x
resp <- liftIO $ H.httpLbs req =<< H.newManager H.tlsManagerSettings
unless (H.responseStatus resp == status200) (throwError $ ICAFocusAPIError resp)
pure $ const (ImageData (H.responseBody resp)) <$> x
loadProduct :: ProductID -> GetM ICAFocusProduct
loadProduct = loadProductData >=> finalizeProductImage
fetchProduct :: PG.PGConnection -> ProductID -> GetM ICAFocusProduct
fetchProduct pg productId = do
datas <- liftIO $ PG.pgQuery pg [PG.pgSQL|
SELECT name, costPerKg, costPerUnit, image, fetchedAt + '24h' < now()
FROM ingredient_data
WHERE productId = ${productId}|]
case datas of
[] -> throwError ProductNotCached
_ : _ : _ -> throwError UniqueKeyViolation
[(_, _, _, _, Just True)] -> throwError OutdatedEntry
[(name, costPerKg, costPerUnit, image, _)] ->
pure $ ICAFocusItem name costPerKg costPerUnit (ImageData image)
cacheProduct :: PG.PGConnection -> ProductID -> ICAFocusProduct -> IO ()
cacheProduct pg productId product =
void $ PG.pgExecute pg [PG.pgSQL|
INSERT INTO ingredient_data (productId, name, costPerKg, costPerUnit, image, fetchedAt)
VALUES (${productId}, ${name product}, ${costPerKg product}, ${costPerUnit product}, ${getData (image product)}, now())
ON CONFLICT (productId)
DO UPDATE SET name = excluded.name, costPerKg = excluded.costPerKg, costPerUnit = excluded.costPerUnit, image = excluded.image, fetchedAt = excluded.fetchedAt|]
getProduct :: PG.PGConnection -> ProductID -> GetM ICAFocusProduct
getProduct pg productId = do
liftIO (runExceptT (fetchProduct pg productId)) >>= \case
Right product -> pure product
Left ProductNotCached -> refresh
Left OutdatedEntry -> refresh
Left e -> throwError e
where
refresh = do
product <- loadProduct productId
liftIO $ cacheProduct pg productId product
pure product

12
backend/Misc.hs 100644
View File

@ -0,0 +1,12 @@
module Misc where
import Data.Aeson
import Data.Aeson.Types
unpackEither :: MonadFail m => Either String a -> m a
unpackEither (Left s) = fail s
unpackEither (Right a) = pure a
(.::) :: FromJSON a => Parser Object -> Key -> Parser a
v .:: k = v >>= (.: k)

View File

@ -1,11 +1,8 @@
CREATE TABLE IF NOT EXISTS ingredient
( product_id INTEGER NOT NULL PRIMARY KEY -- https://handlaprivatkund.ica.se/stores/1004247/api/v4/products/bop?retailerProductId=...
);
CREATE TABLE IF NOT EXISTS ingredient_data
( product_id INTEGER REFERENCES ingredient(product_id)
( productId INTEGER NOT NULL PRIMARY KEY -- https://handlaprivatkund.ica.se/stores/1004247/api/v4/products/bop?retailerProductId=...
, name TEXT NOT NULL
, cost_per_kg REAL NOT NULL
, cost_per_unit REAL NOT NULL
, costPerKg REAL NOT NULL
, costPerUnit REAL NOT NULL
, image BYTEA NOT NULL
, fetchedAt TIMESTAMP NOT NULL
);