packages feed

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 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++[![Hackage](https://img.shields.io/hackage/v/twain.svg?style=flat)](http://hackage.haskell.org/package/twain)+![BSD3 License](http://img.shields.io/badge/license-BSD3-brightgreen.svg)++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