Interactive code snippets not yet available for SoH 2.0, see our Status of of School of Haskell 2.0 blog post

Interfacing with RESTful JSON APIs

The world is full of RESTful JSON APIs that are of interest to developers. While you can often use them in an ad hoc manner if you only need a few features for your Haskell application, sometimes you will be using so much of the API that a complete Haskell interface becomes useful. This tutorial will help you build packages to interface with RESTful JSON APIs. For the sake of the tutorial will implement an interface for the Mailchimp API, which allows developers to manage e-mail marketing mailing lists. By the end of the tutorial you should be well-prepared to create your own packages. This short tutorial assumes an intermediate understanding of Haskell.

Create Your Package

Create a folder for your package. Initialize with cabal init.

mkdir mailchimp
cabal init
# Select default package name, package version, choose your license, etc.

Your package will be created with a basic .cabal file. I recommend adding declarations for the OverloadedStrings GHC extension right out of the gate, as any useful library will make heavy use of the Data.Text datatype. OverloadedStrings allows you to use literal strings in your Haskell code as Data.Text (and a few other types) rather than as String types, which are very inefficient.

Representing API Keys

The first step to supporting the Mailchimp API is parsing the the API key to build request URLs. In Mailchimp's API, unlike many others, the API key includes the datacenter within it, which must be extracted for the URL. We use the following type to represent an API key, which we will put in the Web.Mailchimp module (the complete source for the mailchimp package is on github:

-- | Represents a mailchimp API key, which implicitly includes a datacenter.
data MailchimpApiKey = MailchimpApiKey
  { makApiKey :: Text -- Full API key including datacenter
  , makDatacenter :: Text -- 3-letter datacenter code
  }

While we could require users of the package to pass properly constructed MailchimpApiKeys to the library, we will probably want to make it easy to parse the API keys as provided by Mailchimp, in case someone wants to load keys dynamically. Haskell Platform includes a parser (Parsec) that makes this simple. One could also use regular expressions, which are likewise provided by the Haskell Platform. A full discussion of how to use either of these tools are outside of the scope of this tutorial, but you can find examples of each in Real World Haskell.

-- | Create a MailchimpApiKey from Text
mailchimpKey :: Text -> Maybe MailchimpApiKey
mailchimpKey apiKey = 
  case parse parseKey "(unknown)" apiKey of
    Left _ -> Nothing
    Right (_, dcString) ->
      Just MailchimpApiKey 
        { makApiKey = apiKey 
        , makDatacenter = pack dcString
        }

parseKey :: GenParser st (String, String)
parseKey = do
  key <- many1 hexDigit
  _ <- char '-'
  dc <- many1 alphaNum
  return (key, dc)

Building the Endpoint URL and Other Types

With any normal JSON API you will need a function that builds the endpoint URL for the method you are calling. URLs in Mailchimp's JSON API have the form https://*dc*.api.mailchimp.com/2.0/*section*/*method*.json:

-- | Builds the mailchimp endpoint URL
apiEndpointUrl :: Text -> Text -> Text -> Text
apiEndpointUrl datacenter section method = 
  Data.Text.concat ["https://", datacenter, ".api.mailchimp.com/2.0/", 
      section, "/", method, ".json"]

We will start by supporting the the "lists/subscribe" method of the API, which subscribes a user to a Mailchimp mailing list. It may be helpful to reference the Mailchimp documentation for this method. (Although during the creation of this tutorial I found several errors in the documentation. I imagine you will find the same with APIs you are accessing!) We create the Web.Mailchimp.Lists module which will hold the following types:

-- | Represents an individual mailing list
newtype ListId = ListId {unListId :: Text}
  deriving (Show, Eq)
    
-- | Represents one of the canonical ways of identifying subscribers
data EmailId = Email Text
             | EmailUniqueId Text
             | ListEmailId Text
  deriving (Show, Eq)

-- | Represents an individual merge variable    
type MergeVarsItem = Pair -- From the Aeson library.

-- | The type of e-mail your user will receive
data EmailType = EmailTypeHTML
               | EmailTypeText

E-mail addresses in the Mailchimp API can always be sent in one of three forms. As an e-mail address, as an ID associated with the e-mail address, or as the ID of a particular subscriber within a list. MergeVarsItem represents data associated with the user, such as FNAME (for first name) or LNAME (for last name) or one of a number of special purpose variables. These are used to insert user data into e-mails that you are sending out. We use the Pair type from the Aeson JSON library to represent them, which is just a tuple of (Text, Value), and Value is a JSON object, string, etc., because, while not frequently used, Mailchimp has some special-purpose merge variables that can be sent as JSON objects.

subscribeUser, Version 0

We can now write a simple function to perform the lists/subscribe request. It's helpful to write a specific case before deciding how to move up to a higher level of abstraction. In many cases, such as if you only need to support a single JSON method, you can pretty much stop after writing one function.

-- | The result of calling subscribeUser. Provides three ways of  
--   identifying the user for subsequent calls.
data EmailResult = EmailResult { erEmail :: EmailId
                               , erEmailUniqueId :: EmailId
                               , erListEmailId :: EmailId
                               }
  deriving (Show, Eq)

subscribeUser :: MailchimpApiKey 
              -> ListId 
              -> EmailId 
              -> Maybe [MergeVarsItem] 
              -> Maybe EmailType
              -> Maybe Bool 
              -> Maybe Bool 
              -> Maybe Bool 
              -> Maybe Bool 
              -> IO EmailResult
subscribeUser apiKey 
              listId 
              emailId 
              mergeVars 
              emailType 
              doubleOptin 
              updateExisting 
              replaceInterests 
              sendWelcome = runResourceT $ do
  initReq <- parseUrl $ unpack $ apiEndpointUrl (makDatacenter apiKey) 
    "lists" "subscribe"
  let requestJson = object [ "apikey" .= makApiKey apiKey
                           , "id" .= unListId listId
                           , "email" .= emailId
                           , "merge_vars" .= fmap object mergeVars
                           , "email_type" .= emailType
                           , "double_optin" .= doubleOptin
                           , "update_existing" .= updateExisting
                           , "replace_interests" .= replaceInterests
                           , "send_welcome" .= sendWelcome
                           ]
  let req = initReq { requestBody = RequestBodyLBS $ encode requestJson 
                    , method = methodPost
                    }
  man <- liftIO $ newManager def 
  response <- httpLbs req man 
  let meResult = decode $ responseBody response
  case meResult of
    Just emailResult -> return emailResult
    Nothing -> liftIO $ mzero

Now, this version has a couple of problems (and doesn't compile), but it's a good first try. EmailResult is just a record to wrap up the returned value from the "lists/subscribe" method call. Mailchimp always returns subscriber info with all three representations. A typical response might look like {"email":"example@example.com","euid":"abc123","leid":"abc123"}.

To actually perform the request, we use the http-conduit package. That package requires everything be run in a ResourceT monad transformer (which manages creating and freeing resources, such as network connections), so the function starts with runResourceT. Within this context (ResourceT IO), any actions in the IO monad must be called with liftIO.

First, we parse the endpoint URL to create the intial request (initReq). Then we set the request options to include our request body and HTTP method. Creating the request body uses the Aeson library to create and encode JSON using the object, encode and decode functions. httpLbs (from http-conduit) is the function that actually performs the request, and we are going to expect back a result of Maybe EmailResult. If we couldn't parse the response we will fail with an error (mzero).

Creating JSON Instances

The first problem with the code is that we currently don't know how to convert EmailId and EmailType to JSON, and we don't know how to convert EmailResult from JSON. We will use the Aeson library to create instances that tell Haskell how to do these conversions. The instances look like this:

instance ToJSON EmailType where
  toJSON EmailTypeHTML = "html"
  toJSON EmailTypeText = "text"

instance ToJSON EmailId where
  toJSON (Email t) = object ["email" .= t]
  toJSON (EmailUniqueId t) = object ["euid" .= t]
  toJSON (ListEmailId t) = object ["leid" .= t]

instance FromJSON EmailResult where
  parseJSON (Object v) = do
    email <- v .: "email"
    euid <- v .: "euid"
    leid <- v .: "leid"
    return $ EmailResult (Email email) (EmailUniqueId euid) (ListEmailId leid)
  parseJSON _ = mzero 

The ToJSON instances simply take one of their respective values and create the appropriate JSON objects. FromJSON is a little more complex. The parseJSON function of FromJSON runs in the Parser monad. Within Parser, the (.:) operator accesses object values by key, allowing us to construct the final value. The parseJSON instance function can parse any type of JSON value (not only objects, but integers, arrays, etc.) but anything other than an object is not an EmailReturn, so we call mzero. If one were reading a field that had a Maybe type (i.e. an optional field), one would instead use the (.:?) operator from Aeson.

While Aeson includes template haskell functions to create ToJSON and FromJSON instances from record definitions for you, beware that the created instances for FromJSON in the current version on Hackage (0.6.1) have a couple major failings when it comes to RESTful JSON APIs. First, it will fail to parse objects that have extra keys that do not appear in your records. Second, it requires that optional Maybe fields actually be present in the parsed JSON with null value, otherwise the parse fails. The function to derive ToJSON instances also adds null entries for Nothing values, which would be fine in most APIs but actually causes problems in Mailchimp. A future version is intended to address some of these problems, but is not on Hackage yet.

More generally, APIs are usually being served in dynamic programming languages, and not from Aeson. There will be lots of edge cases that aren't handled in the way you expect, along with errors. For example, Mailchimp in some instances serializes numbers as JSON strings rather than integers, and it sometimes returns empty lists instead of null values. The only way to know is with extensive unit testing of the API (you can see some example unit tests of Mailchimp in the repository, though they are far from complete).

Better Error Handling

The next issue with subscribeUser is that it doesn't currently handle errors gracefully. When the Mailchimp API signals an error, it always returns an HTTP status other than 200. Using httpLbs, this will immediately cause an HttpException to be thrown. We would rather throw Exception types specific to the Mailchimp API so that users of our library can handle them as appropriate for their application.

Let's create a type to represent API exceptions in Web.Mailchimp.Client. For now we will just include the method-specific error for this particular method in addition to the API-wide errors.

data MailchimpError = InvalidApiKey Int Text Text
                    | UserDisabled Int Text Text
                    | UserInvalidRole Int Text Text
                    | TooManyConnections Int Text Text
                    | UserUnderMaintenance Int Text Text
                    | UserInvalidAction Int Text Text
                    | ValidationError Int Text Text
                    | ListDoesNotExist Int Text Text
                    -- Undocumented error
                    | ListAlreadySubscribed Int Text Text
                    | OtherMailchimpError Int Text Text
  deriving (Typeable, Show, Eq)

instance Exception MailchimpError -- Requires Typeable

instance FromJSON MailchimpError where
  parseJSON (Object v) = do
    status <- v .: "status"
    when (status /= ("error" :: Text)) mzero
    name <- v .: "name"
    code <- v .: "code"
    message <- v .: "error" -- Documentation shows this is under the 
                            -- "message" key, but it is incorrect
    return $ (errConstructor name) code name message
   where
      errConstructor name = case (name :: Text) of
        "Invalid_ApiKey" -> InvalidApiKey 
        "User_Disabled" -> UserDisabled 
        "User_InvalidRole" -> UserInvalidRole 
        "Too_Many_Connections" -> TooManyConnections 
        "User_UnderMaintenance" -> UserUnderMaintenance 
        "User_InvalidAction" -> UserInvalidAction 
        "ValidationError" -> ValidationError 
        "List_DoesNotExist" -> ListDoesNotExist 
        "List_AlreadySubscribed" -> ListAlreadySubscribed
        _ -> OtherMailchimpError 
  parseJSON _ = mzero

Exception is defined in Control.Exception.Base and will allow us to throw and catch MailchimpErrors. Next, let's revise subscribeUser to include our error handling code:

subscribeUser :: MailchimpApiKey 
              -> ListId 
              -> EmailId 
              -> Maybe [MergeVarsItem] 
              -> Maybe EmailType
              -> Maybe Bool 
              -> Maybe Bool 
              -> Maybe Bool 
              -> Maybe Bool 
              -> IO EmailReturn
subscribeUser apiKey 
              listId 
              emailId 
              mergeVars 
              emailType 
              doubleOptin 
              updateExisting 
              replaceInterests 
              sendWelcome = runResourceT $ do
  initReq <- parseUrl $ unpack $ apiEndpointUrl (makDatacenter apiKey) 
    "lists" "subscribe"
  let requestJson = object [ "apikey" .= makApiKey apiKey
                           , "id" .= unListId listId
                           , "email" .= emailId
                           , "merge_vars" .= fmap object mergeVars
                           , "email_type" .= emailType
                           , "double_optin" .= doubleOptin
                           , "update_existing" .= updateExisting
                           , "replace_interests" .= replaceInterests
                           , "send_welcome" .= sendWelcome
                           ]
  let req = initReq { requestBody = RequestBodyLBS $ encode requestJson 
                    , method = methodPost
                    }
  man <- liftIO $ newManager def 
  response <- catch (httpLbs req man) 
    (\e -> 
      case e :: HttpException of
        StatusCodeException _ headers _ -> do
          let (mResponse :: Maybe MailchimpError) = fromStrict `fmap` 
                (lookup "X-Response-Body-Start" headers) >>= decode
          maybe (throwIO e) id (throwIO `fmap` mResponse)
        _ -> throwIO e)
  let mResult = decode $ responseBody response
  case mResult of
    Just emailResult -> return emailResult
    Nothing -> throwIO $ OtherMailchimpError (-1) "ParseError" 
                           "Could not parse result JSON from Mailchimp"    

Because we are in the ResourceT IO monad, we use throwIO and catch from lifted-base. They work the same as their analogs in base, but are generalized to a wider variety of monadic contexts. When httpLbs gets a non-200 result, it throws a StatusCodeException, which we catch. Mailchimp's API sends the response body of errors in the X-Response-Body-Start header of the result, so we attempt to decode that and if possible, we throw the appropriate MailchimpError, otherwise we throw the original HttpException. We will also throw an OtherMailchimpError if we couldn't parse the result JSON.

Trying it Out

We now have a working function, so lets try it in ghci. Unfortunately, Mailchimp doesn't have a test mode for their API, so I used my API key and a test list:

~ cabal-ghci
> :l Web.Mailchimp.Lists Web.Mailchimp.Client
> :m Web.Mailchimp.Lists Web.Mailchimp.Client
> let key = MailchimpApiKey "<your key here>" "<dc code>"
> subscribeUser key (ListId "<list id>") (Email "example@example.com") Nothing (Just EmailTypeHTML) Nothing Nothing Nothing Nothing
EmailReturn {erEmail = Email "example@example.com", erEmailUniqueId = EmailUniqueId "<id>", erListEmailId = ListEmailId "<id>"}

The function worked. And if you run it again, you see that error handling also works:

> subscribeUser key (ListId "<list id>") (Email "example@example.com") Nothing (Just EmailTypeHTML) Nothing Nothing Nothing Nothing
*** Exception: ListAlreadySubscribed 214 "List_AlreadySubscribed" "example@example.com is already subscribed to list Users Newsletter. Click here to update your profile."

Wunderbar!

Create query Function

Of course, we don't want to write such a long function for each of Mailchimp's 104 API methods, so let's extract some common functionality from subscribeUser that we'll use over and over:

query :: FromJSON x => MailchimpApiKey -> Text -> Text -> Value -> IO x
query apiKey section method request = runResourceT $ do
  initReq <- parseUrl $ unpack $ apiEndpointUrl (makDatacenter apiKey) 
    section method
  let req = initReq { requestBody = RequestBodyLBS $ encode request 
                    , method = methodPost
                    }
  man <- liftIO $ newManager def
  response <- catch (httpLbs req man) catchHttpException
  case decode $ responseBody response of
    Just result -> return result
    Nothing -> throwIO $ OtherMailchimpError (-1) "ParseError" 
                         "Could not parse result JSON from Mailchimp"
 where
  catchHttpException :: HttpException -> ResourceT (IO a)
  catchHttpException e@(StatusCodeException _ headers _) =
    maybe (throwIO e) id (throwIO `fmap` (decodeError headers))
  catchHttpException e = throwIO e
  decodeError :: ResponseHeaders -> Maybe MailchimpError
  decodeError headers = fromStrict `fmap` 
    (lookup "X-Response-Body-Start" headers) >>= decode


subscribeUser :: MailchimpApiKey 
              -> ListId 
              -> EmailId 
              -> Maybe [MergeVarsItem] 
              -> Maybe EmailType
              -> Maybe Bool 
              -> Maybe Bool 
              -> Maybe Bool 
              -> Maybe Bool 
              -> IO EmailReturn
subscribeUser apiKey 
              listId 
              emailId 
              mergeVars 
              emailType 
              doubleOptin 
              updateExisting 
              replaceInterests 
              sendWelcome = 
  query apiKey "lists" "subscribe" request
 where
  request = object [ "apikey" .= makApiKey apiKey
                   , "id" .= unListId listId
                   , "email" .= emailId
                   , "merge_vars" .= fmap object mergeVars
                   , "email_type" .= emailType
                   , "double_optin" .= doubleOptin
                   , "update_existing" .= updateExisting
                   , "replace_interests" .= replaceInterests
                   , "send_welcome" .= sendWelcome
                   ]

And now we know that all of our API methods can be written with a function as simple as subscribeUser is here.

Using ReaderT

There are still a couple improvements that could be made to the interface. First, we are creating a new Manager each time query is called, which is very inefficient as it's an expensive operation. Second, if you are making multiple calls within the library, the current syntax could be slightly improved. Suppose that you want to delete all your folders created before a certain date, and you are in a non-IO monadic context, such as in a Yesod Handler. The code to perform that operation would look like this:

liftIO $ do
  now <- getCurrentTime
  folders <- listFolders apiKey "campaign"
  mapM_ (\f -> deleteFolder apiKey (folderId f) (folderType f)) $ 
    filter (\folder -> dateCreated folder < (now - 60 * 60 * 24 * 7)) folders

For every action, we are passing in apiKey even though the key is not changing. One can imagine even more complex actions where it would be more of a burden. To solve this problem and the problem of creating potentially hundreds of managers, let's wrap up our actions in a monad, specfically in ReaderT. We'll also create a MailchimpConfig type to carry the apiKey and the shared Manager:

data MailchimpConfig = MailchimpConfig 
  { mcApiKey :: MailchimpApiKey
  , mcManager :: Manager
  }

-- | Creates a MailchimpConfig with a new Manager
defaultMailchimpConfig :: MonadIO m => MailchimpApiKey -> m MailchimpConfig
defaultMailchimpConfig apiKey = do
  man <- liftIO $ newManager def
  return MailchimpConfig { mcApiKey = apiKey
                         , mcManager = man
                         }

type Mailchimp a = ReaderT MailchimpConfig (ResourceT IO) a

runMailchimp :: (MonadIO m) => MailchimpConfig -> Mailchimp a -> m a
runMailchimp config action = 
  liftIO $ runResourceT $ runReaderT action config

MailchimpConfig is just a simple type to carry around the configuration, and defaultMailchimpConfig is a way to create a default configuration with a new Manager. We will run all of our actions in the Mailchimp monad stack, which is IO, ResourceT (used by Network.HTTP.Conduit) and a ReaderT, which will allow us to query the MailchimpConfig without having to pass it around in every function.

With these changes query and subscribeUser now look like this:

query :: (FromJSON x) => Text -> Text -> Value -> Mailchimp x
query section method request = do
  config <- ask
  initReq <- liftIO $ parseUrl $ unpack $ apiEndpointUrl 
                      (makDatacenter $ mcApiKey config) section method
  let req = initReq { requestBody = RequestBodyLBS $ encode request 
                    , method = methodPost
                    }
  response <- catch (httpLbs req $ mcManager config) catchHttpException
  case decode $ responseBody response of
    Just result -> return result
    Nothing -> throwIO $ OtherMailchimpError (-1) "ParseError" 
                         "Could not parse result JSON from Mailchimp"
 where
  catchHttpException :: HttpException -> Mailchimp a
  catchHttpException e@(StatusCodeException _ headers _) = 
    maybe (throwIO e) id (throwIO `fmap` (decodeError headers))
  catchHttpException e = throwIO e
  decodeError :: ResponseHeaders -> Maybe MailchimpError
  decodeError headers = fromStrict `fmap` 
    (lookup "X-Response-Body-Start" headers) >>= decode

subscribeUser :: ListId 
              -> EmailId 
              -> Maybe [MergeVarsItem] 
              -> Maybe EmailType
              -> Maybe Bool 
              -> Maybe Bool 
              -> Maybe Bool 
              -> Maybe Bool 
              -> Mailchimp EmailReturn
subscribeUser listId 
              emailId 
              mergeVars 
              emailType 
              doubleOptin 
              updateExisting 
              replaceInterests 
              sendWelcome = do
  apiKey <- askApiKey
  query "lists" "subscribe" $ request apiKey
 where
  request apiKey = object [ "apikey" .= makApiKey apiKey
                          , "id" .= unListId listId
                          , "email" .= emailId
                          , "merge_vars" .= fmap object mergeVars
                          , "email_type" .= emailType
                          , "double_optin" .= doubleOptin
                          , "update_existing" .= updateExisting
                          , "replace_interests" .= replaceInterests
                          , "send_welcome" .= sendWelcome
                          ]

Dealing with Mailchimp Idiosyncrasies

As is the case with many JSON APIs, Mailchimp has a couple idiosyncrasies that are not immediately apparent. First, Aeson assumes that Nothing in a JSON object that's being created should create the associated key with a null JSON value, but the Mailchimp API actually checks for the existence of the key rather than a non-null value and treats it differently (it is treated as a "false" for optional parameters), so we must filter out Nothing entries before the object is created. To replace the object function of Aeson, we create filterObject:

filterObject list =
  object $ filter notNothing list
 where
  notNothing (_, Null) = False
  notNothing _ = True

Another issue with the API is Mailchimp's time format. Mailchimp sends and receives times as "YYYY-MM-DD HH:MM:SS" in GMT, while Aeson only tries to read UTCTime in ECMA-262/ISO-8601 format (YYYY-MM-DDTHH:MM:SS.sZ). Instead of relying on Aeson's instance of ToJSON UTCTime and FromJSON UTCTime, we newtype UTCTime and instance the new type:

newtype MCTime = MCTime {unMCTime :: UTCTime}

mcFormatString :: String
mcFormatString = "%F %T"

instance ToJSON MCTime where
  toJSON (MCTime t) = String $ pack $ 
    formatTime defaultTimeLocale mcFormatString t
instance FromJSON MCTime where
  parseJSON (String s) = maybe mzero (return . MCTime) $ 
    parseTime defaultTimeLocale mcFormatString (unpack s)
  parseJSON _ = mzero

This makes use of the formatTime function from the time package so we can continue to provide UTCTime in all of our interface functions. Obviously these two issues may not effect future APIs you are interfacing with, but it may give you a flavor of the sort of problems that can crop up. With those problems out of the way, let's try running the code with our new Monad.

~ ghci -fglasgow-exts
> :set -XOverloadedStrings
> :l Web.Mailchimp.Lists Web.Mailchimp.Client
> :m Web.Mailchimp.Lists Web.Mailchimp.Client
> let key = MailchimpApiKey "<your key here>" "<dc code>"
> cfg <- defaultMailchimpConfig key
> runMailchimp cfg $ subscribeUser (ListId "f771e2e2be") (Email "example@cardsharp.ly") Nothing (Just EmailTypeHTML) Nothing Nothing Nothing Nothing 
> EmailReturn {erEmail = Email "example@example.com", erEmailUniqueId = EmailUniqueId "<id>", erListEmailId = ListEmailId "<id>""

Adding Logging

Everything seems to be working, but after implementing a few more API methods, a problem has come up. While we could add liftIO $ print x to debug, it's tedious and not great for production. Let's add real logging to make debugging somewhat easier. You can use the LoggerT monad transformer to add logging to your monad, which requires only a couple changes to the Mailchimp definition:

type Mailchimp a = LoggingT (ReaderT MailchimpConfig (ResourceT IO)) a

runMailchimp :: (MonadIO m) => MailchimpConfig -> Mailchimp a -> m a
runMailchimp config action = 
  liftIO $ runResourceT $ flip runReaderT config $ runStderrLoggingT action

Now we can add debugging statements anywhere in the Mailchimp monad and they will be printed to stderr. For example, to log the request and response bodies in query, note the added $(logDebug) statements, which use Template Haskell to create log messages that include line numbers.

query :: (FromJSON x) => Text -> Text -> Value -> Mailchimp x
query section apiMethod request = do
  config <- ask
  initReq <- liftIO $ parseUrl $ unpack $ apiEndpointUrl 
                      (makDatacenter $ mcApiKey config) section apiMethod
  let req = initReq { requestBody = RequestBodyLBS $ encode request 
                    , method = methodPost
                    }
  $(logDebug) $ pack . show $ requestBody req
  response <- catch (httpLbs req $ mcManager config) catchHttpException
  $(logDebug) $ pack . show $ responseBody response
  case decode $ responseBody response of
    Just result -> return result
    Nothing -> throwIO $ OtherMailchimpError (-1) "ParseError" 
                         "Could not parse result JSON from Mailchimp"
 where
  catchHttpException :: HttpException -> Mailchimp a
  catchHttpException e@(StatusCodeException _ headers _) = do
    $(logDebug) $ pack . show $ decodeError headers
    maybe (throwIO e) id (throwIO `fmap` (decodeError headers))
  catchHttpException e = throwIO e
  decodeError :: ResponseHeaders -> Maybe MailchimpError
  decodeError headers = fromStrict `fmap` 
    (lookup "X-Response-Body-Start" headers) >>= decode

If you are just using your package outside of the context of Yesod applications, you can probably stop here and still have a nice, full-featured library that's easy to use in IO. If you are using Yesod, though, there is one more useful change.

MailchimpT Monad Transformer

The way Mailchimp is currently written it might interact negatively with existing logging functionality in a user's application. They may want to bring their own logging to the table. For example, if you are developing a Yesod application, your Handler monad already includes MonadLogger, and you can control the output level for development vs. production. Instead of giving Mailchimp a defined monad stack, let's make it into a monad transformer that requires logging.

By making Mailchimp into a monad transformer, we also allow users to interleave actions from the Mailchimp monad and their own monad in a single do-block (or other standard monadic operations). To make the change, we enable RankNTypes (this allows the type constraints in the below type definition) and specify in our Mailchimp type the monadic instances that we require to run, in this case MonadIO (for IO), MonadLogger (for logging) and MonadBaseControl IO (used by query to throw and catch exceptions from ResourceT). Using monad transformers in this manner basically specifies a required set of capabilities. To make life easier, we move uses of runResourceT down into query only where they are needed rather than in runMailchimp, because we don't need it's functionality everywhere and because getting all of its constraints to typecheck is a pain.

type MailchimpT m a = (MonadIO m, MonadLogger m, MonadBaseControl IO m) => 
                        ReaderT MailchimpConfig m a

runMailchimpT :: (MonadIO m, MonadLogger m, MonadBaseControl IO m) => 
                   MailchimpConfig -> MailchimpT m a -> m a
runMailchimpT config action = 
  runReaderT action config

-- | Runs Mailchimp in IO, ignoring the existing monadic context 
--   and logging to stderr
runMailchimp :: (MonadIO m) => 
                  (MailchimpConfig -> MailchimpT (LoggingT IO) a -> m a)
runMailchimp config action =
  liftIO $ runStderrLoggingT $ flip runReaderT config action

If users of the package don't want to use MailchimpT, they can instead use runMailchimp in IO which will build up the monad transformer stack itself.

Conclusion

At this point the core of the package is written, and the rest is just translating the API documentation into Haskell code, i.e. extensive boilerplate. If you want to see what all this boilerplate looks like, you can check out the complete source for Web.Mailchimp.Lists on GitHub. Hopefully you have found this tutorial useful, thanks for reading, and feedback is always appreciated.

comments powered by Disqus