Organizing Your App With Snaplets

mightybyte edited this page Aug 3, 2012 · 9 revisions

A snaplet is a composable web app.

Using Snaplets

Adding a Snaplet to your Application

Adding a snaplet to your application is an easy three-step process.

  1. Add the snaplet to your application state
  2. Initialize the snaplet in your application initializer with nestSnaplet
  3. Return the initialized value in the constructed App

For example, to add the Foo snaplet to your application, first you'll include Snaplet Foo in your App data structure like this:

data App = App
    { _heist :: Snaplet (Heist App)
    , _foo   :: Snaplet Foo
    }

Next you'll add a line like this to your application initializer.

f <- nestSnaplet "" foo $ fooInit fooParam

Then add f to the App that is returned by the initializer.

return $ App h f

Calling snaplet functions

Many of the functions provided by snaplets will have type signatures like fooFunc :: ... -> Handler b Foo a. In order to use these functions, you have to transform them to something that will work in a bigger context. The functions defined in the MonadSnaplet type class provide this functionality. The most common of these functions is with:

with :: Lens v (Snaplet v') -> m b v' a -> m b v a	 

This function can let us convert a Handler b v' into a Handler b v provided we have a Lens v (Snaplet v'). For a top level application you will usually be working with handlers of type Handler App App, so in order to work with the Foo snaplet, you need to convert Handler b Foo a into Handler App App a.

Writing Snaplets

Calling snaplets from other snaplets

Let's say we have an application with the following top-level state type that uses two snaplets as follows:

data App = App
    { _logger :: Snaplet Logger
    , _wiki   :: Snaplet Wiki
    }

You're writing the wiki snaplet and you want to make it call the logger snaplet. How do you go about doing this? There are several possible approaches:

Specialize to App

Perhaps the simplest way of allowing your snaplet to use other snaplets is to use App for the base state of your snaplet's handlers.

Handler App v a

This makes it easy to call the logger snaplet using the withTop function. The problem with this is that your wiki snaplet cannot be reused because it depends on the App data type which is specific to your application. If you are not reusing the snaplet, you might as well just build it into your application directly and skip the extra snaplet infrastructure, so this approach should never be used.

Pass a lens to handlers

Since a lens is a pointer to a snaplet, you could just pass the logger lens as a parameter to any wiki handler function that needs to call the logger snaplet.

Lens b (Snaplet Logger) -> Handler b v a

Then the handler function can just call the logger function it needs using withTop and the lens that was passed in. The downside to this approach is every time a wiki handler function is called that needs the logger, the user will have to pass in the lens. This could get annoying very quickly.

Pass a lens to the initializer

The logical way to fix the problem with the last approach is to pass the logger lens to the wiki snaplet once so that all the wiki handlers can use it. The way to do this is to pass the lens to the wiki initializer and store the lens in the wiki's state type. It would look something like this:

data Wiki b = Wiki
    { loggerLens :: Lens b (Snaplet Logger)
    , ...
    }
initWiki lens = makeSnaplet ... $ do
    ...
    return $ Wiki lens ...

The initializer stores the lens in the wiki state data structure and the handlers use it to call the logger handlers. The wiki Handlers will look something like this:

wikiHandler :: Handler b (Wiki b) a
wikiHandler = do
    lens <- gets loggerLens
    withTop lens $ writeLog ...

Pass handlers to the Initializer

It may be the case that the wiki snaplet only needs one or two functions provided by the logger snaplet. If that is the case, then instead of passing the logger lens into the wiki initializer, you could pass the actual handlers into the wiki initializer. It might look something like this:

data Wiki b = Wiki
    { writeLogHandler :: Handler b b ()
    , clearLogHandler :: Handler b b ()
    , ...
    }

initWiki writeHandler clearHandler = makeSnaplet ... $ do
    ...
    return $ Wiki writeHandler clearHandler ...

This approach has some desireable properties. First, it's completely independent of your choice of logger snaplet. In the previous approach the wiki snaplet would have the logger package as a dependency. This forces the user to use the logger you chose. They wouldn't necessarily have to use it for all their other logging needs, but they'd have to include in their application to pass to the wiki snaplet. This approach can work with any logger. In that sense, it is very flexible and future-proof.

The problem with this approach is that it won't be appropriate for all situations. What if the wiki snaplet needed to use 20 different functions exported by the logger snaplet? In that case you'd have to pass all of them to the wiki initializer, which would be painful. This is where the previous approach of passing in a lens becomes very convenient. The lens gives you access to every logger function. So in that sense, the lens approach is more flexible and future-proof.

Use a HasLogger type class

We can make add some convenience to these approaches using a type class. Type classes are nice because they essentially define type-driven global constants for certain names. They save the end user some repetition, but the act of saving that repetition locks the user into a single instance of the snaplet. In many cases this is fine, but there are situations where the user won't want that.

There are several different was to formulate such a type class. Here are some of the formulations we've used.

Lens approach

class HasLogger b where
    loggerLens :: Lens (Snaplet b) (Snaplet Logger)

The first argument to the Lens constructor is "Snaplet b" instead of "b" because that makes it possible to create an identity instance:

instance HasLogger Logger where
    loggerLens = id

Handler approach

If you use the "pass handlers to the initializer" approach described above, then you could abstract that pattern into a type class.

class HasLogger b where
    writeLog :: Text -> Handler b b ()
    clearLog :: Handler b b ()

Monad approach

In some situations, it's desireable to decouple your snaplet API from the Handler monad so that it can be used outside the context of a web server. If that is the case, then you will want to formulate your type class something like this:

class (Monad m) => HasLogger m where
    writeLog :: Text -> m ()
    clearLog :: m ()

The (Monad m) constraint may need to be something more specific like MonadIO, MonadCatchIO, or something more specific to your needs, but the basic idea is the same. A good example of this pattern is snaplet-postgresql-simple.

Change the structure and have the wiki snaplet include the logger as a subsnaplet

Another approach that I haven't yet seen used in practice would be to make the wiki snaplet include the logger as a sub-snaplet. The advantage to this is that it doesn't place any additional demands on the user of the wiki snaplet. The disadvantage is that the wiki's logger will be completely encapsulated and the top level application won't be able to have multiple snaplets use the same logger. In the case of a logger, this might be fine. But in some other situations, it might not be what you want. For instance, we feel that you should almost never use the Heist snaplet this way, because doing so would mean that splices bound by the wiki snaplet won't be available to the rest of the application.