Jampot.dev

Trials and tribulations of building a Haskell service

December 18, 2020

The task is simple: build a service to show the release dates of movies.

How hard could it be?

To fend off the bastard that is analysis paralysis, I’ll be working incrementally and noting what I learn on the way. Let’s go!

Query the movie database

First things first, I want to query a movie api and return a list of movies. This might not end up being that useful, but it’s a start.

The movie database (TMDb) has a good api, and lucky for us there’s an api wrapper on Hackage that seems sensible, so let’s use that.

To test out the api wrapper I wrote a small cli app that lets us input a movie name and get a list of movies in response.

First, we’ve got to get the api key and form the settings we’ll use to query the api:

main :: IO ()
main = do
    apiKey <- getEnv "TMDB_API_KEY"

    let settings = Settings (T.pack apiKey) (Just $ T.pack "en")

    putStrLn "Welcome to the movie lookup :) Start typing!"
    movieToLookup <- getLine
    movieSearchLoop (runTheMovieDB settings) movieToLookup

Loop around, querying the api with movieToLookup, and printing the result.

movieSearchLoop run movieToLookup = do
 result <- run $ searchAndListMovies (T.pack movieToLookup)
 case result of
     Right movie -> putStrLn movie >> askForAnotherGo run
     Left failure -> putStr "Lookup failed" >> askForAnotherGo run

Then keep looping until the user is sick of it.

askForAnotherGo run = do
    putStrLn "Enter q to quit, or search again."
    input <- getLine
    case input of
        "q" -> pure ()
        _   -> movieSearchLoop run input

This gives something along the lines of

./movie-release-dates
Welcome to the movie lookup :) Start typing!
the ma
2525: The Mat (17/03/1993)
645: The Matrix (06/03/1999)
Enter q to quit, or search again

On first glance, this seems fine. But the type signatures tell a different story.

movieSearchLoop ::
    (TheMovieDB String -> IO (Either a String))
    -> String
    -> IO ()

askForAnotherGo ::
    (TheMovieDB String -> IO (Either a String))
    -> IO ()

The problem

It turns out that we want to pass the TMDb api settings around a lot, as they’re required whenever we want to query it. Giving every function an extra parameter for the settings is unwieldy, so let’s get rid of it.

Intuition from C# and Java expects us to be able to use something like dependency injection (DI) to solve this, with a context to grab from and magic up what you need.

The solution

Lucky for us, we have a super spooky Haskell construct that can help us on our way: the reader monad.

What the Reader monad allows us to do is ask for something. In this case, we’re going to take the thought of that DI context (that we so deeply crave) and use Reader to get it.

However much I’d like to jump in, there’s still one more hurdle. Using Reader on its own isn’t going to be fit for purpose here, as our end goal is to use these Settings to query the TMDb api - which requires IO. Without IO in the type signature, we can’t do anything with the outside world. That’s where the monad transformer comes in.

As always, Wikipedia has a great definition:

A monad transformer is a type constructor which takes a monad as an argument and returns a monad as a result

Simple, right? All it means is that we can use Reader in conjunction with another monad (like IO). The monad transformer we’ll use is ReaderT, with an argument of Settings and a resulting monad of IO (). This gives us a ReaderT ‘stack’ of:

ReaderT Settings IO ()

Now we can rewrite the functions we wrote previously using this ReaderT stack.

Starting with the search loop, it’s been streamlined, and doesn’t do anything with Reader.

movieSearchLoop :: ReaderT Settings IO ()
movieSearchLoop = do
    input <- liftIO getLine
    case input of
        "q" -> pure ()
        movie -> lookupMovie movie

What’s more interesting is the new lookup function. The tmdbSettings <- ask is where the magic is happening, as when inside Reader (due to the do notation) we can ask the Reader for what it’s got. In this case, the settings that we wanted to be passed around!

lookupMovie :: String -> ReaderT Settings IO ()
lookupMovie movie = do
    tmdbSettings <- ask
    result <- liftIO $ runTheMovieDB tmdbSettings $ searchAndListMovies (T.pack movie)
    liftIO $ putStrLn $ case result of
        Right movie -> movie
        Left error -> "Lookup failed."
    liftIO $ putStrLn "Enter q to quit, or search again."
    movieSearchLoop

You may have noticed that liftIO keeps popping up, which is needed to ‘lift’ IO methods over the Reader monad that we’re in.

A valid question and something I’ve failed to mention so far is where are we populating these settings into Reader, and how are we getting back to the pure IO () result that the main method needs?

The answer is that we need to run the reader, using runReaderT:

runReaderT movieSearchLoop settings

That has a type signature of

ReaderT r m a -> r -> m a

So for our movieSearchLoop, we get

ReaderT Settings IO () -> Settings -> IO ()

Which, fairly intuitively, will initialise the ReaderT to the input Settings. This will then be evaluated to IO () - which is what we need for the main method!

In summary

We’ve explored a motivation for using the Reader monad, and the various thoughts behind using it in a working program.

There’ll be other parts to building this service, so keep an eye out for any future posts!