heddit-0.2: src/Network/Reddit/Internal.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
-- |
-- Module : Network.Reddit.Internal
-- Copyright : (c) 2021 Rory Tyler Hayford
-- License : BSD-3-Clause
-- Maintainer : rory.hayford@protonmail.com
-- Stability : experimental
-- Portability : GHC
--
module Network.Reddit.Internal
( runAction
, runAction_
, runActionWith
, runActionWith_
, updateToken
, mkRequest
, getMany
, redditURL
, oauthURL
, getMe
) where
import Conduit ( (.|), runConduit )
import Control.Monad
import Control.Monad.Catch ( MonadThrow(throwM) )
import Control.Monad.IO.Class ( MonadIO(liftIO) )
import Control.Monad.Reader ( asks )
import Data.Aeson
( FromJSON
, decode
, eitherDecode
, encode
)
import Data.Bool
import Data.ByteString ( ByteString )
import qualified Data.ByteString.Lazy as LB
import qualified Data.CaseInsensitive as CI
import Data.Conduit.Binary ( sinkLbs )
import qualified Data.Foldable as F
import Data.Foldable ( for_ )
import Data.Generics.Product ( HasField(field) )
import Data.Ix
import Data.List.Split ( chunksOf )
import Data.Sequence ( Seq )
import qualified Data.Text as T
import Data.Text ( Text )
import qualified Data.Text.Encoding as T
import Data.Time.Clock.POSIX
import Lens.Micro
import Network.HTTP.Client.Conduit
( Request
, RequestBody(RequestBodyLBS)
, Response
, withResponse
)
import qualified Network.HTTP.Client.Conduit as H
import Network.HTTP.Client.MultipartFormData ( formDataBody )
import qualified Network.HTTP.Conduit as H
import qualified Network.HTTP.Types as H
import Network.Reddit.Auth
import Network.Reddit.Types
import Network.Reddit.Types.Account
import Network.Reddit.Utils
import UnliftIO.IORef
import Web.FormUrlEncoded
( ToForm(toForm)
, urlEncodeFormStable
)
import Web.HttpApiData ( ToHttpApiData(..) )
-- | Run an 'APIAction' and decode the response JSON, governed by the type
-- parameterizing the action
runAction :: forall a m. (MonadReddit m, FromJSON a) => APIAction a -> m a
runAction action@APIAction { .. } = do
ensureToken
(resp, x) <- runActionWith followRedirects =<< prepareRequest action
updateRateLimits resp
pure x
-- | Run an action, discarding the response body
runAction_ :: forall m. MonadReddit m => APIAction () -> m ()
runAction_ action = do
ensureToken
updateRateLimits =<< runActionWith_ =<< prepareRequest action
ensureToken :: MonadReddit m => m ()
ensureToken = do
expiresIn <- readClientState $ field @"accessToken" . field @"expiresIn"
obtained <- readClientState $ field @"tokenObtained"
now <- liftIO getPOSIXTime
when (now > (obtained - 10) + expiresIn) $ updateToken now
updateToken :: MonadReddit m => POSIXTime -> m ()
updateToken now = do
newToken <- refreshAccessToken
state <- asks (^. field @"clientState")
atomicModifyIORef' state $ \s ->
( s
& (field @"tokenObtained" .~ now)
. (field @"accessToken" .~ newToken)
, ()
)
-- | Update the current rate limit info, reading it from Reddit\'s response
-- headers
updateRateLimits :: MonadReddit m => Response (RawBody m) -> m ()
updateRateLimits resp = do
now <- liftIO getPOSIXTime
for_ (resp & H.responseHeaders & readRateLimits now) $ \rls -> do
state <- asks (^. field @"clientState")
atomicModifyIORef' state $ \s -> (s & field @"limits" ?~ rls, ())
runActionWith :: forall a m.
(MonadReddit m, FromJSON a)
=> Bool
-> Request
-> m (Response (RawBody m), a)
runActionWith followRedirects req = withResponse @_ @m req $ \resp -> do
let body = resp & H.responseBody
status = resp & H.responseStatus
statusCode = status & H.statusCode
headers = resp & H.responseHeaders
cookies = resp & H.responseCookieJar
bodyBS <- runConduit $ body .| sinkLbs
if
| inRange (300, 308) statusCode && not followRedirects -> throwM
. Redirected
$ H.getRedirectedRequest req headers cookies statusCode
| otherwise -> case eitherDecode @a bodyBS of
Right x -> pure (resp, x)
Left err -> case decode @APIException bodyBS of
Just e -> throwM e
Nothing -> throwM . flip JSONParseError bodyBS
$ "runAction: Error parsing JSON - " <> T.pack err
runActionWith_
:: forall m. MonadReddit m => Request -> m (Response (RawBody m))
runActionWith_ req = withResponse @_ @m req $ \resp -> do
let body = resp & H.responseBody
status = resp & H.responseStatus
statusCode = status & H.statusCode
bodyBS <- runConduit $ body .| sinkLbs
if
| (statusCode >= 300) -> case decode @APIException bodyBS of
Just e -> throwM e
Nothing -> throwM
$ JSONParseError "runAction_: Failed to parse error JSON"
bodyBS
| otherwise -> pure resp
prepareRequest :: MonadReddit m => APIAction a -> m Request
prepareRequest act@APIAction { .. } =
bool (mkRequest redditURL act)
(setHeaders =<< mkRequest oauthURL act)
needsAuth
mkRequest :: MonadIO m => ByteString -> APIAction a -> m Request
mkRequest host APIAction { .. } = case requestData of
WithJSON d -> pure $ case method of
p
| p `elem` [ POST, PUT, PATCH ] -> request
{ H.requestBody = RequestBodyLBS $ encode d
, H.requestHeaders =
[ (CI.mk "content-type", "application/json") ]
}
| otherwise -> request
WithForm fd -> pure $ case method of
GET -> request
{ H.queryString = (request & H.queryString)
<> "&"
<> LB.toStrict (urlEncodeFormStable fd)
}
p
| p `elem` [ POST, PUT, PATCH ] -> request
{ H.requestBody = RequestBodyLBS $ urlEncodeFormStable fd
, H.requestHeaders = [ ( CI.mk "content-type"
, "application/x-www-form-urlencoded"
)
]
}
_ -> request
WithMultipart ps -> case method of
POST -> formDataBody ps request
_ -> pure request
NoData -> pure request
where
request = H.defaultRequest
{ H.host = host
, H.secure = True
, H.port = 443
, H.method = bshow method
, H.path = joinPathSegments pathSegments
-- add @raw_json@ param to get unescaped HTML in response bodies
, H.queryString = bool mempty rawJSONQuery rawJSON
, H.redirectCount = bool 0 10 followRedirects
, H.checkResponse = checkResponse
}
rawJSONQuery =
H.renderQuery True $ H.toQuery @[(Text, Text)] [ ("raw_json", "1") ]
setHeaders :: MonadReddit m => Request -> m Request
setHeaders req = do
userAgent <- asks (^. field @"authConfig" . field @"userAgent")
token <- readClientState $ field @"accessToken" . field @"token"
let newHeaders = [ (CI.mk "authorization", auth)
, (CI.mk "user-agent", writeUA userAgent)
]
auth = "bearer " <> T.encodeUtf8 token
headers = req & H.requestHeaders
pure req { H.requestHeaders = newHeaders <> headers }
-- | Get the items that correspond to a container of 'Thing' instances, for example
-- a sequence of 'Network.Reddit.Types.Comment.CommentID's, which will evaluate to a
-- 'Seq' of 'Network.Reddit.Types.Comment.Comment's
getMany :: forall a b t m.
(MonadReddit m, Foldable t, Thing b, FromJSON a, FromJSON b)
=> ItemOpts
-> t b
-> m (Seq a)
getMany opts ids = mconcat <$> traverse getChunk (chunked ids)
where
chunked = chunksOf apiRequestLimit . F.toList
getChunk chunk = runAction (mkAction @(Listing b a) chunk)
<&> (^. field @"children")
mkAction :: forall c. [b] -> APIAction c
mkAction cs = (defaultAPIAction @c)
{ pathSegments = [ "api", "info" ]
, requestData = WithForm
$ toForm opts
<> mkTextForm [ ("id", fullname cs)
, ("limit", toQueryParam @Int apiRequestLimit)
]
}
-- NOTE
-- This action needs to be in this module to avoid cyclic module dependencies
-- | Get account information for the currently logged-in user. If you are using
-- an 'ApplicationOnly' client, calling this will throw an 'InvalidRequest'
getMe :: MonadReddit m => m Account
getMe = asks (^. field @"authConfig" . field @"appType") >>= \case
ApplicationOnly {} ->
throwM $ InvalidRequest "getMe: This action requires a user context"
_ ->
runAction defaultAPIAction { pathSegments = [ "api", "v1", "me" ] }