packages feed

hyperbole-0.2.0: src/Web/Hyperbole/Application.hs

module Web.Hyperbole.Application
  ( waiApplication
  -- , webSocketApplication
  , application
  , websocketsOr
  ) where

import Control.Monad (forever)
import Data.ByteString (ByteString)
import Data.ByteString.Lazy qualified as L
import Data.String.Conversions (cs)
import Data.Text (Text, pack)
import Data.Text qualified as T
import Effectful
import Effectful.Error.Static
import Effectful.Reader.Static
import Network.HTTP.Types (Method, Query, parseQuery, status200, status400, status404)
import Network.HTTP.Types.Header (HeaderName)
import Network.Wai qualified as Wai
import Network.Wai.Handler.WebSockets (websocketsOr)
import Network.WebSockets (Connection, PendingConnection, defaultConnectionOptions)
import Network.WebSockets qualified as WS
import Web.Hyperbole.Effect
import Web.Hyperbole.Route
import Web.View (View, renderLazyByteString)


{- | Start both a websockets and a WAI server. Wai app serves initial pages, and attempt to process actions via sockets
  If the socket connection is unavailable, will fall back to the WAI app to process actions
-}
application :: (Route route) => (L.ByteString -> L.ByteString) -> (route -> Eff '[Hyperbole, IOE] ()) -> Wai.Application
application toDoc actions =
  websocketsOr
    defaultConnectionOptions
    (socketApplication actions)
    (waiApplication toDoc actions)


waiApplication :: (Route route) => (L.ByteString -> L.ByteString) -> (route -> Eff '[Hyperbole, IOE] ()) -> Wai.Application
waiApplication toDoc actions request respond = do
  req <- fromWaiRequest request
  res <- runEff $ runHyperboleRoute req actions
  sendResponse res
 where
  fromWaiRequest wr = do
    bd <- liftIO $ Wai.consumeRequestBodyLazy wr
    pure $ Request (Wai.pathInfo wr) (Wai.queryString wr) bd

  -- TODO: logging?
  sendResponse :: Response -> IO Wai.ResponseReceived
  sendResponse (ErrParse e) = respBadRequest ("Parse Error: " <> cs e)
  sendResponse ErrNoHandler = respBadRequest "No Handler Found"
  sendResponse NotFound = respNotFound
  sendResponse (Response vw) = do
    let body = addDocument (Wai.requestMethod request) (renderLazyByteString vw)
    respHtml body

  respBadRequest e =
    respond $ Wai.responseLBS status400 [contentType ContentText] e

  respNotFound =
    respond $ Wai.responseLBS status404 [contentType ContentText] "Not Found"

  respHtml body = do
    let headers = [contentType ContentHtml]
    respond $ Wai.responseLBS status200 headers body

  -- convert to document if GET. Subsequent POST requests will only include fragments
  addDocument :: Method -> L.ByteString -> L.ByteString
  addDocument "GET" bd = toDoc bd
  addDocument _ bd = bd


data ContentType
  = ContentHtml
  | ContentText


contentType :: ContentType -> (HeaderName, ByteString)
contentType ContentHtml = ("Content-Type", "text/html; charset=utf-8")
contentType ContentText = ("Content-Type", "text/plain; charset=utf-8")


socketApplication :: (Route route) => (route -> Eff '[Hyperbole, IOE] ()) -> PendingConnection -> IO ()
socketApplication actions pending = do
  conn <- WS.acceptRequest pending
  forever $ talk conn
 where
  talk :: Connection -> IO ()
  talk conn = do
    res <- runSocket $ do
      req <- request
      liftIO $ print (req.path, req.query, req.body)
      liftIO $ runEff $ runHyperboleRoute req actions

    case res of
      Right (Response vw) -> sendView vw
      Right (ErrParse t) -> sendError $ "ErrParse " <> t
      Right ErrNoHandler -> sendError @Text "ErrNoHandler"
      Right NotFound -> sendError @Text "NotFound"
      Left err -> sendError err
   where
    runSocket :: Eff '[Error SocketError, Reader Connection, IOE] Response -> IO (Either SocketError Response)
    runSocket = runEff . runReader conn . runErrorNoCallStack @SocketError

    request :: (IOE :> es, Reader Connection :> es, Error SocketError :> es) => Eff es Request
    request = do
      t <- receive
      case parseMessage t of
        Left e -> throwError e
        Right r -> pure r

    receive :: (Reader Connection :> es, IOE :> es) => Eff es Text
    receive = do
      c <- ask @Connection
      liftIO $ WS.receiveData c

    parseMessage :: Text -> Either SocketError Request
    parseMessage t = do
      (path, query, body) <- messageParts t
      pure $ Request path query (cs body)

    messageParts :: Text -> Either SocketError ([Text], Query, Text)
    messageParts t = do
      case T.splitOn "\n" t of
        [url, q, body] -> pure (paths url, query q, body)
        [url, q] -> pure (paths url, query q, "")
        _ -> Left $ InvalidMessage t
     where
      paths p = filter (/= "") $ T.splitOn "/" p
      query q = parseQuery (cs q)

    sendView :: View () () -> IO ()
    sendView vw = WS.sendTextData conn $ renderLazyByteString vw

    sendError :: (Show e) => e -> IO ()
    sendError e = WS.sendTextData conn $ pack (show e)


data SocketError
  = InvalidMessage Text
  deriving (Show, Eq)