twain (empty) → 1.0.0.0
raw patch · 8 files changed
+737/−0 lines, 8 filesdep +aesondep +basedep +bytestringsetup-changed
Dependencies added: aeson, base, bytestring, case-insensitive, cookie, either, http-types, text, time, transformers, wai, wai-extra, warp
Files
- LICENSE +30/−0
- README.md +22/−0
- Setup.hs +2/−0
- changelog.md +5/−0
- src/Web/Twain.hs +324/−0
- src/Web/Twain/Internal.hs +112/−0
- src/Web/Twain/Types.hs +190/−0
- twain.cabal +52/−0
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright Alex Mingoia (c) 2021++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of Alex Mingoia nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,22 @@+# Twain++[](http://hackage.haskell.org/package/twain)+++Twain is a tiny web application framework for [WAI](http://hackage.haskell.org/package/wai).++- Simple routing with path captures.+- Parameter parsing of cookies, path, query, and body.+- Compose responses from an app environment using a reader-like monad.+- Helpers for redirects, headers, status codes.+- Routes decompose into WAI middleware.++```haskell+import Web.Twain++main :: IO ()+main = do+ twain 8080 "my app" $ do+ get "/" $ do+ send $ html "Hello, World!"+```
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ changelog.md view
@@ -0,0 +1,5 @@+# Change Log++## 1.0.0.0 [2021-05-11]++- Initial release.
+ src/Web/Twain.hs view
@@ -0,0 +1,324 @@+module Web.Twain+ ( -- * Twain to WAI+ twain,+ twain',+ twainApp,++ -- * Middleware and Routes.+ middleware,+ get,+ put,+ patch,+ post,+ delete,+ notFound,+ onException,+ addRoute,++ -- * Request and Parameters.+ env,+ param,+ param',+ paramMaybe,+ params,+ file,+ files,+ header,+ headers,+ request,++ -- * Responses.+ send,+ next,+ redirect301,+ redirect302,+ redirect303,+ text,+ html,+ json,+ xml,+ raw,+ status,+ withHeader,+ withCookie,+ withCookie',+ expireCookie,+ module Web.Twain.Types,+ module Network.HTTP.Types,+ )+where++import Control.Exception (SomeException)+import Data.Aeson (ToJSON)+import qualified Data.Aeson as JSON+import Data.ByteString.Char8 as Char8+import Data.ByteString.Lazy as BL+import qualified Data.CaseInsensitive as CI+import Data.Either.Combinators (rightToMaybe)+import qualified Data.List as L+import Data.Text as T+import Data.Text.Encoding+import Data.Time+import Network.HTTP.Types+import Network.Wai (Application, Middleware, Request, Response, mapResponseHeaders, mapResponseStatus, requestHeaders, responseLBS)+import Network.Wai.Handler.Warp (Port, Settings, defaultSettings, runEnv, runSettings, setOnExceptionResponse, setPort)+import Network.Wai.Parse (File, FileInfo, defaultParseRequestBodyOptions)+import System.Environment (lookupEnv)+import Web.Cookie+import Web.Twain.Internal+import Web.Twain.Types++-- | Run a Twain app on `Port` using the given environment.+--+-- If a PORT environment variable is set, it will take precendence.+--+-- > twain 8080 "My App" $ do+-- > middleware logger+-- > get "/" $ do+-- > appTitle <- env+-- > send $ text ("Hello from " <> appTitle)+-- > get "/greetings/:name"+-- > name <- param "name"+-- > send $ text ("Hello, " <> name)+-- > notFound $ do+-- > send $ status status404 $ text "Not Found"+twain :: Port -> e -> TwainM e () -> IO ()+twain port env m = do+ mp <- lookupEnv "PORT"+ let p = maybe port read mp+ st = exec m env+ app = composeMiddleware $ middlewares st+ handler = onExceptionResponse st+ settings' = setOnExceptionResponse handler $ setPort p defaultSettings+ runSettings settings' app++-- | Run a Twain app passing Warp `Settings`.+twain' :: Settings -> e -> TwainM e () -> IO ()+twain' settings env m = do+ let st = exec m env+ app = composeMiddleware $ middlewares st+ settings' = setOnExceptionResponse (onExceptionResponse st) settings+ runSettings settings' app++-- | Create a WAI `Application` from a Twain app and environment.+twainApp :: e -> TwainM e () -> Application+twainApp env m = composeMiddleware $ middlewares $ exec m env++-- | Use the given middleware. The first declared is the outermost middleware+-- (it has first access to request and last action on response).+middleware :: Middleware -> TwainM e ()+middleware m = modify (\st -> st {middlewares = m : middlewares st})++get :: PathPattern -> RouteM e a -> TwainM e ()+get = addRoute (Just "GET")++put :: PathPattern -> RouteM e a -> TwainM e ()+put = addRoute (Just "PUT")++patch :: PathPattern -> RouteM e a -> TwainM e ()+patch = addRoute (Just "PATCH")++post :: PathPattern -> RouteM e a -> TwainM e ()+post = addRoute (Just "POST")++delete :: PathPattern -> RouteM e a -> TwainM e ()+delete = addRoute (Just "DELETE")++-- | Add a route if nothing else is found. This matches any request, so it+-- should go last.+notFound :: RouteM e a -> TwainM e ()+notFound = addRoute Nothing (MatchPath (const (Just [])))++-- | Render a `Response` on exceptions.+onException :: (SomeException -> Response) -> TwainM e ()+onException handler = modify $ \st -> st {onExceptionResponse = handler}++-- | Add a route matching `Method` (optional) and `PathPattern`.+addRoute :: Maybe Method -> PathPattern -> RouteM e a -> TwainM e ()+addRoute method pat route =+ modify $ \st ->+ let m = routeMiddleware method pat route (environment st)+ in st {middlewares = m : middlewares st}++-- | Get the app environment.+env :: RouteM e e+env = RouteM $ \st -> return $ Right (reqEnv st, st)++-- | Get a parameter. Looks in query, path, cookie, and body (in that order).+--+-- If no parameter is found, or parameter fails to parse, `next` is called+-- which passes control to subsequent routes and middleware.+param :: ParsableParam a => Text -> RouteM e a+param name = do+ pM <- fmap snd . L.find ((==) name . fst) <$> params+ maybe next (either (const next) pure . parseParam) pM++-- | Get a parameter or error if missing or parse failure.+param' :: ParsableParam a => Text -> RouteM e (Either Text a)+param' name = do+ pM <- fmap snd . L.find ((==) name . fst) <$> params+ return $ maybe (Left ("missing parameter: " <> name)) parseParam pM++-- | Get an optional parameter. `Nothing` is returned for missing parameter or+-- parse failure.+paramMaybe :: ParsableParam a => Text -> RouteM e (Maybe a)+paramMaybe name = do+ pM <- fmap snd . L.find ((==) name . fst) <$> params+ return $ maybe Nothing (rightToMaybe . parseParam) pM++-- | Get all parameters from query, path, cookie, and body (in that order).+params :: RouteM e [Param]+params = fst <$> parseBody defaultParseRequestBodyOptions++-- | Get uploaded `FileInfo`.+file :: Text -> RouteM e (Maybe (FileInfo BL.ByteString))+file name = fmap snd . L.find ((==) (encodeUtf8 name) . fst) <$> files++-- | Get all uploaded files.+files :: RouteM e [File BL.ByteString]+files = snd <$> parseBody defaultParseRequestBodyOptions++-- | Get the value of a request `Header`. Header names are case-insensitive.+header :: Text -> RouteM e (Maybe Text)+header name = do+ let ciname = CI.mk (encodeUtf8 name)+ fmap (decodeUtf8 . snd) . L.find ((==) ciname . fst) <$> headers++-- | Get the request headers.+headers :: RouteM e [Header]+headers = requestHeaders <$> request++-- | Get the JSON value from request body.+bodyJson :: JSON.FromJSON a => RouteM e (Either String a)+bodyJson = do+ jsonE <- parseBodyJson+ case jsonE of+ Left e -> return (Left e)+ Right v -> case JSON.fromJSON v of+ JSON.Error e -> return (Left e)+ JSON.Success a -> return (Right a)++-- | Get the WAI `Request`.+request :: RouteM e Request+request = reqWai <$> routeState++-- | Send a `Response`.+--+-- > send $ text "Hello, World!"+--+-- Send an `html` response:+--+-- > send $ html "<h1>Hello, World!</h1>"+--+-- Modify the `status`:+--+-- > send $ status status404 $ text "Not Found"+--+-- Send a response `withHeader`:+--+-- > send $ withHeader (hServer, "Twain + Warp") $ text "Hello"+--+-- Send a response `withCookie`:+--+-- > send $ withCookie "key" "val" $ text "Hello"+send :: Response -> RouteM e a+send res = RouteM $ \_ -> return $ Left (Respond res)++-- | Pass control to the next route or middleware.+next :: RouteM e a+next = RouteM $ \_ -> return (Left Next)++-- | Construct a `Text` response.+--+-- Sets the Content-Type and Content-Length headers.+text :: Text -> Response+text body =+ let lbs = BL.fromStrict (encodeUtf8 body)+ typ = (hContentType, "text/plain; charset=utf-8")+ len = (hContentLength, Char8.pack (show (BL.length lbs)))+ in raw status200 [typ, len] lbs++-- | Construct an HTML response.+--+-- Sets the Content-Type and Content-Length headers.+html :: BL.ByteString -> Response+html body =+ let lbs = body+ typ = (hContentType, "text/html; charset=utf-8")+ len = (hContentLength, Char8.pack (show (BL.length lbs)))+ in raw status200 [typ, len] lbs++-- | Construct a JSON response using `ToJSON`.+--+-- Sets the Content-Type and Content-Length headers.+json :: ToJSON a => a -> Response+json val =+ let lbs = JSON.encode val+ typ = (hContentType, "application/json; charset=utf-8")+ len = (hContentLength, Char8.pack (show (BL.length lbs)))+ in raw status200 [typ, len] lbs++-- | Construct an XML response.+--+-- Sets the Content-Type and Content-Length headers.+xml :: BL.ByteString -> Response+xml body =+ let lbs = body+ typ = (hContentType, "application/xml; charset=utf-8")+ len = (hContentLength, Char8.pack (show (BL.length lbs)))+ in raw status200 [typ, len] lbs++-- | Construct a raw response from a lazy `ByteString`.+raw :: Status -> [Header] -> BL.ByteString -> Response+raw status headers body = responseLBS status headers body++-- | Set the `Status` for a `Response`.+status :: Status -> Response -> Response+status s = mapResponseStatus (const s)++-- | Add a `Header` to response.+withHeader :: Header -> Response -> Response+withHeader header = mapResponseHeaders (header :)++-- | Add a cookie to the response with the given key and value.+--+-- Note: This uses `defaultSetCookie`.+withCookie :: Text -> Text -> Response -> Response+withCookie key val res =+ let setCookie =+ defaultSetCookie+ { setCookieName = encodeUtf8 key,+ setCookieValue = encodeUtf8 val+ }+ header = (CI.mk "Set-Cookie", setCookieByteString setCookie)+ in mapResponseHeaders (header :) res++-- | Add a `SetCookie` to the response.+withCookie' :: SetCookie -> Response -> Response+withCookie' setCookie res =+ let header = (CI.mk "Set-Cookie", setCookieByteString setCookie)+ in mapResponseHeaders (header :) res++-- | Add a header to expire (unset) a cookie with the given key.+expireCookie :: Text -> Response -> Response+expireCookie key res = do+ let zeroTime = UTCTime (ModifiedJulianDay 0) (secondsToDiffTime 0)+ setCookie =+ defaultSetCookie+ { setCookieName = encodeUtf8 key,+ setCookieExpires = Just zeroTime+ }+ header = (CI.mk "Set-Cookie", setCookieByteString setCookie)+ in mapResponseHeaders (header :) res++-- | Create a redirect response with 301 status (Moved Permanently).+redirect301 :: Text -> Response+redirect301 url = raw status301 [(hLocation, encodeUtf8 url)] ""++-- | Create a redirect response with 302 status (Found).+redirect302 :: Text -> Response+redirect302 url = raw status302 [(hLocation, encodeUtf8 url)] ""++-- | Create a redirect response 303 status (See Other).+redirect303 :: Text -> Response+redirect303 url = raw status303 [(hLocation, encodeUtf8 url)] ""
+ src/Web/Twain/Internal.hs view
@@ -0,0 +1,112 @@+module Web.Twain.Internal where++import Control.Exception (throwIO)+import Control.Monad (join)+import Control.Monad.IO.Class (liftIO)+import qualified Data.Aeson as JSON+import qualified Data.ByteString as B+import Data.ByteString.Builder (toLazyByteString)+import qualified Data.ByteString.Lazy as BL+import Data.Int+import Data.List as L+import Data.Text as T+import Data.Text.Encoding+import Network.HTTP.Types (Method, hCookie, status204)+import Network.Wai (Application, Middleware, Request, lazyRequestBody, queryString, requestHeaders, requestMethod, responseLBS)+import Network.Wai.Parse (File, ParseRequestBodyOptions, lbsBackEnd, parseRequestBodyEx)+import Web.Cookie (SetCookie, parseCookiesText, renderSetCookie)+import Web.Twain.Types++type MaxRequestSizeBytes = Int64++routeState :: RouteM e (RouteState e)+routeState = RouteM $ \s -> return (Right (s, s))++setRouteState :: RouteState e -> RouteM e ()+setRouteState s = RouteM $ \_ -> return (Right ((), s))++concatParams :: RouteState e -> [Param]+concatParams p =+ reqBodyParams p+ <> reqCookieParams p+ <> reqPathParams p+ <> reqQueryParams p++composeMiddleware :: [Middleware] -> Application+composeMiddleware = L.foldl' (\a m -> m a) emptyApp++routeMiddleware ::+ Maybe Method ->+ PathPattern ->+ RouteM e a ->+ e ->+ Middleware+routeMiddleware method pat (RouteM route) env app req respond =+ case match method pat req of+ Nothing -> app req respond+ Just pathParams -> do+ let st =+ RouteState+ { reqBodyParams = [],+ reqBodyFiles = [],+ reqPathParams = pathParams,+ reqQueryParams = decodeQueryParam <$> queryString req,+ reqCookieParams = cookieParams req,+ reqBodyJson = Left "missing JSON body",+ reqBodyParsed = False,+ reqEnv = env,+ reqWai = req+ }+ action <- route st+ case action of+ Left (Respond res) -> respond res+ _ -> app req respond++match :: Maybe Method -> PathPattern -> Request -> Maybe [Param]+match method (MatchPath f) req+ | maybe True (requestMethod req ==) method = f req+ | otherwise = Nothing++parseBody :: ParseRequestBodyOptions -> RouteM e ([Param], [File BL.ByteString])+parseBody opts = do+ s <- routeState+ if reqBodyParsed s+ then return (concatParams s, reqBodyFiles s)+ else do+ (ps, fs) <- liftIO $ parseRequestBodyEx opts lbsBackEnd (reqWai s)+ let sb =+ s+ { reqBodyParams = decodeBsParam <$> ps,+ reqBodyFiles = fs,+ reqBodyParsed = True+ }+ setRouteState sb+ return (concatParams sb, reqBodyFiles sb)++parseBodyJson :: RouteM e (Either String JSON.Value)+parseBodyJson = do+ s <- routeState+ if reqBodyParsed s+ then return (reqBodyJson s)+ else do+ jsonE <- liftIO $ JSON.eitherDecode <$> lazyRequestBody (reqWai s)+ setRouteState $ s {reqBodyJson = jsonE, reqBodyParsed = True}+ return jsonE++cookieParams :: Request -> [Param]+cookieParams req =+ let headers = snd <$> L.filter ((==) hCookie . fst) (requestHeaders req)+ in join $ parseCookiesText <$> headers++setCookieByteString :: SetCookie -> B.ByteString+setCookieByteString setCookie =+ BL.toStrict (toLazyByteString (renderSetCookie setCookie))++decodeQueryParam :: (B.ByteString, Maybe B.ByteString) -> Param+decodeQueryParam (a, b) = (decodeUtf8 a, maybe "" decodeUtf8 b)++decodeBsParam :: (B.ByteString, B.ByteString) -> Param+decodeBsParam (a, b) = (decodeUtf8 a, decodeUtf8 b)++emptyApp :: Application+emptyApp req respond = respond $ responseLBS status204 [] ""
+ src/Web/Twain/Types.hs view
@@ -0,0 +1,190 @@+module Web.Twain.Types where++import Control.Exception (SomeException)+import Control.Monad (ap)+import Control.Monad.IO.Class (MonadIO, liftIO)+import Data.Aeson as JSON+import qualified Data.ByteString as B+import qualified Data.ByteString.Lazy as BL+import Data.Either.Combinators (mapRight)+import Data.Int+import Data.List as L+import Data.String (IsString, fromString)+import Data.Text as T+import Data.Text.Encoding+import qualified Data.Text.Lazy as TL+import Data.Word+import Network.Wai (Middleware, Request, Response, pathInfo)+import Network.Wai.Handler.Warp (defaultOnExceptionResponse)+import Network.Wai.Parse (File, ParseRequestBodyOptions)+import Numeric.Natural++-- | TwainM provides a monad interface for composing routes and middleware.+newtype TwainM e a = TwainM (TwainState e -> (a, TwainState e))++data TwainState e+ = TwainState+ { middlewares :: [Middleware],+ environment :: e,+ onExceptionResponse :: (SomeException -> Response)+ }++instance Functor (TwainM e) where+ fmap f (TwainM g) = TwainM $ \s ->+ let (a, sb) = g s+ in (f a, sb)++instance Applicative (TwainM e) where+ pure = return+ (<*>) = ap++instance Monad (TwainM e) where+ return a = TwainM (\s -> (a, s))+ (TwainM m) >>= fn = TwainM $ \s ->+ let (a, sb) = m s+ (TwainM mb) = fn a+ in mb sb++modify :: (TwainState e -> TwainState e) -> TwainM e ()+modify f = TwainM (\s -> ((), f s))++exec :: TwainM e a -> e -> TwainState e+exec (TwainM f) e = snd (f (TwainState [] e defaultOnExceptionResponse))++-- | `RouteM` is a Reader-like monad that can "short-circuit" and return a WAI+-- response using a given environment. This provides convenient branching with+-- do notation for redirects, error responses, etc.+data RouteM e a+ = RouteM (RouteState e -> IO (Either RouteAction (a, RouteState e)))++data RouteAction+ = Respond Response+ | Next++data RouteState e+ = RouteState+ { reqBodyParams :: [Param],+ reqBodyFiles :: [File BL.ByteString],+ reqPathParams :: [Param],+ reqQueryParams :: [Param],+ reqCookieParams :: [Param],+ reqBodyJson :: Either String JSON.Value,+ reqBodyParsed :: Bool,+ reqEnv :: e,+ reqWai :: Request+ }++instance Functor (RouteM e) where+ fmap f (RouteM g) = RouteM $ \s -> mapRight (\(a, b) -> (f a, b)) `fmap` g s++instance Applicative (RouteM e) where+ pure = return+ (<*>) = ap++instance Monad (RouteM e) where+ return a = RouteM $ \s -> return (Right (a, s))+ (RouteM act) >>= fn = RouteM $ \s -> do+ eres <- act s+ case eres of+ Left ract -> return (Left ract)+ Right (a, sb) -> do+ let (RouteM fres) = fn a+ fres sb++instance MonadIO (RouteM e) where+ liftIO act = RouteM $ \s -> act >>= \a -> return (Right (a, s))++type Param = (Text, Text)++data PathPattern = MatchPath (Request -> Maybe [Param])++instance IsString PathPattern where+ fromString s = MatchPath (matchPath (T.pack s))++matchPath :: Text -> Request -> Maybe [Param]+matchPath path req =+ go (splitPath path) (pathInfo req) (Just [])+ where+ splitPath = L.filter (not . T.null) . T.split (== '/')+ go (p : ps) (r : rs) m@(Just pms) =+ if not (T.null p) && T.head p == ':'+ then go ps rs (Just ((T.drop 1 p, r) : pms))+ else if p == r then go ps rs m else Nothing+ go [] [] pms = pms+ go _ _ _ = Nothing++-- | Parse values from request parameters.+class ParsableParam a where+ parseParam :: Text -> Either Text a++ -- | Default implementation parses comma-delimited lists.+ parseParamList :: Text -> Either Text [a]+ parseParamList t = mapM parseParam (T.split (== ',') t)++-- ParsableParam class and instance code is from Andrew Farmer and Scotty+-- framework, with slight modifications.++instance ParsableParam TL.Text where parseParam = Right . TL.fromStrict++instance ParsableParam T.Text where parseParam = Right++instance ParsableParam B.ByteString where parseParam = Right . encodeUtf8++instance ParsableParam BL.ByteString where parseParam = Right . BL.fromStrict . encodeUtf8++instance ParsableParam Char where+ parseParam t = case T.unpack t of+ [c] -> Right c+ _ -> Left "parseParam Char: no parse"+ parseParamList = Right . T.unpack -- String++instance ParsableParam () where+ parseParam t = if T.null t then Right () else Left "parseParam Unit: no parse"++instance (ParsableParam a) => ParsableParam [a] where parseParam = parseParamList++instance ParsableParam Bool where+ parseParam t =+ if t' == T.toCaseFold "true"+ then Right True+ else+ if t' == T.toCaseFold "false"+ then Right False+ else Left "parseParam Bool: no parse"+ where+ t' = T.toCaseFold t++instance ParsableParam Double where parseParam = readEither++instance ParsableParam Float where parseParam = readEither++instance ParsableParam Int where parseParam = readEither++instance ParsableParam Int8 where parseParam = readEither++instance ParsableParam Int16 where parseParam = readEither++instance ParsableParam Int32 where parseParam = readEither++instance ParsableParam Int64 where parseParam = readEither++instance ParsableParam Integer where parseParam = readEither++instance ParsableParam Word where parseParam = readEither++instance ParsableParam Word8 where parseParam = readEither++instance ParsableParam Word16 where parseParam = readEither++instance ParsableParam Word32 where parseParam = readEither++instance ParsableParam Word64 where parseParam = readEither++instance ParsableParam Natural where parseParam = readEither++-- | Useful for creating 'ParsableParam' instances for things that already implement 'Read'.+readEither :: Read a => Text -> Either Text a+readEither t = case [x | (x, "") <- reads (T.unpack t)] of+ [x] -> Right x+ [] -> Left "readEither: no parse"+ _ -> Left "readEither: ambiguous parse"
+ twain.cabal view
@@ -0,0 +1,52 @@+cabal-version: 1.12++-- This file has been generated from package.yaml by hpack version 0.33.0.+--+-- see: https://github.com/sol/hpack+--+-- hash: e9b3b8be1e3550c9e2a2a692102a1a947006bd579d57a084aacef52344e4b74d++name: twain+version: 1.0.0.0+synopsis: Tiny web application framework for WAI.+description: Twain is tiny web application framework for WAI. It provides routing, parameter parsing, and a reader-like monad for composing responses from an environment.+category: Web+homepage: https://github.com/alexmingoia/twain#readme+bug-reports: https://github.com/alexmingoia/twain/issues+author: Alex Mingoia+maintainer: alex@alexmingoia.com+copyright: 2021 Alexander C. Mingoia+license: BSD3+license-file: LICENSE+build-type: Simple+extra-source-files:+ README.md+ changelog.md++source-repository head+ type: git+ location: https://github.com/alexmingoia/twain++library+ exposed-modules:+ Web.Twain Web.Twain.Types+ other-modules:+ Web.Twain.Internal+ hs-source-dirs:+ src+ default-extensions: OverloadedStrings+ build-depends:+ aeson >=1.4 && <1.7+ , base >=4.7 && <5+ , bytestring >=0.10 && <0.11+ , case-insensitive >=1.2 && <1.3+ , cookie >=0.4 && <0.6+ , either >=5.0 && <5.1+ , http-types >=0.12 && <0.13+ , text >=1.2.3 && <1.3+ , time >=1.8 && <1.9.9+ , transformers >=0.5.6 && <0.6+ , wai >=3.2 && <3.3+ , wai-extra >=3.0 && <3.2+ , warp >=3.2 && <3.4+ default-language: Haskell2010