module Webby.Server where import qualified Data.Aeson as A import qualified Data.Binary.Builder as Bu import qualified Data.ByteString.Lazy as LB import qualified Data.HashMap.Strict as H import qualified Data.List as L import qualified Data.Text as T import Network.Mime import qualified UnliftIO.Concurrent as Conc import qualified UnliftIO.Exception as E import Web.HttpApiData import Webby.Types import Prelude asksWEnv :: (WEnv appEnv -> a) -> WebbyM appEnv a asksWEnv getter = WebbyM $ lift $ asks getter -- | Retrieve all path captures captures :: WebbyM appEnv Captures captures = asksWEnv weCaptures -- | Retrieve a particular capture getCapture :: (FromHttpApiData a) => Text -> WebbyM appEnv a getCapture capName = do cs <- captures case H.lookup capName cs of Nothing -> throwIO $ WebbyMissingCapture capName Just cap -> either (throwIO . WebbyParamParseError capName . show) return $ parseUrlPiece cap -- | Set response status setStatus :: Status -> WebbyM appEnv () setStatus sts = do wVar <- asksWEnv weResp Conc.modifyMVar_ wVar $ \wr -> return $ wr {wrStatus = sts} -- | Append given header to the response headers addHeader :: Header -> WebbyM appEnv () addHeader h = do wVar <- asksWEnv weResp Conc.modifyMVar_ wVar $ \wr -> do let hs = wrHeaders wr return $ wr {wrHeaders = hs ++ [h]} -- | Similar to 'addHeader' but replaces a header setHeader :: Header -> WebbyM appEnv () setHeader (k, v) = do wVar <- asksWEnv weResp Conc.modifyMVar_ wVar $ \wr -> do let hs = wrHeaders wr ohs = filter ((/= k) . fst) hs return $ wr {wrHeaders = ohs ++ [(k, v)]} resp400 :: Text -> WebbyM appEnv a resp400 msg = do setStatus status400 json $ A.object ["error" A..= A.String msg] finish -- | Get all request query params as a list of key-value pairs params :: WebbyM appEnv [(Text, Text)] params = do qparams <- queryToQueryText . queryString <$> request return $ fmap (\(q, mv) -> (,) q $ fromMaybe "" mv) qparams -- | Checks if the request contains the given query param flag :: Text -> WebbyM appEnv Bool flag name = isJust . L.lookup name <$> params -- | Gets the given query param's value param :: (FromHttpApiData a) => Text -> WebbyM appEnv (Maybe a) param p = do ps <- params case L.lookup p ps of Nothing -> return Nothing Just myParam -> either (throwIO . WebbyParamParseError p . show) (return . Just) $ parseQueryParam myParam -- | Similar to 'param' except that it returns the handler with a '400 -- BadRequest' if the query param is missing. param_ :: (FromHttpApiData a) => Text -> WebbyM appEnv a param_ p = do myParam <- param p maybe (resp400 $ T.concat [p, " missing in params"]) return myParam -- | Get the given header value header :: HeaderName -> WebbyM appEnv (Maybe Text) header n = do hs <- requestHeaders <$> request return $ headMay $ map (decodeUtf8 . snd) $ filter ((n ==) . fst) hs -- | Get the 'Network.Wai.Request' of the handler request :: WebbyM appEnv Request request = asksWEnv weRequest -- | Returns an action that returns successive chunks of the rquest -- body. It returns an empty bytestring after the request body is -- consumed. getRequestBodyChunkAction :: WebbyM appEnv (WebbyM appEnv ByteString) getRequestBodyChunkAction = liftIO . getRequestBodyChunk <$> asksWEnv weRequest -- | Get all the request headers headers :: WebbyM appEnv [Header] headers = requestHeaders <$> request -- | Returns request body size in bytes requestBodyLength :: WebbyM appEnv (Maybe Int64) requestBodyLength = do hMay <- header hContentLength return $ do val <- hMay parseInt val -- | Used to return early from an API handler finish :: WebbyM appEnv a finish = E.throwIO FinishThrown -- | Send an image in the response body. Also -- sets @Content-Type@ header to @mimeType -- e.g. image/svg+xml image :: ByteString -> MimeType -> WebbyM appEnv () image bs mimeType = do setHeader (hContentType, mimeType) raw bs -- | Send a binary stream in the response body. Also -- sets @Content-Type@ header to @application/octet-stream@ blob :: ByteString -> WebbyM appEnv () blob bs = do setHeader (hContentType, "application/octet-stream") raw bs -- | Send a binary stream in the response body. Doesn't -- set @Content-Type@ header raw :: ByteString -> WebbyM appEnv () raw bs = do wVar <- asksWEnv weResp Conc.modifyMVar_ wVar $ \wr -> return $ wr {wrRespData = Right $ Bu.fromByteString bs} -- | Send plain-text in the response body. Also -- sets @Content-Type@ header to @text/plain; charset=utf-8@ text :: Text -> WebbyM appEnv () text txt = do setHeader (hContentType, "text/plain; charset=utf-8") wVar <- asksWEnv weResp Conc.modifyMVar_ wVar $ \wr -> return $ wr { wrRespData = Right $ Bu.fromByteString $ encodeUtf8 txt } -- | Return the raw request body as a lazy bytestring requestBodyLBS :: WebbyM appEnv LByteString requestBodyLBS = do req <- request liftIO $ lazyRequestBody req -- | Parse the request body as a JSON object and return it. Raises -- 'WebbyJSONParseError' exception if parsing is unsuccessful. jsonData :: A.FromJSON a => WebbyM appEnv a jsonData = do req <- request body <- liftIO $ lazyRequestBody req either (throwIO . WebbyJSONParseError . T.pack) return $ A.eitherDecode body -- | Set the body of the response to the JSON encoding of the given value. Also -- sets @Content-Type@ header to @application/json; charset=utf-8@ json :: A.ToJSON b => b -> WebbyM appEnv () json j = do setHeader (hContentType, "application/json; charset=utf-8") wVar <- asksWEnv weResp Conc.modifyMVar_ wVar $ \wr -> return $ wr { wrRespData = Right $ Bu.fromLazyByteString $ A.encode j } -- | Set the body of the response to a StreamingBody. Doesn't set the -- @Content-Type@ header, so you probably want to do that on your own with -- 'setHeader'. stream :: StreamingBody -> WebbyM appEnv () stream s = do wVar <- asksWEnv weResp Conc.modifyMVar_ wVar $ \wr -> return $ wr {wrRespData = Left s} matchRequest :: Request -> [(RoutePattern, a)] -> Maybe (Captures, a) matchRequest _ [] = Nothing matchRequest req ((RoutePattern method pathSegs, handler) : rs) = if requestMethod req == method then case go (pathInfo req) pathSegs H.empty of Nothing -> matchRequest req rs Just cs -> return (cs, handler) else matchRequest req rs where go [] p h | mconcat p == "" = Just h | otherwise = Nothing go p [] h | mconcat p == "" = Just h | otherwise = Nothing go (p : ps) (l : pat) h | T.head l == ':' = go ps pat $ H.insert (T.drop 1 l) p h | p == l = go ps pat h | otherwise = Nothing errorResponse404 :: WebbyM appEnv () errorResponse404 = setStatus status404 invalidRoutesErr :: [Char] invalidRoutesErr = "Invalid route specification: contains duplicate routes or routes with overlapping capture patterns." -- | Use this function to create a WAI application. It takes a user/application -- defined @appEnv@ data type and a list of routes. Routes are matched in the -- given order. If none of the requests match a request, a default 404 response -- is returned. mkWebbyApp :: env -> WebbyServerConfig env -> IO Application mkWebbyApp env wsc = return mkApp where shortCircuitHandler = [ -- Handler for FinishThrown exception to guide -- short-circuiting handlers to early completion E.Handler (\(ex :: FinishThrown) -> E.throwIO ex) ] mkApp req respond = do let defaultHandler = errorResponse404 routes = wscRoutes wsc exceptionHandlerMay = wscExceptionHandler wsc (cs, handler) = fromMaybe (H.empty, defaultHandler) $ matchRequest req routes wEnv <- do v <- Conc.newMVar defaultWyResp return $ WEnv v cs req env exceptionHandlerMay ( do runWebbyM wEnv $ handler `E.catches` ( shortCircuitHandler <> fmap (\(WebbyExceptionHandler e) -> E.Handler e) (maybeToList exceptionHandlerMay) ) webbyReply wEnv respond ) `E.catches` [ -- Handles Webby' exceptions while parsing parameters -- and request body E.Handler ( \(ex :: WebbyError) -> case ex of wmc@(WebbyMissingCapture _) -> respond $ responseLBS status404 [] $ encodeUtf8 $ displayException wmc _ -> respond $ responseLBS status400 [] $ encodeUtf8 $ displayException ex ), -- Handles Webby's finish statement E.Handler (\(_ :: FinishThrown) -> webbyReply wEnv respond) ] webbyReply wEnv respond' = do let wVar = weResp wEnv wr <- Conc.takeMVar wVar case wrRespData wr of Left s -> respond' $ responseStream (wrStatus wr) (wrHeaders wr) s Right b -> do let clen = LB.length $ Bu.toLazyByteString b respond' $ responseBuilder (wrStatus wr) (wrHeaders wr ++ [(hContentLength, show clen)]) b -- | Create a route for a user-provided HTTP request method, pattern -- and handler function. mkRoute :: Method -> Text -> WebbyM appEnv () -> (RoutePattern, WebbyM appEnv ()) mkRoute m p h = let p' = if | T.null p -> "/" | T.head p /= '/' -> "/" <> p | otherwise -> p in (RoutePattern m (drop 1 $ T.splitOn "/" p'), h) -- | Create a route for a @POST@ request method, given the path pattern -- and handler. post :: Text -> WebbyM appEnv () -> (RoutePattern, WebbyM appEnv ()) post = mkRoute methodPost -- | Create a route for a @GET@ request method, given the path pattern -- and handler. get :: Text -> WebbyM appEnv () -> (RoutePattern, WebbyM appEnv ()) get = mkRoute methodGet -- | Create a route for a @PUT@ request method, given the path pattern -- and handler. put :: Text -> WebbyM appEnv () -> (RoutePattern, WebbyM appEnv ()) put = mkRoute methodPut -- | Create a route for a @DELETE@ request method, given path pattern and handler. delete :: Text -> WebbyM appEnv () -> (RoutePattern, WebbyM appEnv ()) delete = mkRoute methodDelete