Spock II: Databases and Sessions!

Spock.png

Last week we learned the basics of the the Spock library. We saw how to set up some simple routes. Like Servant, there's a bit of dependent-type machinery with routing. But we didn't need to learn any complex operators. We just needed to match up the number of arguments to our routes. We also saw how to use an application state to persist some data between requests.

This week, we'll add a couple more complex features to our Spock application. First we'll connect to a database. Second, we'll use sessions to keep track of users.

For some more examples of useful Haskell libraries, check out our Production Checklist!

Adding a Database

Last week, we added some global application state. Even with this improvement, our vistor count doesn't persist. When we reset the server, everything goes away, and our users will see a different number. We can change this by adding a database connection to our server. We'll follow the Spock tutorial example and connect to an SQLite database by using Persistent.

If you haven't used Persistent before, take a look at this tutorial in our Haskell Web series! You can also look at our sample code on Github for any of the boilerplate you might be missing. Here's the super simple schema we'll use. Remember that Persistent will give us an auto-incrementing primary key.

share [mkPersist sqlSettings, mkMigrate "migrateAll"] [persistLowerCase|
  NameEntry json
    name Text
    deriving Show
|]

Spock expects us to use a pool of connections to our database when we use it. So let's create one to an SQLite file using createSqlitePool. We need to run this from a logging monad. While we're at it, we can migrate our database from the main startup function. This ensures we're using an up-to-date schema:

import Database.Persist.Sqlite (createSqlitePool)

...

main :: IO ()
main = do
  ref <- newIORef M.empty
  pool <- runStdoutLoggingT $ createSqlitePool "spock_example.db" 5
  runStdoutLoggingT $ runSqlPool (runMigration migrateAll) pool
  ...

Now that we've created this pool, we can pass that to our configuration. We'll use the PCPool constructor. We're now using an SQLBackend for our server, so we'll also have to change the type of our router to reflect this:

main :: IO ()
main = do
  …
  spockConfig <-
    defaultSpockCfg EmptySession (PCPool pool) (AppState ref)
  runSpock 8080 (spock spockConfig app)

app :: SpockM SqlBackend MySession AppState ()
app = ...

Now we want to update our route action to access the database instead of this map. But first, we'll write a helper function that will allow us to call any SQL action from within our SpockM monad. It looks like this:

runSQL :: (HasSpock m, SpockConn m ~ SqlBackend)
  => SqlPersistT (LoggingT IO) a -> m a
runSQL action = runQuery $ \conn -> 
  runStdoutLoggingT $ runSqlConn action conn

At the core of this is the runQuery function from the Spock library. It works since our router now uses SpockM SqlBackend instead of SpockM (). Now let's write a couple SQL actions we can use. We'll have one performing a lookup by name, and returning the Key of the first entry that matches, if one exists. Then we'll also have one that will insert a new name and return its key.

fetchByName
  :: T.Text
  -> SqlPersistT (LoggingT IO) (Maybe Int64)
fetchByName name = (fmap (fromSqlKey . entityKey)) <$> 
  (listToMaybe <$> selectList [NameEntryName ==. name] [])

insertAndReturnKey
  :: T.Text
  -> SqlPersistT (LoggingT IO) Int64
insertAndReturnKey name = fromSqlKey <$> insert (NameEntry name)

Now we can use these functions instead of our map!

app :: SpockM SqlBackend MySession AppState ()
app = do
  get root $ text "Hello World!"
  get ("hello" <//> var) $ \name -> do
    existingKeyMaybe <- runSQL $ fetchByName name
    visitorNumber <- case existingKeyMaybe of
      Nothing -> runSQL $ insertAndReturnKey name
      Just i -> return i
    text ("Hello " <> name <> ", you are visitor number " <> 
      T.pack (show visitorNumber))

And voila! We can shutdown our server between runs, and we'll preserve the visitors we've seen!

Tracking Users

Now, using a route to identify our users isn't what we want to do. Anyone can visit any route after all! So for the last modification to the server, we're going to add a small "login" functionality. We'll use the App's session to track what user is currently visiting. Our new flow will look like this:

  1. We'll change our entry route to /hello.
  2. If the user visits this, we'll show a field allowing them to enter their name and log in.
  3. Pressing the login button will send a post request to our server. This will update the session to match the session ID with the username.
  4. It will then send the user to the /home page, which will greet them and present a logout button.
  5. If they log out, we'll clear the session.

Note that using the session is different from using the app state map that we had in the first part. We share the app state across everyone who uses our server. But the session will contain user-specific references.

Adding a Session

The first step is to change our session type. Once again, we'll use a IORef wrapper around a map. This time though, we'll use a simple type synonym to simplify things. Here's our type definition and the updated main function.

type MySession = IORef (M.Map T.Text T.Text)

main :: IO ()
main = do
  ref <- newIORef M.empty
  -- Initialize a reference for the session
  sessionRef <- newIORef M.empty
  pool <- runStdoutLoggingT $ createSqlitePool "spock_example.db" 5
  runStdoutLoggingT $ runSqlPool (runMigration migrateAll) pool
  -- Pass that reference!
  spockConfig <-
    defaultSpockCfg sessionRef (PCPool pool) (AppState ref)
  runSpock 8080 (spock spockConfig app)

Updating the Hello Page

Now let's update our "Hello" page. Check out the appendix below for what our helloHTML looks like. It's a "login" form with a username field and a submit button.

-- Notice we use MySession!
app :: SpockM SqlBackend MySession AppState ()
app = do
  get root $ text "Hello World!"
  get "hello" $ html helloHTML
  ...

Now we need to add a handler for the post request to /hello. We'll use the post function instead of get. Now instead of our action taking an argument, we'll extract the post body using the body function. If our application were more complicated, we would want to use a proper library for Form URL encoding and decoding. But for this small example, we'll use a simple helper decodeUsername. You can view this helper in the appendix.

app :: SpockM SqlBackend MySession AppState ()
app = do
  …
  post "hello" $ do
    nameEntry <- decodeUsername <$> body
    ...

Now we want to save this user using our session and then redirect them to the home page. First we'll need to get the session ID and the session itself. We use the functions getSessionId and readSession for this. Then we'll want to update our session by associating the name with the session ID. Finally, we'll redirect to home.

post "hello" $ do
  nameEntry <- decodeUsername <$> body
  sessId <- getSessionId 
  currentSessionRef <- readSession
  liftIO $ modifyIORef' currentSessionRef $
    M.insert sessId (nameEntryName nameEntry)
  redirect "home"

The Home Page

Now on the home page, we'll want to check if we've got a user associated with the session ID. If we do, we'll display some text greeting that user (and also display a logout button). Again, we need to invoke getSessionId and readSession. If we have no user associated with the session, we'll bounce them back to the hello page.

get "home" $ do
  sessId <- getSessionId 
  currentSessionRef <- readSession
  currentSession <- liftIO $ readIORef currentSessionRef
  case M.lookup sessId currentSession of
    Nothing -> redirect "hello"
    Just name -> html $ homeHTML name

The last piece of functionality we need is to "logout". We'll follow the familiar pattern of getting the session ID and session. This time, we'll change the session by clearing the session key. Then we'll redirect the user back to the hello page.

post "logout" $ do
  sessId <- getSessionId 
  currentSessionRef <- readSession
  liftIO $ modifyIORef' currentSessionRef $ M.delete sessId
  redirect "hello"

And now our site tracks our users' sessions! We can access the same page as a different user on different sessions!

Conclusion

This wraps up our exploration of the Spock library! We've done a shallow but wide look at some of the different features Spock has to offer. We saw several different ways to persist information across requests on our server! Connecting to a database is the most important. But using the session is a pretty advanced feature that is quite easy in Spock!

For some more cool examples of Haskell web libraries, take a look at our Web Skills Series! You can also download our Production Checklist for even more ideas!

Appendix - HTML Fragments and Helpers

helloHTML :: T.Text
helloHTML =
  "<html>\
    \<body>\
      \<p>Hello! Please enter your username!\
      \<form action=\"/hello\" method=\"post\">\
        \Username: <input type=\"text\" name=\"username\"><br>\
        \<input type=\"submit\"><br>\
      \</form>\
    \</body>\
  \</html>"

homeHTML :: T.Text -> T.Text
homeHTML name =
  "<html><body><p>Hello " <> name <> 
    "</p>\
    \<form action=\"logout\" method=\"post\">\
      \<input type=\"submit\" name=\"logout_button\"<br>\
    \</form>\
  \</body>\
  \</html>" 

-- Note: 61 -> '=' in ASCII
-- We expect input like "username=christopher"
parseUsername :: B.ByteString -> T.Text
parseUsername input = decodeUtf8 $ B.drop 1 tail_
  where
    tail_ = B.dropWhile (/= 61) input