packages feed

api-builder 0.1.1.0 → 0.2.0.0

raw patch · 17 files changed

+368/−339 lines, 17 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

- APIBuilder.API: baseURL :: Monad m => Text -> APIT s e m ()
- APIBuilder.API: customizeRequest :: Monad m => (Request -> Request) -> APIT s e m ()
- APIBuilder.API: customizeRoute :: Monad m => (Route -> Route) -> APIT s e m ()
- APIBuilder.API: liftBuilder :: Monad m => StateT Builder (StateT s m) a -> APIT s e m a
- APIBuilder.API: liftEither :: Monad m => EitherT (APIError e) (StateT Builder (StateT s m)) a -> APIT s e m a
- APIBuilder.API: liftState :: Monad m => StateT s m a -> APIT s e m a
- APIBuilder.API: name :: Monad m => Text -> APIT s e m ()
- APIBuilder.API: routeRequest :: Builder -> Route -> Maybe Request
- APIBuilder.API: routeResponse :: MonadIO m => Route -> APIT s e m (Response ByteString)
- APIBuilder.API: runAPI :: MonadIO m => Builder -> s -> APIT s e m a -> m (Either (APIError e) a)
- APIBuilder.API: runRoute :: (FromJSON a, FromJSON e, MonadIO m) => Route -> APIT s e m a
- APIBuilder.API: type API s e a = APIT s e IO a
- APIBuilder.API: type APIT s e m a = EitherT (APIError e) (StateT Builder (StateT s m)) a
- APIBuilder.Builder: Builder :: Text -> Text -> (Route -> Route) -> (Request -> Request) -> Builder
- APIBuilder.Builder: _baseURL :: Builder -> Text
- APIBuilder.Builder: _customizeRequest :: Builder -> Request -> Request
- APIBuilder.Builder: _customizeRoute :: Builder -> Route -> Route
- APIBuilder.Builder: _name :: Builder -> Text
- APIBuilder.Builder: basicBuilder :: Text -> Text -> Builder
- APIBuilder.Builder: data Builder
- APIBuilder.Builder: instance Show Builder
- APIBuilder.Decoding: decode :: (FromJSON a, FromJSON e) => ByteString -> Either (APIError e) a
- APIBuilder.Error: APIError :: a -> APIError a
- APIBuilder.Error: HTTPError :: HttpException -> APIError a
- APIBuilder.Error: InvalidURLError :: APIError a
- APIBuilder.Error: ParseError :: String -> APIError a
- APIBuilder.Error: data APIError a
- APIBuilder.Error: instance Show a => Show (APIError a)
- APIBuilder.Examples.StackOverflow: Question :: Text -> Bool -> Int -> [Text] -> Question
- APIBuilder.Examples.StackOverflow: Questions :: [Question] -> Questions
- APIBuilder.Examples.StackOverflow: answersRoute :: Route
- APIBuilder.Examples.StackOverflow: data Question
- APIBuilder.Examples.StackOverflow: getAnswers :: IO (Either (APIError ()) Questions)
- APIBuilder.Examples.StackOverflow: instance Eq Question
- APIBuilder.Examples.StackOverflow: instance Eq Questions
- APIBuilder.Examples.StackOverflow: instance FromJSON Question
- APIBuilder.Examples.StackOverflow: instance FromJSON Questions
- APIBuilder.Examples.StackOverflow: instance Show Question
- APIBuilder.Examples.StackOverflow: instance Show Questions
- APIBuilder.Examples.StackOverflow: isAnswered :: Question -> Bool
- APIBuilder.Examples.StackOverflow: newtype Questions
- APIBuilder.Examples.StackOverflow: score :: Question -> Int
- APIBuilder.Examples.StackOverflow: stackOverflow :: Builder
- APIBuilder.Examples.StackOverflow: tags :: Question -> [Text]
- APIBuilder.Examples.StackOverflow: title :: Question -> Text
- APIBuilder.Query: class ToQuery a
- APIBuilder.Query: instance ToQuery Bool
- APIBuilder.Query: instance ToQuery Int
- APIBuilder.Query: instance ToQuery Integer
- APIBuilder.Query: instance ToQuery Text
- APIBuilder.Query: instance ToQuery a => ToQuery (Maybe a)
- APIBuilder.Query: instance ToQuery a => ToQuery [a]
- APIBuilder.Query: toQuery :: ToQuery a => a -> Maybe Text
- APIBuilder.Routes: (=.) :: ToQuery a => Text -> a -> (Text, Maybe Text)
- APIBuilder.Routes: Route :: [URLFragment] -> [URLParam] -> Method -> Route
- APIBuilder.Routes: data Route
- APIBuilder.Routes: fragments :: Route -> [URLFragment]
- APIBuilder.Routes: httpMethod :: Route -> Method
- APIBuilder.Routes: instance Eq Route
- APIBuilder.Routes: instance Read Route
- APIBuilder.Routes: instance Show Route
- APIBuilder.Routes: routeURL :: Text -> Route -> Text
- APIBuilder.Routes: type URLFragment = Text
- APIBuilder.Routes: type URLParam = (Text, Maybe Text)
- APIBuilder.Routes: urlParams :: Route -> [URLParam]
+ Network.API.Builder.API: baseURL :: Monad m => Text -> APIT s e m ()
+ Network.API.Builder.API: customizeRequest :: Monad m => (Request -> Request) -> APIT s e m ()
+ Network.API.Builder.API: customizeRoute :: Monad m => (Route -> Route) -> APIT s e m ()
+ Network.API.Builder.API: execAPI :: MonadIO m => Builder -> s -> APIT s e m a -> m (Either (APIError e) a)
+ Network.API.Builder.API: liftBuilder :: Monad m => StateT Builder (StateT s m) a -> APIT s e m a
+ Network.API.Builder.API: liftEither :: Monad m => EitherT (APIError e) (ReaderT Manager (StateT Builder (StateT s m))) a -> APIT s e m a
+ Network.API.Builder.API: liftManager :: Monad m => ReaderT Manager (StateT Builder (StateT s m)) a -> APIT s e m a
+ Network.API.Builder.API: liftState :: Monad m => StateT s m a -> APIT s e m a
+ Network.API.Builder.API: name :: Monad m => Text -> APIT s e m ()
+ Network.API.Builder.API: routeRequest :: Builder -> Route -> Maybe Request
+ Network.API.Builder.API: routeResponse :: MonadIO m => Route -> APIT s e m (Response ByteString)
+ Network.API.Builder.API: runAPI :: MonadIO m => Builder -> Manager -> s -> APIT s e m a -> m (Either (APIError e) a, Builder, s)
+ Network.API.Builder.API: runRoute :: (FromJSON a, FromJSON e, MonadIO m) => Route -> APIT s e m a
+ Network.API.Builder.API: type API s e a = APIT s e IO a
+ Network.API.Builder.API: type APIT s e m a = EitherT (APIError e) (ReaderT Manager (StateT Builder (StateT s m))) a
+ Network.API.Builder.Builder: Builder :: Text -> Text -> (Route -> Route) -> (Request -> Request) -> Builder
+ Network.API.Builder.Builder: _baseURL :: Builder -> Text
+ Network.API.Builder.Builder: _customizeRequest :: Builder -> Request -> Request
+ Network.API.Builder.Builder: _customizeRoute :: Builder -> Route -> Route
+ Network.API.Builder.Builder: _name :: Builder -> Text
+ Network.API.Builder.Builder: basicBuilder :: Text -> Text -> Builder
+ Network.API.Builder.Builder: data Builder
+ Network.API.Builder.Builder: instance Show Builder
+ Network.API.Builder.Decoding: decode :: (FromJSON a, FromJSON e) => ByteString -> Either (APIError e) a
+ Network.API.Builder.Error: APIError :: a -> APIError a
+ Network.API.Builder.Error: HTTPError :: HttpException -> APIError a
+ Network.API.Builder.Error: InvalidURLError :: APIError a
+ Network.API.Builder.Error: ParseError :: String -> APIError a
+ Network.API.Builder.Error: data APIError a
+ Network.API.Builder.Error: instance Eq a => Eq (APIError a)
+ Network.API.Builder.Error: instance Show a => Show (APIError a)
+ Network.API.Builder.Examples.StackOverflow: Question :: Text -> Bool -> Int -> [Text] -> Question
+ Network.API.Builder.Examples.StackOverflow: Questions :: [Question] -> Questions
+ Network.API.Builder.Examples.StackOverflow: answersRoute :: Route
+ Network.API.Builder.Examples.StackOverflow: data Question
+ Network.API.Builder.Examples.StackOverflow: getAnswers :: IO (Either (APIError ()) Questions)
+ Network.API.Builder.Examples.StackOverflow: instance Eq Question
+ Network.API.Builder.Examples.StackOverflow: instance Eq Questions
+ Network.API.Builder.Examples.StackOverflow: instance FromJSON Question
+ Network.API.Builder.Examples.StackOverflow: instance FromJSON Questions
+ Network.API.Builder.Examples.StackOverflow: instance Show Question
+ Network.API.Builder.Examples.StackOverflow: instance Show Questions
+ Network.API.Builder.Examples.StackOverflow: isAnswered :: Question -> Bool
+ Network.API.Builder.Examples.StackOverflow: newtype Questions
+ Network.API.Builder.Examples.StackOverflow: score :: Question -> Int
+ Network.API.Builder.Examples.StackOverflow: stackOverflow :: Builder
+ Network.API.Builder.Examples.StackOverflow: tags :: Question -> [Text]
+ Network.API.Builder.Examples.StackOverflow: title :: Question -> Text
+ Network.API.Builder.Query: class ToQuery a
+ Network.API.Builder.Query: instance ToQuery Bool
+ Network.API.Builder.Query: instance ToQuery Int
+ Network.API.Builder.Query: instance ToQuery Integer
+ Network.API.Builder.Query: instance ToQuery Text
+ Network.API.Builder.Query: instance ToQuery a => ToQuery (Maybe a)
+ Network.API.Builder.Query: instance ToQuery a => ToQuery [a]
+ Network.API.Builder.Query: toQuery :: ToQuery a => a -> Maybe Text
+ Network.API.Builder.Routes: (=.) :: ToQuery a => Text -> a -> (Text, Maybe Text)
+ Network.API.Builder.Routes: Route :: [URLFragment] -> [URLParam] -> Method -> Route
+ Network.API.Builder.Routes: data Route
+ Network.API.Builder.Routes: fragments :: Route -> [URLFragment]
+ Network.API.Builder.Routes: httpMethod :: Route -> Method
+ Network.API.Builder.Routes: instance Eq Route
+ Network.API.Builder.Routes: instance Read Route
+ Network.API.Builder.Routes: instance Show Route
+ Network.API.Builder.Routes: routeURL :: Text -> Route -> Text
+ Network.API.Builder.Routes: type URLFragment = Text
+ Network.API.Builder.Routes: type URLParam = (Text, Maybe Text)
+ Network.API.Builder.Routes: urlParams :: Route -> [URLParam]

Files

api-builder.cabal view
@@ -1,5 +1,5 @@ name: api-builder-version: 0.1.1.0+version: 0.2.0.0 synopsis: Library for easily building REST API wrappers in Haskell license: BSD3 author: Fraser Murray@@ -11,14 +11,14 @@  library   exposed-modules:-    APIBuilder-    APIBuilder.API-    APIBuilder.Builder-    APIBuilder.Decoding-    APIBuilder.Error-    APIBuilder.Examples.StackOverflow-    APIBuilder.Query-    APIBuilder.Routes+    Network.API.Builder+    Network.API.Builder.API+    Network.API.Builder.Builder+    Network.API.Builder.Decoding+    Network.API.Builder.Error+    Network.API.Builder.Examples.StackOverflow+    Network.API.Builder.Query+    Network.API.Builder.Routes   build-depends:     base >= 4.6 && < 4.8,     aeson == 0.7.*,
− src/APIBuilder.hs
@@ -1,10 +0,0 @@--- | Exports a bunch of stuff. Check 'APIBuilder.API', 'APIBuilder.Builder',---   'APIBuilder.Decoding', 'APIBuilder.Error', and 'APIBuilder.Routes'.-module APIBuilder-  ( module Exports ) where--import APIBuilder.API as Exports-import APIBuilder.Builder as Exports-import APIBuilder.Decoding as Exports-import APIBuilder.Error as Exports-import APIBuilder.Routes as Exports
− src/APIBuilder/API.hs
@@ -1,116 +0,0 @@-module APIBuilder.API (-  -- * API-    API-  , APIT-  -- ** Running the API-  , runAPI-  , runRoute-  , routeResponse-  , routeRequest-  -- ** Lifting-  , liftEither-  , liftBuilder-  , liftState-  -- ** Changing the @Builder@ within the API-  , name-  , baseURL-  , customizeRoute-  , customizeRequest ) where--import APIBuilder.Builder-import APIBuilder.Decoding-import APIBuilder.Error-import APIBuilder.Routes--import Control.Exception-import Control.Monad.Trans.Either-import Control.Monad.Trans.State-import Control.Monad.Trans.Class (lift)-import Control.Monad.IO.Class (MonadIO, liftIO)-import Data.Aeson (FromJSON)-import Data.Text (Text)-import qualified Data.Text as T-import Data.ByteString.Lazy (ByteString)-import Network.HTTP.Conduit---- | Main API type. @s@ is the API's internal state, @e@ is the API's custom error type,---   and @a@ is the result when the API runs. Based on the @APIT@ transformer.-type API s e a = APIT s e IO a---- | Main API transformer type. @s@ is the API's internal state, @e@ is the API's custom error type,---   and @a@ is the result when the API runs.-type APIT s e m a = EitherT (APIError e) (StateT Builder (StateT s m)) a---- | Lifts an action that works on an @API@ to an action that works on an @API@.---   This function is provided solely for future-proofing in the case that more transformers---   need to be stacked on top - it's implemented simply as @id@ for the moment.-liftEither :: Monad m => EitherT (APIError e) (StateT Builder (StateT s m)) a -> APIT s e m a-liftEither = id---- | Lifts an action that operates on a @Builder@ to one that works on an @API@. Useful---   mainly for gaining access to a @Builder@ from inside an @API@.-liftBuilder :: Monad m => StateT Builder (StateT s m) a -> APIT s e m a-liftBuilder = lift---- | Lifts an action on an @API@'s state type @s@ to one that works on the @API@. Good---   for messing with the state from inside the @API@.-liftState :: Monad m => StateT s m a -> APIT s e m a-liftState = lift . lift---- | Runs an @API@ by executing its transformer stack and dumping it all into @IO@.-runAPI :: MonadIO m-       => Builder -- ^ initial @Builder@ for the @API@-       -> s -- ^ initial state @s@ for the @API@-       -> APIT s e m a -- ^ the actual @API@ to run-       -> m (Either (APIError e) a) -- ^ IO action that returns either an error or the result-runAPI b s api = evalStateT (evalStateT (runEitherT api) b) s---- | Runs a @Route@. Infers the type to convert to from the JSON with the @a@ in @API@,---   and infers the error type from @e@.-runRoute :: (FromJSON a, FromJSON e, MonadIO m) => Route -> APIT s e m a-runRoute route = routeResponse route >>= hoistEither . decode . responseBody----- | Runs a @Route@, but only returns the response and does nothing towards---   decoding the response.-routeResponse :: (MonadIO m) => Route -> APIT s e m (Response ByteString)-routeResponse route = do-  b <- liftBuilder get-  req <- hoistEither $ routeRequest b route `eitherOr` InvalidURLError-  resp <- do-    r <- liftIO $ try $ withManager (httpLbs req)-    hoistEither $ either (Left . HTTPError) Right r-  return resp--eitherOr :: Maybe a -> b -> Either b a-a `eitherOr` b =-  case a of-    Just x -> Right x-    Nothing -> Left b---- | Try to construct a @Request@ from a @Route@ (with the help of the @Builder@). Returns @Nothing@ if---   the URL is invalid or there is another error with the @Route@.-routeRequest :: Builder -> Route -> Maybe Request-routeRequest b route =-  let initialURL = parseUrl (T.unpack $ routeURL (_baseURL b) (_customizeRoute b route)) in-  fmap (\url -> _customizeRequest b $ url { method = httpMethod route }) initialURL---- | Modify the @name@ of the @Builder@ from inside an API. Using this is probably not the best idea,---   it's nice if the @Builder@'s name is stable at least.-name :: Monad m => Text -> APIT s e m ()-name t = liftBuilder $ modify (\b -> b { _name = t })---- | Modify the @baseURL@ of the @Builder@ from inside an API.---   Can be useful for changing the API's endpoints for certain requests.-baseURL :: Monad m => Text -> APIT s e m ()-baseURL t = liftBuilder $ modify (\b -> b { _baseURL = t })---- | Modify every @Route@ before it runs. Useful for adding extra params to every query,---   for example.-customizeRoute :: Monad m => (Route -> Route) -> APIT s e m ()-customizeRoute f = liftBuilder $ modify (\b -> b { _customizeRoute = f })---- | Modify every @Request@ before the API fetches it. Useful for adding headers to every request,---   for example.-customizeRequest :: Monad m => (Request -> Request) -> APIT s e m ()-customizeRequest f = liftBuilder $ modify (\b -> b { _customizeRequest = f })
− src/APIBuilder/Builder.hs
@@ -1,26 +0,0 @@-module APIBuilder.Builder-  ( Builder(..)-  , basicBuilder ) where--import APIBuilder.Routes--import Network.HTTP.Conduit (Request)-import Data.Text (Text)-import qualified Data.Text as T---- | Builder type for the API. Keeps track of the API's name and base URL, and how---   to modify Routes and Requests before they're run.-data Builder = Builder { _name :: Text-                       , _baseURL :: Text-                       , _customizeRoute :: Route -> Route-                       , _customizeRequest :: Request -> Request }--instance Show Builder where-  show b = "Builder { name = " ++ T.unpack (_name b) ++ "}"---- | Makes a basic builder, i.e. one that simply has a name and base URL---   and doesn't fiddle with Routes / Requests.-basicBuilder :: Text -- ^ name-             -> Text -- ^ base url-             -> Builder -- ^ a simple @Builder@-basicBuilder n b = Builder n b id id
− src/APIBuilder/Decoding.hs
@@ -1,29 +0,0 @@-module APIBuilder.Decoding-  ( decode ) where--import APIBuilder.Error--import Data.Aeson.Types (parseEither)-import Data.Aeson.Parser (value)-import Data.Aeson (FromJSON, parseJSON)-import Data.Attoparsec.Lazy (parse, eitherResult)-import qualified Data.ByteString.Lazy as BS---- | Try to parse a value from a JSON @ByteString@, and if not, try to---   parse a useful error from the JSON. If we can't do that, complain about---   a @ParseError@.-decode :: (FromJSON a, FromJSON e) => BS.ByteString -> Either (APIError e) a-decode s =-  case eitherDecode s of-    Right x -> Right x-    Left err ->-      case eitherDecode s of-        Right x -> Left $ APIError x-        Left _ -> Left $ ParseError err---- | @Data.Aeson@'s @decode@ function will only parse a value if it's---   an array or an object, in accordance with some RFC somewhere. Unfortunately---   not all JSON APIs respect this, so we have to mess with parsers and values and---   whatnot.-eitherDecode :: (FromJSON a) => BS.ByteString -> Either String a-eitherDecode s = eitherResult (parse value s) >>= parseEither parseJSON
− src/APIBuilder/Error.hs
@@ -1,17 +0,0 @@-module APIBuilder.Error-  ( APIError(..) ) where--import Network.HTTP.Conduit (HttpException)---- | Error type for the @API@, where @a@ is the type that should be returned when---   something goes wrong on the other end - i.e. any error that isn't directly related---   to this library.-data APIError a = APIError a -- ^ A type that represents any error that happens on the API end.-                             --   Define your own custom type with a @FromJSON@ instance if you-                             --   want to handle them, or you can use @()@ if you just want to-                             --   ignore them all.-                | HTTPError HttpException -- ^ Something went wrong when we tried to do a HTTP operation.-                | InvalidURLError -- ^ You're trying to create an invalid URL somewhere - check your-                                  --   @Builder@'s base URL and your @Route@s.-                | ParseError String -- ^ Failed when parsing the response, and it wasn't an error on their end.-  deriving Show
− src/APIBuilder/Examples/StackOverflow.hs
@@ -1,43 +0,0 @@--- | Defines a basic example of API use - check the readme for more detail---   or check the tutorial at <https://github.com/intolerable/api-builder>-module APIBuilder.Examples.StackOverflow where--import APIBuilder-import Control.Applicative-import Data.Aeson-import Data.Monoid (mempty)-import Data.Text (Text)--data Question = Question { title :: Text-                         , isAnswered :: Bool-                         , score :: Int-                         , tags :: [Text] }-  deriving (Show, Eq)--newtype Questions = Questions [Question]-  deriving (Show, Eq)--instance FromJSON Question where-  parseJSON (Object o) =-    Question <$> o .: "title"-             <*> o .: "is_answered"-             <*> o .: "score"-             <*> o .: "tags"-  parseJSON _ = mempty--instance FromJSON Questions where-  parseJSON (Object o) = Questions <$> o .: "items"-  parseJSON _ = mempty--stackOverflow :: Builder-stackOverflow = basicBuilder "StackOverflow API" "http://api.stackexchange.com"--answersRoute :: Route-answersRoute = Route { fragments = [ "2.2", "questions" ]-                     , urlParams = [ "order" =. ("desc" :: Text)-                                   , "sort" =. ("activity" :: Text)-                                   , "site" =. ("stackoverflow" :: Text) ]-                     , httpMethod = "GET" }--getAnswers :: IO (Either (APIError ()) Questions)-getAnswers = runAPI stackOverflow () $ runRoute answersRoute
− src/APIBuilder/Query.hs
@@ -1,28 +0,0 @@-module APIBuilder.Query where--import Data.Maybe-import Data.Text (Text)-import qualified Data.Text as Text--class ToQuery a where-  toQuery :: a -> Maybe Text--instance ToQuery Integer where-  toQuery = Just . Text.pack . show--instance ToQuery Bool where-  toQuery True = Just "true"-  toQuery False = Just "false"--instance ToQuery Int where-  toQuery = Just . Text.pack . show--instance ToQuery Text where-  toQuery = Just--instance ToQuery a => ToQuery (Maybe a) where-  toQuery (Just a) = toQuery a-  toQuery Nothing = Nothing--instance ToQuery a => ToQuery [a] where-  toQuery = Just . Text.intercalate "," . mapMaybe toQuery
− src/APIBuilder/Routes.hs
@@ -1,61 +0,0 @@-module APIBuilder.Routes-  ( Route(..)-  , URLFragment-  , URLParam-  , (=.)-  , routeURL ) where--import Control.Arrow ((***))-import Data.Maybe (mapMaybe)-import Data.Monoid ((<>))-import Data.Text (Text)-import qualified Data.Text as T-import qualified Network.HTTP.Base as HTTP (urlEncodeVars)-import qualified Network.HTTP.Types.Method as HTTP--import APIBuilder.Query---- | Alias for @Text@ to store the URL fragments for each @Route@.-type URLFragment = Text---- | Alias to @(Text, Maybe Text)@ used to store each query that gets---   tacked onto the request.-type URLParam = (Text, Maybe Text)---- | Convenience function for building @URLParam@s. Right-hand argument must---   have a @ToQuery@ instance so it can be converted to the appropriate---   representation in a query string. Query values do not need to be---   escaped.------ >>> "api_type" =. ("json" :: Text)--- ("api_type", Just "json")-(=.) :: ToQuery a => Text -> a -> (Text, Maybe Text)-k =. v = (k, toQuery v)---- | Main type for routes in the API. Used to represent the URL minus the actual---   endpoint URL as well as the query string and the HTTP method used to communicate---   with the server.-data Route = Route { fragments :: [URLFragment]-                   , urlParams :: [URLParam]-                   , httpMethod :: HTTP.Method }-  deriving (Show, Read, Eq)---- | Converts a Route to a URL. Drops any @Nothing@ values from the query, separates the---   fragments with "/" and tacks them onto the end of the base URL.-routeURL :: Text -- ^ base URL for the @Route@ (you can usually get this from the @Builder@)-         -> Route -- ^ the @Route@ to process-         -> Text -- ^ the finalized URL as a @Text@-routeURL baseURL (Route fs ps _) =-  let path = T.intercalate "/" fs-  in baseURL <> "/" <> path <> pathParamsSep fs <> buildParams ps--pathParamsSep :: [URLFragment] -> Text-pathParamsSep [] = "?"-pathParamsSep xs = if T.isInfixOf "." (last xs) then "?" else "/?"--buildParams :: [URLParam] -> Text-buildParams = T.pack . HTTP.urlEncodeVars . map (T.unpack *** T.unpack) . mapMaybe collectParams--collectParams :: URLParam -> Maybe (Text, Text)-collectParams (a, Just x) = Just (a,x)-collectParams (_, Nothing) = Nothing
+ src/Network/API/Builder.hs view
@@ -0,0 +1,11 @@+-- | Exports a bunch of stuff. Check 'Network.API.Builder.API', 'Network.API.Builder.Builder',+--   'Network.API.Builder.Decoding', 'Network.API.Builder.Error', and 'Network.API.Builder.Routes'.+module Network.API.Builder+  ( module Exports ) where++import Network.API.Builder.API as Exports+import Network.API.Builder.Builder as Exports+import Network.API.Builder.Decoding as Exports+import Network.API.Builder.Error as Exports+import Network.API.Builder.Query as Exports+import Network.API.Builder.Routes as Exports
+ src/Network/API/Builder/API.hs view
@@ -0,0 +1,136 @@+module Network.API.Builder.API (+  -- * API+    API+  , APIT+  -- ** Running the API+  , execAPI+  , runAPI+  , runRoute+  , routeResponse+  , routeRequest+  -- ** Lifting+  , liftManager+  , liftEither+  , liftBuilder+  , liftState+  -- ** Changing the @Builder@ within the API+  , name+  , baseURL+  , customizeRoute+  , customizeRequest ) where++import Network.API.Builder.Builder+import Network.API.Builder.Decoding+import Network.API.Builder.Error+import Network.API.Builder.Routes++import Control.Exception+import Control.Monad.IO.Class (MonadIO, liftIO)+import Control.Monad.Trans.Class (lift)+import Control.Monad.Trans.Either+import Control.Monad.Trans.Reader+import Control.Monad.Trans.State+import Data.Aeson (FromJSON)+import Data.ByteString.Lazy (ByteString)+import Data.Text (Text)+import Network.HTTP.Conduit+import qualified Data.Text as T++-- | Main API type. @s@ is the API's internal state, @e@ is the API's custom error type,+--   and @a@ is the result when the API runs. Based on the @APIT@ transformer.+type API s e a = APIT s e IO a++-- | Main API transformer type. @s@ is the API's internal state, @e@ is the API's custom error type,+--   and @a@ is the result when the API runs.+type APIT s e m a = EitherT (APIError e) (ReaderT Manager (StateT Builder (StateT s m))) a++-- | Lifts an action that works on an @API@ to an action that works on an @API@.+--   This function is provided solely for future-proofing in the case that more transformers+--   need to be stacked on top - it's implemented simply as @id@ for the moment.+liftEither :: Monad m => EitherT (APIError e) (ReaderT Manager (StateT Builder (StateT s m))) a -> APIT s e m a+liftEither = id++liftManager :: Monad m => ReaderT Manager (StateT Builder (StateT s m)) a -> APIT s e m a+liftManager = lift++-- | Lifts an action that operates on a @Builder@ to one that works on an @API@. Useful+--   mainly for gaining access to a @Builder@ from inside an @API@.+liftBuilder :: Monad m => StateT Builder (StateT s m) a -> APIT s e m a+liftBuilder = lift . lift++-- | Lifts an action on an @API@'s state type @s@ to one that works on the @API@. Good+--   for messing with the state from inside the @API@.+liftState :: Monad m => StateT s m a -> APIT s e m a+liftState = lift . lift . lift++-- | Runs an @API@ by executing its transformer stack and dumping it all into @IO@. Only returns the actual result.+execAPI :: MonadIO m+       => Builder -- ^ initial @Builder@ for the @API@+       -> s -- ^ initial state @s@ for the @API@+       -> APIT s e m a -- ^ the actual @API@ to run+       -> m (Either (APIError e) a) -- ^ IO action that returns either an error or the result+execAPI b s api = do+  m <- liftIO $ newManager conduitManagerSettings+  (res, _, _) <- runAPI b m s api+  liftIO $ closeManager m+  return res++-- | Runs an @API@ by executing its transformer stack and dumping it all into @IO@. Returns the actual result as well as the final states of the @Builder@ and custom state @s@.+runAPI :: MonadIO m+       => Builder -- ^ initial @Builder@ for the @API@+       -> Manager -- ^ manager for working with conduit functions+       -> s -- ^ initial state @s@ for the @API@+       -> APIT s e m a -- ^ the actual @API@ to run+       -> m (Either (APIError e) a, Builder, s) -- ^ IO action that returns either an error or the result, as well as the final states+runAPI b m s api = do+  ((res, b'), s') <- runStateT (runStateT (runReaderT (runEitherT api) m) b) s+  return (res, b', s')++-- | Runs a @Route@. Infers the type to convert to from the JSON with the @a@ in @API@,+--   and infers the error type from @e@.+runRoute :: (FromJSON a, FromJSON e, MonadIO m) => Route -> APIT s e m a+runRoute route = routeResponse route >>= hoistEither . decode . responseBody++-- | Runs a @Route@, but only returns the response and does nothing towards+--   decoding the response.+routeResponse :: (MonadIO m) => Route -> APIT s e m (Response ByteString)+routeResponse route = do+  b <- liftBuilder get+  m <- liftManager ask+  req <- hoistEither $ routeRequest b route `eitherOr` InvalidURLError+  do+    r <- liftIO $ try $ httpLbs req m+    hoistEither $ either (Left . HTTPError) Right r++eitherOr :: Maybe a -> b -> Either b a+a `eitherOr` b =+  case a of+    Just x -> Right x+    Nothing -> Left b++-- | Try to construct a @Request@ from a @Route@ (with the help of the @Builder@). Returns @Nothing@ if+--   the URL is invalid or there is another error with the @Route@.+routeRequest :: Builder -> Route -> Maybe Request+routeRequest b route =+  let initialURL = parseUrl (T.unpack $ routeURL (_baseURL b) (_customizeRoute b route)) in+  fmap (\url -> _customizeRequest b $ url { method = httpMethod route }) initialURL++-- | Modify the @name@ of the @Builder@ from inside an API. Using this is probably not the best idea,+--   it's nice if the @Builder@'s name is stable at least.+name :: Monad m => Text -> APIT s e m ()+name t = liftBuilder $ modify (\b -> b { _name = t })++-- | Modify the @baseURL@ of the @Builder@ from inside an API.+--   Can be useful for changing the API's endpoints for certain requests.+baseURL :: Monad m => Text -> APIT s e m ()+baseURL t = liftBuilder $ modify (\b -> b { _baseURL = t })++-- | Modify every @Route@ before it runs. Useful for adding extra params to every query,+--   for example.+customizeRoute :: Monad m => (Route -> Route) -> APIT s e m ()+customizeRoute f = liftBuilder $ modify (\b -> b { _customizeRoute = f })++-- | Modify every @Request@ before the API fetches it. Useful for adding headers to every request,+--   for example.+customizeRequest :: Monad m => (Request -> Request) -> APIT s e m ()+customizeRequest f = liftBuilder $ modify (\b -> b { _customizeRequest = f })
+ src/Network/API/Builder/Builder.hs view
@@ -0,0 +1,26 @@+module Network.API.Builder.Builder+  ( Builder(..)+  , basicBuilder ) where++import Network.API.Builder.Routes++import Data.Text (Text)+import Network.HTTP.Conduit (Request)+import qualified Data.Text as T++-- | Builder type for the API. Keeps track of the API's name and base URL, and how+--   to modify Routes and Requests before they're run.+data Builder = Builder { _name :: Text+                       , _baseURL :: Text+                       , _customizeRoute :: Route -> Route+                       , _customizeRequest :: Request -> Request }++instance Show Builder where+  show b = "Builder { name = " ++ T.unpack (_name b) ++ "}"++-- | Makes a basic builder, i.e. one that simply has a name and base URL+--   and doesn't fiddle with Routes / Requests.+basicBuilder :: Text -- ^ name+             -> Text -- ^ base url+             -> Builder -- ^ a simple @Builder@+basicBuilder n b = Builder n b id id
+ src/Network/API/Builder/Decoding.hs view
@@ -0,0 +1,29 @@+module Network.API.Builder.Decoding+  ( decode ) where++import Network.API.Builder.Error++import Data.Aeson (FromJSON, parseJSON)+import Data.Aeson.Parser (value)+import Data.Aeson.Types (parseEither)+import Data.Attoparsec.Lazy (parse, eitherResult)+import qualified Data.ByteString.Lazy as BS++-- | Try to parse a value from a JSON @ByteString@, and if not, try to+--   parse a useful error from the JSON. If we can't do that, complain about+--   a @ParseError@.+decode :: (FromJSON a, FromJSON e) => BS.ByteString -> Either (APIError e) a+decode s =+  case eitherDecode s of+    Right x -> Right x+    Left err ->+      case eitherDecode s of+        Right x -> Left $ APIError x+        Left _ -> Left $ ParseError err++-- | @Data.Aeson@'s @decode@ function will only parse a value if it's+--   an array or an object, in accordance with some RFC somewhere. Unfortunately+--   not all JSON APIs respect this, so we have to mess with parsers and values and+--   whatnot.+eitherDecode :: (FromJSON a) => BS.ByteString -> Either String a+eitherDecode s = eitherResult (parse value s) >>= parseEither parseJSON
+ src/Network/API/Builder/Error.hs view
@@ -0,0 +1,24 @@+module Network.API.Builder.Error+  ( APIError(..) ) where++import Network.HTTP.Conduit (HttpException)++-- | Error type for the @API@, where @a@ is the type that should be returned when+--   something goes wrong on the other end - i.e. any error that isn't directly related+--   to this library.+data APIError a = APIError a -- ^ A type that represents any error that happens on the API end.+                             --   Define your own custom type with a @FromJSON@ instance if you+                             --   want to handle them, or you can use @()@ if you just want to+                             --   ignore them all.+                | HTTPError HttpException -- ^ Something went wrong when we tried to do a HTTP operation.+                | InvalidURLError -- ^ You're trying to create an invalid URL somewhere - check your+                                  --   @Builder@'s base URL and your @Route@s.+                | ParseError String -- ^ Failed when parsing the response, and it wasn't an error on their end.+  deriving Show++instance Eq a => Eq (APIError a) where+  (HTTPError _) == _ = False+  _ == (HTTPError _) = False+  (APIError a) == (APIError b) = a == b+  InvalidURLError == InvalidURLError = True+  (ParseError a) == (ParseError b) = a == b
+ src/Network/API/Builder/Examples/StackOverflow.hs view
@@ -0,0 +1,43 @@+-- | Defines a basic example of API use - check the readme for more detail+--   or check the tutorial at <https://github.com/intolerable/api-builder>+module Network.API.Builder.Examples.StackOverflow where++import Network.API.Builder+import Control.Applicative+import Data.Aeson+import Data.Monoid (mempty)+import Data.Text (Text)++data Question = Question { title :: Text+                         , isAnswered :: Bool+                         , score :: Int+                         , tags :: [Text] }+  deriving (Show, Eq)++newtype Questions = Questions [Question]+  deriving (Show, Eq)++instance FromJSON Question where+  parseJSON (Object o) =+    Question <$> o .: "title"+             <*> o .: "is_answered"+             <*> o .: "score"+             <*> o .: "tags"+  parseJSON _ = mempty++instance FromJSON Questions where+  parseJSON (Object o) = Questions <$> o .: "items"+  parseJSON _ = mempty++stackOverflow :: Builder+stackOverflow = basicBuilder "StackOverflow API" "http://api.stackexchange.com"++answersRoute :: Route+answersRoute = Route { fragments = [ "2.2", "questions" ]+                     , urlParams = [ "order" =. ("desc" :: Text)+                                   , "sort" =. ("activity" :: Text)+                                   , "site" =. ("stackoverflow" :: Text) ]+                     , httpMethod = "GET" }++getAnswers :: IO (Either (APIError ()) Questions)+getAnswers = execAPI stackOverflow () $ runRoute answersRoute
+ src/Network/API/Builder/Query.hs view
@@ -0,0 +1,29 @@+module Network.API.Builder.Query where++import Data.Maybe+import Data.Text (Text)+import qualified Data.Text as Text++class ToQuery a where+  toQuery :: a -> Maybe Text++instance ToQuery Integer where+  toQuery = Just . Text.pack . show++instance ToQuery Bool where+  toQuery True = Just "true"+  toQuery False = Just "false"++instance ToQuery Int where+  toQuery = Just . Text.pack . show++instance ToQuery Text where+  toQuery = Just++instance ToQuery a => ToQuery (Maybe a) where+  toQuery (Just a) = toQuery a+  toQuery Nothing = Nothing++instance ToQuery a => ToQuery [a] where+  toQuery [] = Nothing+  toQuery xs = Just $ Text.intercalate "," $ mapMaybe toQuery xs
+ src/Network/API/Builder/Routes.hs view
@@ -0,0 +1,61 @@+module Network.API.Builder.Routes+  ( Route(..)+  , URLFragment+  , URLParam+  , (=.)+  , routeURL ) where++import Control.Arrow ((***))+import Data.Maybe (mapMaybe)+import Data.Monoid ((<>))+import Data.Text (Text)+import qualified Data.Text as T+import qualified Network.HTTP.Base as HTTP (urlEncodeVars)+import qualified Network.HTTP.Types.Method as HTTP++import Network.API.Builder.Query++-- | Alias for @Text@ to store the URL fragments for each @Route@.+type URLFragment = Text++-- | Alias to @(Text, Maybe Text)@ used to store each query that gets+--   tacked onto the request.+type URLParam = (Text, Maybe Text)++-- | Convenience function for building @URLParam@s. Right-hand argument must+--   have a @ToQuery@ instance so it can be converted to the appropriate+--   representation in a query string. Query values do not need to be+--   escaped.+--+-- >>> "api_type" =. ("json" :: Text)+-- ("api_type", Just "json")+(=.) :: ToQuery a => Text -> a -> (Text, Maybe Text)+k =. v = (k, toQuery v)++-- | Main type for routes in the API. Used to represent the URL minus the actual+--   endpoint URL as well as the query string and the HTTP method used to communicate+--   with the server.+data Route = Route { fragments :: [URLFragment]+                   , urlParams :: [URLParam]+                   , httpMethod :: HTTP.Method }+  deriving (Show, Read, Eq)++-- | Converts a Route to a URL. Drops any @Nothing@ values from the query, separates the+--   fragments with "/" and tacks them onto the end of the base URL.+routeURL :: Text -- ^ base URL for the @Route@ (you can usually get this from the @Builder@)+         -> Route -- ^ the @Route@ to process+         -> Text -- ^ the finalized URL as a @Text@+routeURL baseURL (Route fs ps _) =+  let path = T.intercalate "/" fs+  in baseURL <> "/" <> path <> pathParamsSep fs <> buildParams ps++pathParamsSep :: [URLFragment] -> Text+pathParamsSep [] = "?"+pathParamsSep xs = if T.isInfixOf "." (last xs) then "?" else "/?"++buildParams :: [URLParam] -> Text+buildParams = T.pack . HTTP.urlEncodeVars . map (T.unpack *** T.unpack) . mapMaybe collectParams++collectParams :: URLParam -> Maybe (Text, Text)+collectParams (a, Just x) = Just (a,x)+collectParams (_, Nothing) = Nothing