pusher-http-haskell 1.0.0.0 → 1.1.0.0
raw patch · 9 files changed
+274/−182 lines, 9 files
Files
- pusher-http-haskell.cabal +1/−1
- src/Network/Pusher.hs +46/−26
- src/Network/Pusher/Data.hs +88/−8
- src/Network/Pusher/Internal.hs +33/−33
- src/Network/Pusher/Internal/Auth.hs +49/−23
- src/Network/Pusher/Internal/HTTP.hs +40/−34
- src/Network/Pusher/Protocol.hs +3/−49
- test/Auth.hs +7/−3
- test/Protocol.hs +7/−5
pusher-http-haskell.cabal view
@@ -1,5 +1,5 @@ name: pusher-http-haskell-version: 1.0.0.0+version: 1.1.0.0 cabal-version: >=1.18 build-type: Simple license: MIT
src/Network/Pusher.hs view
@@ -13,17 +13,11 @@ well as functions for generating auth signatures for private and presence channels. -The idea is that you must create a Pusher data structure with your credentials,-then your write your block of code that calls functions from this library, and-finally "run" this code with the Pusher data structure you created.--The types of the functions are general enough to allow you to use the provided-PusherM as the return type, or PusherT if you wish to use other monads in your-applications monad transformer stack.+First create a 'Pusher' data structure with your Pusher 'Credentials', and then+call the functions defined in this module to make the HTTP requests. -The functions return a MonadError type which means any can fail, and you must-handle these failures in your client application. In the simplist case you can-instantiate the type to an Either and case split on it.+If any of the requests fail, the return values of the functions will result in+a 'Left' 'PusherError' when run. An example of how you would use these functions: @@ -47,19 +41,37 @@ See https://pusher.com/docs/rest_api for more detail on the HTTP requests. -} module Network.Pusher (- -- * The Pusher config type+ -- * Data types+ -- ** Pusher config type Pusher(..) , Credentials(..)+ , AppID+ , AppKey+ , AppSecret , getPusher , getPusherWithHost , getPusherWithConnManager- -- * Events+ -- ** Channels+ , Channel(..)+ , ChannelName+ , ChannelType(..)+ , renderChannel+ , renderChannelPrefix+ , parseChannel+ -- ** Events+ , Event+ , EventData+ , SocketID+ -- * HTTP Requests+ -- ** Trigger events , trigger- -- * Channel queries+ -- ** Channel queries , channels , channel , users -- * Authentication+ , AuthString+ , AuthSignature , authenticatePresence , authenticatePrivate -- * Errors@@ -72,7 +84,6 @@ import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Maybe (maybeToList) import Data.Monoid ((<>))-import Data.Text.Encoding (encodeUtf8) import Network.HTTP.Client (Manager) import qualified Data.Aeson as A import qualified Data.ByteString as B@@ -80,29 +91,39 @@ import qualified Data.Text as T import Network.Pusher.Data- ( Pusher(..)+ ( AppID+ , AppKey+ , AppSecret+ , Channel(..)+ , ChannelName+ , ChannelType(..) , Credentials(..)+ , Event+ , EventData+ , Pusher(..)+ , SocketID , getPusher , getPusherWithHost , getPusherWithConnManager+ , parseChannel+ , renderChannel+ , renderChannelPrefix ) import Network.Pusher.Error(PusherError(..)) import Network.Pusher.Internal.Auth- ( authenticatePresence+ ( AuthSignature+ , AuthString+ , authenticatePresence , authenticatePrivate , makeQS ) import Network.Pusher.Internal.Util (getTime) import Network.Pusher.Protocol- ( Channel- , ChannelInfoQuery+ ( ChannelInfoQuery , ChannelsInfo , ChannelsInfoQuery- , ChannelType , FullChannelInfo , Users- , renderChannel- , renderChannelPrefix , toURLParam ) import qualified Network.Pusher.Internal as Pusher@@ -114,11 +135,10 @@ => Pusher -> [Channel] -- ^The list of channels to trigger to- -> T.Text- -- ^The event- -> T.Text- -- ^The data to send (often encoded JSON)- -> Maybe T.Text+ -> Event+ -> EventData+ -- ^Often encoded JSON+ -> Maybe SocketID -- ^An optional socket ID of a connection you wish to exclude -> m (Either PusherError ()) trigger pusher chans event dat socketId =
src/Network/Pusher/Data.hs view
@@ -1,35 +1,64 @@+{-# LANGUAGE DeriveGeneric #-}+ {-| Module : Network.Pusher.Data-Description : Data structure to store Pusher config+Description : Data structure representing Pusher concepts and config Copyright : (c) Will Sewell, 2016 Licence : MIT Maintainer : me@willsewell.com Stability : experimental -You must create an instance of this datatype with your particular Pusher app-credentials in order to run the main API functions.+You must create an instance of the Pusher datatype with your particular Pusher+app credentials in order to run the main API functions.++The other types represent Pusher channels and Pusher event fields. -}-module Network.Pusher.Data- ( Pusher(..)+module Network.Pusher.Data (+ -- * Pusher config data type+ AppID+ , AppKey+ , AppSecret+ , Pusher(..) , Credentials(..) , getPusher , getPusherWithHost , getPusherWithConnManager+ -- * Channels+ , Channel(..)+ , ChannelName+ , ChannelType(..)+ , renderChannel+ , renderChannelPrefix+ , parseChannel+ -- Events+ , Event+ , EventData+ , SocketID ) where import Control.Applicative ((<$>), (<*>)) import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Aeson ((.:))+import Data.Foldable (asum)+import Data.Hashable (Hashable) import Data.Maybe (fromMaybe) import Data.Monoid ((<>)) import Data.Text.Encoding (encodeUtf8)+import GHC.Generics (Generic) import Network.HTTP.Client (Manager, defaultManagerSettings, newManager)+import Test.QuickCheck (Arbitrary, arbitrary, elements) import qualified Data.Aeson as A import qualified Data.ByteString as B import qualified Data.Text as T import Network.Pusher.Internal.Util (failExpectObj, show') +type AppID = Integer++type AppKey = B.ByteString++type AppSecret = B.ByteString+ -- |All the required configuration needed to interact with the API. data Pusher = Pusher { pusherHost :: T.Text@@ -40,9 +69,9 @@ -- |The credentials for the current app. data Credentials = Credentials- { credentialsAppID :: Integer- , credentialsAppKey :: B.ByteString- , credentialsAppSecret :: B.ByteString+ { credentialsAppID :: AppID+ , credentialsAppKey :: AppKey+ , credentialsAppSecret :: AppSecret } instance A.FromJSON Credentials where@@ -79,3 +108,54 @@ getConnManager :: MonadIO m => m Manager getConnManager = liftIO $ newManager defaultManagerSettings++type ChannelName = T.Text++-- |The possible types of Pusher channe.+data ChannelType = Public | Private | Presence deriving (Eq, Generic, Show)++instance Hashable ChannelType++instance Arbitrary ChannelType where+ arbitrary = elements [Public, Private, Presence]++renderChannelPrefix :: ChannelType -> T.Text+renderChannelPrefix Public = ""+renderChannelPrefix Private = "private-"+renderChannelPrefix Presence = "presence-"++-- |The channel name (not including the channel type prefix) and its type.+data Channel = Channel+ { channelType :: ChannelType+ , channelName :: ChannelName+ } deriving (Eq, Generic, Show)++instance Hashable Channel++instance Arbitrary Channel where+ arbitrary = Channel <$> arbitrary <*> (T.pack <$> arbitrary)++renderChannel :: Channel -> T.Text+renderChannel (Channel cType cName) = renderChannelPrefix cType <> cName++-- |Convert string representation, e.g. private-chan into the datatype+parseChannel :: T.Text -> Channel+parseChannel chan =+ -- Attempt to parse it as a private or presence channel; default to public+ fromMaybe+ (Channel Public chan)+ (asum [parseChanAs Private, parseChanAs Presence])+ where+ parseChanAs chanType =+ let split = T.splitOn (renderChannelPrefix chanType) chan in+ -- If the prefix appears at the start, then the first element will be empty+ if length split > 1 && T.null (head split) then+ Just $ Channel chanType (T.concat $ tail split)+ else+ Nothing++type Event = T.Text++type EventData = T.Text++type SocketID = T.Text
src/Network/Pusher/Internal.hs view
@@ -7,9 +7,7 @@ Stability : experimental -} module Network.Pusher.Internal- ( PusherRequestParams(..)- , PusherRequestBody- , mkTriggerRequest+ ( mkTriggerRequest , mkChannelsRequest , mkChannelRequest , mkUsersRequest@@ -24,39 +22,41 @@ import qualified Data.ByteString.Lazy as BL import qualified Data.Text as T -import Network.Pusher.Data (Pusher(..), Credentials(..))+import Network.Pusher.Data+ ( Channel+ , ChannelType+ , Credentials(..)+ , Event+ , EventData+ , Pusher(..)+ , SocketID+ , renderChannel+ , renderChannelPrefix+ ) import Network.Pusher.Error(PusherError(..)) import Network.Pusher.Internal.Auth (makeQS)+import Network.Pusher.Internal.HTTP+ ( RequestBody+ , RequestParams(RequestParams)+ , RequestQueryString+ ) import Network.Pusher.Protocol- ( Channel- , ChannelInfoQuery+ ( ChannelInfoQuery , ChannelsInfo , ChannelsInfoQuery- , ChannelType , FullChannelInfo , Users- , renderChannel- , renderChannelPrefix , toURLParam ) -data PusherRequestParams = PusherRequestParams- { pusherRequestEndpoint :: T.Text- -- ^The API endpoint, for example http://api.pusherapp.com/apps/123/events- , pusherRequestQueryString :: [(B.ByteString, B.ByteString)]- -- ^List of query string parameters as key-value tuples- }--type PusherRequestBody = A.Value- mkTriggerRequest :: Pusher -> [Channel]- -> T.Text- -> T.Text- -> Maybe T.Text+ -> Event+ -> EventData+ -> Maybe SocketID -> Int- -> Either PusherError (PusherRequestParams, PusherRequestBody)+ -> Either PusherError (RequestParams, RequestBody) mkTriggerRequest pusher chans event dat socketId time = do when (length chans > 10)@@ -79,7 +79,7 @@ -> T.Text -> ChannelsInfoQuery -> Int- -> PusherRequestParams+ -> RequestParams mkChannelsRequest pusher channelTypeFilter prefixFilter attributes time = let prefix = maybe "" renderChannelPrefix channelTypeFilter <> prefixFilter@@ -95,7 +95,7 @@ -> Channel -> ChannelInfoQuery -> Int- -> PusherRequestParams+ -> RequestParams mkChannelRequest pusher chan attributes time = let params = [("info", encodeUtf8 $ toURLParam attributes)]@@ -103,7 +103,7 @@ in mkGetRequest pusher subPath params time -mkUsersRequest :: Pusher -> Channel -> Int -> PusherRequestParams+mkUsersRequest :: Pusher -> Channel -> Int -> RequestParams mkUsersRequest pusher chan time = let subPath = "channels/" <> renderChannel chan <> "/users"@@ -113,29 +113,29 @@ mkGetRequest :: Pusher -> T.Text- -> [(B.ByteString, B.ByteString)]+ -> RequestQueryString -> Int- -> PusherRequestParams+ -> RequestParams mkGetRequest pusher subPath params time = let (ep, fullPath) = mkEndpoint pusher subPath qs = mkQS pusher "GET" fullPath params "" time in- PusherRequestParams ep qs+ RequestParams ep qs mkPostRequest :: Pusher -> T.Text- -> [(B.ByteString, B.ByteString)]+ -> RequestQueryString -> B.ByteString -> Int- -> PusherRequestParams+ -> RequestParams mkPostRequest pusher subPath params bodyBS time = let (ep, fullPath) = mkEndpoint pusher subPath qs = mkQS pusher "POST" fullPath params bodyBS time in- PusherRequestParams ep qs+ RequestParams ep qs -- |Build a full endpoint from the details in Pusher and the subPath. mkEndpoint@@ -153,10 +153,10 @@ :: Pusher -> T.Text -> T.Text- -> [(B.ByteString, B.ByteString)]+ -> RequestQueryString -> B.ByteString -> Int- -> [(B.ByteString, B.ByteString)]+ -> RequestQueryString mkQS pusher = let credentials = pusherCredentials pusher
src/Network/Pusher/Internal/Auth.hs view
@@ -13,7 +13,9 @@ users; these functions are re-exported in the main Pusher module. -} module Network.Pusher.Internal.Auth- ( authenticatePresence+ ( AuthString+ , AuthSignature+ , authenticatePresence , authenticatePresenceWithEncoder , authenticatePrivate, makeQS ) where@@ -22,29 +24,40 @@ import Data.Text.Encoding (encodeUtf8) import GHC.Exts (sortWith) import qualified Data.Aeson as A+import qualified Data.Aeson.Encode as A import qualified Data.ByteString as B import qualified Data.ByteString.Char8 as BC import qualified Data.ByteString.Base16 as B16 import qualified Data.ByteString.Lazy as BL import qualified Data.Text as T+import qualified Data.Text.Lazy as TL+import qualified Data.Text.Lazy.Builder as TL import qualified Crypto.Hash.MD5 as MD5 import qualified Crypto.Hash.SHA256 as SHA256 import qualified Crypto.MAC.HMAC as HMAC -import Network.Pusher.Data (Credentials(..))+import Network.Pusher.Data+ ( AppKey+ , AppSecret+ , Channel+ , Credentials(..)+ , SocketID+ , renderChannel+ )+import Network.Pusher.Internal.HTTP (RequestQueryString) import Network.Pusher.Internal.Util (show') -- |Generate the required query string parameters required to send API requests -- to Pusher. makeQS- :: B.ByteString- -> B.ByteString+ :: AppKey+ -> AppSecret -> T.Text -> T.Text- -> [(B.ByteString, B.ByteString)] -- ^Any additional parameters+ -> RequestQueryString -- ^Any additional parameters -> B.ByteString -> Int -- ^Current UNIX timestamp- -> [(B.ByteString, B.ByteString)]+ -> RequestQueryString makeQS appKey appSecret method path params body ts = let -- Generate all required parameters and add them to the list of existing ones@@ -65,24 +78,34 @@ ("auth_signature", authSig) : allParams -- |Render key-value tuple mapping of query string parameters into a string.-formQueryString :: [(B.ByteString, B.ByteString)] -> B.ByteString+formQueryString :: RequestQueryString -> B.ByteString formQueryString = B.intercalate "&" . map (\(a, b) -> a <> "=" <> b) +-- | The bytestring to sign with the app secret to create a signature from.+type AuthString = B.ByteString++-- | A Pusher auth signature.+type AuthSignature = B.ByteString+ -- |Create a Pusher auth signature of a string using the provided credentials.-authSignature :: B.ByteString -> B.ByteString -> B.ByteString+authSignature :: AppSecret -> AuthString -> AuthSignature authSignature appSecret authString = B16.encode $ HMAC.hmac SHA256.hash 64 appSecret authString + -- |Generate an auth signature of the form "app_key:auth_sig" for a user of a -- private channel. authenticatePrivate- :: Credentials -> B.ByteString -> B.ByteString -> B.ByteString-authenticatePrivate cred socketID channelName =+ :: Credentials+ -> SocketID+ -> Channel+ -> AuthSignature+authenticatePrivate cred socketID channel = let sig = authSignature (credentialsAppSecret cred)- (socketID <> ":" <> channelName)+ (encodeUtf8 $ socketID <> ":" <> renderChannel channel) in credentialsAppKey cred <> ":" <> sig @@ -90,27 +113,30 @@ -- presence channel. authenticatePresence :: A.ToJSON a- => Credentials -> B.ByteString -> B.ByteString -> a -> B.ByteString+ => Credentials+ -> SocketID+ -> Channel+ -> a+ -> AuthSignature authenticatePresence =- authenticatePresenceWithEncoder (BL.toStrict . A.encode)+ authenticatePresenceWithEncoder+ (TL.toStrict . TL.toLazyText . A.encodeToTextBuilder . A.toJSON) -- |As above, but allows the encoder of the user data to be specified. This is -- useful for testing because the encoder can be mocked; aeson's encoder enodes -- JSON object fields in arbitrary orders, which makes it impossible to test. authenticatePresenceWithEncoder :: A.ToJSON a- => (a -> B.ByteString) -- ^The encoder of the user data.+ => (a -> T.Text) -- ^The encoder of the user data. -> Credentials- -> B.ByteString- -> B.ByteString+ -> SocketID+ -> Channel -> a- -> B.ByteString-authenticatePresenceWithEncoder userEncoder cred socketID channelName userData =+ -> AuthSignature+authenticatePresenceWithEncoder userEncoder cred socketID channel userData = let- sig = authSignature (credentialsAppSecret cred)- ( socketID <> ":"- <> channelName <> ":"- <> userEncoder userData- )+ authString =+ encodeUtf8 $ socketID <> ":" <> renderChannel channel <> ":" <> userEncoder userData+ sig = authSignature (credentialsAppSecret cred) authString in credentialsAppKey cred <> ":" <> sig
src/Network/Pusher/Internal/HTTP.hs view
@@ -13,7 +13,13 @@ values to the typclasses we are using in this library. Non 200 responses are converted into MonadError errors. -}-module Network.Pusher.Internal.HTTP (get, post) where+module Network.Pusher.Internal.HTTP+ ( RequestParams(..)+ , RequestQueryString+ , RequestBody+ , get+ , post+ ) where import Control.Arrow (second) import Control.Exception (displayException)@@ -21,20 +27,6 @@ import Control.Monad.Trans.Except (ExceptT(ExceptT), throwE) import Data.Monoid ((<>)) import Data.Text.Encoding (decodeUtf8')-import Network.HTTP.Client- ( Manager- , Request- , RequestBody(RequestBodyLBS)- , Response- , httpLbs- , method- , parseUrl- , requestBody- , requestHeaders- , responseBody- , responseStatus- , setQueryString- ) import Network.HTTP.Types.Header (hContentType) import Network.HTTP.Types.Method (methodPost) import Network.HTTP.Types.Status (statusCode, statusMessage)@@ -43,20 +35,31 @@ import qualified Data.ByteString.Char8 as BC import qualified Data.ByteString.Lazy as BL import qualified Data.Text as T+import qualified Network.HTTP.Client as HTTP.Client import Network.Pusher.Error (PusherError(..))-import Network.Pusher.Internal (PusherRequestParams(PusherRequestParams)) import Network.Pusher.Internal.Util (show') +data RequestParams = RequestParams+ { requestEndpoint :: T.Text+ -- ^The API endpoint, for example http://api.pusherapp.com/apps/123/events+ , requestQueryString :: RequestQueryString+ -- ^List of query string parameters as key-value tuples+ }++type RequestQueryString = [(B.ByteString, B.ByteString)]++type RequestBody = A.Value+ -- |Issue an HTTP GET request. On a 200 response, the response body is returned. -- On failure, an error will be thrown into the MonadError instance. get :: A.FromJSON a- => Manager- -> PusherRequestParams+ => HTTP.Client.Manager+ -> RequestParams -> ExceptT PusherError IO a -- ^The body of the response-get connManager (PusherRequestParams ep qs) = do+get connManager (RequestParams ep qs) = do req <- ExceptT $ return $ mkRequest ep qs resp <- doReqest connManager req either@@ -67,38 +70,41 @@ -- |Issue an HTTP POST request. post :: A.ToJSON a- => Manager- -> PusherRequestParams+ => HTTP.Client.Manager+ -> RequestParams -> a -> ExceptT PusherError IO ()-post connManager (PusherRequestParams ep qs) body = do+post connManager (RequestParams ep qs) body = do req <- ExceptT $ return $ mkPost (A.encode body) <$> mkRequest ep qs _ <- doReqest connManager req return () mkRequest :: T.Text- -> [(B.ByteString, B.ByteString)]- -> Either PusherError Request+ -> RequestQueryString+ -> Either PusherError HTTP.Client.Request mkRequest ep qs =- case parseUrl $ T.unpack ep of+ case HTTP.Client.parseRequest $ T.unpack ep of Nothing -> Left $ PusherArgumentError $ "failed to parse url: " <> ep- Just req -> Right $ setQueryString (map (second Just) qs) req+ Just req -> Right $ HTTP.Client.setQueryString (map (second Just) qs) req -mkPost :: BL.ByteString -> Request -> Request+mkPost :: BL.ByteString -> HTTP.Client.Request -> HTTP.Client.Request mkPost body req = req- { method = methodPost- , requestHeaders = [(hContentType, "application/json")]- , requestBody = RequestBodyLBS body+ { HTTP.Client.method = methodPost+ , HTTP.Client.requestHeaders = [(hContentType, "application/json")]+ , HTTP.Client.requestBody = HTTP.Client.RequestBodyLBS body } -doReqest :: Manager -> Request -> ExceptT PusherError IO BL.ByteString+doReqest+ :: HTTP.Client.Manager+ -> HTTP.Client.Request+ -> ExceptT PusherError IO BL.ByteString doReqest connManager req = do- response <- liftIO $ httpLbs req connManager- let status = responseStatus response+ response <- liftIO $ HTTP.Client.httpLbs req connManager+ let status = HTTP.Client.responseStatus response if statusCode status == 200 then- return $ responseBody response+ return $ HTTP.Client.responseBody response else let decoded = decodeUtf8' $ statusMessage status in throwE $
src/Network/Pusher/Protocol.hs view
@@ -15,20 +15,16 @@ There are also types for query string parameters. -} module Network.Pusher.Protocol- ( Channel(..)- , ChannelInfo(..)+ ( ChannelInfo(..) , ChannelInfoAttributes(..) , ChannelInfoQuery(..) , ChannelsInfo(..) , ChannelsInfoQuery(..) , ChannelsInfoAttributes(..)- , ChannelType(..) , FullChannelInfo(..) , User(..) , Users(..)- , parseChannel- , renderChannel- , renderChannelPrefix+ , ToURLParam , toURLParam ) where @@ -46,49 +42,7 @@ import qualified Data.Text as T import Network.Pusher.Internal.Util (failExpectObj)---- |The possible types of Pusher channe.-data ChannelType = Public | Private | Presence deriving (Eq, Generic, Show)--instance Hashable ChannelType--instance Arbitrary ChannelType where- arbitrary = elements [Public, Private, Presence]--renderChannelPrefix :: ChannelType -> T.Text-renderChannelPrefix Public = ""-renderChannelPrefix Private = "private-"-renderChannelPrefix Presence = "presence-"---- |The channel name (not including the channel type prefix) and its type.-data Channel = Channel- { channelType :: ChannelType- , channelName :: T.Text- } deriving (Eq, Generic, Show)--instance Hashable Channel--instance Arbitrary Channel where- arbitrary = Channel <$> arbitrary <*> (T.pack <$> arbitrary)--renderChannel :: Channel -> T.Text-renderChannel (Channel cType cName) = renderChannelPrefix cType <> cName---- |Convert string representation, e.g. private-chan into the datatype-parseChannel :: T.Text -> Channel-parseChannel chan =- -- Attempt to parse it as a private or presence channel; default to public- fromMaybe- (Channel Public chan)- (asum [parseChanAs Private, parseChanAs Presence])- where- parseChanAs chanType =- let split = T.splitOn (renderChannelPrefix chanType) chan in- -- If the prefix appears at the start, then the first element will be empty- if length split > 1 && T.null (head split) then- Just $ Channel chanType (T.concat $ tail split)- else- Nothing+import Network.Pusher.Data (Channel, parseChannel) -- |Types that can be serialised to a querystring parameter value. class ToURLParam a where
test/Auth.hs view
@@ -2,7 +2,11 @@ import Test.Hspec (Spec, describe, it, shouldBe) -import Network.Pusher (Credentials(..))+import Network.Pusher+ ( Channel(..)+ , ChannelType(Private, Presence)+ , Credentials(..)+ ) import Network.Pusher.Internal.Auth ( authenticatePrivate , authenticatePresenceWithEncoder@@ -29,7 +33,7 @@ describe "Auth.authenticatePrivate" $ it "works" $ -- Data from https://pusher.com/docs/auth_signatures#worked-example- authenticatePrivate credentials "1234.1234" "private-foobar"+ authenticatePrivate credentials "1234.1234" (Channel Private "foobar") `shouldBe` "278d425bdf160c739803:58df8b0c36d6982b82c3ecf6b4662e34fe8c25bba48f5369f135bf843651c3a4" describe "Auth.authenticatePresenceWithEncoder" $@@ -42,7 +46,7 @@ (const userData) credentials "1234.1234"- "presence-foobar"+ (Channel Presence "foobar") ("doesn't matter" :: String) `shouldBe` "278d425bdf160c739803:afaed3695da2ffd16931f457e338e6c9f2921fa133ce7dac49f529792be6304c"
test/Protocol.hs view
@@ -7,16 +7,18 @@ import qualified Data.HashSet as HS import qualified Data.Text as T -import Network.Pusher.Protocol+import Network.Pusher ( Channel(..)- , ChannelInfo(..)- , ChannelsInfo(..) , ChannelType(Public, Presence, Private)+ , parseChannel+ , renderChannel+ )+import Network.Pusher.Protocol+ ( ChannelInfo(..)+ , ChannelsInfo(..) , FullChannelInfo(..) , User(..) , Users(..)- , parseChannel- , renderChannel ) test :: Spec