packages feed

hyperbole-0.5.0: src/Web/Hyperbole/Server/Wai.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE QuasiQuotes #-}

module Web.Hyperbole.Server.Wai where

import Data.Bifunctor (first)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.List qualified as L
import Data.Maybe (fromMaybe)
import Data.String.Conversions (cs)
import Data.String.Interpolate (i)
import Effectful
import Effectful.Exception (throwIO)
import Network.HTTP.Types (Header, HeaderName, status200, status400, status401, status404, status500)
import Network.Wai qualified as Wai
import Network.Wai.Internal (ResponseReceived (..))
import Web.Atomic (att, (@))
import Web.Cookie qualified
import Web.Hyperbole.Data.Cookie (Cookie, Cookies)
import Web.Hyperbole.Data.Cookie qualified as Cookie
import Web.Hyperbole.Data.Encoded (Encoded, encodedParseText)
import Web.Hyperbole.Data.URI (path, uriToText)
import Web.Hyperbole.Effect.Hyperbole
import Web.Hyperbole.Server.Message
import Web.Hyperbole.Server.Options
import Web.Hyperbole.Types.Client
import Web.Hyperbole.Types.Event
import Web.Hyperbole.Types.Request
import Web.Hyperbole.Types.Response
import Web.Hyperbole.View (View, addContext, renderLazyByteString, script', type_)


handleRequestWai
  :: (IOE :> es)
  => ServerOptions
  -> Wai.Request
  -> (Wai.Response -> IO ResponseReceived)
  -> Eff (Hyperbole : es) Response
  -> Eff es Wai.ResponseReceived
handleRequestWai options req respond actions = do
  -- NOTE: Remember, this is called for both updates AND for page loads
  body <- liftIO $ Wai.consumeRequestBodyLazy req
  rq <- either throwIO pure $ do
    fromWaiRequest req body
  (res, client, rmts) <- runHyperbole rq actions
  liftIO $ sendResponse options rq client res rmts respond


sendResponse :: ServerOptions -> Request -> Client -> Response -> [Remote] -> (Wai.Response -> IO ResponseReceived) -> IO Wai.ResponseReceived
sendResponse options req client res remotes respond = do
  let metas = requestMetadata req <> responseMetadata req.path client remotes
  respond $ response metas res
 where
  response :: Metadata -> Response -> Wai.Response
  response metas = \case
    (Err err) ->
      respondError (errStatus err) [] $ options.serverError err
    (Response _ vw) -> do
      respondHtml status200 (clientHeaders client) $ renderViewResponse metas vw
    (Redirect u) -> do
      let url = uriToText u
      -- We have to use a 200 javascript redirect because javascript
      -- will redirect the fetch(), while we want to redirect the whole page
      -- see index.ts sendAction()
      let hs = ("Location", cs url) : clientHeaders client
      respondHtml status200 hs $ renderViewResponse (metaRedirect u <> metas) $ do
        script'
          -- static script is safe to execute
          [i|
            let metaInput = document.getElementById("hyp.metadata").innerText;
            let meta = Hyperbole.parseMetadata(metaInput)
            if (meta.redirect) {
              window.location = meta.redirect
            }
            else {
              console.error("Invalid Redirect", meta.rediect)
            }
          |]

  errStatus = \case
    NotFound -> status404
    ErrParse _ -> status400
    ErrQuery _ -> status400
    ErrSession _ _ -> status400
    ErrAuth _ -> status401
    _ -> status500

  -- convert to document if full page request
  addDocument :: BL.ByteString -> BL.ByteString
  addDocument body =
    case req.event of
      Nothing -> options.toDocument body
      _ -> body

  renderViewResponse :: Metadata -> View Body () -> BL.ByteString
  renderViewResponse metas vw =
    addDocument $ renderLazyByteString (addContext metas $ scriptMeta metas) <> "\n\n" <> renderLazyByteString (addContext Body vw)

  respondError s hs serr = respondHtml s hs $ renderViewResponse (metaError serr.message) serr.body
  respondHtml s hs = Wai.responseLBS s (contentType ContentHtml : hs)
  -- respondText s hs = Wai.responseLBS s (contentType ContentText : hs)

  -- via HTTP, we want to manually set some headers rather than just rely on the client
  clientHeaders :: Client -> [Header]
  clientHeaders = setCookies
   where
    setCookies clnt =
      fmap setCookie $ Cookie.toList clnt.session

    setCookie :: Cookie -> (HeaderName, BS.ByteString)
    setCookie cookie =
      ("Set-Cookie", Cookie.render req.path cookie)


scriptMeta :: Metadata -> View Metadata ()
scriptMeta m =
  script' @ type_ "application/hyp.metadata" . att "id" "hyp.metadata" $
    cs $
      "\n" <> renderMetadata m <> "\n"


messageFromBody :: BL.ByteString -> Either MessageError Message
messageFromBody inp = do
  first (\e -> InvalidMessage e (cs inp)) $ parseActionMessage (cs inp)


fromWaiRequest :: Wai.Request -> BL.ByteString -> Either MessageError Request
fromWaiRequest wr body = do
  let pth = path $ cs $ Wai.rawPathInfo wr
      query = Wai.queryString wr
      headers = Wai.requestHeaders wr
      cookie = fromMaybe "" $ L.lookup "Cookie" headers
      host = Host $ fromMaybe "" $ L.lookup "Host" headers
      requestId = RequestId $ cs $ fromMaybe "" $ L.lookup "Hyp-RequestId" headers
      method = Wai.requestMethod wr
      event = lookupEvent headers

  cookies <- fromCookieHeader cookie

  pure $
    Request
      { body = body
      , path = pth
      , event
      , query
      , method
      , cookies
      , host
      , requestId
      }
 where
  lookupEvent :: [Header] -> Maybe (Event TargetViewId Encoded)
  lookupEvent headers = do
    viewId <- TargetViewId . cs <$> L.lookup "Hyp-ViewId" headers
    actText <- cs <$> L.lookup "Hyp-Action" headers
    case encodedParseText actText of
      Left _ -> Nothing
      Right a -> pure $ Event viewId a


-- Client only returns ONE Cookie header, with everything concatenated
fromCookieHeader :: BS.ByteString -> Either MessageError Cookies
fromCookieHeader h =
  case Cookie.parse (Web.Cookie.parseCookies h) of
    Left err -> Left $ InvalidCookie h err
    Right a -> pure a


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