{-# LANGUAGE CPP #-} {-# LANGUAGE DeriveDataTypeable #-} {-# LANGUAGE FunctionalDependencies #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE OverloadedStrings #-} -- | Request sending and Response parsing module Lastfm.Response ( -- * Compute request signature -- $sign Secret(..) , sign -- * Perform requests , Connection , withConnection , newConnection , lastfm , lastfm_ , Supported , Format(..) -- ** Errors , LastfmError(..) , _LastfmBadResponse , _LastfmEncodedError , _LastfmHttpError #ifdef TEST , parse , md5 #endif ) where #if __GLASGOW_HASKELL__ < 710 import Control.Applicative #endif import Control.Exception (SomeException(..), Exception(..), catch) import Control.Monad.IO.Class (MonadIO(liftIO)) import Crypto.Hash (Digest, MD5, hash) import Data.Aeson ((.:), Value(..), decode) import Data.Aeson.Types (parseMaybe) import qualified Data.ByteString.Lazy as Lazy import qualified Data.ByteString as Strict import Data.Map (Map) import qualified Data.Map as M import Data.Monoid import Data.Profunctor (Choice, dimap, right') import Data.String (IsString(..)) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Encoding as T import qualified Data.Text.Read as T import Data.Typeable (Typeable, cast) import qualified Network.HTTP.Client as Http import qualified Network.HTTP.Client.TLS as Http import Text.XML (Document, parseLBS, def) import Text.XML.Cursor import Lastfm.Internal -- $sign -- -- The signature is required for every -- authenticated API request. Basically, -- every such request appends the md5 footprint -- of its arguments to the query as -- described at -- | 'Supported' provides parsing for the chosen 'Format' -- -- 'JSON' is parsed to 'Value' type from aeson, while 'XML' -- is parsed to 'Document' from xml-conduit class Supported f r | f -> r, r -> f where prepareRequest :: R f -> R f parseResponseBody :: Lazy.ByteString -> Maybe r parseResponseEncodedError :: r -> Maybe LastfmError instance Supported 'JSON Value where prepareRequest r = r { _query = M.singleton "format" "json" `M.union` _query r } parseResponseBody = decode parseResponseEncodedError = parseMaybe $ \(Object o) -> do code <- o .: "error" msg <- o .: "message" return (LastfmEncodedError code msg) instance Supported 'XML Document where prepareRequest = id parseResponseBody = either (const Nothing) Just . parseLBS def parseResponseEncodedError doc = case fromDocument doc of cur | [mcode] <- cur $| element "lfm" >=> child >=> element "error" >=> attribute "code" , Right (code, _) <- T.decimal mcode , [msg] <- cur $| element "lfm" >=> child >=> element "error" >=> child >=> content -> Just (LastfmEncodedError code (T.strip msg)) | otherwise -> Nothing parse :: Supported f r => Lazy.ByteString -> Either LastfmError r parse body = case parseResponseBody body of Just v | Just e <- parseResponseEncodedError v -> Left e | otherwise -> Right v Nothing -> Left (LastfmBadResponse body) base :: R f base = R { _host = "https://ws.audioscrobbler.com/2.0/" , _method = "GET" , _query = mempty } -- | Different ways last.fm response can be unusable data LastfmError = -- | last.fm thinks it responded with something legible, but it really isn't LastfmBadResponse Lazy.ByteString -- | last.fm error code and message string | LastfmEncodedError Int Text -- | wrapped http-conduit exception | LastfmHttpError Http.HttpException deriving (Show, Typeable) -- | Admittedly, this isn't the best 'Eq' instance ever -- but not having 'Eq' 'C.HttpException' does not leave much a choice instance Eq LastfmError where LastfmBadResponse bs == LastfmBadResponse bs' = bs == bs' LastfmEncodedError e s == LastfmEncodedError e' t = e == e' && s == t LastfmHttpError _ == LastfmHttpError _ = True _ == _ = False instance Exception LastfmError where fromException e@(SomeException se) | Just e' <- fromException e = Just (LastfmHttpError e') | otherwise = cast se class AsLastfmError t where _LastfmError :: (Choice p, Applicative m) => p LastfmError (m LastfmError) -> p t (m t) instance AsLastfmError LastfmError where _LastfmError = id {-# INLINE _LastfmError #-} instance AsLastfmError SomeException where _LastfmError = dimap (\e -> maybe (Left e) Right (fromException e)) (either pure (fmap toException)) . right' {-# INLINE _LastfmError #-} -- | This is a @ Prism' 'LastfmError' 'Lazy.ByteString' @ in disguise _LastfmBadResponse :: (Choice p, Applicative m, AsLastfmError e) => p Lazy.ByteString (m Lazy.ByteString) -> p e (m e) _LastfmBadResponse = _LastfmError . dimap go (either pure (fmap LastfmBadResponse)) . right' where go (LastfmBadResponse bs) = Right bs go x = Left x {-# INLINE go #-} {-# INLINE _LastfmBadResponse #-} -- | This is a @ Prism' 'LastfmError' ('Int', 'String') @ in disguise _LastfmEncodedError :: (Choice p, Applicative m, AsLastfmError e) => p (Int, Text) (m (Int, Text)) -> p e (m e) _LastfmEncodedError = _LastfmError . dimap go (either pure (fmap (uncurry LastfmEncodedError))) . right' where go (LastfmEncodedError n v) = Right (n, v) go x = Left x {-# INLINE go #-} {-# INLINE _LastfmEncodedError #-} -- | This is a @ Prism' 'LastfmError' 'C.HttpException' @ in disguise _LastfmHttpError :: (Choice p, Applicative m, AsLastfmError e) => p Http.HttpException (m Http.HttpException) -> p e (m e) _LastfmHttpError = _LastfmError . dimap go (either pure (fmap LastfmHttpError)) . right' where go (LastfmHttpError e) = Right e go x = Left x {-# INLINE go #-} {-# INLINE _LastfmHttpError #-} -- | Application secret newtype Secret = Secret Text deriving (Show, Eq, Typeable) instance IsString Secret where fromString = Secret . fromString -- | Sign the 'Request' with the 'Secret' so it's ready to be sent sign :: Secret -> Request f Sign -> Request f Ready sign s = coerce . (<* signature) where signature = wrap $ \r@R { _query = q } -> r { _query = apiSig s . authToken $ q } authToken :: Map Text Text -> Map Text Text authToken q = maybe q (M.delete "password") $ do password <- M.lookup "password" q username <- M.lookup "username" q return (M.insert "authToken" (md5 (username <> md5 password)) q) apiSig :: Secret -> Map Text Text -> Map Text Text apiSig (Secret s) q = M.insert "api_sig" (signer (foldr M.delete q ["format", "callback"])) q where signer = md5 . M.foldrWithKey (\k v xs -> k <> v <> xs) s -- | Get supplied string md5 hash hex representation md5 :: Text -> Text md5 = T.pack . show . (hash :: Strict.ByteString -> Digest MD5) . T.encodeUtf8 -- | Lastfm connection manager newtype Connection = Connection Http.Manager -- | Creating an HTTPS connection manager is expensive; it's advised to use -- a single 'Connection' for all communications with last.fm withConnection :: MonadIO m => (Connection -> m a) -> m a withConnection f = f =<< newConnection -- | Create a 'Connection'. Note that there's no need to close the connection manually, -- as it will be closed automatically when no longer in use, that is, all the requests have -- been processed and it's about to be garbage collected. newConnection :: MonadIO m => m Connection newConnection = liftIO (fmap Connection (Http.newManager Http.tlsManagerSettings)) -- | Perform the 'Request' and parse the response lastfm :: Supported f r => Connection -> Request f Ready -> IO (Either LastfmError r) lastfm man = lastfmWith man parse . finalize -- | Perform the 'Request' ignoring any responses lastfm_ :: Supported f r => Connection -> Request f Ready -> IO (Either LastfmError ()) lastfm_ man = lastfmWith man (\_ -> Right ()) . finalize -- | Send the 'R' and parse the 'Response' with the supplied parser lastfmWith :: Connection -> (Lazy.ByteString -> Either LastfmError a) -> R f -> IO (Either LastfmError a) lastfmWith (Connection man) p r = do req <- Http.parseUrlThrow (render r) let req' = req { Http.method = _method r } p . Http.responseBody <$> Http.httpLbs req' man `catch` (return . Left) -- | Get the 'R' from the 'Request' finalize :: Supported f r => Request f Ready -> R f finalize x = (prepareRequest . unwrap x) base