packages feed

project-m36-0.6: src/bin/ProjectM36/Server/WebSocket.hs

{-# LANGUAGE LambdaCase #-}
module ProjectM36.Server.WebSocket where
-- while the tutd client performs TutorialD parsing on the client, the websocket server will pass tutd to be parsed and executed on the server- otherwise I have to pull in ghcjs as a dependency to allow client-side parsing- that's not appealing because then the frontend is not language-agnostic, but this could change in the future, perhaps by sending different messages over the websocket
-- ideally, the wire protocol should not be exposed to a straight string-based API ala SQL, so we could make perhaps a javascript DSL which compiles to the necessary JSON- anaylyze tradeoffs

-- launch the project-m36-server
-- proxy all connections to it through ProjectM36.Client
import Control.Monad (forever)
import qualified Data.Text as T
import qualified Network.WebSockets as WS
import ProjectM36.Server.RemoteCallTypes.Json ()
import ProjectM36.Client.Json ()
import ProjectM36.DataFrame
import ProjectM36.Relation.Show.Term
import ProjectM36.Relation.Show.HTML
import Data.Aeson
import TutorialD.Interpreter
import TutorialD.Interpreter.Base (TutorialDOperatorResult(..))
import ProjectM36.Client
import Control.Exception
import Data.Attoparsec.Text
import Control.Applicative
import Text.Megaparsec.Error
import Data.Functor

websocketProxyServer :: Port -> Hostname -> WS.ServerApp
websocketProxyServer port host pending = do    
  conn <- WS.acceptRequest pending
  let unexpectedMsg = WS.sendTextData conn ("messagenotexpected" :: T.Text)
  --phase 1- accept database name for connection
  dbmsg <- WS.receiveData conn :: IO T.Text
  let connectdbmsg = "connectdb:"
  if not (connectdbmsg `T.isPrefixOf` dbmsg) then unexpectedMsg >> WS.sendClose conn ("" :: T.Text)
    else do
        let dbname = T.unpack $ T.drop (T.length connectdbmsg) dbmsg
        bracket (createConnection conn dbname port host) 
          (\case
              Right dbconn -> close dbconn
              Left _ -> pure ()) $ \case 
            Left err -> sendError conn err
            Right dbconn -> do
                eSessionId <- createSessionAtHead dbconn "master"
                case eSessionId of
                  Left err -> sendError conn err
                  Right sessionId -> do
                    --phase 2- accept tutoriald commands
                    _ <- forever $ do
                      pInfo <- promptInfo sessionId dbconn
                      --figure out why sending three times during startup is necessary
                      sendPromptInfo pInfo conn                
                      sendPromptInfo pInfo conn
                      msg <- WS.receiveData conn :: IO T.Text
                      case parseOnly parseExecuteMessage msg of
                        Left _ -> unexpectedMsg
                        Right (presentation, tutdString) ->
                          case parseTutorialD tutdString of
                            Left err -> handleOpResult conn dbconn presentation (DisplayErrorResult ("parse error: " `T.append` T.pack (parseErrorPretty err)))
                            Right parsed -> do
                              let timeoutFilter exc = if exc == RequestTimeoutException 
                                                          then Just exc 
                                                          else Nothing
                                  responseHandler = do
                                    result <- evalTutorialD sessionId dbconn SafeEvaluation parsed
                                    pInfo' <- promptInfo sessionId dbconn
                                    sendPromptInfo pInfo' conn                       
                                    handleOpResult conn dbconn presentation result
                              catchJust timeoutFilter responseHandler (\_ -> handleOpResult conn dbconn presentation (DisplayErrorResult "Request Timed Out."))
                    pure ()
    
notificationCallback :: WS.Connection -> NotificationCallback    
notificationCallback conn notifName evaldNotif = WS.sendTextData conn (encode (object ["notificationname" .= notifName,
                                                                                       "evaldnotification" .= evaldNotif
                                        ]))
    
--this creates a new database for each connection- perhaps not what we want (?)
createConnection :: WS.Connection -> DatabaseName -> Port -> Hostname -> IO (Either ConnectionError Connection)
createConnection wsconn dbname port host = connectProjectM36 (RemoteProcessConnectionInfo dbname (createNodeId host port) (notificationCallback wsconn))

sendError :: (ToJSON a) => WS.Connection -> a -> IO ()
sendError conn err = WS.sendTextData conn (encode (object ["displayerror" .= err]))

handleOpResult :: WS.Connection -> Connection -> Presentation -> TutorialDOperatorResult -> IO ()
handleOpResult conn db _ QuitResult = WS.sendClose conn ("close" :: T.Text) >> close db
handleOpResult conn  _ _ (DisplayResult out) = WS.sendTextData conn (encode (object ["display" .= out]))
handleOpResult _ _ _ (DisplayIOResult ioout) = ioout
handleOpResult conn _ presentation (DisplayErrorResult err) = do
  let jsono = ["json" .= err | jsonPresentation presentation]
      texto = ["text" .= err | textPresentation presentation]
      htmlo = ["html" .= err | htmlPresentation presentation]
  WS.sendTextData conn (encode (object ["displayerror" .= object (jsono ++ texto ++ htmlo)]))
handleOpResult conn _ _ (DisplayParseErrorResult _ err) = WS.sendTextData conn (encode (object ["displayparseerrorresult" .= show err]))
handleOpResult conn _ _ QuietSuccessResult = WS.sendTextData conn (encode (object ["acknowledged" .= True]))
handleOpResult conn _ presentation (DisplayRelationResult rel) = do
  let jsono = ["json" .= rel | jsonPresentation presentation]
      texto = ["text" .= showRelation rel | textPresentation presentation]
      htmlo = ["html" .= relationAsHTML rel | htmlPresentation presentation]
  WS.sendTextData conn (encode (object ["displayrelation" .= object (jsono ++ texto ++ htmlo)]))
handleOpResult conn _ presentation (DisplayDataFrameResult df) = do
  let jsono = ["json" .= df | jsonPresentation presentation]
      texto = ["text" .= showDataFrame df | textPresentation presentation]
      htmlo = ["html" .= dataFrameAsHTML df | htmlPresentation presentation]
  WS.sendTextData conn (encode (object ["displaydataframe" .= object (jsono ++ texto ++ htmlo)]))
  
-- get current schema and head name for client
promptInfo :: SessionId -> Connection -> IO (HeadName, SchemaName)
promptInfo sessionId conn = do
  eHeadName <- headName sessionId conn  
  eSchemaName <- currentSchemaName sessionId conn
  pure (either (const "<unknown>") id eHeadName, either (const "<no schema>") id eSchemaName)
  
sendPromptInfo :: (HeadName, SchemaName) -> WS.Connection -> IO ()
sendPromptInfo (hName, sName) conn = WS.sendTextData conn (encode (object ["promptInfo" .= object ["headname" .= hName, "schemaname" .= sName]]))

--a returning relation can be returned as JSON, Text (for consoles), or HTML
data Presentation = Presentation {
  jsonPresentation :: Bool, 
  textPresentation :: Bool,
  htmlPresentation :: Bool }
                    
data PresentationFlag = JSONFlag | TextFlag | HTMLFlag
 
parseExecuteMessage :: Parser (Presentation, T.Text)
parseExecuteMessage = do
  _ <- string "executetutd/"
  flags <- sepBy ((string "json" $> JSONFlag) <|>
                  (string "text" $> TextFlag) <|>
                  (string "html" $> HTMLFlag)) "+"
  let presentation = foldr (\flag acc -> case flag of 
                               JSONFlag -> acc {jsonPresentation = True}
                               TextFlag -> acc {textPresentation = True}
                               HTMLFlag -> acc {htmlPresentation = True}) (Presentation False False False) flags
  _ <- char ':'
  tutd <- T.pack <$> manyTill anyChar endOfInput
  pure (presentation, tutd)