mig-0.1.0.0: src/Mig/Internal/Types.hs
-- | Internal types and functions
module Mig.Internal.Types
( -- * types
Server (..)
, Req (..)
, Resp (..)
, RespBody (..)
, QueryMap
, ToText (..)
, Error (..)
-- * constructors
, toConst
, toMethod
, toWithBody
, toWithCapture
, toWithPath
, toWithHeader
, toWithFormData
, toWithPathInfo
, FormBody (..)
-- * responses
, text
, json
, html
, raw
, ok
, badRequest
, setContent
-- * WAI
, ServerConfig (..)
, Kilobytes
, toApplication
-- * utils
, setRespStatus
, handleError
, toResponse
, fromRequest
, pathHead
) where
import Data.Bifunctor
import Data.String
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Text.Lazy qualified as TL
import Data.Aeson (ToJSON)
import Data.Aeson qualified as Json
import Data.ByteString (ByteString)
import Data.ByteString qualified as B
import Data.ByteString.Lazy qualified as BL
import Text.Blaze.Html (Html)
import Text.Blaze.Html (ToMarkup)
import Text.Blaze.Html qualified as Html
import Network.HTTP.Types.Method (Method)
import Network.HTTP.Types.Header (ResponseHeaders, RequestHeaders, HeaderName)
import Network.HTTP.Types.Status (Status, ok200, status500, status413)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text.Encoding qualified as Text
import Data.List qualified as List
import Network.Wai
import Text.Blaze.Renderer.Utf8 qualified as Html
import Data.Maybe
import Data.Sequence (Seq (..), (|>))
import Data.Sequence qualified as Seq
import Data.Foldable
import Control.Monad.IO.Class
import Control.Monad.Catch
import Data.Typeable
import Network.Wai.Parse qualified as Parse
-- | Http response
data Resp = Resp
{ status :: Status
-- ^ status
, headers :: ResponseHeaders
-- ^ headers
, body :: RespBody
-- ^ response body
}
instance IsString Resp where
fromString = text . TL.pack
-- | Http response body
data RespBody
= TextResp Text
| HtmlResp Html
| JsonResp Json.Value
| FileResp FilePath
| StreamResp
| RawResp BL.ByteString
-- | Http request
data Req = Req
{ path :: [Text]
-- ^ URI path
, query :: QueryMap
-- ^ query parameters
, headers :: RequestHeaders
-- ^ request headers
, method :: Method
-- ^ request method
, readBody :: IO (Either (Error Text) BL.ByteString)
-- ^ lazy body reader. Error can happen if size is too big (configured on running the server)
, readFormBody :: IO (Either (Error Text) FormBody)
}
data FormBody = FormBody
{ params :: [(ByteString, ByteString)]
, files :: [(ByteString, Parse.FileInfo BL.ByteString)]
}
-- Errors
-- | Errors
data Error a = Error
{ status :: Status
-- error status
, body :: a
-- message or error details
}
deriving (Show)
instance (Typeable a, Show a) => Exception (Error a) where
-- | Map of query parameters for fast-access
type QueryMap = Map ByteString ByteString
-- | Bad request response
badRequest :: Text -> Resp
badRequest message =
Resp
{ status = status500
, headers = setContent "text/plain"
, body = TextResp message
}
-- | Server type. It is a function fron request to response.
-- Some servers does not return valid value. We use it to find right path.
--
-- Example:
--
-- > server :: Server IO
-- > server =
-- > "api" /. "v1" /.
-- > mconcat
-- > [ "foo" /. (\(Query @"name" arg) -> Get @Json (handleFoo arg)
-- > , "bar" /. Post @Json handleBar
-- > ]
-- >
-- > handleFoo :: Int -> IO Text
-- > handleBar :: IO Text
--
-- Note that server is monoid and it can be constructed with Monoid functions and
-- path constructor @(/.)@. To pass inputs for handler we can use special newtype wrappers:
--
-- * @Query@ - for required query parameters
-- * @Optional@ - for optional query parameters
-- * @Capture@ - for parsing elements of URI
-- * @Body@ - fot JSON-body input
-- * @RawBody@ - for raw ByteString input
-- * @Header@ - for headers
--
-- To distinguish by HTTP-method we use corresponding constructors: Get, Post, Put, etc.
-- Let's discuss the structure of the constructor. Let's take Get for example:
--
-- > newtype Get ty m a = Get (m a)
--
-- Let's look at the arguments of he type
--
-- * @ty@ - type of the response. it can be: Text, Html, Json, ByteString
-- * @m@ - underlying server monad
-- * @a@ - result type. It should be convertible to the type of the response.
--
-- also result can be wrapped to special data types to modify Http-response.
-- we have wrappers:
--
-- * @SetStatus@ - to set status
-- * @AddHeaders@ - to append headers
-- * @Either (Error err)@ - to response with errors
newtype Server m = Server { unServer :: Req -> m (Maybe Resp) }
toConst :: Functor m => m Resp -> Server m
toConst act = Server $ const $ Just <$> act
toMethod :: Monad m => Method -> m Resp -> Server m
toMethod method act = Server $ checkMethod method act
checkMethod :: Monad m => Method -> m Resp -> Req -> m (Maybe Resp)
checkMethod method act req
| null req.path && req.method == method = Just <$> act
| otherwise = pure Nothing
toWithBody :: MonadIO m => (BL.ByteString -> Server m) -> Server m
toWithBody act = Server $ \req -> do
eBody <- liftIO req.readBody
case eBody of
Right body -> unServer (act body) req
Left err -> pure $ Just $ setRespStatus err.status (text err.body)
-- TODO: make it size limited by HTTP-body size
toWithFormData :: MonadIO m => (FormBody -> Server m) -> Server m
toWithFormData act = Server $ \req -> do
eFormBody <- liftIO req.readFormBody
case eFormBody of
Right formBody -> unServer (act formBody) req
Left err -> pure $ Just $ setRespStatus err.status (text err.body)
-- | Size of the input body
type Kilobytes = Int
-- | Read request body in chunks
readRequestBody :: IO B.ByteString -> Maybe Kilobytes -> IO (Either (Error Text) [B.ByteString])
readRequestBody readChunk maxSize = loop 0 Seq.empty
where
loop :: Kilobytes -> Seq B.ByteString -> IO (Either (Error Text) [B.ByteString])
loop !currentSize !result
| isBigger currentSize = pure outOfSize
| otherwise = do
chunk <- readChunk
if B.null chunk
then pure $ Right (toList result)
else loop (currentSize + B.length chunk) (result |> chunk)
outOfSize :: Either (Error Text) a
outOfSize = Left (Error status413 (Text.pack $ "Request is too big Jim!"))
isBigger = case maxSize of
Just size -> \current -> current > size
Nothing -> const False
toWithPath :: Monad m => Text -> Server m -> Server m
toWithPath route act = Server $ \req ->
case hasPath route req.path of
Just restPath -> unServer act (req { path = restPath })
_ -> pure Nothing
type Path = [Text]
hasPath :: Text -> Path -> Maybe Path
hasPath route (path:restPath)
| route == path = Just restPath
| otherwise = Nothing
hasPath _ _ = Nothing
toWithCapture :: Monad m => (Text -> Server m) -> Server m
toWithCapture act = Server $ \req ->
case pathHead req of
Just (arg, nextReq) -> unServer (act arg) nextReq
Nothing -> pure Nothing
pathHead :: Req -> Maybe (Text, Req)
pathHead req =
case req.path of
hd : tl -> Just (hd, req { path = tl })
_ -> Nothing
toWithHeader :: HeaderName -> (Maybe ByteString -> Server m) -> Server m
toWithHeader name act = Server $ \req ->
unServer (act (fmap snd $ List.find ((== name) . fst) req.headers)) req
toWithPathInfo :: ([Text] -> Server m) -> Server m
toWithPathInfo act = Server $ \req ->
unServer (act req.path) req
instance Monad m => Semigroup (Server m) where
(<>) (Server serverA) (Server serverB) = Server $ \req -> do
mRespA <- serverA req
maybe (serverB req) (pure . Just) mRespA
instance Monad m => Monoid (Server m) where
mempty = Server (const $ pure Nothing)
-- | Values convertible to lazy text
class ToText a where
toText :: a -> Text
instance ToText TL.Text where
toText = TL.toStrict
instance ToText Text where
toText = id
instance ToText Int where
toText = Text.pack . show
instance ToText Float where
toText = Text.pack . show
instance ToText String where
toText = fromString
{-# INLINE setContent #-}
-- | Headers to set content type
setContent :: ByteString -> ResponseHeaders
setContent contentType =
[("Content-Type", contentType <>"; charset=utf-8")]
setRespStatus :: Status -> Resp -> Resp
setRespStatus status (Resp _ headers body) = Resp status headers body
-- | Json response constructor
json :: (ToJSON resp) => resp -> Resp
json = (ok (setContent "text/json") . JsonResp . Json.toJSON)
-- | Text response constructor
text :: ToText a => a -> Resp
text = ok (setContent "text/plain") . TextResp . toText
-- | Html response constructor
html :: (ToMarkup a) => a -> Resp
html = ok (setContent "text/html") . HtmlResp . Html.toHtml
-- | Raw bytestring response constructor
raw :: BL.ByteString -> Resp
raw = ok (setContent "text/plain") . RawResp
-- | Respond with ok 200-status
ok :: ResponseHeaders -> RespBody -> Resp
ok headers body = Resp ok200 headers body
-- | Handle errors
handleError ::(Exception a, MonadCatch m) => (a -> Server m) -> Server m -> Server m
handleError handler (Server act) = Server $ \req ->
(act req) `catch` (\err -> unServer (handler err) req)
-------------------------------------------------------------------------------------
-- render to WAI
-- | Server config
data ServerConfig = ServerConfig
{ maxBodySize :: Maybe Kilobytes
}
-- | Convert server to WAI-application
toApplication :: ServerConfig -> Server IO -> Application
toApplication config server req processResponse = do
mResp <- unServer (handleError onErr server) =<< fromRequest config.maxBodySize req
processResponse $ toResponse $ fromMaybe noResult mResp
where
noResult = badRequest "Server produces nothing"
onErr :: SomeException -> Server IO
onErr err = toConst $ pure $ badRequest $ "Error: Exception has happened: " <> toText (show err)
-- | Convert response to low-level WAI-response
toResponse :: Resp -> Response
toResponse resp =
case resp.body of
TextResp textResp -> lbs $ BL.fromStrict (Text.encodeUtf8 textResp)
HtmlResp htmlResp -> lbs (Html.renderMarkup htmlResp)
JsonResp jsonResp -> lbs (Json.encode jsonResp)
FileResp file -> responseFile resp.status resp.headers file Nothing
RawResp str -> lbs str
StreamResp -> undefined
where
lbs = responseLBS resp.status resp.headers
-- | Read request from low-level WAI-request
-- First argument limits the size of input body. The body is read in chunks.
fromRequest :: Maybe Kilobytes -> Request -> IO Req
fromRequest maxSize req =
pure $ Req
{ path = pathInfo req
, query = Map.fromList $ mapMaybe (\(key, mVal) -> (key, ) <$> mVal) (queryString req)
, headers = requestHeaders req
, method = requestMethod req
, readBody = fmap (fmap BL.fromChunks) $ readRequestBody (getRequestBodyChunk req) maxSize
, readFormBody = getReadFormBody req
}
getReadFormBody :: Request -> IO (Either (Error Text) FormBody)
getReadFormBody req = do
case Parse.getRequestBodyType req of
Nothing -> pure $ Right (FormBody [] [])
Just reqBodyType -> do
eResult <- try @IO @SomeException (Parse.sinkRequestBody Parse.lbsBackEnd reqBodyType (getRequestBodyChunk req))
pure $ bimap (const toError) (uncurry FormBody) eResult
where
toError = Error status413 (Text.pack $ "Request is too big!")