{-# LANGUAGE OverloadedStrings, Rank2Types #-} -- | Low-level tools for querying the NationStates API. -- -- Most of the time, you should use the high-level wrappers in e.g. -- "NationStates.Nation" instead. But if you need something not provided -- by these wrappers, then feel free to use this module directly. module NationStates.Core ( -- * Requests NS, makeNS, makeNS', requestNS, apiVersion, -- * Query strings Query(..), -- * Connection manager Context(..), -- * Utilities wordsBy, readMaybe, expect, expected, -- * Data structures module NationStates.Types, ) where import Control.Applicative import qualified Data.ByteString.Char8 as BC import Data.Functor.Compose import qualified Data.Foldable as F import Data.List import Data.Monoid import Data.Map (Map) import qualified Data.Map as Map import Data.Maybe import Data.Set (Set) import qualified Data.Set as Set import Network.HTTP.Client import qualified Network.HTTP.Types as HTTP import Text.Read import Text.XML.Light import Prelude -- GHC 7.10 import NationStates.Types -- | A request to the NationStates API. -- -- * Construct an @NS@ using 'makeNS' or 'makeNS''. -- * Compose @NS@ values using the 'Applicative' interface. -- * Execute an @NS@ using 'requestNS'. -- -- This type wraps a query string, along with a function that parses the -- response. The funky type machinery keeps these two parts in sync, as -- long as you stick to the 'Applicative' interface. -- -- @ -- type NS a = ('Query', Query -> 'Element' -> a) -- @ type NS = Compose ((,) Query) (Compose ((->) Query) ((->) Element)) -- | Construct a request for a single shard. -- -- For example, this code requests the -- -- shard: -- -- @ -- motto :: NS String -- motto = makeNS \"motto\" \"MOTTO\" -- @ -- -- For more complex requests (e.g. nested elements), try 'makeNS'' instead. makeNS :: String -- ^ Shard name -> String -- ^ XML element name -> NS String makeNS shard elemName = makeNS' shard Nothing [] parse where parse _ = strContent . fromMaybe errorMissing . findChild (unqual elemName) errorMissing = error $ "missing <" ++ elemName ++ "> element" -- | Construct a request for a single shard. makeNS' :: String -- ^ Shard name -> Maybe Integer -- ^ Shard ID -> [(String, String)] -- ^ List of options -> (Query -> Element -> a) -- ^ Function for parsing the response -> NS a makeNS' name maybeId options parse = Compose (Query { queryShards = Map.singleton name (Set.singleton maybeId), queryOptions = Map.fromList options }, Compose parse) -- | Perform a request on the NationStates API. requestNS :: Maybe (String, String) -- ^ Request type -> NS a -- ^ Set of shards to request -> Context -- ^ Connection manager -> IO a requestNS kindAndName (Compose (q, Compose p)) c = parse . responseBody <$> (contextRateLimit c $ httpLbs req (contextManager c)) where parse = p q . fromMaybe (error "invalid response") . parseXMLDoc req = initRequest { queryString = HTTP.renderQuery True (HTTP.toQuery $ F.toList kindAndName ++ [("q", shards), ("v", show apiVersion)]) <> BC.pack options, requestHeaders = ("User-Agent", BC.pack $ contextUserAgent c) : requestHeaders initRequest } (shards, options) = queryToUrl q initRequest :: Request Just initRequest = parseUrl "https://www.nationstates.net/cgi-bin/api.cgi" -- | The version of the NationStates API used by this package. -- -- Every request to NationStates includes this number. This means that -- if the response format changes, existing code will continue to work -- under the old API. -- -- This number should match the current API version, as given by -- . If not, -- please file an issue. apiVersion :: Integer apiVersion = 7 -- | Keeps track of rate limits and TLS connections. -- -- You should create a single 'Context' at the start of your program, -- then share it between multiple threads and requests. data Context = Context { contextManager :: Manager, contextRateLimit :: forall a. IO a -> IO a, contextUserAgent :: String } -- | Keeps track of the set of shards to request. data Query = Query { queryShards :: Map String (Set (Maybe Integer)), queryOptions :: Map String String } deriving Show instance Monoid Query where mempty = Query mempty mempty mappend a b = Query { queryShards = Map.unionWith Set.union (queryShards a) (queryShards b), queryOptions = Map.unionWithKey mergeOptions (queryOptions a) (queryOptions b) } where mergeOptions key _ _ = error $ "conflicting values for option " ++ show key queryToUrl :: Query -> (String, String) queryToUrl q = (shards, options) where shards = intercalate "+" [ name ++ F.foldMap (\i -> "-" ++ show i) maybeId | (name, is) <- Map.toList $ queryShards q, maybeId <- Set.toList is ] options = concat [ ";" ++ k ++ "=" ++ v | (k, v) <- Map.toList $ queryOptions q ] -- | Split a list by the given predicate, dropping empty sublists. -- -- >>> wordsBy (== ',') "the_vines,motesardo-east_adanzi,yellowapple" -- ["the_vines", "montesardo-east_adanzi", "yellowapple"] -- -- >>> wordsBy (== ',') "" -- [] wordsBy :: (a -> Bool) -> [a] -> [[a]] wordsBy p s = case dropWhile p s of [] -> [] s' -> let (w, s'') = break p s' in w : wordsBy p s'' -- | Parse an input string using the given parser function. -- -- If parsing fails, raise an 'error'. -- -- >>> expect "integer" readMaybe "42" :: Integer -- 42 -- -- >>> expect "integer" readMaybe "butts" :: Integer -- *** Exception: invalid integer: "butts" expect :: String -> (String -> Maybe a) -> String -> a expect want parse = fromMaybe <$> expected want <*> parse -- | Raise an 'error'. -- -- >>> expected "integer" "butts" -- *** Exception: invalid integer: "butts" expected :: String -> String -> a expected want s = error $ "invalid " ++ want ++ ": " ++ show s