asana-1.0.1.1: 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: { ... } }@
type role Single representational
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: [{ ... }] }@
type role Page representational
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> }@
type role ApiData representational
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"