packages feed

asana-1.0.0.0: library/Asana/Api/Request.hs

module Asana.Api.Request
  ( AsanaAccessKey(..)
  , HasAsanaAccessKey(..)
  , Single(..)
  , Page(..)
  , NextPage(..)
  , ApiData(..)
  , getAll
  , getAllParams
  , getSingle
  , put
  , post
  , maxRequests
  ) where

import Asana.Api.Prelude

import Data.Aeson
import Data.Aeson.Casing (aesonPrefix, snakeCase)
import qualified Data.ByteString.Lazy as BSL
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Data.Text.Encoding.Error as T
import Network.HTTP.Simple
  ( JSONException(JSONConversionException, JSONParseException)
  , Request
  , Response
  , addRequestHeader
  , getResponseBody
  , getResponseHeader
  , getResponseStatusCode
  , httpJSON
  , parseRequest_
  , setRequestBodyJSON
  , setRequestMethod
  )
import UnliftIO.Concurrent (threadDelay)

newtype AsanaAccessKey = AsanaAccessKey
    { unAsanaAccessKey :: Text
    }

class HasAsanaAccessKey env where
  asanaAccessKeyL :: Lens' env AsanaAccessKey

instance HasAsanaAccessKey AsanaAccessKey where
  asanaAccessKeyL = id

maxRequests :: Int
maxRequests = 50

-- | Type for a single-resource response, containing @{ data: { ... } }@
newtype Single a = Single
  { sData :: a
  }
  deriving newtype (Eq, Show)
  deriving stock Generic

instance FromJSON a => FromJSON (Single a) where
  parseJSON = genericParseJSON $ aesonPrefix snakeCase

-- | Type for a list-resource response, containing @{ data: [{ ... }] }@
data Page a = Page
  { pData :: [a]
  , pNextPage :: Maybe NextPage
  }
  deriving stock (Eq, Generic, Show)

instance FromJSON a => FromJSON (Page a) where
  parseJSON = genericParseJSON $ aesonPrefix snakeCase

-- | The @next_page@ element of a paginated response
data NextPage = NextPage
  { npOffset :: Text
  , npPath :: Text
  , npUri :: Text
  }
  deriving stock (Eq, Generic, Show)

instance FromJSON NextPage where
  parseJSON = genericParseJSON $ aesonPrefix snakeCase

-- | Generic type for un/wrapping an item as @{ data: <item> }@
newtype ApiData a = ApiData
  { adData :: a
  }
  deriving newtype (Show, Eq)
  deriving stock Generic

instance FromJSON a => FromJSON (ApiData a) where
  parseJSON = genericParseJSON $ aesonPrefix snakeCase

instance ToJSON a => ToJSON (ApiData a) where
  toJSON = genericToJSON $ aesonPrefix snakeCase
  toEncoding = genericToEncoding $ aesonPrefix snakeCase

-- | Naively GET all pages of a paginated resource
getAll
  :: ( MonadUnliftIO m
     , MonadLogger m
     , MonadReader env m
     , HasAsanaAccessKey env
     , FromJSON a
     )
  => String
  -> m [a]
getAll path = getAllParams path []

getAllParams
  :: ( MonadUnliftIO m
     , MonadLogger m
     , MonadReader env m
     , HasAsanaAccessKey env
     , FromJSON a
     )
  => String
  -> [(String, String)]
  -> m [a]
getAllParams path params = go Nothing
 where
  go mOffset = do
    Page d mNextPage <- get path params 50 mOffset

    maybe (pure d) (fmap (d ++) . go . Just . T.unpack . npOffset) mNextPage

-- | Get a single resource
getSingle
  :: ( MonadUnliftIO m
     , MonadLogger m
     , MonadReader env m
     , HasAsanaAccessKey env
     , FromJSON a
     )
  => String
  -> m a
getSingle path = sData <$> get path [] 1 Nothing

get
  :: ( MonadUnliftIO m
     , MonadLogger m
     , MonadReader env m
     , HasAsanaAccessKey env
     , FromJSON a
     )
  => String
  -> [(String, String)]
  -> Int
  -> Maybe String
  -> m a
get path params limit mOffset = do
  AsanaAccessKey key <- view asanaAccessKeyL
  let
    request =
      parseRequest_
        $ "https://app.asana.com/api/1.0"
        <> path
        <> "?limit="
        <> show limit -- Ignored on not paging responses
        <> maybe "" ("&offset=" <>) mOffset
        <> concatMap (\(k, v) -> "&" <> k <> "=" <> v) params
  response <- retry 50 $ httpJSON (addAuthorization key request)
  when (300 <= getResponseStatusCode response)
    $ logWarnNS "Asana"
    $ "GET failed, status: "
    <> pack (show $ getResponseStatusCode response)
  pure $ getResponseBody response

put
  :: ( MonadUnliftIO m
     , MonadLogger m
     , MonadReader env m
     , HasAsanaAccessKey env
     , ToJSON a
     )
  => String
  -> a
  -> m Value
put = httpAction "PUT"

post
  :: ( MonadUnliftIO m
     , MonadLogger m
     , MonadReader env m
     , HasAsanaAccessKey env
     , ToJSON a
     )
  => String
  -> a
  -> m Value
post = httpAction "POST"

httpAction
  :: ( MonadUnliftIO m
     , MonadLogger m
     , MonadReader env m
     , HasAsanaAccessKey env
     , ToJSON a
     )
  => ByteString
  -> String
  -> a
  -> m Value
httpAction verb path payload = do
  AsanaAccessKey key <- view asanaAccessKeyL
  let request = parseRequest_ $ "https://app.asana.com/api/1.0" <> path

  response <- retry 10 $ httpJSON
    (setRequestMethod verb . setRequestBodyJSON payload $ addAuthorization
      key
      request
    )
  when (300 <= getResponseStatusCode response) $ logWarnNS "Asana" $ mconcat
    [ "Request failed"
    , "\n  method: " <> T.decodeUtf8 verb
    , "\n  status: " <> pack (show $ getResponseStatusCode response)
    , "\n  body  : " <> T.decodeUtf8
      (BSL.toStrict $ encode $ toJSON $ getResponseBody @Value response)
    ]

  pure $ getResponseBody response

addAuthorization :: Text -> Request -> Request
addAuthorization key =
  addRequestHeader "Authorization" $ "Bearer " <> T.encodeUtf8 key

retry
  :: forall a m
   . (MonadUnliftIO m, MonadLogger m)
  => Int
  -> m (Response a)
  -> m (Response a)
retry attempt go
  | attempt <= 0 = go
  | otherwise = handler =<< go `catch` handleParseError
 where
  handleParseError :: JSONException -> m (Response a)
  handleParseError e = case e of
    JSONParseException _ rsp _ -> orThrow e rsp
    JSONConversionException _ rsp _ -> orThrow e rsp

  orThrow :: Exception e => e -> Response b -> m (Response a)
  orThrow e response
    | getResponseStatusCode response == 429 = do
      let seconds = getResponseDelay response
      logWarnNS "Asana" $ "Retrying after " <> pack (show seconds) <> " seconds"
      threadDelay $ seconds * 1000000
      retry (pred attempt) go
    | otherwise = liftIO $ throwIO e

  handler :: Response a -> m (Response a)
  handler response
    | getResponseStatusCode response == 429 = do
      let seconds = getResponseDelay response
      logWarnNS "Asana" $ "Retrying after " <> pack (show seconds) <> " seconds"
      threadDelay $ seconds * 100000
      retry (pred attempt) go
    | otherwise = pure response

getResponseDelay :: Response a -> Int
getResponseDelay =
  fromMaybe 0
    . readMaybe
    . T.unpack
    . T.decodeUtf8With T.lenientDecode
    . mconcat
    . getResponseHeader "Retry-After"