packages feed

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

{-# LANGUAGE LambdaCase, CPP, GeneralizedNewtypeDeriving, DeriveAnyClass, DeriveGeneric, DerivingStrategies, ScopedTypeVariables #-}
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 ProjectM36.Base
import TutorialD.Interpreter
import ProjectM36.Interpreter as I (ConsoleResult(..), SafeEvaluationFlag(..)) 
import ProjectM36.Client
import Control.Exception
import Text.Megaparsec.Error
import Data.Either (fromRight)
import qualified Data.ByteString.Lazy as BS
import qualified Data.UUID as UUID
import GHC.Generics
import qualified Data.Text.Encoding as TE
import qualified Network.RPC.Curryer.Client as CRPC

#if MIN_VERSION_megaparsec(7,0,0)
import Data.List.NonEmpty as NE
#endif

websocketProxyServer :: Port -> Hostname -> WS.ServerApp
websocketProxyServer port host pending = do
  conn <- WS.acceptRequest pending
  let unexpectedMsg expecting received = do
        print (expecting, received)
        sendResponse conn (MessageNotExpected expecting)
  --phase 1- accept database name for connection
  dbmsg <- WS.receiveData conn :: IO BS.ByteString
  case eitherDecode dbmsg of
    Left _ -> do
      unexpectedMsg "ConnectionSetupRequest" dbmsg
      WS.sendClose conn ("" :: T.Text)
    Right (ConnectionSetupRequest dbname) -> do
        bracket (createConnection conn dbname port host)
          (\case
              Right dbconn -> close dbconn
              Left _ -> pure ()) $ \case
            Left err -> sendResponse conn (TextErrorResponse (RequestId UUID.nil) (T.pack (show err)))
            Right dbconn -> do
              sendResponse conn ConnectionSetupResponse
              --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 BS.ByteString
                case eitherDecode msg of
                        Left err -> unexpectedMsg "Request" err
                        Right (CreateSessionAtHeadRequest reqId branchName) -> do
                          ret <- createSessionAtHead dbconn branchName
                          case ret of
                            Left err -> sendResponse conn (RelationalErrorResponse reqId err)
                            Right sessionId -> sendResponse conn (CreateSessionAtHeadResponse reqId sessionId) 
                        Right (ExecuteTutorialDRequest reqId sessionId presentation (TutorialDText tutdString)) ->
                          case parseTutorialD tutdString of
                            Left err -> do
                              let parseErr = ParseError $ T.pack
                                    (parseErrorPretty . NE.head . bundleErrors $ err)

                              sendResponse conn (RelationalErrorResponse reqId parseErr)
                            Right parsed -> do
                              let timeoutFilter exc = if exc == RequestTimeoutException
                                                          then Just exc
                                                          else Nothing
                                  responseHandler = do
                                    result <- evalTutorialD sessionId dbconn SafeEvaluation parsed
                                    (headName', schemaName) <- promptInfo sessionId dbconn
                                    sendResponse conn (PromptInfoResponse sessionId headName' schemaName)
                                    let resp = makeResponse reqId presentation result
                                    handleResponse conn dbconn resp
                              catchJust timeoutFilter responseHandler (\_ ->
                                                                         sendResponse conn (TimeoutResponse reqId))
              pure ()

        
notificationCallback :: WS.Connection -> NotificationCallback
notificationCallback conn notifName evaldNotif =
  sendResponse conn (NotificationResponse notifName 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 (RemoteConnectionInfo dbname (RemoteServerHostAddress host port) CRPC.UnencryptedConnectionConfig (notificationCallback wsconn) "admin")  -- should use TLS auth

handleResponse :: WS.Connection -> Connection -> Response -> IO ()
handleResponse conn dbconn resp =
  case resp of
    ConnectionClosedResponse -> do
      close dbconn 
      sendResp
    DisplayTextResponse{} -> sendResp
    RelationResponse{} -> sendResp
    DataFrameResponse{} -> sendResp
    CreateSessionAtHeadResponse{} -> sendResp
    SuccessResponse{} -> sendResp
    TimeoutResponse{} -> sendResp
    PromptInfoResponse{} -> sendResp
    RelationalErrorResponse{} -> sendResp
    TextErrorResponse{} -> sendResp
    HintWithResponse{} -> sendResp
    ConnectionSetupResponse{} -> sendResp
    NotificationResponse{} -> sendResp
    MessageNotExpected{} -> sendResp
    where
      sendResp = sendResponse conn resp
    
makeResponse :: RequestId -> Presentation -> ConsoleResult -> Response
makeResponse reqId presentation consoleResult =
  case consoleResult of
    QuitResult -> do
      ConnectionClosedResponse
    DisplayResult out ->
      DisplayTextResponse reqId out
    DisplayIOResult _ ->
      -- we can't send it over the websocket, so just ignore it- in other context, this just used to launch plots       
      SuccessResponse reqId
    DisplayErrorResult err ->
      TextErrorResponse reqId err
    DisplayParseErrorResult _ err -> 
      let err' = ParseError $ T.pack (parseErrorPretty . NE.head . bundleErrors $ err) in
        RelationalErrorResponse reqId err'
    I.QuietSuccessResult ->
      SuccessResponse reqId
    DisplayRelationResult rel ->
      RelationResponse reqId rel presentation
    DisplayDataFrameResult df -> 
      DataFrameResponse reqId df presentation
    DisplayRelationalErrorResult relErr ->
      RelationalErrorResponse reqId relErr
    DisplayHintWith txt conResult ->
      HintWithResponse reqId txt (makeResponse reqId presentation conResult)
  
sendResponse :: WS.Connection -> Response -> IO ()
sendResponse conn response =
  -- this could be optimized to work with a lazy bytestring
  WS.sendTextData conn (TE.decodeUtf8 (BS.toStrict (encode response)))

-- get current schema and head name for client
promptInfo :: SessionId -> Connection -> IO (HeadName, SchemaName)
promptInfo sessionId conn = do
  eCurrentHead <- currentHead sessionId conn
  eSchemaName <- currentSchemaName sessionId conn
  let headName = case eCurrentHead of
                   Left _err -> "<unknown>"
                   Right (CurrentHeadTransactionId tid) -> T.pack (show tid)
                   Right (CurrentHeadBranch hname) -> hname
  pure (headName, fromRight "<no schema>" 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 }
  deriving (Generic, ToJSON, FromJSON)


jsonOnlyPresentation :: Presentation
jsonOnlyPresentation = Presentation { jsonPresentation = True,
                                  textPresentation = False,
                                  htmlPresentation = False }
                   
data PresentationFlag = JSONFlag | TextFlag | HTMLFlag
  deriving (Generic, ToJSON, FromJSON)

newtype RequestId = RequestId UUID.UUID
  deriving newtype (ToJSON, FromJSON, Eq, Show)

newtype TutorialDText = TutorialDText T.Text
  deriving newtype (ToJSON, FromJSON)

newtype ConnectionSetupRequest = ConnectionSetupRequest DatabaseName

instance FromJSON ConnectionSetupRequest where
  parseJSON = withObject "ConnectionSetupRequest" $ \o -> do
    tag::T.Text <- o .: "tag"
    case tag of
      "ConnectionSetupRequest" ->
        ConnectionSetupRequest <$> o .: "databaseName"
      other -> fail ("bad tag: " <> show other)

instance ToJSON ConnectionSetupRequest where
  toJSON (ConnectionSetupRequest dbName) =
    object [ "tag" .= ("ConnectionSetupRequest"::T.Text),
             "databaseName" .= dbName ]

data Request = ExecuteTutorialDRequest RequestId SessionId Presentation TutorialDText |
               CreateSessionAtHeadRequest RequestId HeadName

data Response = RelationResponse RequestId Relation Presentation |
                DataFrameResponse RequestId DataFrame Presentation |
                CreateSessionAtHeadResponse RequestId SessionId |
                SuccessResponse RequestId |
                TimeoutResponse RequestId |
                PromptInfoResponse SessionId HeadName SchemaName |
                RelationalErrorResponse RequestId RelationalError |
                TextErrorResponse RequestId T.Text |
                ConnectionClosedResponse |
                ConnectionSetupResponse |
                DisplayTextResponse RequestId T.Text |
                HintWithResponse RequestId T.Text Response |
                NotificationResponse NotificationName EvaluatedNotification |
                MessageNotExpected T.Text

instance ToJSON Request where
  toJSON (ExecuteTutorialDRequest reqId sessionId presentation tutd) =
    object [ "tag" .= ("ExecuteTutorialDRequest"::T.Text),
             "requestId" .= reqId,
             "sessionId" .= sessionId,
             "presentation" .= presentation,
             "tutoriald" .= tutd ]
  toJSON (CreateSessionAtHeadRequest reqId headName') =
    object [ "tag" .= ("CreateSessionAtHeadRequest"::T.Text),
             "requestId" .= reqId,
             "headName" .= headName' ]

instance FromJSON Request where
  parseJSON = withObject "ExecuteTutorialDRequest" $ \o -> do
    tag::T.Text <- o .: "tag"
    case tag of
      "ExecuteTutorialDRequest" ->
        ExecuteTutorialDRequest <$> o .: "requestId"
                                <*> o .: "sessionId"
                                <*> o .: "presentation"
                                <*> o .: "tutoriald"
      "CreateSessionAtHeadRequest" ->
        CreateSessionAtHeadRequest <$> o .: "requestId"
                                   <*> o .: "headName"
      other -> fail ("bad tag: " <> show other)

-- this is useful for Haskell clients to decode JSON messages
instance FromJSON Response where
  parseJSON = withObject "Response" $ \o -> do
    tag::T.Text <- o .: "tag"
    case tag of
      "RelationResponse" -> do
        mJsonRel <- o .: "jsonRelation"
        case mJsonRel of
          Nothing -> fail "missing json relation"
          Just jsonRel ->
            RelationResponse <$>  o .: "requestId" <*> pure jsonRel <*> pure jsonOnlyPresentation
      "DataFrameResponse" -> do
        mJsonDF <- o .: "jsonDataFrame"
        case mJsonDF of
          Nothing -> fail "missing json dataframe"
          Just jsonDF ->
            DataFrameResponse <$> o .: "requestId" <*> pure jsonDF <*> pure jsonOnlyPresentation
      "CreateSessionAtHeadResponse" ->
        CreateSessionAtHeadResponse <$> o .: "requestId" <*> o .: "sessionId"
      "SuccessResponse" ->
        SuccessResponse <$> o .: "requestId"
      "TimeoutResponse" ->
        TimeoutResponse <$> o .: "requestId"
      "PromptInfoResponse" ->
        PromptInfoResponse <$> o .: "sessionId" <*> o .: "headName" <*> o .: "schemaName"
      "RelationalErrorResponse" ->
        RelationalErrorResponse <$> o .: "requestId" <*> o .: "error"
      "TextErrorResponse" ->
        TextErrorResponse <$> o .: "requestId" <*> o .: "error"
      "ConnectionClosedResponse" ->
        pure ConnectionClosedResponse
      "DisplayTextResponse" ->
        DisplayTextResponse <$> o .: "requestId" <*> o .: "text"
      "HintWithResponse" ->
        HintWithResponse <$> o .: "requestId" <*> o .: "hintText" <*> o .: "response"
      "ConnectionSetupResponse" ->
        pure ConnectionSetupResponse
      "NotificationResponse" ->
        NotificationResponse <$> o .: "notificationName" <*> o .: "evaluatedNotification"
      "MessageNotExpected" ->
        MessageNotExpected <$> o .: "expected"
      other ->
        fail ("unexpected tag: " <> T.unpack other)

instance ToJSON Response where
  toJSON (RelationResponse reqId rel presentation) =
    object [ "tag" .= ("RelationResponse"::T.Text),
             "requestId" .= reqId,
             "jsonRelation" .= if jsonPresentation presentation then
               Just rel else Nothing,
             "textRelation" .= if textPresentation presentation then
               Just (showRelation rel) else Nothing,
             "htmlRelation" .= if htmlPresentation presentation then
               Just (relationAsHTML rel) else Nothing
             
           ]
  toJSON (DataFrameResponse reqId df presentation) =
    object [ "tag" .= ("DataFrameResponse"::T.Text),
             "requestId" .= reqId,
             "jsonDataFrame" .= if jsonPresentation presentation then
               Just df else Nothing,
             "textDataFrame" .= if textPresentation presentation then
               Just (showDataFrame df) else Nothing,
             "htmlDataFrame" .= if htmlPresentation presentation then
               Just (dataFrameAsHTML df) else Nothing
             ]
  toJSON (CreateSessionAtHeadResponse reqId sessionId) =
    object [ "tag" .= ("CreateSessionAtHeadResponse"::T.Text),
             "requestId" .= reqId,
             "sessionId" .= sessionId ]
  toJSON (SuccessResponse reqId) =
    object [ "tag" .= ("SuccessResponse"::T.Text),
             "requestId" .= reqId ]
  toJSON (TimeoutResponse reqId) = 
    object [ "tag" .= ("TimeoutResponse"::T.Text),
             "requestId" .= reqId ]
  toJSON (PromptInfoResponse sessionId headName' schemaName) =
    object [ "tag" .= ("PromptInfoResponse"::T.Text),
             "sessionId" .= sessionId,
             "headName" .= headName',
             "schemaName" .= schemaName ]
  toJSON (RelationalErrorResponse reqId relErr) =
    object [ "tag" .= ("RelationalErrorResponse"::T.Text),
             "requestId" .= reqId,
             "error" .= relErr ]
  toJSON (TextErrorResponse reqId err) =
    object [ "tag" .= ("TextErrorResponse"::T.Text),
             "requestId" .= reqId,
             "error" .= err ]
  toJSON ConnectionClosedResponse =
    object [ "tag" .= ("ConnectionClosedResponse"::T.Text) ]
  toJSON (DisplayTextResponse reqId txt) =
    object [ "tag" .= ("DisplayTextResponse"::T.Text),
             "requestId" .= reqId,
             "text" .= txt ]
  toJSON (HintWithResponse reqId hintTxt resp) =
    object [ "tag" .= ("HintWithResponse"::T.Text),
             "requestId" .= reqId,
             "hintText" .= hintTxt,
             "response" .= resp ]
  toJSON ConnectionSetupResponse =
    object [ "tag" .= ("ConnectionSetupResponse"::T.Text),
             "status" .= ("ready"::T.Text) ]
  toJSON (NotificationResponse notificationName evaldNotification) =
    object [ "tag" .= ("NotificationResponse"::T.Text),
             "notificationName" .= notificationName,
             "evaluatedNotification" .= evaldNotification ]
  toJSON (MessageNotExpected expected) =
    object [ "tag" .= ("MessageNotExpected"::T.Text),
             "expected" .= expected ]