What Are Snaplets?
A snaplet is a composable web application. Snaplets allow you to build self-contained pieces of functionality and glue them together to make larger applications. Here are some of the things provided by the snaplet API:
Infrastructure for application state/environment
Snaplet initialization, reload, and cleanup
Management of filesystem data and automatic snaplet installation
Unified config file infrastructure
One example might be a wiki snaplet. It would be distributed as a haskell package that would be installed with cabal and would probably include code, config files, HTML templates, stylesheets, JavaScript, images, etc. The snaplet’s code would provide the necessary API to let your application interact seamlessly with the wiki functionality. When you run your application for the first time, all of the wiki snaplet’s filesystem resources will automatically be copied into the appropriate places. Then you will immediately be able to customize the wiki to fit your needs by editing config files, providing your own stylesheets, etc. We will discuss this in more detail later.
A snaplet can represent anything from backend Haskell infrastructure with no user facing functionality to a small widget like a chat box that goes in the corner of a web page to an entire standalone website like a blog or forum. The possibilities are endless. A snaplet is a web application, and web applications are snaplets. This means that using snaplets and writing snaplets are almost the same thing, and it’s trivial to drop a whole website into another one.
We’re really excited about the possibilities available with snaplets. In fact, Snap already ships with snaplets for sessions, authentication, and templating (with Heist). This gives you useful functionality out of the box, and jump starts your own snaplet development by demonstrating some useful design patterns. So without further ado, let’s get started.
Snaplet Overview
The heart of the snaplets infrastructure is state management. Most nontrivial pieces of a web app need some kind of state or environment data. Components that do not need any kind of state or environment are probably more appropriate as a standalone library than as a snaplet.
Before we continue, we must clarify an important point. The Snap web server processes each request in its own green thread. This means that each request will receive a separate copy of the state defined by your application and snaplets, and modifications to that state only affect the local thread that generates a single response. From now on, when we talk about state this is what we are talking about. If you need global application state, you have to use a thread-safe construct such as an MVar or IORef.
This post is written in literate Haskell. It uses a small external module called Part2 that is available here. You can also install the full code in the current directory with the command snap init tutorial
from the snap-templates
package. First we need to get imports out of the way.
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Control.Lens.TH
import Data.IORef
import qualified Data.ByteString.Char8 as B
import Data.Maybe
import Snap
import Snap.Snaplet.Heist
import Part2
We start our application by defining a data structure to hold the state. This data structure includes the state of all snaplets (wrapped in a Snaplet) used by our application as well as any other state we might want.
data App = App
{ _heist :: Snaplet (Heist App)
, _foo :: Snaplet Foo
, _bar :: Snaplet Bar
, _companyName :: IORef B.ByteString
}
makeLenses ''App
The field names begin with an underscore because of some more complicated things going on under the hood. However, all you need to know right now is that you should prefix things with an underscore and then call makeLenses
. This lets you use the names without an underscore in the rest of your application.
The next thing we need to do is define an initializer.
appInit :: SnapletInit App App
appInit = makeSnaplet "myapp" "My example application" Nothing $ do
hs <- nestSnaplet "heist" heist $ heistInit "templates"
fs <- nestSnaplet "foo" foo $ fooInit
bs <- nestSnaplet "" bar $ nameSnaplet "newname" $ barInit foo
addRoutes [ ("hello", writeText "hello world")
, ("fooname", with foo namePage)
, ("barname", with bar namePage)
, ("company", companyHandler)
]
wrapSite (<|> heistServe)
ref <- liftIO $ newIORef "fooCorp"
return $ App hs fs bs ref
For now don’t worry about all the details of this code. We’ll work through the individual pieces one at a time. The basic idea here is that to initialize an application, we first initialize each of the snaplets, add some routes, run a function wrapping all the routes, and return the resulting state data structure. This example demonstrates the use of a few of the most common snaplet functions.
nestSnaplet
All calls to child snaplet initializer functions must be wrapped in a call to nestSnaplet. The first parameter is a URL path segment that is used to prefix all routes defined by the snaplet. This lets you ensure that there will be no problems with duplicate routes defined in different snaplets. If the foo snaplet defines a route /foopage
, then in the above example, that page will be available at /foo/foopage
. Sometimes though, you might want a snaplet’s routes to be available at the top level. To do that, just pass an empty string to nestSnaplet as shown above with the bar snaplet.
In our example above, the bar snaplet does something that needs to know about the foo snaplet. Maybe foo is a database snaplet and bar wants to store or read something. In order to make that happen, it needs to have a “handle” to the snaplet. Our handles are whatever field names we used in the App data structure minus the initial underscore character. They are automatically generated by the makeLenses
function. For now it’s sufficient to think of them as a getter and a setter combined (to use an OO metaphor).
The second parameter to nestSnaplet is the lens to the snaplet you’re nesting. In order to place a piece into the puzzle, you need to know where it goes.
nameSnaplet
The author of a snaplet defines a default name for the snaplet in the first argument to the makeSnaplet function. This name is used for the snaplet’s directory in the filesystem. If you don’t want to use the default name, you can override it with the nameSnaplet
function. Also, if you want to have two instances of the same snaplet, then you will need to use nameSnaplet
to give at least one of them a unique name.
addRoutes
The addRoutes
function is how an application (or snaplet) defines its routes. Under the hood the snaplet infrastructure merges all the routes from all snaplets, prepends prefixes from nestSnaplet
calls, and passes the list to Snap’s route function.
A route is a tuple of a URL and a handler function that will be called when the URL is requested. Handler is a wrapper around the Snap monad that handles the snaplet’s infrastructure. During initialization, snaplets use the Initializer
monad. During runtime, they use the Handler
monad. We’ll discuss Handler
in more detail later. If you’re familiar with Snap’s old extension system, you can think of it as roughly equivalent to the Application monad. It has a MonadState
instance that lets you access and modify the current snaplet’s state, and a MonadSnap
instance providing the request-processing functions defined in Snap.Types.
wrapSite
wrapSite
allows you to apply an arbitrary Handler
transformation to the top-level handler. This is useful if you want to do some generic processing at the beginning or end of every request. For instance, a session snaplet might use it to touch a session activity token before routing happens. It could also be used to implement custom logging. The example above uses it to define heistServe (provided by the Heist snaplet) as the default handler to be tried if no other handler matched. This may seem like an easy way to define routes, but if you string them all together in this way each handler will be evaluated sequentially and you’ll get O(n) time complexity, whereas routes defined with addRoutes
have O(log n) time complexity. Therefore, in a real-world application you would probably want to have ("", heistServe)
in the list passed to addRoutes
.
with
The last unfamiliar function in the example is with
. Here it accompanies a call to the function namePage
. namePage
is a simple example handler and looks like this.
namePage :: Handler b v ()
namePage = do
mname <- getSnapletName
writeText $ fromMaybe "This shouldn't happen" mname
This function is a generic handler that gets the name of the current snaplet and writes it into the response with the writeText
function defined by the snap-core project. The type variables ‘b’ and ‘v’ indicate that this function will work in any snaplet with any base application. The ‘with’ function is used to run namePage
in the context of the snaplets foo and bar for the corresponding routes.
Site Reloading
Snaplet Initializers serve dual purpose as both initializers and reloaders. Reloads are triggered by a special handler that is bound to the /admin/reload
route. This handler re-runs the site initializer and if it is successful, loads the newly generated in-memory state. To prevent denial of service attacks, the reload route is only accessible from localhost.
If there are any errors during reload, you would naturally want to see them in the HTTP response returned by the server. However, when these same initializers are run when you first start your app, you will want to see status messages printed to the console. To make this possible we provide the printInfo
function. You should use it to output any informational messages generated by your initializers. If you print directly to standard output or standard error, then those messages will not be available in your browser when you reload the site.
Working with state
Handler b v
has a MonadState v
instance. This means that you can access all your snaplet state through the get, put, gets, and modify functions that are probably familiar from the state monad. In our example application we demonstrate this with companyHandler
.
companyHandler :: Handler App App ()
companyHandler = method GET getter <|> method POST setter
where
getter = do
nameRef <- gets _companyName
name <- liftIO $ readIORef nameRef
writeBS name
setter = do
mname <- getParam "name"
nameRef <- gets _companyName
liftIO $ maybe (return ()) (writeIORef nameRef) mname
getter
If you set a GET request to /company
, you’ll get the string “fooCorp” back. If you send a POST request, it will set the IORef held in the _companyName
field in the App
data structure to the value of the name
field. Then it calls the getter to return that value back to you so you can see it was actually changed. Again, remember that this change only persists across requests because we used an IORef. If _companyName
was just a plain string and we had used modify, the changed result would only be visible in the rest of the processing for that request.
The Heist Snaplet
The astute reader might ask why there is no with heist
in front of the call to heistServe
. And indeed, that would normally be the case. But we decided that an application will never need more than one instance of a Heist snaplet. So we provided a type class called HasHeist
that allows an application to define the global reference to its Heist snaplet by writing a HasHeist
instance. In this example we define the instance as follows:
instance HasHeist App where heistLens = subSnaplet heist
Now all we need is a simple main function to serve our application.
main :: IO ()
main = serveSnaplet defaultConfig appInit
This completes a full working application. We did leave out a little dummy code for the Foo and Bar snaplets. This code is included in Part2.hs. For more information look in our API documentation, specifically the Snap.Snaplet module. No really, that wasn’t a joke. The API docs are written as prose. They should be very easy to read, while having the benefit of including all the actual type signatures.
Filesystem Data and Automatic Installation
Some snaplets will have data stored in the filesystem that should be installed into the directory of any project that uses it. Here’s an example of what a snaplet filesystem layout might look like:
foosnaplet/
|-- *devel.cfg*
|-- db.cfg
|-- public/
|-- stylesheets/
|-- images/
|-- js/
|-- *snaplets/*
|-- *heist/*
|-- templates/
|-- subsnaplet1/
|-- subsnaplet2/
Only the starred items are actually enforced by current code, but we want to establish the others as a convention. The file devel.cfg is automatically read by the snaplet infrastructure. It is available to you via the getSnapletUserConfig
function. Config files use the format defined by Bryan O’Sullivan’s excellent configurator package. In this example, the user has chosen to put db config items in a separate file and use configurator’s import functionality to include it in devel.cfg. If foosnaplet uses nestSnaplet
or embedSnaplet
to include any other snaplets, then filesystem data defined by those snaplets will be included in subdirectories under the snaplets/
directory.
So how do you tell the snaplet infrastructure that your snaplet has filesystem data that should be installed? Look at the definition of appInit above. The third argument to the makeSnaplet function is where we specify the filesystem directory that should be installed. That argument has the type Maybe (IO FilePath)
. In this case we used Nothing
because our simple example doesn’t have any filesystem data. As an example, let’s say you are creating a snaplet called killerapp that will be distributed as a hackage project called snaplet-killerapp. Your project directory structure will look something like this:
snaplet-killerapp/
|-- resources/
|-- snaplet-killerapp.cabal
|-- src/
All of the files and directories listed above under foosnaplet/ will be in resources/. Somewhere in the code you will define an initializer for the snaplet that will look like this:
killerInit = makeSnaplet "killerapp" "42" (Just dataDir) $ do
The primary function of Cabal is to install code. But it has the ability to install data files and provides a function called getDataDir
for retrieving the location of these files. Since it returns a different result depending on what machine you’re using, the third argument to makeSnaplet
has to be Maybe (IO FilePath)
instead of the more natural pure version. To make things more organized, we use the convention of putting all your snaplet’s data files in a subdirectory called resources. So we need to create a small function that appends /resources
to the result of getDataDir
.
import Paths_snaplet_killerapp
dataDir = liftM (++"/resources") getDataDir
If our project is named snaplet-killerapp, the getDataDir
function is defined in the module Paths_snaplet_killerapp, which we have to import. To make everything work, you have to tell Cabal about your data files by including a section like the following in snaplet-killerapp.cabal:
data-files:
resources/devel.cfg,
resources/public/stylesheets/style.css,
resources/snaplets/heist/templates/page.tpl
Now whenever your snaplet is used, its filesystem data will be automagically copied into the local project that is using it, whenever the application is run and it sees that the snaplet’s directory does not already exist. If the user upgrades to a new version of the snaplet and the new version made changes to the filesystem resources, those resources will NOT be automatically copied in by default. Resource installation only happens when the snaplets/foo
directory does not exist. If you want to get the latest version of the filesystem resources, remove the snaplets/foo
directory, and restart your app.