packages feed

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

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

-- |
-- Module      : Network.Reddit.Utils
-- Copyright   : (c) 2021 Rory Tyler Hayford
-- License     : BSD-3-Clause
-- Maintainer  : rory.hayford@protonmail.com
-- Stability   : experimental
-- Portability : GHC
--
module Network.Reddit.Utils
    ( bshow
    , defaultAPIAction
    , joinPathSegments
    , writeUA
    , emptyPaginator
    , paginatorToFormData
    , apiRequestLimit
    , mkTextForm
    , mkTextFormData
    , submissionIDFromURL
    , subAPIPath
    , subAboutPath
    , textObject
    , textEncode
    , joinPerms
    , splitPath
    , splitURL
    , catchEmptyListing
    ) where

import           Control.Monad.Catch             ( MonadCatch(catch)
                                                 , MonadThrow(throwM)
                                                 )

import           Data.Aeson                      ( eitherDecode )
import           Data.ByteString                 ( ByteString )
import qualified Data.ByteString.Char8           as C8
import           Data.Containers.ListUtils       ( nubOrd )
import qualified Data.Foldable                   as F
import           Data.Generics.Wrapped           ( wrappedFrom, wrappedTo )
import           Data.List                       ( (\\) )
import qualified Data.Text                       as T
import           Data.Text                       ( Text )
import qualified Data.Text.Encoding              as T

import           Network.Reddit.Types
import           Network.Reddit.Types.Submission
import           Network.Reddit.Types.Subreddit

import           URI.ByteString
                 ( Authority(..)
                 , URIRef(URI, uriPath, uriAuthority)
                 , laxURIParserOptions
                 , parseURI
                 )

import           Web.FormUrlEncoded              ( ToForm(toForm) )
import           Web.HttpApiData                 ( ToHttpApiData(..) )

-- | Default settings for an 'APIAction' - a GET request with no path, form
-- data, or query string, and which requires authentication headers
defaultAPIAction :: APIAction a
defaultAPIAction = APIAction
    { method          = GET
    , pathSegments    = mempty
    , requestData     = NoData
    , needsAuth       = True
    , followRedirects = True
    , rawJSON         = True
    , checkResponse   = \_ _ -> pure ()
    }

-- | Join a collection of 'PathSegment's, with a leading slash
joinPathSegments :: Foldable t => t PathSegment -> ByteString
joinPathSegments = T.encodeUtf8 . foldr (\a b -> "/" <> a <> b) mempty

-- | Convert a 'UserAgent' to its textual value
writeUA :: UserAgent -> ByteString
writeUA UserAgent { .. } = T.encodeUtf8 withInfo
  where
    withInfo = mconcat [ info, " ", "(", "by ", author, ")" ]

    info     = T.intercalate ":" [ platform, appID, version ]

paginatorToFormData :: (Thing t, Paginable a) => Paginator t a -> WithData
paginatorToFormData = WithForm . toForm

-- | An empty, default 'Paginator'. Includes the default 'PaginateOptions' for
-- the type @a@
emptyPaginator :: forall t a. Paginable a => Paginator t a
emptyPaginator = Paginator
    { before   = Nothing
    , after    = Nothing
    , limit    = 25
    , showAll  = False
    , srDetail = False
    , opts     = defaultOpts @a
    }

-- | Convert @(Text, Text)@ pairs into a URL-encoded 'Form'
mkTextFormData :: [(Text, Text)] -> WithData
mkTextFormData = WithForm . mkTextForm

apiRequestLimit :: Num n => n
apiRequestLimit = 100

-- | Parse a 'SubmissionID' from a Reddit URL
submissionIDFromURL :: MonadThrow m => Text -> m SubmissionID
submissionIDFromURL url = case parsed of
    Left _           -> invalidURL "Invalid URL provided"
    Right URI { .. }
        | Just Authority { authorityHost } <- uriAuthority    --
            -> case wrappedTo authorityHost of
                host
                    | host `elem` [ "reddit.com", "www.reddit.com" ]    --
                        -> case splitPath uriPath of
                            [ "gallery", sid ] -> pure $ mkID sid
                            ("comments" : sid : _) -> pure $ mkID sid
                            ("r" : _ : "comments" : sid : _) ->
                                pure $ mkID sid
                            _ -> invalidURL
                                $ mconcat [ "Path must be one of "
                                          , "/r/<SUBREDDIT>/comments/<ID>/<NAME>/, "
                                          , "/gallery/<ID>, or /comments/<ID>/"
                                          ]

                    | host == "redd.it" -> case splitPath uriPath of
                        [ sid ] -> pure $ mkID sid
                        _       -> invalidURL "Path may only contain /<ID>"

                    | otherwise -> invalidURL "Unrecognized host"
        | otherwise -> invalidURL "URL authority not present or unrecognized"
  where
    parsed     = parseURI laxURIParserOptions $ T.encodeUtf8 url

    invalidURL = throwM . InvalidRequest

    mkID       = wrappedFrom . T.decodeUtf8

-- | Get the API path for a subreddit given its 'SubredditName'
subAPIPath :: SubredditName -> PathSegment -> [PathSegment]
subAPIPath sname path = [ "r", toUrlPiece sname, "api", path ]

-- | Get the \"about\" path for a subreddit given its 'SubredditName'
subAboutPath :: SubredditName -> PathSegment -> [PathSegment]
subAboutPath sname path = [ "r", toUrlPiece sname, "about", path ]

-- | Turn a container of permissions into a string Reddit uses to configure
-- permissions for different roles. Included permissions are prefixed with
-- \"+\", omitted ones with \"-\"
--
-- Can be used with 'ModPermission's and 'LivePermission's
joinPerms
    :: (Foldable t, Ord a, Enum a, Bounded a, ToHttpApiData a) => t a -> Text
joinPerms perms = T.intercalate ","
    $ mconcat [ [ "-all" ]
              , prefixPerm "-" <$> omitted
              , prefixPerm "+" <$> included
              ]
  where
    included     = nubOrd $ F.toList perms

    omitted      = [ minBound .. ] \\ included

    prefixPerm t = (t <>) . toQueryParam

-- | Split a URL path
splitPath :: ByteString -> [ByteString]
splitPath = drop 1 . C8.split '/'

-- | Get the host and path segments from a URL
splitURL :: MonadThrow m => URL -> m (ByteString, [PathSegment])
splitURL url = case parseURI laxURIParserOptions $ T.encodeUtf8 url of
    Right URI { .. }
        | Just Authority { authorityHost } <- uriAuthority --
            ->
            pure (wrappedTo authorityHost, T.decodeUtf8 <$> splitPath uriPath)
        | otherwise -> invalidURL
    Left _           -> invalidURL
  where
    invalidURL = throwM $ InvalidResponse "splitURL: Couldn't parse URL"

-- | HACK
-- For some reason, if a subreddit does not exist, Reddit returns an
-- empty @Listing@ instead of returning 404
catchEmptyListing :: MonadReddit m => m a -> m a
catchEmptyListing action = catch @_ @APIException action $ \case
    e@(JSONParseError _ body) -> case eitherDecode @(Listing () ()) body of
        Right _ -> throwM . ErrorWithStatus
            $ StatusMessage 404 "Resource does not exist"
        Left _  -> throwM e
    e -> throwM e