packages feed

welshy (empty) → 0.1.0.0

raw patch · 9 files changed

+719/−0 lines, 9 filesdep +aesondep +basedep +blaze-buildersetup-changed

Dependencies added: aeson, base, blaze-builder, bytestring, conduit, http-types, lifted-base, resourcet, text, transformers, unordered-containers, wai, warp

Files

+ LICENSE view
@@ -0,0 +1,21 @@++The MIT License (MIT)++Copyright (c) 2013 Michael Schröder++Permission is hereby granted, free of charge, to any person obtaining a copy of+this software and associated documentation files (the "Software"), to deal in+the Software without restriction, including without limitation the rights to+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of+the Software, and to permit persons to whom the Software is furnished to do so,+subject to the following conditions:++The above copyright notice and this permission notice shall be included in all+copies or substantial portions of the Software.++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ Web/Welshy.hs view
@@ -0,0 +1,159 @@+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}++module Web.Welshy+    ( welshy, welshyApp++      -- * Middleware & Routing+    , Welshy, middleware, route, RoutePattern+    , get, post, put, patch, delete, head, options++      -- * Actions+    , Action+    , halt, pass+    , catchIO++      -- ** Request+    , request, body+    , capture, captures+    , queryParam, maybeQueryParam, queryParams+    , jsonParam, maybeJsonParam, jsonParams, jsonData+    , bearerAuth++      -- ** Response+    , status, header+    , text, text'+    , html, html'+    , json+    , file, filePart+    , source++      -- * Parameter Parsing+    , Param+    , FromText(..), maybeFromText+    ) where++import Control.Applicative+import Control.Exception+import qualified Control.Exception.Lifted as Lifted+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Writer hiding (pass)+import Data.Monoid+import Data.Text (Text)+import qualified Data.Text as T+import Network.HTTP.Types+import Network.Wai+import Network.Wai.Handler.Warp+import System.IO++import Prelude hiding (head)++import Web.Welshy.Action+import Web.Welshy.FromText+import Web.Welshy.Request+import Web.Welshy.Response++-----------------------------------------------------------------------++-- Note [Exception handling]+-- ~~~~~~~~~~~~~~~~~~~~~~~~~+-- Ideally, all exceptions would be caught by the server and a 500 response+-- sent to the client. Alas, due to lazyness this is not possible: any+-- exceptions occuring inside a 'ResponseBuilder' would need to be caught on+-- a higher level. ('Warp' provides 'settingsOnException', but at that point+-- the connection has already been dropped and we can't send a response to+-- the client anymore.)+--+-- Effectively, this means that if something like the following happens+-- the client will simply get an empty reply:+--+-- > text $ error "wat"+--+-- (The exception will be logged to stderr though.)++-- TODO: clarify this in user-visible documentation.++-----------------------------------------------------------------------++-- | We use this monad to compose WAI 'Middleware', using the 'middleware'+-- and 'route' functions.+newtype Welshy a = Welshy (Writer [Middleware] a)+    deriving (Functor, Applicative, Monad)++-- | Run a Welshy app using the Warp server.+welshy :: Port -> Welshy () -> IO ()+welshy p w = do+    putStr "Aye, Dwi iawn 'n feddw!"+    putStrLn $ " (port " ++ show p ++ ") (ctrl-c to quit)"+    run p (welshyApp w)++-- | Turns a Welshy app into a WAI 'Application'.+welshyApp :: Welshy () -> Application+welshyApp (Welshy w) = foldr id notFound (catchError : execWriter w)+    where+        notFound :: Application+        notFound _ = return $ ResponseBuilder notFound404 [] mempty++        -- see Note [Exception Handling]+        catchError :: Middleware+        catchError app req = Lifted.catch (app req) $ \e -> do+            liftIO $ hPrint stderr (e :: SomeException)+            return $ ResponseBuilder status500 [] mempty++-----------------------------------------------------------------------++-- | Insert middleware into the app. Note that unlike in Scotty,+-- each middleware is run at the point of insertion.+middleware :: Middleware -> Welshy ()+middleware = Welshy . tell . pure++get :: RoutePattern -> Action () -> Welshy ()+get = route GET++post :: RoutePattern -> Action () -> Welshy ()+post = route POST++put :: RoutePattern -> Action () -> Welshy ()+put = route PUT++patch :: RoutePattern -> Action () -> Welshy ()+patch = route PATCH++delete :: RoutePattern -> Action () -> Welshy ()+delete = route DELETE++head :: RoutePattern -> Action () -> Welshy ()+head = route HEAD++options :: RoutePattern -> Action () -> Welshy ()+options = route OPTIONS++-- | Sinatra-style route pattern. Named parameters are prepended with+-- a colon (e.g. @\"\/users\/:id\"@) and can be accessed with 'capture'.+type RoutePattern = Text++-- | Define a route for an HTTP method and URL pattern that runs the given+-- action. Routes are matched in the order they are defined. If no route+-- matches, a 404 response is returned.+route :: StdMethod -> RoutePattern -> Action () -> Welshy ()+route met pat act = middleware $ \nextApp req ->+    case matchRoute met pat req of+        Nothing   -> nextApp req+        Just caps -> execAction act caps nextApp req++matchRoute :: StdMethod -> RoutePattern -> Request -> Maybe [Param]+matchRoute met pat req =+    if Right met == parseMethod (requestMethod req)+        then go (filter (/= T.empty) $ T.split (=='/') pat) (pathInfo req) []+        else Nothing+    where+        go []     []     prs  = Just prs+        go []     _      _    = Nothing+        go _      []     _    = Nothing+        go (p:ps) (r:rs) prs+            | p == r          = go ps rs prs+            | T.null p        = Nothing+            | T.head p == ':' = go ps rs $ (T.tail p, r) : prs+            | otherwise       = Nothing
+ Web/Welshy/Action.hs view
@@ -0,0 +1,149 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++module Web.Welshy.Action where++import Control.Applicative+import Control.Arrow+import Control.Exception+import Control.Monad+import Control.Monad.IO.Class+import Control.Monad.Trans.Class+import Control.Monad.Trans.Resource+import qualified Data.Aeson as A+import qualified Data.ByteString.Lazy as BL+import Data.Conduit.Lazy+import Data.Maybe+import Data.Monoid+import Data.Text (Text)+import Network.HTTP.Types+import Network.Wai++-----------------------------------------------------------------------++data Result a = Ok a Response | Halt (Action ()) | Pass++newtype Action a = Action { runAction :: Env -> Response -> IO (Result a) }++instance Functor Action where+    fmap f m = Action $ \r s -> runAction m r s >>= \case+        Ok a s1 -> return $ Ok (f a) s1+        Halt m1 -> return $ Halt m1+        Pass    -> return $ Pass++instance Applicative Action where+    pure = return+    (<*>) = ap++-- TODO: this instance should be highlighted in the documentation+instance Alternative Action where+    empty = mzero+    (<|>) = mplus++instance Monad Action where+    return a = Action $ \_ s -> return $ Ok a s+    m >>= k  = Action $ \r s -> runAction m r s >>= \case+        Ok a s1 -> runAction (k a) r s1+        Halt s1 -> return $ Halt s1+        Pass    -> return $ Pass++    fail msg = halt $ error msg++instance MonadPlus Action where+    mzero       = fail "mzero"+    m `mplus` n = Action $ \r s -> runAction m r s >>= \case+        Ok a s1 -> return $ Ok a s1+        Halt __ -> runAction n r s+        Pass    -> runAction n r s++instance MonadIO Action where+    liftIO m = Action $ \_ s -> do+        a <- m+        return $ Ok a s++-- | Like `catch` but with the exception handler and result in 'Action'.+catchIO :: Exception e => IO a -> (e -> Action a) -> Action a+catchIO act h = either h return =<< (liftIO $ try $ act)++-----------------------------------------------------------------------++-- | A route, query or form parameter and its value.+type Param = (Text, Text)++data Env = Env { _captures    :: [Param]+               , _queryParams :: [Param]+               , _body        :: BL.ByteString+               , _jsonParams  :: Maybe A.Object+               , _request     :: Request }++mkEnv :: [Param] -> Request -> ResourceT IO Env+mkEnv _captures _request = do+    _body <- BL.fromChunks <$> lazyConsume (requestBody _request)+    let _queryParams = queryText _request ++ formParams _body _request+        _jsonParams = either (const Nothing) Just (A.eitherDecode _body)+    return Env {..}++queryText :: Request -> [Param]+queryText = map (second $ fromMaybe "") . queryToQueryText . queryString++formParams :: BL.ByteString -> Request -> [Param]+formParams b req =+    case lookup hContentType (requestHeaders req) of+        Just "application/x-www-form-urlencoded" ->+            map (second $ fromMaybe "") $ queryToQueryText $+            parseQuery $ BL.toStrict $ b+        _ -> []+++execAction :: Action () -> [Param] -> Middleware+execAction act0 caps nextApp req = run act0 =<< mkEnv caps req+    where+        run :: Action () -> Env -> ResourceT IO Response+        run act env = (lift $ runAction act env okRes) >>= \case+            Ok _ res  -> return res+            Halt act' -> run act' env+            Pass      -> nextApp req++        okRes :: Response+        okRes = ResponseBuilder ok200 [] mempty++-----------------------------------------------------------------------++-- | Stop running the current action and continue with another one.+-- The other action will live in the same request environment and can access+-- the same route captures, but it will start with a fresh default response.+--+-- This is incredibly useful for error handling. For example:+--+-- > patch "/users/:uid" $ do+-- >     uid <- capture "uid"+-- >     user <- getUserFromDB uid+-- >             `catchIO` (\_ -> halt $ status notFound404)+-- >     ...+halt :: Action () -> Action a+halt m = Action $ \_ _ -> return $ Halt m++-- | Stop the current action and continue with the next matching route.+pass :: Action a+pass = Action $ \_ _ -> return Pass++-- | Get the raw WAI 'Request'.+request :: Action Request+request = Action $ \r s -> return $ Ok (_request r) s++-- | Get all query parameters.+queryParams :: Action [Param]+queryParams = Action $ \r s -> return $ Ok (_queryParams r) s++-- | Get all route captures.+captures :: Action [Param]+captures = Action $ \r s -> return $ Ok (_captures r) s++-- | Get the request body.+body :: Action BL.ByteString+body = Action $ \r s -> return $ Ok (_body r) s++-- | Modify the raw WAI 'Response'.+modifyResponse :: (Response -> Response) -> Action ()+modifyResponse f = Action $ \_ s -> return $ Ok () (f s)
+ Web/Welshy/FromText.hs view
@@ -0,0 +1,50 @@+module Web.Welshy.FromText where++import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Lazy as TL+import Data.Int+import Data.Word+import Text.Read (readEither)++-- | A type that can be converted from a strict 'Text' value.+-- Used for parsing route captures, query parameters, header values, etc.+--+-- Minimal complete definition: 'fromText'.+class FromText a where+    fromText :: Text -> Either String a++    -- | Allows a specialized way of parsing lists of values.+    -- The default definition uses 'fromText' to parse comma-delimited lists.+    fromTextList :: Text -> Either String [a]+    fromTextList = mapM fromText . T.split (== ',')++-- | > maybeFromText = either (const Nothing) Just . fromText+maybeFromText :: FromText a => Text -> Maybe a+maybeFromText = either (const Nothing) Just . fromText++instance FromText a => FromText [a] where+    fromText = fromTextList++instance FromText Char where+    fromText t = case T.unpack t of+                       [c] -> Right c+                       _   -> Left "fromText Char: no parse"+    fromTextList = Right . T.unpack++instance FromText Text    where fromText = Right+instance FromText TL.Text where fromText = Right . TL.fromStrict+instance FromText Int     where fromText = readEither . T.unpack+instance FromText Int8    where fromText = readEither . T.unpack+instance FromText Int16   where fromText = readEither . T.unpack+instance FromText Int32   where fromText = readEither . T.unpack+instance FromText Int64   where fromText = readEither . T.unpack+instance FromText Integer where fromText = readEither . T.unpack+instance FromText Word    where fromText = readEither . T.unpack+instance FromText Word8   where fromText = readEither . T.unpack+instance FromText Word16  where fromText = readEither . T.unpack+instance FromText Word32  where fromText = readEither . T.unpack+instance FromText Word64  where fromText = readEither . T.unpack+instance FromText Bool    where fromText = readEither . T.unpack+instance FromText Double  where fromText = readEither . T.unpack+instance FromText Float   where fromText = readEither . T.unpack
+ Web/Welshy/Request.hs view
@@ -0,0 +1,126 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}++module Web.Welshy.Request where++import Control.Applicative+import Control.Monad+import Data.Aeson (FromJSON, fromJSON)+import qualified Data.Aeson as A+import qualified Data.ByteString as B+import qualified Data.HashMap.Strict as HashMap+import Data.Text (Text)+import qualified Data.Text as T+import qualified Data.Text.Encoding as T+import Network.HTTP.Types+import Network.Wai++import Web.Welshy.Action+import Web.Welshy.FromText+import Web.Welshy.Response++-----------------------------------------------------------------------++-- | Get a parameter captured by the route pattern.+--+--     * If the parameter does not exist, fails with an error.+--+--     * If the parameter was found but could not be parsed, 'pass' is called.+--       This means routes are typed to a degree.+--+capture :: FromText a => Text -> Action a+capture k = (lookup k <$> captures) >>= \case+    Nothing  -> fail ("unknown capture: " ++ T.unpack k)+    Just raw -> case fromText raw of+        Left  _ -> pass+        Right v -> return v++-- TODO: provide way to customize default response+-- | Get a query parameter.+--+--     * If the parameter does not exist or could not be parsed,+--       the action halts with HTTP status @400 Bad Request@.+--+queryParam :: FromText a => Text -> Action a+queryParam k = (lookup k <$> queryParams) >>= \case+    Nothing  -> halt $ status badRequest400+    Just raw -> case fromText raw of+        Left  _ -> halt $ status badRequest400+        Right v -> return v++-- | Like 'queryParam', but returns 'Nothing' if the parameter wasn't found.+--+--     * If the parameter could not be parsed,+--       the action halts with HTTP status @400 BadRequest@+--+maybeQueryParam :: FromText a => Text -> Action (Maybe a)+maybeQueryParam k = (lookup k <$> queryParams) >>= \case+    Nothing  -> return Nothing+    Just raw -> case fromText raw of+        Left  _ -> halt $ status badRequest400+        Right v -> return (Just v)++-- | Get a JSON parameter.+--+--     * If the request body is not a JSON dictionary,+--       or if the parameter does not exist or could not be parsed,+--       the action halts with HTTP status @400 Bad Request@.+--+jsonParam :: FromJSON a => Text -> Action a+jsonParam k = (HashMap.lookup k <$> jsonParams) >>= \case+    Nothing  -> halt $ status badRequest400+    Just raw -> case fromJSON raw of+        A.Error   _ -> halt $ status badRequest400+        A.Success v -> return v+++-- | Like 'jsonParam', but returns 'Nothing' if the parameter wasn't found.+--+--     * If the request body is not a JSON dictionary,+--       the action halts with HTTP status @400 Bad Request@.+--+maybeJsonParam :: FromJSON a => Text -> Action (Maybe a)+maybeJsonParam k = (HashMap.lookup k <$> jsonParams) >>= \case+    Nothing  -> return Nothing+    Just raw -> case fromJSON raw of+        A.Error   _ -> halt $ status badRequest400+        A.Success v -> return (Just v)+++-- | Parse the request body as a JSON object.+--+--     * If the body could not be parsed,+--       the action halts with HTTP status @400 Bad Request@.+--+jsonData :: FromJSON a => Action a+jsonData = A.decode <$> body >>= \case+    Nothing -> halt $ status badRequest400+    Just v  -> return v++-- | Get all JSON parameters.+--+--     * If the request body is not a JSON dictionary,+--       the action halts with HTTP status @400 Bad Request@.+--+jsonParams :: Action A.Object+jsonParams = Action $ \r s -> do+    case _jsonParams r of+        Nothing -> return $ Halt $ status badRequest400+        Just v  -> return $ Ok v s++-----------------------------------------------------------------------++-- | Get the bearer token from an authorization header using the @Bearer@+-- authentication scheme (RFC 6750).+--+-- If the request does not have a (syntactically) valid authorization+-- header for the Bearer scheme, the action halts with HTTP status+-- @401 Unauthorized@.+bearerAuth :: FromText a => Action a+bearerAuth = do+    headers <- requestHeaders <$> request+    maybe (halt $ status unauthorized401) return $ do+        credentials <- lookup hAuthorization headers+        let (scheme, raw) = B.splitAt 7 credentials+        guard (scheme == "Bearer ")+        maybeFromText $ T.decodeUtf8 raw
+ Web/Welshy/Response.hs view
@@ -0,0 +1,96 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}++module Web.Welshy.Response where++import Blaze.ByteString.Builder+import Data.Aeson (ToJSON)+import qualified Data.Aeson as A+import Data.ByteString (ByteString)+import Data.Conduit+import Data.Text (Text)+import qualified Data.Text.Encoding as T+import qualified Data.Text.Lazy as TL+import qualified Data.Text.Lazy.Encoding as TL+import Network.HTTP.Types+import Network.Wai++import Web.Welshy.Action++-----------------------------------------------------------------------++-- | Set the HTTP status of the response. The default is 'ok200'.+status :: Status -> Action ()+status s = modifyResponse $ \case+    (ResponseBuilder _ h b)    -> ResponseBuilder s h b+    (ResponseFile    _ h f fp) -> ResponseFile    s h f fp+    (ResponseSource  _ h cs)   -> ResponseSource  s h cs++-- | Add or replace one of the response headers.+header :: HeaderName -> ByteString -> Action ()+header k v = modifyResponse $ \case+    (ResponseBuilder s h b)    -> ResponseBuilder s (update h) b+    (ResponseFile    s h f fp) -> ResponseFile    s (update h) f fp+    (ResponseSource  s h cs)   -> ResponseSource  s (update h) cs+    where update h = (k,v) : filter ((/= k) . fst) h++-- | Set the response body to the given lazy 'TL.Text'+-- and the content-type to @text/plain@.+text :: TL.Text -> Action ()+text t = do+    header hContentType "text/plain"+    _builder $ fromLazyByteString $ TL.encodeUtf8 t++-- | Like 'text' but with a strict 'Text' value.+text' :: Text -> Action ()+text' t = do+    header hContentType "text/plain"+    _builder $ fromByteString $ T.encodeUtf8 t++-- | Set the response body to the given lazy 'TL.Text'+-- and the content-type to @text/html@.+html :: TL.Text -> Action ()+html t = do+    header hContentType "text/html"+    _builder $ fromLazyByteString $ TL.encodeUtf8 t++-- | Like 'html' but with a strict 'Text' value.+html' :: Text -> Action ()+html' t = do+    header hContentType "text/html"+    _builder $ fromByteString $ T.encodeUtf8 t++-- | Set the response body to the JSON encoding of the given value+-- and the content-type to @application/json@.+json :: ToJSON a => a -> Action ()+json v = do+    header hContentType "application/json"+    _builder $ fromLazyByteString $ A.encode v++-- | Sends the given file as the response.+file :: FilePath -> Action ()+file = flip _file Nothing++-- | 'filePart' @f offset byteCount@ sends @byteCount@ bytes of the file @f@,+-- beginning at @offset@, as the response.+filePart :: FilePath -> Integer -> Integer -> Action ()+filePart f offset byteCount = _file f (Just $ FilePart offset byteCount)++_file :: FilePath -> Maybe FilePart -> Action ()+_file f part = modifyResponse $ \case+    (ResponseBuilder s h _)   -> ResponseFile s h f part+    (ResponseFile    s h _ _) -> ResponseFile s h f part+    (ResponseSource  s h _)   -> ResponseFile s h f part++_builder :: Builder -> Action ()+_builder b = modifyResponse $ \case+    (ResponseBuilder s h _)   -> ResponseBuilder s h b+    (ResponseFile    s h _ _) -> ResponseBuilder s h b+    (ResponseSource  s h _)   -> ResponseBuilder s h b++-- | Set the response body to the given 'Source'.+source :: Source (ResourceT IO) (Flush Builder) -> Action ()+source src = modifyResponse $ \case+    (ResponseBuilder s h _)   -> ResponseSource s h src+    (ResponseFile    s h _ _) -> ResponseSource s h src+    (ResponseSource  s h _)   -> ResponseSource s h src
+ example.hs view
@@ -0,0 +1,25 @@+{-# LANGUAGE OverloadedStrings #-}++import Control.Applicative+import Control.Monad+import qualified Data.Text.Lazy as T+import Network.HTTP.Types+import Web.Welshy++fibs :: [Int]+fibs = 0 : 1 : zipWith (+) fibs (tail fibs)++main :: IO ()+main = welshy 3000 $ do+    get "/fibs" $ do+        offset <- queryParam "offset" <|> return 0+        length <- queryParam "length"++        when (offset < 0 || length < 0)+             (halt $ status badRequest400)++        when (offset + length > 1000)+             (halt $ status requestedRangeNotSatisfiable416)++        let result = take length $ drop offset fibs+        text $ T.pack $ show result
+ welshy.cabal view
@@ -0,0 +1,91 @@+name:                welshy+version:             0.1.0.0+synopsis:            Haskell web framework (because Scotty had trouble yodeling)+description:+  A Haskell web framework heavily influenced by the excellent Scotty, +  which was in turn influenced by Ruby's Sinatra.+  .+  Welshy strives to make it easier to do error handling without overly+  complicating the control flow. An example:+  .+  @&#123;-&#35; LANGUAGE OverloadedStrings &#35;-&#125;@+  .+  > import Control.Applicative+  > import Control.Monad+  > import qualified Data.Text.Lazy as T+  > import Network.HTTP.Types+  > import Web.Welshy+  > +  > fibs :: [Int]+  > fibs = 0 : 1 : zipWith (+) fibs (tail fibs)+  >+  > main :: IO ()+  > main = welshy 3000 $ do+  >     get "/fibs" $ do+  >         offset <- queryParam "offset" <|> return 0+  >         length <- queryParam "length"+  >         +  >         when (offset < 0 || length < 0)+  >              (halt $ status badRequest400)+  >         +  >         when (offset + length > 1000)+  >              (halt $ status requestedRangeNotSatisfiable416)+  >         +  >         let result = take length $ drop offset fibs+  >         text $ T.pack $ show result+  .+  Some of the features demonstrated here:+  .+  * You can 'halt' the current action at any point and continue+    with a different one.+  .+  * Functions like 'queryParam' and 'jsonParam' have built-in error handling.+  .+  * Welshy's 'Action' monad is an instance of 'Alternative'.++++license:             MIT+license-file:        LICENSE+author:              Michael Schröder+maintainer:          mcschroeder@gmail.com+homepage:            https://github.com/mcschroeder/welshy+bug-reports:         https://github.com/mcschroeder/welshy/issues+copyright:           (c) 2013 Michael Schröder+category:            Web+build-type:          Simple+cabal-version:       >=1.8++extra-source-files:+  example.hs++library+  exposed-modules:  Web.Welshy+  other-modules:    Web.Welshy.Action,+                    Web.Welshy.FromText,+                    Web.Welshy.Request,+                    Web.Welshy.Response+  build-depends:+      base ==4.6.*+    , aeson ==0.6.*+    , blaze-builder ==0.3.*+    , bytestring ==0.10.*+    , conduit ==1.0.*+    , http-types ==0.8.*+    , lifted-base ==0.2.*+    , resourcet ==0.4.*+    , text ==0.11.*+    , transformers ==0.3.*+    , unordered-containers ==0.2.*+    , wai ==1.4.*+    , warp ==1.3.*  +  +  extensions:+    GeneralizedNewtypeDeriving+    -- LambdaCase+    OverloadedStrings+    RecordWildCards++source-repository head+  type:     git+  location: https://github.com/mcschroeder/welshy.git