{-# OPTIONS_GHC -Wall -fdefer-typed-holes #-} {-# LANGUAGE BlockArguments, DerivingVia, LambdaCase, NamedFieldPuns #-} module Leanpub.Wreq ( -- * The Leanpub monad Leanpub (..), Context (..) -- * Configuration , runLeanpub, Config, configSession, configKey, configKeyFile -- * Leanpub actions , getBookSummary , getBookSalesSummary , createFreeBookCoupon , createManyFreeBookCoupons -- * Wreq , wreqGet, wreqPost, wreqGetAeson, wreqPostAeson, wreqPostAeson_ , WreqResponse, Path, Extension , QueryParam, FormParam (..), Session, newAPISession ) where -- aeson import qualified Data.Aeson -- base import Control.Monad hiding (fail, foldM) import Control.Monad.IO.Class import Control.Monad.Fail import Data.Function ((&)) import Data.Functor.Const import Data.Functor.Identity import qualified Data.List import Data.Maybe import Data.Monoid import Numeric.Natural import Prelude hiding (fail) -- bytestring import qualified Data.ByteString.Char8 import qualified Data.ByteString import qualified Data.ByteString.Lazy -- leanpub-concepts import Leanpub.Concepts -- mwc-random import System.Random.MWC (GenIO, createSystemRandom, uniformR) -- text import Data.Text (Text) import qualified Data.Text import qualified Data.Text.IO -- time import Data.Time (Day) import qualified Data.Time -- transformers import Control.Monad.Trans.Reader -- wreq import Network.Wreq (FormParam (..)) import qualified Network.Wreq import Network.Wreq.Session (Session, newAPISession) import qualified Network.Wreq.Session ------------------------------------------------------------ -- from the 'lens' library type Getting r s a = (a -> Const r a) -> s -> Const r s (^.) :: s -> Getting a s a -> a s ^. l = getConst (l Const s) type ASetter s t a b = (a -> Identity b) -> s -> Identity t (.~) :: ASetter s t a b -> b -> s -> t l .~ b = fmap runIdentity $ l (\_ -> Identity b) ------------------------------------------------------------ text :: String -> Text text = Data.Text.pack ascii :: String -> Data.ByteString.ByteString ascii = Data.ByteString.Char8.pack ------------------------------------------------------------ data Context = Context { contextSession :: Session , contextKeyMaybe :: Maybe ApiSecretKey , contextRandom :: GenIO } {- | There are two ways to run a 'Leanpub' action: 1. Create a 'Context' and then apply the newtyped @Context -> IO a@ function directly. 2. Create a 'Config' and then apply the 'runLeanpub' function. This approach is likely more convenient, because it can do some things automatically like creating the 'Session' and reading your API key from a file. -} newtype Leanpub a = Leanpub (Context -> IO a) deriving ( Functor, Applicative, Monad, MonadIO, MonadFail ) via ReaderT Context IO requireKey :: Leanpub () requireKey = Leanpub \Context{ contextKeyMaybe } -> when (isNothing contextKeyMaybe) (fail "API key is required.") ------------------------------------------------------------ runLeanpub :: Config -> Leanpub a -> IO a runLeanpub (Config config) (Leanpub action) = createContext (config baseConfigData) >>= action createContext :: ConfigData -> IO Context createContext ConfigData{ configData_session, configData_key } = do contextSession <- case configData_session of Nothing -> newAPISession Just x -> return x contextKeyMaybe <- case configData_key of KeyConfig_Pure x -> return (Just x) KeyConfig_File x -> fmap Just (readKeyFile x) KeyConfig_Nothing -> return Nothing contextRandom <- createSystemRandom return Context{ contextSession, contextKeyMaybe, contextRandom } {- | Construct a 'Config' by using '<>' to combine any of the following: * Either 'configKey' or 'configKeyFile' (not both) * Optionally, 'configSession' Then use the config as the first argument to the 'runLeanpub' function. -} newtype Config = Config (ConfigData -> ConfigData) deriving (Semigroup, Monoid) via (Endo ConfigData) data ConfigData = ConfigData { configData_session :: Maybe Session , configData_key :: KeyConfig } data KeyConfig = KeyConfig_Pure ApiSecretKey | KeyConfig_File FilePath | KeyConfig_Nothing baseConfigData :: ConfigData baseConfigData = ConfigData { configData_session = Nothing , configData_key = KeyConfig_Nothing } readKeyFile :: FilePath -> IO ApiSecretKey readKeyFile fp = fmap (ApiSecretKey . Data.Text.strip) (Data.Text.IO.readFile fp) configSession :: Session -> Config configSession x = Config (\c -> c { configData_session = Just x }) configKey :: ApiSecretKey -> Config configKey x = Config (\c -> c { configData_key = KeyConfig_Pure x }) configKeyFile :: FilePath -> Config configKeyFile x = Config (\c -> c { configData_key = KeyConfig_File x }) ------------------------------------------------------------ type WreqResponse = Network.Wreq.Response Data.ByteString.Lazy.ByteString type Path = [Text] type Extension = Text type QueryParam = (Text, Text) urlBase :: Text urlBase = text "https://leanpub.com/" makeUrl :: Path -> Extension -> String makeUrl xs ext = f xs ++ "." ++ Data.Text.unpack ext where f = Data.Text.unpack . Data.Text.intercalate (text "/") . (urlBase :) wreqDefaults :: Network.Wreq.Options wreqDefaults = Network.Wreq.defaults authGetParam :: ApiSecretKey -> (Text, Text) authGetParam (ApiSecretKey key) = (text "api_key", key) authFormParam :: ApiSecretKey -> FormParam authFormParam (ApiSecretKey key) = ascii "api_key" := key wreqGet :: Path -> Extension -> [QueryParam] -> Leanpub WreqResponse wreqGet path extension params = Leanpub \Context{ contextKeyMaybe, contextSession } -> let url = makeUrl path extension params' = maybe id (\key -> (authGetParam key :)) contextKeyMaybe params opts = wreqDefaults & Network.Wreq.params .~ params' in Network.Wreq.Session.getWith opts contextSession url wreqPost :: Path -> Extension -> [FormParam] -> Leanpub WreqResponse wreqPost path extension params = Leanpub \Context{ contextKeyMaybe, contextSession } -> let url = makeUrl path extension params' = maybe id (\key -> (authFormParam key :)) contextKeyMaybe params opts = wreqDefaults in Network.Wreq.Session.postWith opts contextSession url params' ------------------------------------------------------------ extJson :: Extension extJson = text "json" wreqBodyAeson :: MonadFail m => WreqResponse -> m Data.Aeson.Value wreqBodyAeson = either fail return . Data.Aeson.eitherDecode . (^. Network.Wreq.responseBody) wreqGetAeson :: Path -> [QueryParam] -> Leanpub Data.Aeson.Value wreqGetAeson path params = wreqGet path extJson params >>= wreqBodyAeson wreqPostAeson :: Path -> [FormParam] -> Leanpub Data.Aeson.Value wreqPostAeson path params = wreqPost path extJson params >>= wreqBodyAeson wreqPostAeson_ :: Path -> [FormParam] -> Leanpub () wreqPostAeson_ path params = void (wreqPostAeson path params) ------------------------------------------------------------ getBookSummary :: BookSlug -> Leanpub Data.Aeson.Value getBookSummary (BookSlug slug) = wreqGetAeson [slug] [] getBookSalesSummary :: BookSlug -> Leanpub Data.Aeson.Value getBookSalesSummary (BookSlug slug) = do requireKey wreqGetAeson [slug, text "sales"] [] createFreeBookCoupon :: BookSlug -- ^ What book does the coupon give away? -> CouponCode -- ^ The secret that the user needs to have -- to redeem the coupon -> CouponMaxUses -- ^ How many times can each coupon be used? -> Maybe CouponNote -- ^ An optional note to remind you what the -- coupon is for, why it was issued, etc. -> Leanpub Data.Aeson.Value createFreeBookCoupon (BookSlug slug) code uses noteMaybe = do requireKey start <- liftIO getToday wreqPostAeson [slug, text "coupons"] (freeBookParams start code uses noteMaybe) createManyFreeBookCoupons :: (CouponCode -> IO ()) -- ^ Action to perform after creating each coupon, -- e.g. perhaps 'print' for use in a REPL. -> Natural -- ^ How many coupons? -> BookSlug -- ^ What book does the coupon give away? -> CouponMaxUses -- ^ How many times can each coupon be used? -> Maybe CouponNote -- ^ An optional note to remind you what the -- coupon is for, why it was issued, etc. -> Leanpub () createManyFreeBookCoupons done n (BookSlug slug) uses noteMaybe = do requireKey start <- liftIO getToday (sequence_ . replicate (fromIntegral n)) do code <- randomCouponCode wreqPostAeson_ [slug, text "coupons"] (freeBookParams start code uses noteMaybe) liftIO (done code) freeBookParams :: Day -> CouponCode -> CouponMaxUses -> Maybe CouponNote -> [FormParam] freeBookParams start (CouponCode code) uses noteMaybe = catMaybes [ Just (ascii "coupon[coupon_code]" := code) , Just (ascii "coupon[start_date]" := formatDay start) , Just ( ascii "coupon[package_discounts_attributes][0][package_slug]" := "book") , Just ( ascii "coupon[package_discounts_attributes][0][discounted_price]" := "0.00") , case uses of CouponUseUnlimited -> Nothing CouponMaxUses n -> Just ( ascii "coupon[max_uses]" := toInteger n) , case noteMaybe of Nothing -> Nothing Just (CouponNote note) -> Just (ascii "coupon[note]" := note) ] getToday :: IO Day getToday = fmap Data.Time.utctDay Data.Time.getCurrentTime formatDay :: Day -> String formatDay = Data.Time.formatTime Data.Time.defaultTimeLocale "%Y-%m-%d" randomCouponCode :: Leanpub CouponCode randomCouponCode = do s <- sequence (Data.List.replicate 20 randomChar) return (CouponCode (text s)) randomChar :: Leanpub Char randomChar = Leanpub \Context{ contextRandom } -> pickOne contextRandom charset where charset = ['a'..'z'] ++ ['0'..'9'] pickOne :: GenIO -> [a] -> IO a pickOne g xs = do i <- uniformR (0, length xs - 1) g return (xs !! i)