packages feed

pinboard-0.10.2.0: src/Pinboard/Client.hs

{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ConstraintKinds #-}

------------------------------------------------------------------------------
-- | 
-- Module      : Pinboard.Client
-- Copyright   : (c) Jon Schoning, 2015
-- Maintainer  : jonschoning@gmail.com
-- Stability   : experimental
-- Portability : POSIX
------------------------------------------------------------------------------
module Pinboard.Client
  (
    -- * Config
    fromApiToken
  , defaultPinboardConfig
   -- | The PinboardConfig provides authentication via apiToken
  , PinboardConfig(..)
   -- * Monadic
  , runPinboard
  , runPinboardE
  , pinboardJson
   -- * Single
  , runPinboardSingleRaw
  , runPinboardSingleRawBS
  , runPinboardSingleJson
   -- * Sending
  , sendPinboardRequest
   -- * Delaying
  , requestThreadDelay
   -- *  Manager (http-client)
  , newMgr
  , mgrFail
   -- * JSON Handling
  , parseJSONResponse
  , decodeJSONResponse
   -- * Status Codes
  , checkStatusCodeResponse
  , checkStatusCode
   -- * Error Helpers
  , addErrMsg
  , createParserErr
  , httpStatusPinboardError
   -- * Client Dependencies
  , module X
  ) where

import Control.Monad.IO.Class
import Control.Monad.Reader

import Control.Monad.IO.Unlift
import UnliftIO.Exception

import Data.ByteString.Char8 (pack)
import Data.Aeson (FromJSON, eitherDecodeStrict')

import Network.HTTP.Types (urlEncode)
import Network.HTTP.Types.Status (statusCode)

import Network.HTTP.Client
import Network.HTTP.Client.TLS

import Control.Concurrent (threadDelay)
import Control.Monad.Logger

import Pinboard.Types as X
import Pinboard.Error as X
import Pinboard.Util as X
import Pinboard.Logging as X

import Paths_pinboard (version)
import Data.Version (showVersion)

import qualified Data.ByteString.Lazy as LBS
import qualified Data.Text as T
import qualified Data.Text.Encoding as T

import GHC.IO (unsafePerformIO)
import Data.IORef
import Data.Time.Clock
import Data.Time.Calendar
import Data.Bifunctor
import Data.Function

import Control.Applicative
import Prelude

-- | Create a default PinboardConfig using the supplied apiToken (ex: "use:ABCDEF0123456789ABCD")
fromApiToken :: String -> PinboardConfig
fromApiToken token =
  defaultPinboardConfig
  { apiToken = pack token
  }

defaultPinboardConfig :: PinboardConfig
defaultPinboardConfig =
  PinboardConfig
  { apiToken = mempty
  , maxRequestRateMills = 0
  , execLoggingT = runNullLoggingT
  , filterLoggingT = infoLevelFilter
  , lastRequestTime =
    unsafePerformIO $ newIORef (UTCTime (ModifiedJulianDay 0) 0)
  , doThreadDelay = Pinboard.Client.requestThreadDelay
  }
{-# NOINLINE defaultPinboardConfig #-}

--------------------------------------------------------------------------------
-- | Execute computations in the Pinboard monad
runPinboard
  :: MonadUnliftIO m
  => PinboardConfig -> PinboardT m a -> m a
runPinboard config f = liftIO newMgr >>= \mgr -> runPinboardE (config, mgr) f

-- | Execute computations in the Pinboard monad (with specified http Manager)
runPinboardE
  :: MonadUnliftIO m
  => PinboardEnv -> PinboardT m a -> m a
runPinboardE (config, mgr) f =
  runPinboardT (config, mgr) f

-- | Create a Pinboard value from a PinboardRequest w/ json deserialization
pinboardJson
  :: (MonadPinboard m, FromJSON a)
  => PinboardRequest -> m (Either PinboardError a)
pinboardJson req =
  logOnException logSrc $
  do logNST LevelInfo logSrc (toText req)
     env <- ask
     res <-
       liftIO $ sendPinboardRequest env (ensureResultFormatType FormatJson req)
     logNST LevelDebug logSrc (toText res)
     pure (parseJSONResponse res)
  where
    logSrc = "pinboardJson"

--------------------------------------------------------------------------------
runPinboardSingleRaw :: PinboardConfig
                     -> PinboardRequest
                     -> IO (Response LBS.ByteString)
runPinboardSingleRaw config req =
  runLogOnException logSrc config $
  do mgr <- liftIO newMgr
     logNST LevelInfo logSrc (toText req)
     res <- liftIO $ sendPinboardRequest (config, mgr) req
     logNST LevelDebug logSrc (toText res)
     return res
  where
    logSrc = "runPinboardSingleRaw"

runPinboardSingleRawBS
  :: 
  PinboardConfig -> PinboardRequest -> IO (Either PinboardError LBS.ByteString)
runPinboardSingleRawBS config req = do
  res <- runPinboardSingleRaw config req
  case checkStatusCodeResponse res of
    Left e -> logErrorAndThrow e
    Right _ -> (return . return) (responseBody res)
  where
    logSrc = "runPinboardSingleRawBS"
    logErrorAndThrow e =
      runConfigLoggingT config $
      do logNST LevelError logSrc (toText e)
         return (Left e)

runPinboardSingleJson
  :: FromJSON a
  => PinboardConfig -> PinboardRequest -> IO (Either PinboardError a)
runPinboardSingleJson config = runPinboard config . pinboardJson

--------------------------------------------------------------------------------
sendPinboardRequest :: PinboardEnv
                    -> PinboardRequest
                    -> IO (Response LBS.ByteString)
sendPinboardRequest (cfg@PinboardConfig {..}, mgr) PinboardRequest {..} = do
  let encodedParams = ("auth_token", urlEncode False apiToken) : encodeParams requestParams
      paramsText = T.decodeUtf8 (paramsToByteString encodedParams)
      url = T.unpack $ T.concat [requestPath, "?", paramsText]
  req <- buildReq url
  doThreadDelay cfg
  httpLbs req mgr

--------------------------------------------------------------------------------

-- | delays the thread, if the time since the previous request is less than the configured maxRequestRateMills 
requestThreadDelay :: PinboardConfig -> IO ()
requestThreadDelay cfg@PinboardConfig {..} = do
  currentTime <- getCurrentTime
  lastTime <- readIORef lastRequestTime
  let elapsedtime = diffUTCTime currentTime lastTime
      delaytime = max 0 (maxRequestRateSecs - elapsedtime)
  when (delaytime > 0) $
    do runConfigLoggingT cfg $
         let logTxt =
               "DELAY " <> ", lastTime: " <> toText lastTime <>
               ", maxRequestRateSecs: " <>
               toText maxRequestRateSecs <>
               ", elapsedTime: " <>
               toText elapsedtime <>
               ", delayTime: " <>
               toText delaytime
         in logNST LevelInfo "requestThreadDelay" logTxt
       threadDelay (floor (delaytime * 1000000))
  currentTime' <- getCurrentTime
  writeIORef lastRequestTime currentTime'
  where
    maxRequestRateSecs = fromInteger (toInteger maxRequestRateMills) / 1000

--------------------------------------------------------------------------------
buildReq :: String -> IO Request
buildReq url = do
  req <- parseRequest $ "https://api.pinboard.in/v1/" <> url
  return $
    setRequestIgnoreStatus $
    req
    { requestHeaders = [("User-Agent", "pinboard.hs/" <> pack (showVersion version))]
    }

--------------------------------------------------------------------------------
parseJSONResponse
  :: FromJSON a
  => Response LBS.ByteString -> Either PinboardError a
parseJSONResponse response =
  checkStatusCodeResponse response
  *> decodeJSONResponse (responseBody response)

decodeJSONResponse
  :: FromJSON a
  => LBS.ByteString -> Either PinboardError a
decodeJSONResponse s =
  let r = eitherDecodeStrict' (LBS.toStrict s)
  in either (Left . createParserErr . T.pack) return r

--------------------------------------------------------------------------------
checkStatusCodeResponse
  :: Response LBS.ByteString -> Either PinboardError ()
checkStatusCodeResponse resp =
  (checkStatusCode . statusCode . responseStatus) resp
  & (first . addErrMsg . toText . responseBody) resp

checkStatusCode
  :: Int -> Either PinboardError ()
checkStatusCode =
  \case
    200 -> return ()
    400 -> httpStatusPinboardError BadRequest
    401 -> httpStatusPinboardError UnAuthorized
    402 -> httpStatusPinboardError RequestFailed
    403 -> httpStatusPinboardError Forbidden
    404 -> httpStatusPinboardError NotFound
    429 -> httpStatusPinboardError TooManyRequests
    c
      | c >= 500 -> httpStatusPinboardError PinboardServerError
    _ -> httpStatusPinboardError UnknownHTTPCode

--------------------------------------------------------------------------------
httpStatusPinboardError
  :: PinboardErrorHTTPCode -> Either PinboardError a
httpStatusPinboardError err =
  Left
    defaultPinboardError
    { errorType = HttpStatusFailure
    , errorHTTP = Just err
    }

addErrMsg :: T.Text -> PinboardError -> PinboardError
addErrMsg msg err =
  err
  { errorMsg = msg
  }

createParserErr :: T.Text -> PinboardError
createParserErr msg = PinboardError ParseFailure msg Nothing Nothing Nothing

--------------------------------------------------------------------------------
newMgr :: IO Manager
newMgr =
  newManager $ managerSetProxy (proxyEnvironment Nothing) tlsManagerSettings

mgrFail
  :: (Monad m)
  => PinboardErrorType -> SomeException -> m (Either PinboardError b)
mgrFail e msg =
  return $ Left $ PinboardError e (toText msg) Nothing Nothing Nothing