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 +9/−9
- src/APIBuilder.hs +0/−10
- src/APIBuilder/API.hs +0/−116
- src/APIBuilder/Builder.hs +0/−26
- src/APIBuilder/Decoding.hs +0/−29
- src/APIBuilder/Error.hs +0/−17
- src/APIBuilder/Examples/StackOverflow.hs +0/−43
- src/APIBuilder/Query.hs +0/−28
- src/APIBuilder/Routes.hs +0/−61
- src/Network/API/Builder.hs +11/−0
- src/Network/API/Builder/API.hs +136/−0
- src/Network/API/Builder/Builder.hs +26/−0
- src/Network/API/Builder/Decoding.hs +29/−0
- src/Network/API/Builder/Error.hs +24/−0
- src/Network/API/Builder/Examples/StackOverflow.hs +43/−0
- src/Network/API/Builder/Query.hs +29/−0
- src/Network/API/Builder/Routes.hs +61/−0
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