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!