packages feed

heddit-0.0.1: src/Network/Reddit/Types.hs

{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StrictData #-}
{-# LANGUAGE TypeApplications #-}

-- |
-- Module      : Network.Reddit.Types
-- Copyright   : (c) 2021 Rory Tyler Hayford
-- License     : BSD-3-Clause
-- Maintainer  : rory.hayford@protonmail.com
-- Stability   : experimental
-- Portability : GHC
--
module Network.Reddit.Types
    ( -- * Reddit
      RedditT
    , runRedditT
    , MonadReddit
    , UserAgent(..)
    , ClientSite
    , Client(..)
    , ClientState(..)
    , readClientState
    , WithData(..)
    , RateLimits(..)
    , readRateLimits
      -- * Auth
    , AppType(..)
    , AuthConfig(..)
    , AccessToken(..)
    , Token
    , Code
    , Scope(..)
    , PasswordFlow(..)
    , CodeFlow(..)
    , ClientID
    , ClientSecret
    , TokenDuration(..)
    , TokenManager(..)
      -- * Requests
    , APIAction(..)
    , Method(..)
    , PathSegment
      -- * Re-exports
    , module M
    ) where

import           Conduit                               ( MonadUnliftIO )

import           Control.Monad.Catch
                 ( MonadCatch
                 , MonadThrow
                 )
import           Control.Monad.Reader

import           Data.Aeson
                 ( (.:)
                 , (.:?)
                 , FromJSON(parseJSON)
                 , Options(constructorTagModifier)
                 , Value(String)
                 , defaultOptions
                 , genericParseJSON
                 , withObject
                 , withText
                 )
import qualified Data.ByteString.Char8                 as C8
import           Data.Char                             ( toLower )
import           Data.Generics.Product                 ( HasField(field) )
import           Data.Text                             ( Text )
import qualified Data.Text                             as T
import           Data.Time
import           Data.Time.Clock.POSIX

import           GHC.Exts                              ( IsList(fromList) )
import           GHC.Generics                          ( Generic )

import           Lens.Micro

import           Network.HTTP.Client                   ( BodyReader )
import           Network.HTTP.Client.Conduit
                 ( HasHttpManager(..)
                 , Manager
                 , Request
                 , Response
                 )
import           Network.HTTP.Client.MultipartFormData ( Part )
import           Network.HTTP.Types
                 ( HeaderName
                 , ResponseHeaders
                 )
import           Network.Reddit.Types.Internal         as M

import           Text.Read                             ( readMaybe )

import           UnliftIO.IORef

import           Web.FormUrlEncoded                    ( Form, ToForm(..) )
import           Web.HttpApiData
                 ( ToHttpApiData(toQueryParam)
                 , showTextData
                 )

-- | The monad tranformer in which Reddit API transactions can be executed
newtype RedditT m a = RedditT (ReaderT Client m a)
    deriving newtype ( Functor, Applicative, Monad, MonadIO, MonadUnliftIO
                     , MonadReader Client, MonadThrow, MonadCatch )

-- | Run a 'RedditT' action
runRedditT :: Client -> RedditT m a -> m a
runRedditT c (RedditT x) = runReaderT x c

-- | Synonym for constraints that 'RedditT' actions must satisfy
type MonadReddit m =
    (MonadUnliftIO m, MonadThrow m, MonadCatch m, MonadReader Client m)

-- | A client facilitating access to Reddit's API
data Client = Client
    { authConfig   :: AuthConfig
    , manager      :: Manager
    , clientState  :: IORef ClientState
    , tokenManager :: Maybe TokenManager
    }
    deriving stock ( Generic )

instance HasHttpManager Client where
    getHttpManager Client { manager } = manager

-- | Stateful data that may be updated over the course of a 'Client' lifetime
data ClientState = ClientState
    { accessToken   :: AccessToken
      -- | The approximate time at which the token was obtained. This is useful
      -- to compare against the @expiresIn@ field of the 'AccessToken'
    , tokenObtained :: POSIXTime
    , limits        :: Maybe RateLimits
    }
    deriving stock ( Show, Eq, Generic )

-- | For conveniently reading some field from the @IORef ClientState@ inside
-- a 'Client'
readClientState :: MonadReddit m => Lens' ClientState a -> m a
readClientState l = asks (^. field @"clientState") >>= readIORef <&> (^. l)

-- | A unique user agent to identify your application; Reddit applies
-- rate-limiting to common agents, and actively bans misleading ones
data UserAgent = UserAgent
    { -- | The target platform
      platform :: Text
      -- | A unique application ID
    , appID    :: Text
    , version  :: Text
      -- | Your username as contact information
    , author   :: Text
    }
    deriving stock ( Show, Eq, Generic )

-- | A client site corresponds to a field in your auth configuration ini file.
-- For instance, the 'ClientSite' \"mybot\" should map to section such as:
--
--  > [MYBOT]
--  > id = <clientID>
--  > ...
--
-- in your @auth.ini@ file.
--
-- __Note__: The 'ClientSite' and the corresponding ini section are case
-- insensitive!
type ClientSite = Text

-- | Rate limit info
data RateLimits = RateLimits
    { -- | The number of requests remaining in the current rate-limiting
      -- window
      remaining   :: Integer
    , used        :: Integer
      -- | Timestamp of the upper bound on rate-limiting counter reset
    , reset       :: POSIXTime
      -- | Epoch time at which the next request should be made in order
      -- to stay within the current rate limit bounds
    , nextRequest :: POSIXTime
    }
    deriving stock ( Show, Eq, Generic )

-- | Extract rate limit info from response headers. This should only be called
-- after making a request
readRateLimits :: POSIXTime -> ResponseHeaders -> Maybe RateLimits
readRateLimits time hs = do
    remaining <- round <$> lookupHeader @Double "x-ratelimit-remaining"
    used <- lookupHeader "x-ratelimit-used"
    reset <- (time +) . fromInteger
        <$> lookupHeader @Integer "x-ratelimit-reset"
    let nextTimeStamp = max 0 . min 10 $ (reset - fromInteger remaining) / 2
        nextRequest   = min reset $ time + nextTimeStamp
    pure RateLimits { .. }
  where
    lookupHeader :: forall a. Read a => HeaderName -> Maybe a
    lookupHeader v = readMaybe @a . C8.unpack =<< lookup v hs

--Auth-------------------------------------------------------------------------
-- | A configuration
data AuthConfig = AuthConfig
    { -- | Your application's client ID
      clientID  :: ClientID
      -- | The type of your application. This will determine how OAuth
      -- credentials are obtained
    , appType   :: AppType
      -- | Your unique user agent; will be used in the client
      -- that is obtained after authenticating
    , userAgent :: UserAgent
    }
    deriving stock ( Show, Eq, Generic )

-- | The three forms of application that may use the Reddit API, each having
-- different API access patterns
data AppType
    = -- | The simplest type of application. May only be used by the developer
      -- who owns the account. This requires supplying the usernme and password
      -- associated with the account
      ScriptApp ClientSecret PasswordFlow
      -- | For applications running on a server backend
    | WebApp ClientSecret CodeFlow
      -- | For applications installed on devices that the developer does not own
      -- (e.g., a mobile application)
    | InstalledApp CodeFlow
      -- Get an access token for read-only access without a user context. This
      -- will grant an 'Unlimited' OAuth scope, but most endpoints will not work.
      -- If accessing endpoints that require a user context, expect HTTP status
      -- exceptions. Additionally, JSON parsing may fail unexpectedly for various
      -- actions.
      --
      -- Note that although this app type is not associated with a user account, it
      -- is still necessary to register the application using your reddit account
    | ApplicationOnly ClientSecret
    deriving stock ( Show, Eq, Generic )

instance ToForm AppType where
    toForm = \case
        ScriptApp _ pf    -> toForm pf
        ApplicationOnly _ -> fromList [ ("grant_type", "client_credentials") ]
        WebApp _ cf       -> toForm cf
        InstalledApp cf   -> toForm cf

-- | Type synonym for client IDs
type ClientID = Text

-- | Type synonym for client secrets
type ClientSecret = Text

-- | Type synonym for the text of a token
type Token = Text

-- | Type synonym for the text of codes returned from auth URLs, for 'WebApp's
-- and 'InstalledApp's
type Code = Text

-- | Token received after authentication
data AccessToken = AccessToken
    { token        :: Token
      -- |
    , expiresIn    :: NominalDiffTime
    , scope        :: [Scope]
    , refreshToken :: Maybe Token
    }
    deriving stock ( Show, Eq, Generic )

instance FromJSON AccessToken where
    parseJSON = withObject "AccessToken" $ \o -> AccessToken
        <$> o .: "access_token"
        <*> o .: "expires_in"
        <*> (scopeP =<< o .: "scope")
        <*> o .:? "refresh_token"
      where
        scopeP = withText "Scope"
            $ traverse (parseJSON . String) . splitScopes
          where
            splitScopes t = T.split (`elem` [ ' ', ',' ]) t

-- | Simple user credentials for authenticating via 'ScriptApp's
--
-- __Note__: These credentials will be kept in memory!
data PasswordFlow = PasswordFlow
    { -- | The name of the user you are authenticating as
      username :: Text
      -- | The password of the user you are authenticating as
    , password :: Text
    }
    deriving stock ( Show, Eq, Generic )

instance ToForm PasswordFlow where
    toForm PasswordFlow { .. } =
        fromList [ ("grant_type", "password")
                 , ("username", username)
                 , ("password", password)
                 ]

-- | Details for OAuth \"code flow\", for 'WebApp's and 'InstalledApp's
data CodeFlow = CodeFlow
    { -- | This must exactly match the redirect URL you entered when making
      -- your application on Reddit
      redirectURI :: URL
      -- | This is the code that is obtained after a user grants permissions
      -- by visiting the URL generated by 'Network.Reddit.Auth.getAuthURL'.
      -- If you are using a 'TokenManager' with 'Network.Reddit.newClientWithManager',
      -- you can leave this field as empty text, since it won't be used to
      -- get the initial refresh token
    , code        :: Code
    }
    deriving stock ( Show, Eq, Generic )

instance ToForm CodeFlow where
    toForm CodeFlow { .. } = fromList [ ("code", code)
                                      , ("redirect_uri", redirectURI)
                                      , ("grant_type", "authorization_code")
                                      ]

-- | The duration of the access token for 'WebApp's and 'InstalledApp's
data TokenDuration
    = -- | Generates one-hour access tokens without a refresh token
      Temporary
      -- | Generates a one-hour access tokens with a refresh token
      -- that can be used to indefinitely obtain new access tokens
    | Permanent
    deriving stock ( Show, Eq, Generic )

instance ToHttpApiData TokenDuration where
    toQueryParam = showTextData

-- | Monadic actions to load and save 'Token's, specifically refresh tokens, when
-- creating new 'Client's for 'WebApp's and 'InstalledApp's
data TokenManager = TokenManager
    { -- | Load an existing refresh token, for instance from a file or database
      loadToken :: forall m. (MonadIO m, MonadThrow m) => m Token
      -- | Store the new refresh token that is received when exchanging the
      -- existing one for a new 'AccessToken'.
      --
      -- This action must take a @Maybe Token@ as its argument, as it is possible
      -- (albeit perhaps unlikely) that Reddit does not return a new token when
      -- exchanging the existing refresh token for a new access token
    , putToken  :: forall m. (MonadIO m, MonadThrow m) => Maybe Token -> m ()
    }

-- | Represents a specific Reddit functionality that must be explicitly
-- requested
data Scope
    = Accounts -- ^ Corresponds to \"account\" in text form
    | Creddits
    | Edit
    | Flair
    | History
    | Identity
    | LiveManage
    | ModConfig
    | ModContributors
    | ModFlair
    | ModLog
    | ModMail
    | ModOthers
    | ModPosts
    | ModSelf
    | ModTraffic
    | ModWiki
    | MySubreddits
    | PrivateMessages
    | Read
    | Report
    | Save
    | StructuredStyles
    | Submit
    | Subscribe
    | Vote
    | WikiEdit
    | WikiRead
    | Unlimited -- ^ For all scopes, corresponds to \"*\"
    deriving stock ( Generic, Eq, Show, Ord, Enum )

instance FromJSON Scope where
    parseJSON = genericParseJSON defaultOptions { constructorTagModifier }
      where
        constructorTagModifier = \case
            "Unlimited" -> "*"
            "Accounts"  -> "account"
            scope       -> toLower <$> scope

instance ToHttpApiData Scope where
    toQueryParam = \case
        Unlimited -> "*"
        Accounts  -> "account"
        s         -> showTextData s

--Requests---------------------------------------------------------------------
-- | HTTP method, excluding those not used in the Reddit API
data Method
    = GET
    | POST
    | DELETE
    | PUT
    | PATCH
    deriving stock ( Show, Eq, Generic )

-- | Data, either as JSON or URL-encoded form, to be attached to requests
data WithData
    = WithJSON Value
    | WithForm Form
    | WithMultipart [Part]
    | NoData
    deriving stock ( Show, Generic )

-- | An API request parameterized by the type it evaluates to when executed
data APIAction a = APIAction
    { method          :: Method
    , pathSegments    :: [PathSegment]
    , requestData     :: WithData
    , needsAuth       :: Bool
    , followRedirects :: Bool
    , rawJSON         :: Bool
    , checkResponse   :: Request -> Response BodyReader -> IO ()
    }
    deriving stock ( Generic )

-- | Type synonym for a segment of a URL path
type PathSegment = Text