{-# OPTIONS_GHC -Wall -fdefer-typed-holes #-} {-# LANGUAGE BlockArguments, DerivingVia, LambdaCase, RecordWildCards #-} 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 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 -- lens import Control.Lens ((&), (.~), (^.)) -- rando import System.Random.Pick (pickOne) -- 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 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 } {- | 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{..} -> 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{..} = 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 return Context{..} {- | 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{..} -> 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{..} -> 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 <- liftIO 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 :: IO CouponCode randomCouponCode = do s <- sequence (Data.List.replicate 20 randomChar) return (CouponCode (text s)) where randomChar = pickOne charset charset = ['a'..'z'] ++ ['0'..'9']