packages feed

postgres-websockets 0.9.0.0 → 0.10.0.0

raw patch · 9 files changed

+152/−109 lines, 9 filesPVP ok

version bump matches the API change (PVP)

API changes (from Hackage documentation)

+ PostgresWebsockets.Config: [configMetaChannel] :: AppConfig -> Maybe Text
- PostgresWebsockets: postgresWsMiddleware :: IO UTCTime -> Text -> ByteString -> Pool -> Multiplexer -> Application -> Application
+ PostgresWebsockets: postgresWsMiddleware :: Context -> Middleware
- PostgresWebsockets.Config: AppConfig :: Text -> Maybe Text -> Text -> Int -> Text -> ByteString -> Bool -> Int -> Int -> AppConfig
+ PostgresWebsockets.Config: AppConfig :: Text -> Maybe Text -> Text -> Int -> Text -> Maybe Text -> ByteString -> Bool -> Int -> Int -> AppConfig

Files

postgres-websockets.cabal view
@@ -1,5 +1,5 @@ name:                postgres-websockets-version:             0.9.0.0+version:             0.10.0.0 synopsis:            Middleware to map LISTEN/NOTIFY messages to Websockets description:         Please see README.md homepage:            https://github.com/diogob/postgres-websockets#readme@@ -25,6 +25,7 @@   other-modules:       Paths_postgres_websockets                      , PostgresWebsockets.Server                      , PostgresWebsockets.Middleware+                     , PostgresWebsockets.Context   build-depends:       base >= 4.7 && < 5                      , hasql-pool >= 0.5 && < 0.6                      , text >= 1.2 && < 1.3
src/PostgresWebsockets.hs view
@@ -11,6 +11,6 @@   , postgresWsMiddleware   ) where -import           PostgresWebsockets.Middleware-import           PostgresWebsockets.Server-import           PostgresWebsockets.Config+import PostgresWebsockets.Middleware ( postgresWsMiddleware )+import PostgresWebsockets.Server ( serve )+import PostgresWebsockets.Config ( prettyVersion, loadConfig )
src/PostgresWebsockets/Claims.hs view
@@ -11,14 +11,15 @@   ( ConnectionInfo,validateClaims   ) where -import           Control.Lens-import qualified Crypto.JOSE.Types   as JOSE.Types-import           Crypto.JWT-import qualified Data.HashMap.Strict as M-import           Protolude+import Protolude+import Control.Lens+import Crypto.JWT import Data.List import Data.Time.Clock (UTCTime)+import qualified Crypto.JOSE.Types as JOSE.Types+import qualified Data.HashMap.Strict as M import qualified Data.Aeson as JSON+  type Claims = M.HashMap Text JSON.Value type ConnectionInfo = ([ByteString], ByteString, Claims)
src/PostgresWebsockets/Config.hs view
@@ -31,6 +31,7 @@   , configHost              :: Text   , configPort              :: Int   , configListenChannel     :: Text+  , configMetaChannel       :: Maybe Text   , configJwtSecret         :: ByteString   , configJwtSecretIsBase64 :: Bool   , configPool              :: Int@@ -68,6 +69,7 @@                 <*> var str "PGWS_HOST" (def "*4" <> helpDef show <> help "Address the server will listen for websocket connections")                 <*> var auto "PGWS_PORT" (def 3000 <> helpDef show <> help "Port the server will listen for websocket connections")                 <*> var str "PGWS_LISTEN_CHANNEL" (def "postgres-websockets-listener" <> helpDef show <> help "Master channel used in the database to send or read messages in any notification channel")+                <*> optional (var str "PGWS_META_CHANNEL" (help "Websockets channel used to send events about the server state changes."))                 <*> var str "PGWS_JWT_SECRET" (help "Secret used to sign JWT tokens used to open communications channels")                 <*> var auto "PGWS_JWT_SECRET_BASE64" (def False <> helpDef show <> help "Indicate whether the JWT secret should be decoded from a base64 encoded string")                 <*> var auto "PGWS_POOL_SIZE" (def 10 <> helpDef show <> help "How many connection to the database should be used by the connection pool")
+ src/PostgresWebsockets/Context.hs view
@@ -0,0 +1,39 @@+{-|+Module      : PostgresWebsockets.Context+Description : Produce a context capable of running postgres-websockets sessions+-}+module PostgresWebsockets.Context+        ( Context (..)+        , mkContext+        ) where++import Protolude+import Data.Time.Clock (UTCTime, getCurrentTime)+import Control.AutoUpdate       ( defaultUpdateSettings+                                , mkAutoUpdate+                                , updateAction+                                )+import qualified Hasql.Pool as P++import PostgresWebsockets.Config ( AppConfig(..) )+import PostgresWebsockets.HasqlBroadcast (newHasqlBroadcaster)+import PostgresWebsockets.Broadcast (Multiplexer)++data Context = Context {+    ctxConfig :: AppConfig+  , ctxPool :: P.Pool+  , ctxMulti :: Multiplexer+  , ctxGetTime :: IO UTCTime+  }++-- | Given a configuration and a shutdown action (performed when the Multiplexer's listen connection dies) produces the context necessary to run sessions+mkContext :: AppConfig -> IO () -> IO Context+mkContext conf@AppConfig{..} shutdown = do+  Context conf+    <$> P.acquire (configPool, 10, pgSettings)+    <*> newHasqlBroadcaster shutdown (toS configListenChannel) configRetries pgSettings+    <*> mkGetTime+  where+    mkGetTime :: IO (IO UTCTime)+    mkGetTime = mkAutoUpdate defaultUpdateSettings {updateAction = getCurrentTime}+    pgSettings = toS configDatabase
src/PostgresWebsockets/HasqlBroadcast.hs view
@@ -19,11 +19,11 @@  import Hasql.Connection import Hasql.Notifications-import Data.Aeson              (decode, Value(..))-import Data.HashMap.Lazy       (lookupDefault)+import Data.Aeson (decode, Value(..))+import Data.HashMap.Lazy (lookupDefault) import Data.Either.Combinators (mapBoth)-import Data.Function           (id)-import Control.Retry           (RetryStatus(..), retrying, capDelay, exponentialBackoff)+import Data.Function (id)+import Control.Retry (RetryStatus(..), retrying, capDelay, exponentialBackoff)  import PostgresWebsockets.Broadcast @@ -99,11 +99,11 @@         _ -> d     lookupStringDef _ d _ = d     channelDef = lookupStringDef "channel"-    openProducer msgs = do+    openProducer msgQ = do       con <- getCon       listen con $ toPgIdentifier ch       waitForNotifications-        (\c m-> atomically $ writeTQueue msgs $ toMsg c m)+        (\c m-> atomically $ writeTQueue msgQ $ toMsg c m)         con  putErrLn :: Text -> IO ()
src/PostgresWebsockets/Middleware.hs view
@@ -10,39 +10,48 @@   ( postgresWsMiddleware   ) where -import qualified Hasql.Pool                     as H-import qualified Hasql.Notifications            as H-import qualified Network.Wai                    as Wai+import Protolude+import Data.Time.Clock (UTCTime)+import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds, posixSecondsToUTCTime)+import Control.Concurrent.AlarmClock (newAlarmClock, setAlarm)+import qualified Hasql.Notifications as H+import qualified Hasql.Pool as H+import qualified Network.Wai as Wai import qualified Network.Wai.Handler.WebSockets as WS-import qualified Network.WebSockets             as WS-import           Protolude+import qualified Network.WebSockets as WS -import qualified Data.Aeson                     as A-import qualified Data.ByteString.Char8          as BS-import qualified Data.ByteString.Lazy           as BL-import qualified Data.HashMap.Strict            as M-import qualified Data.Text.Encoding.Error       as T-import           Data.Time.Clock (UTCTime)-import           Data.Time.Clock.POSIX          (utcTimeToPOSIXSeconds, posixSecondsToUTCTime)-import           Control.Concurrent.AlarmClock (newAlarmClock, setAlarm)-import           PostgresWebsockets.Broadcast          (Multiplexer, onMessage)-import qualified PostgresWebsockets.Broadcast          as B-import           PostgresWebsockets.Claims+import qualified Data.Aeson as A+import qualified Data.ByteString.Char8 as BS+import qualified Data.ByteString.Lazy as BL+import qualified Data.HashMap.Strict as M+import qualified Data.Text.Encoding.Error as T +import PostgresWebsockets.Broadcast (onMessage)+import PostgresWebsockets.Claims ( ConnectionInfo, validateClaims )+import PostgresWebsockets.Context ( Context(..) )+import PostgresWebsockets.Config (AppConfig(..))+import qualified PostgresWebsockets.Broadcast as B+++data Event =+    WebsocketMessage+  | ConnectionOpen+  deriving (Show, Eq, Generic)+ data Message = Message   { claims  :: A.Object-  , channel :: Text+  , event   :: Event   , payload :: Text+  , channel :: Text   } deriving (Show, Eq, Generic) +instance A.ToJSON Event instance A.ToJSON Message  -- | Given a secret, a function to fetch the system time, a Hasql Pool and a Multiplexer this will give you a WAI middleware.-postgresWsMiddleware :: IO UTCTime -> Text -> ByteString -> H.Pool -> Multiplexer -> Wai.Application -> Wai.Application+postgresWsMiddleware :: Context -> Wai.Middleware postgresWsMiddleware =-  WS.websocketsOr WS.defaultConnectionOptions `compose` wsApp-  where-    compose = (.) . (.) . (.) . (.) . (.)+  WS.websocketsOr WS.defaultConnectionOptions . wsApp  -- private functions jwtExpirationStatusCode :: Word16@@ -50,9 +59,9 @@  -- when the websocket is closed a ConnectionClosed Exception is triggered -- this kills all children and frees resources for us-wsApp :: IO UTCTime -> Text -> ByteString -> H.Pool -> Multiplexer -> WS.ServerApp-wsApp getTime dbChannel secret pool multi pendingConn =-  getTime >>= validateClaims requestChannel secret (toS jwtToken) >>= either rejectRequest forkSessions+wsApp :: Context -> WS.ServerApp+wsApp Context{..} pendingConn =+  ctxGetTime >>= validateClaims requestChannel (configJwtSecret ctxConfig) (toS jwtToken) >>= either rejectRequest forkSessions   where     hasRead m = m == ("r" :: ByteString) || m == ("rw" :: ByteString)     hasWrite m = m == ("w" :: ByteString) || m == ("rw" :: ByteString)@@ -85,12 +94,21 @@               Just _ -> pure ()               Nothing -> pure () +            let sendNotification msg channel = sendMessageWithTimestamp $ websocketMessageForChannel msg channel+                sendMessageToDatabase = sendToDatabase ctxPool (configListenChannel ctxConfig)+                sendMessageWithTimestamp = timestampMessage ctxGetTime >=> sendMessageToDatabase+                websocketMessageForChannel = Message validClaims WebsocketMessage+                connectionOpenMessage = Message validClaims ConnectionOpen++            case configMetaChannel ctxConfig of+              Nothing -> pure ()+              Just ch -> sendMessageWithTimestamp $ connectionOpenMessage (toS $ BS.intercalate "," chs) ch+             when (hasRead mode) $-              forM_ chs $ flip (onMessage multi) $ WS.sendTextData conn . B.payload+              forM_ chs $ flip (onMessage ctxMulti) $ WS.sendTextData conn . B.payload              when (hasWrite mode) $-              let sendNotifications = void . H.notifyPool pool dbChannel . toS-              in notifySession validClaims conn getTime sendNotifications chs+              notifySession conn sendNotification chs              waitForever <- newEmptyMVar             void $ takeMVar waitForever@@ -98,30 +116,22 @@ -- Having both channel and claims as parameters seem redundant -- But it allows the function to ignore the claims structure and the source -- of the channel, so all claims decoding can be coded in the caller-notifySession :: A.Object-              -> WS.Connection-              -> IO UTCTime-              -> (ByteString -> IO ())-              -> [ByteString]-              -> IO ()-notifySession claimsToSend wsCon getTime send chs =+notifySession :: WS.Connection -> (Text -> Text -> IO ()) -> [ByteString] -> IO ()+notifySession wsCon sendToChannel chs =   withAsync (forever relayData) wait   where-    relayData = do +    relayData = do       msg <- WS.receiveData wsCon-      forM_ chs (relayChannelData msg . toS)--    relayChannelData msg ch = do-      claims' <- claimsWithTime ch-      send $ jsonMsg ch claims' msg--    -- we need to decode the bytestring to re-encode valid JSON for the notification-    jsonMsg :: Text -> M.HashMap Text A.Value -> ByteString -> ByteString-    jsonMsg ch cl = BL.toStrict . A.encode . Message cl ch . decodeUtf8With T.lenientDecode+      forM_ chs (sendToChannel msg . toS) -    claimsWithTime :: Text -> IO (M.HashMap Text A.Value)-    claimsWithTime ch = do-      time <- utcTimeToPOSIXSeconds <$> getTime-      return $ M.insert "message_delivered_at" (A.Number $ realToFrac time) (claimsWithChannel ch)+sendToDatabase :: H.Pool -> Text -> Message -> IO ()+sendToDatabase pool dbChannel =+  notify . jsonMsg+  where+    notify = void . H.notifyPool pool dbChannel . toS+    jsonMsg = BL.toStrict . A.encode -    claimsWithChannel ch = M.insert "channel" (A.String ch) claimsToSend+timestampMessage :: IO UTCTime -> Message -> IO Message+timestampMessage getTime msg@Message{..} = do+  time <- utcTimeToPOSIXSeconds <$> getTime+  return $ msg{ claims = M.insert "message_delivered_at" (A.Number $ realToFrac time) claims}
src/PostgresWebsockets/Server.hs view
@@ -6,47 +6,35 @@         ( serve          ) where -import           Protolude-import           PostgresWebsockets.Middleware-import           PostgresWebsockets.Config-import           PostgresWebsockets.HasqlBroadcast (newHasqlBroadcaster)+import Protolude+import Network.Wai.Application.Static ( staticApp, defaultFileServerSettings )+import Network.Wai (Application, responseLBS)+import Network.HTTP.Types (status200)+import Network.Wai.Handler.Warp ( runSettings )+import Network.Wai.Middleware.RequestLogger (logStdout) -import qualified Hasql.Pool as P-import           Network.Wai.Application.Static-import           Data.Time.Clock (UTCTime, getCurrentTime)-import Control.AutoUpdate       ( defaultUpdateSettings-                                , mkAutoUpdate-                                , updateAction-                                )-import           Network.Wai (Application, responseLBS)-import           Network.HTTP.Types (status200)-import           Network.Wai.Handler.Warp-import           Network.Wai.Middleware.RequestLogger (logStdout)+import PostgresWebsockets.Middleware ( postgresWsMiddleware )+import PostgresWebsockets.Config ( AppConfig(..), warpSettings )+import PostgresWebsockets.Context ( mkContext )  -- | Start a stand-alone warp server using the parameters from AppConfig and a opening a database connection pool. serve :: AppConfig -> IO () serve conf@AppConfig{..} = do   shutdownSignal <- newEmptyMVar-  let listenChannel = toS configListenChannel-      pgSettings = toS configDatabase-      waitForShutdown cl = void $ forkIO (takeMVar shutdownSignal >> cl)+  let waitForShutdown cl = void $ forkIO (takeMVar shutdownSignal >> cl)       appSettings = warpSettings waitForShutdown conf    putStrLn $ ("Listening on port " :: Text) <> show configPort    let shutdown = putErrLn ("Broadcaster connection is dead" :: Text) >> putMVar shutdownSignal ()-  pool <- P.acquire (configPool, 10, pgSettings)-  multi <- newHasqlBroadcaster shutdown listenChannel configRetries pgSettings-  getTime <- mkGetTime+  ctx <- mkContext conf shutdown    runSettings appSettings $-    postgresWsMiddleware getTime listenChannel configJwtSecret pool multi $+    postgresWsMiddleware ctx $     logStdout $ maybe dummyApp staticApp' configPath   die "Shutting down server..."    where-    mkGetTime :: IO (IO UTCTime)-    mkGetTime = mkAutoUpdate defaultUpdateSettings {updateAction = getCurrentTime}     staticApp' :: Text -> Application     staticApp' = staticApp . defaultFileServerSettings . toS     dummyApp :: Application
test/ServerSpec.hs view
@@ -13,36 +13,38 @@ import           Network.Socket (withSocketsDo)  testServerConfig :: AppConfig-testServerConfig = AppConfig +testServerConfig = AppConfig                     { configDatabase = "postgres://localhost/postgres"                     , configPath = Nothing                     , configHost = "*"                     , configPort = 8080                     , configListenChannel = "postgres-websockets-test-channel"                     , configJwtSecret = "reallyreallyreallyreallyverysafe"+                    , configMetaChannel = Nothing                     , configJwtSecretIsBase64 = False                     , configPool = 10+                    , configRetries = 5                     }  startTestServer :: IO ThreadId startTestServer = do     threadId <- forkIO $ serve testServerConfig-    threadDelay 1000+    threadDelay 500000     pure threadId  withServer :: IO () -> IO () withServer action =   bracket startTestServer-          killThread+          (\tid -> killThread tid >> threadDelay 500000)           (const action)  sendWsData :: Text -> Text -> IO () sendWsData uri msg =-    withSocketsDo $ -        WS.runClient -            "localhost" -            (configPort testServerConfig) -            (toS uri) +    withSocketsDo $+        WS.runClient+            "localhost"+            (configPort testServerConfig)+            (toS uri)             (`WS.sendTextData` msg)  testChannel :: Text@@ -58,27 +60,27 @@ waitForWsData uri = do     msg <- newEmptyMVar     void $ forkIO $-        withSocketsDo $ -            WS.runClient -                "localhost" -                (configPort testServerConfig) -                (toS uri) +        withSocketsDo $+            WS.runClient+                "localhost"+                (configPort testServerConfig)+                (toS uri)                 (\c -> do                     m <- WS.receiveData c                     putMVar msg m                 )-    threadDelay 1000+    threadDelay 10000     pure msg  waitForMultipleWsData :: Int -> Text -> IO (MVar [ByteString]) waitForMultipleWsData messageCount uri = do     msg <- newEmptyMVar     void $ forkIO $-        withSocketsDo $ -            WS.runClient -                "localhost" -                (configPort testServerConfig) -                (toS uri) +        withSocketsDo $+            WS.runClient+                "localhost"+                (configPort testServerConfig)+                (toS uri)                 (\c -> do                     m <- replicateM messageCount (WS.receiveData c)                     putMVar msg m@@ -112,6 +114,6 @@                     sendWsData testAndSecondaryChannel "test data"                     msgsJson <- takeMVar msgs -                    forM_ -                        msgsJson +                    forM_+                        msgsJson                         (\msgJson -> (msgJson ^? key "payload" . _String) `shouldBe` Just "test data")