nylas (empty) → 0.1.0
raw patch · 9 files changed
+623/−0 lines, 9 filesdep +aesondep +basedep +bytestringsetup-changed
Dependencies added: aeson, base, bytestring, lens, lens-aeson, pipes, pipes-aeson, pipes-bytestring, pipes-http, pipes-parse, text, time, wreq
Files
- CHANGELOG.md +3/−0
- LICENSE +30/−0
- Quickstart.hs +27/−0
- README.md +84/−0
- Setup.hs +2/−0
- nylas.cabal +38/−0
- src/Network/Nylas.hs +7/−0
- src/Network/Nylas/Client.hs +95/−0
- src/Network/Nylas/Types.hs +337/−0
+ CHANGELOG.md view
@@ -0,0 +1,3 @@+# 0.1.0++* Initial release.
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2015, Brian Schroeder++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++ * Redistributions of source code must retain the above copyright+ notice, this list of conditions and the following disclaimer.++ * Redistributions in binary form must reproduce the above+ copyright notice, this list of conditions and the following+ disclaimer in the documentation and/or other materials provided+ with the distribution.++ * Neither the name of Brian Schroeder nor the names of other+ contributors may be used to endorse or promote products derived+ from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ Quickstart.hs view
@@ -0,0 +1,27 @@+{-# LANGUAGE OverloadedStrings #-}++module Quickstart where++import qualified Data.ByteString.Char8 as B+import Data.Monoid ((<>))+import Pipes (runEffect, (>->))+import Pipes.HTTP (newManager, tlsManagerSettings)+import qualified Pipes.Prelude as P++import Network.Nylas++token :: AccessToken+token = AccessToken "_PUT_YOUR_ACCESS_TOKEN_HERE_"++main :: IO ()+main = do+ mgr <- newManager tlsManagerSettings+ let cursor = Nothing+ res <- consumeDeltas mgr token cursor (P.map show >-> P.print)+ case res of+ Left (ParsingError err remainder) -> do+ putStrLn $ "ERROR: " <> show err+ putStrLn "next few items:"+ runEffect $ remainder >-> P.take 3 >-> P.map B.unpack >-> P.stdoutLn+ Left (ConsumerError _) -> error "not possible"+ Right _ -> return ()
+ README.md view
@@ -0,0 +1,84 @@+# nylas++A _partially completed_ Haskell client for the [Nylas](https://nylas.com)+[HTTP API](https://www.nylas.com/docs/platform), powered by+[lens](https://hackage.haskell.org/package/lens) and+[pipes](https://hackage.haskell.org/package/pipes). At the moment I've only+implemented support for the endpoints I need.++Pull requests for support of additional endpoints would be greatly appreciated!++## Features++* constant-space stream ingestion of the+ [changes](https://www.nylas.com/docs/platform#deltas) (currently only message+ and thread objects) to an inbox since a given transactional cursor (or the+ beginning of time)+* requesting a message object+* requesting a thread object++## Quickstart++The following example prints out all of the (thread and message) changes to an+inbox using pipes:++```haskell+{-# LANGUAGE OverloadedStrings #-}++module Quickstart where++import qualified Data.ByteString.Char8 as B+import Data.Monoid ((<>))+import Pipes (runEffect, (>->))+import Pipes.HTTP (newManager, tlsManagerSettings)+import qualified Pipes.Prelude as P++import Network.Nylas++token :: AccessToken+token = AccessToken "_PUT_YOUR_ACCESS_TOKEN_HERE_"++main :: IO ()+main = do+ mgr <- newManager tlsManagerSettings+ let cursor = Nothing+ res <- consumeDeltas mgr token cursor (P.map show >-> P.print)+ case res of+ Left (ParsingError err remainder) -> do+ putStrLn $ "ERROR: " <> show err+ putStrLn "next few items:"+ runEffect $ remainder >-> P.take 3 >-> P.map B.unpack >-> P.stdoutLn+ Left (ConsumerError _) -> error "not possible"+ Right _ -> return ()+```++## License (3-clause BSD)++Copyright (c) 2015, Brian Schroeder++All rights reserved.++Redistribution and use in source and binary forms, with or without modification,+are permitted provided that the following conditions are met:++* Redistributions of source code must retain the above copyright notice, this+ list of conditions and the following disclaimer.++* Redistributions in binary form must reproduce the above copyright notice, this+ list of conditions and the following disclaimer in the documentation and/or+ other materials provided with the distribution.++* Neither the name of Brian Schroeder nor the names of other contributors may be+ used to endorse or promote products derived from this software without+ specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON+ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ nylas.cabal view
@@ -0,0 +1,38 @@+name: nylas+version: 0.1.0+synopsis: Client for the Nylas API+description: A client for the Nylas HTTP API, powered by lens and pipes.+license: BSD3+license-file: LICENSE+author: Brian Schroeder+maintainer: bts@gmail.com+copyright: 2015, Brian Schroeder+category: Network, API+build-type: Simple+cabal-version: >=1.10+homepage: https://github.com/bts/nylas-hs+bug-reports: https://github.com/bts/nylas-hs/issues+extra-source-files: README.md CHANGELOG.md Quickstart.hs++source-repository head+ type: git+ location: git://github.com/bts/nylas-hs.git++library+ exposed-modules: Network.Nylas, Network.Nylas.Client, Network.Nylas.Types+ build-depends: aeson >= 0.8 && < 0.10+ , base >= 4.8 && < 4.9+ , bytestring >= 0.10.6.0 && < 0.11+ , lens >= 4.5 && < 5.0+ , lens-aeson >= 1.0.0.4 && < 1.1+ , pipes >= 4.1.0 && < 5.0+ , pipes-aeson >= 0.4.1.3 && < 0.5+ , pipes-bytestring >= 2.1.1 && < 3.0+ , pipes-parse >= 3.0.2 && < 4.0+ , text >= 1.2.1.1 && < 1.3+ , wreq >= 0.4.0.0 && < 0.5+ , pipes-http >= 1.0.2 && < 2.0+ , time >= 1.5 && < 2.0+ hs-source-dirs: src+ default-language: Haskell2010+ ghc-options: -Wall
+ src/Network/Nylas.hs view
@@ -0,0 +1,7 @@+module Network.Nylas+ ( module Network.Nylas.Client+ , module Network.Nylas.Types+ ) where++import Network.Nylas.Client+import Network.Nylas.Types
+ src/Network/Nylas/Client.hs view
@@ -0,0 +1,95 @@+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}++module Network.Nylas.Client+ ( -- * Streaming Delta Consumption+ consumeDeltas+ -- * Requesting Individual Objects+ , getMessage+ , getThread+ ) where++import Control.Lens (view, (&), (.~), (?~), (^.))+import Control.Monad ((=<<))+import qualified Data.ByteString.Char8 as B+import Data.Either (Either (Left, Right))+import Data.Eq ((/=))+import Data.Functor ((<$>))+import Data.Maybe (Maybe, maybe)+import Data.Monoid ((<>))+import qualified Data.Text as T+import qualified Data.Text.Encoding as E+import GHC.Base (($))+import qualified Network.Wreq as W+import Pipes (Consumer, Producer, runEffect, (>->))+import Pipes.Aeson (DecodingError)+import qualified Pipes.Aeson.Unchecked as AU+import Pipes.HTTP (Manager, Request, applyBasicAuth,+ parseUrl, responseBody, withHTTP)+import qualified Pipes.Prelude as P+import System.IO (IO)++import Network.Nylas.Types++authenticatedOpts :: AccessToken -> W.Options -> W.Options+authenticatedOpts (AccessToken t) = W.auth ?~ W.basicAuth (E.encodeUtf8 t) ""++authenticatedReq :: AccessToken -> Request -> Request+authenticatedReq (AccessToken t) =+ applyBasicAuth (E.encodeUtf8 t) (E.encodeUtf8 "")++deltaStreamUrl :: Maybe Cursor -> Url+deltaStreamUrl mCursor =+ T.unpack $ "https://api.nylas.com/delta/streaming?cursor="+ <> maybe "0" _cursorId mCursor++-- | Consume 'Delta's for the inbox associated with the provided 'AccessToken'+-- since the optional 'Cursor' from Nylas' transactional+-- <https://www.nylas.com/docs/platform#streaming_delta_updates Streaming Delta Updates endpoint>.+--+-- Clients should keep track of the 'Cursor' from the latest 'Delta' consumed to+-- resume consumption in the future.+--+-- Any errors encountered during consumption (e.g. while writing to a database)+-- should be surfaced using the 'ConsumerError' value constructor of+-- 'StreamingError'.+consumeDeltas+ :: Manager+ -> AccessToken+ -> Maybe Cursor+ -> Consumer Delta IO (Either StreamingError ())+ -> IO (Either StreamingError ())+consumeDeltas m t mCursor consumer = do+ req <- parseUrl $ deltaStreamUrl mCursor+ withHTTP (authenticatedReq t req) m $ \resp -> do+ let body = responseBody resp >-> P.takeWhile (/= "\n")+ deltas = wrapError <$> view AU.decoded body+ runEffect $ deltas >-> consumer++ where+ wrapError :: Either (DecodingError, Producer B.ByteString IO ()) ()+ -> Either StreamingError ()+ wrapError (Left (err, leftovers)) = Left $ ParsingError err leftovers+ wrapError _ = Right ()++messageUrl :: NylasId -> Url+messageUrl (NylasId i) = T.unpack $ "https://api.nylas.com/messages/" <> i++-- | Fetch the 'Message' identified by 'NylasId' from the account associated+-- with 'AccessToken'.+getMessage :: Manager -> AccessToken -> NylasId -> IO Message+getMessage mgr t i = (^. W.responseBody) <$> (W.asJSON =<< W.getWith opts url)+ where opts = W.defaults & authenticatedOpts t+ & W.manager .~ Right mgr+ url = messageUrl i++threadUrl :: NylasId -> Url+threadUrl (NylasId i) = T.unpack $ "https://api.nylas.com/threads/" <> i++-- | Fetch the 'Thread' identified by 'NylasId' from the account associated with+-- 'AccessToken'.+getThread :: Manager -> AccessToken -> NylasId -> IO Thread+getThread mgr t i = (^. W.responseBody) <$> (W.asJSON =<< W.getWith opts url)+ where opts = W.defaults & authenticatedOpts t+ & W.manager .~ Right mgr+ url = threadUrl i
+ src/Network/Nylas/Types.hs view
@@ -0,0 +1,337 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TemplateHaskell #-}++module Network.Nylas.Types where++import Control.Applicative (empty, pure, (<*>))+import Control.Lens (makeLenses, makePrisms, (^.))+import Data.Aeson (FromJSON (parseJSON), ToJSON (toJSON),+ Value (String, Object, Bool), object,+ (.:), (.:?))+import Data.Aeson.Types (Parser)+import Data.Bool (Bool (True, False))+import qualified Data.ByteString.Char8 as B+import Data.Eq (Eq)+import Data.Functor (fmap, (<$>))+import Data.Int (Int)+import Data.Maybe (Maybe (Nothing, Just))+import Data.Monoid ((<>))+import Data.String (String)+import Data.Text (Text)+import Data.Time.Clock (UTCTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import GHC.Base (($), (.))+import GHC.Generics (Generic)+import GHC.Real (fromIntegral)+import Pipes (Producer)+import Pipes.Aeson (DecodingError)+import Prelude (Show)+import System.IO (IO)++-- * Types++type Url = String++-- | The <https://www.nylas.com/docs/platform#authentication access token> from+-- Nylas to access data from a single inbox.+newtype AccessToken = AccessToken Text deriving (Eq, Show)++-- | The unique ID for a Nylas object.+newtype NylasId = NylasId { _nylasId :: Text } deriving (Eq, Show, Generic)++-- ** Nylas Object Types++-- | A tuple of an email address and the optional name accompanying it, for+-- participants in 'Thread's and 'Message's.+data Mailbox+ = Mailbox+ { _mailboxName :: Maybe Text+ , _mailboxEmail :: Text+ } deriving (Eq, Show)++-- | A file attached to a 'Message'.+data File+ = File+ { _fileId :: NylasId+ , _fileContentType :: Text+ , _fileName :: Text+ , _fileSize :: Int+ , _fileContentId :: Maybe Text+ } deriving (Eq, Show)++-- | The time a message was sent.+newtype MessageTime = MessageTime { _utcTime :: UTCTime }+ deriving (Eq, Show, Generic)++-- | Whether a 'Message' has been read in the inbox.+data ReadStatus = MessageRead+ | MessageUnread+ deriving (Eq, Show)++-- | Whether a 'Message' or 'Thread' has been starred in the inbox.+data StarStatus = Starred+ | Unstarred+ deriving (Eq, Show)++-- | A Gmail-style label applied to a 'Message' or 'Thread'.+data Label+ = Label+ { _labelId :: NylasId+ , _labelName :: Text+ , _labelDisplayName :: Text+ } deriving (Eq, Show)++-- | An IMAP-style folder in which 'Message's can reside. A 'Thread' can contain+-- 'Messages' spanning multiple folders.+data Folder+ = Folder+ { _folderId :: NylasId+ , _folderName :: Text+ , _folderDisplayName :: Text+ } deriving (Eq, Show)++-- | An email message.+data Message+ = Message+ { _messageId :: NylasId+ , _messageSubject :: Text+ , _messageSenders :: [Mailbox]+ , _messageToRecipients :: [Mailbox]+ , _messageCcRecipients :: [Mailbox]+ , _messageBccRecipients :: [Mailbox]+ -- TODO: add support for (undocumented?) reply-tos+ , _messageTime :: MessageTime+ , _messageThreadId :: NylasId+ , _messageFiles :: [File]+ , _messageSnippet :: Text+ , _messageLabels :: Maybe [Label]+ , _messageFolder :: Maybe Folder+ , _messageBody :: Text+ , _messageRead :: ReadStatus+ , _messageStarred :: StarStatus+ } deriving (Eq, Show)++-- | Whether a 'Thread' contains any attached 'File's.+data AttachmentsStatus = HasAttachments+ | NoAttachments+ deriving (Eq, Show)++-- | A chain of 'Message's.+data Thread+ = Thread+ { _threadId :: NylasId+ , _threadSubject :: Text+ , _threadFirstTimestamp :: MessageTime+ , _threadLastTimestamp :: MessageTime+ , _threadParticipants :: [Mailbox]+ , _threadSnippet :: Text+ , _threadLabels :: Maybe [Label]+ , _threadFolders :: Maybe [Folder]+ , _threadMessageIds :: [NylasId]+ , _threadDraftIds :: [NylasId]+ , _threadVersion :: Int+ , _threadStarred :: StarStatus+ , _threadAttachments :: AttachmentsStatus+ } deriving (Eq, Show)++-- ** Streaming Types++-- | The transactional cursor as of a certain 'Delta'. When requesting a stream+-- of updates, clients should provide the 'Cursor' for the last 'Delta' they+-- successfully consumed.+newtype Cursor = Cursor { _cursorId :: Text } deriving (Eq, Show, Generic)++-- | An error in a streaming pipeline involving 'consumeDeltas'.+data StreamingError = ParsingError DecodingError (Producer B.ByteString IO ())+ -- ^ An error in parsing JSON+ | ConsumerError Text+ -- ^ An error produced by clients while consuming 'Delta's++-- | The change operation wrapping the affected object in a 'Delta'. If the+-- object has been created or modified (as opposed to deleted), the object is+-- nested within the 'Change'.+data Change a+ = Create a+ | Modify a+ | Delete+ deriving (Eq, Show)++-- | The type of modification to the inbox the 'Delta' contains.+data DeltaChange+ = CalendarChange+ | ContactChange+ | EventChange+ | FileChange+ | MessageChange (Change Message)+ | DraftChange+ | ThreadChange (Change Thread)+ | LabelChange+ | FolderChange+ deriving (Eq, Show)++-- | A change to an inbox with a transactional 'Cursor' to continue processing+-- from this 'Delta' in the future.+data Delta+ = Delta+ { _deltaCursor :: Cursor+ , _deltaObjectId :: NylasId+ , _deltaChange :: DeltaChange+ } deriving (Eq, Show)++-- * Lenses++makeLenses ''NylasId+makeLenses ''Mailbox+makeLenses ''File+makeLenses ''MessageTime+makePrisms ''ReadStatus+makePrisms ''StarStatus+makeLenses ''Label+makeLenses ''Folder+makeLenses ''Message+makePrisms ''AttachmentsStatus+makeLenses ''Thread+makeLenses ''Cursor+makePrisms ''Change+makePrisms ''DeltaChange+makeLenses ''Delta++-- * Utilities++-- | A list of the recipients of all types for a 'Message'.+recipients :: Message -> [Mailbox]+recipients m = m ^. messageToRecipients+ <> m ^. messageCcRecipients+ <> m ^. messageBccRecipients++-- Instances++instance FromJSON Mailbox where+ parseJSON (Object v) =+ Mailbox <$> fmap nonEmpty (v .: "name")+ <*> v .: "email"+ where+ nonEmpty "" = Nothing+ nonEmpty str = Just str+ parseJSON _ = empty++instance FromJSON File where+ parseJSON (Object v) =+ File <$> v .: "id"+ <*> v .: "content_type"+ <*> v .: "filename"+ <*> v .: "size"+ <*> v .:? "content_id"+ parseJSON _ = empty++instance FromJSON MessageTime where+ parseJSON n = (MessageTime . posixSecondsToUTCTime . fromIntegral)+ <$> (parseJSON n :: Parser Int)++instance FromJSON StarStatus where+ parseJSON (Bool True) = pure Starred+ parseJSON (Bool False) = pure Unstarred+ parseJSON _ = empty++instance FromJSON Label where+ parseJSON (Object v) =+ Label <$> v .: "id"+ <*> v .: "name"+ <*> v .: "display_name"+ parseJSON _ = empty++instance FromJSON Folder where+ parseJSON (Object v) =+ Folder <$> v .: "id"+ <*> v .: "name"+ <*> v .: "display_name"+ parseJSON _ = empty++instance FromJSON Message where+ parseJSON (Object v) =+ Message <$> v .: "id"+ <*> v .: "subject"+ <*> v .: "from"+ <*> v .: "to"+ <*> v .: "cc"+ <*> v .: "bcc"+ <*> v .: "date"+ <*> v .: "thread_id"+ <*> v .: "files"+ <*> v .: "snippet"+ <*> v .:? "labels"+ <*> v .:? "folder"+ <*> v .: "body"+ <*> fmap fromUnread (v .: "unread")+ <*> v .: "starred"+ where+ fromUnread :: Bool -> ReadStatus+ fromUnread True = MessageUnread+ fromUnread False = MessageRead+ parseJSON _ = empty++instance FromJSON AttachmentsStatus where+ parseJSON (Bool True) = pure HasAttachments+ parseJSON (Bool False) = pure NoAttachments+ parseJSON _ = empty++instance FromJSON Thread where+ parseJSON (Object v) =+ Thread <$> v .: "id"+ <*> v .: "subject"+ <*> v .: "first_message_timestamp"+ <*> v .: "last_message_timestamp"+ <*> v .: "participants"+ <*> v .: "snippet"+ <*> v .:? "labels"+ <*> v .:? "folders"+ <*> v .: "message_ids"+ <*> v .: "draft_ids"+ <*> v .: "version"+ <*> v .: "starred"+ <*> v .: "has_attachments"+ parseJSON _ = empty++instance FromJSON Cursor where+ parseJSON (String s) = pure $ Cursor s+ parseJSON _ = empty++instance FromJSON NylasId where+ parseJSON (String s) = pure $ NylasId s+ parseJSON _ = empty++instance FromJSON Delta where+ parseJSON (Object deltaV) = do+ cursor <- deltaV .: "cursor"+ nyId <- deltaV .: "id"+ (String event) <- deltaV .: "event"+ (String objectType) <- deltaV .: "object"+ mObjV <- deltaV .:? "attributes"+ dc <- case objectType of+ "calendar" -> pure CalendarChange+ "contact" -> pure ContactChange+ "event" -> pure EventChange+ "file" -> pure FileChange+ "message" -> MessageChange <$> parseChange event mObjV+ "draft" -> pure DraftChange+ "thread" -> ThreadChange <$> parseChange event mObjV+ "label" -> pure LabelChange+ "folder" -> pure FolderChange+ _ -> empty+ pure $ Delta cursor nyId dc++ where+ parseChange :: FromJSON a => Text -> Maybe Value -> Parser (Change a)+ parseChange "create" (Just objV) = Create <$> parseJSON objV+ parseChange "modify" (Just objV) = Modify <$> parseJSON objV+ parseChange "delete" Nothing = pure Delete+ parseChange _ _ = empty++ parseJSON _ = empty++-- HACK: since pipes-aeson's stream decoding lens supports bidirectional usage,+-- we need to provide this dummy instance:+instance ToJSON Delta where+ toJSON _ = object []