The Snap Framework for web applications in Haskell
Gregory Collins CUFP 2011, Tokyo, Japan Friday, September 23, 2011 |
In this workshop, we’ll be creating a web application in Haskell using the Snap Framework.
This tutorial is intended for people at an intermediate level in Haskell.
If you’re a beginner, it may be too hard.
If you’re an expert, it’s probably too easy — but there will be room to explore the problem further if you finish early.
We’ll be writing a simple chat room application using long-polling, XMLHttpRequest, and JSON.
The data model/chat room logic and the front-end HTML/JavaScript have been provided for you.
You’ll be writing:
code to convert between Haskell datatypes and JSON representations
the JSON API backend, including authentication logic and handlers for the various requests the frontend will be sending you.
There will be some lecture in this tutorial session, but I’m intending for this workshop to be pretty hands-on:
More hacking, less blabbing
Please feel free to ask questions, both during the lectures and during the hacking time.
Install the Snap Framework:
$ cabal update
$ cabal install snap
First, a quick refresher about HTTP: Clients connect to the server’s listening port, and send an HTTP request:
POST / HTTP/1.1
User-Agent: curl/7.19.7 ....
Host: localhost:8000
Accept: */*
Content-Length: 5
Content-Type: text/plain
Hello
The server responds with a response:
HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Type: text/plain
Content-Length: 11
Date: Sun, 18 Sep 2011 23:49:05 GMT
Server: Snap/0.5.4
...data...
HTTP status line
HTTP headers
request body
HTTP status line (HTTP version, status code, status message)
HTTP headers
response body
OK, so now let’s take a look at the Snap API. The Snap API docs are available on Hackage (http://hackage.haskell.org/package/snap-core
), with most of the core stuff in the Snap.Types module.
Well, basically the stuff we mentioned earlier.
HTTP headers
query string/form parameters
other metadata (like “request method”, “is secure/https”, “uri requested”, etc)
an Enumerator for the request body
HTTP headers (and cookies, which turn into HTTP headers later)
metadata (like content length, HTTP status code, HTTP version, etc)
an Enumerator for the response body.
The Snap monad is essentially a state monad over (Request,Response)
(although there are some other things in there too).
Some things you can do in the Snap Monad:
Statefully fetch or modify a Request
or Response
:
foo :: Snap ()
foo = do
rq <- getRequest
modifyResponse $ setHeader "foo" "bar"
putRequest $ rqSetParam "param" ["value"]
Get failure/Alternative
/MonadPlus
semantics: a Snap
handler can choose not to handle a given request, using empty
or its synonym pass
, and you can try alternative handlers with the <|>
operator:
a :: Snap Int
a = pass
b :: Snap Int
b = return 7
c :: Snap Int
c = a <|> b -- try running a, if it fails then try b
These semantics are really useful for guarding against certain preconditions in your handlers, for example:
foo :: Snap ()
foo = method GET getFoo <|>
method POST postFoo <|>
method PUT putFoo
The Snap monad also supports early termination using finishWith
; if you know you want to respond with a specific request, terminating all other processing, you can do that. See, for example, this function from the Snap core library:
redirect' :: MonadSnap m => ByteString -> Int -> m a
redirect' target status = do
r <- getResponse
finishWith
$ setResponseCode status
$ setContentLength 0
$ modifyResponseBody (const $ enumBuilder mempty)
$ setHeader "Location" target r
The Snap monad is an instance of MonadIO
, so you can use liftIO
to run things from the IO
monad:
foo :: Snap ()
foo = liftIO fireZeMissiles
You can set a timeout which will kill the handler thread after n seconds of inactivity:
foo :: Snap ()
foo = setTimeout 30
The Snap core library also gives you convenience functions for queueing output to be written to the ‘Response’:
foo :: (forall a . Enumerator a) -> Snap ()
foo someEnumerator = do
writeBS "I'm a strict bytestring"
writeLBS "I'm a lazy bytestring"
writeText "I'm strict text"
addToOutput someEnumerator
Handing an HTTP transaction in Snap is broken up into phases:
parsing the request + headers from the input (but not yet touching the request body, so it can be streamed)
handing the parsed request and empty response to the user handler running in the Snap monad, where the request body can be read and the response is produced
control leaves the user handler and the response headers and body are delivered.
Important: data is not sent right away!
Although you might call writeText
to ask for some text to be written to the output, this data is not written immediately.
Instead, you’re building up a function that will be run to generate the response body after the user handler finishes.
Snap gives you a function called dir
that matches a prefix of the request URI (per directory/path component, i.e. parts delimited by “/”), and fails if the path doesn’t match:
foo :: Snap ()
foo = dir "foo/bar" doSomething
Snap also gives you a function called route
that takes a list of (path, handler)
pairs and gives you an efficient routing table. If you prefix a part of the routed path with a colon, it “captures” this part of the URI as a query parameter:
foo :: Snap ()
foo = route [ ("article", renderIndex)
, ("article/:id", renderArticle)
, ("login", method POST doLogin) ]
Paths in Snap Requests
are broken up into parts depending on how you have matched or processed them:
rqURI
always contains the complete path requested, including the query string
rqContextPath
contains the part of the path we’ve matched so far. It always begins and ends with a ‘/’.
rqPathInfo
contains the part of the path we haven’t matched.
rqQueryString
contains the URI query string.
Let’s say the user requested /foo/bar/baz?q=hello+world
, and control passes from the toplevel into this handler:
foo :: Snap ()
foo = dir "foo/bar" doSomething
Within doSomething
:
rqURI
is "/foo/bar/baz?q=hello+world"
rqContextPath
is "/foo/bar/"
rqPathInfo
is "baz"
rqQueryString
is "hello+world"
.
You can serve files from the disk using Snap.Util.FileServe
, and you can handle file uploads using Snap.Util.FileUploads
.
Let’s make sure our Snap installations are working and we understand Snap’s path routing. First, initialize a barebones project:
$ mkdir snap-test-project
$ cd snap-test-project
$ snap init -b
This command should generate some files in the snap-test-project
directory:
snap-test-project.cabal
src/Main.hs
Running cabal install
in this directory should produce a binary called dist/build/snap-test-project/snap-test-project
which you can run to get a working web server.
Edit src/Main.hs
to add a handler to the routing table to do something simple (up to you).
The application we’ll be building uses JSON apis on the backend to handle the “business logic” for the chat room.
We’ll be using Bryan O’Sullivan’s excellent aeson (http://hackage.haskell.org/package/aeson
) library to encode/decode JSON to/from Haskell datatypes.
Let’s take a look at the aeson API now, starting with Data.Aeson
.
JSON has six different data types:
/* null */
null
/* Booleans */
true
false
/* String literals */
"foo"
/* Numbers (integer and floating point) */
1
1.2
/* Arrays */
[ 1, 2, 3, 4 ]
/* objects (key-value maps) */
{
"foo": "bar",
"baz": "quux"
}
-- A JSON "object" (key/value map).
type Object = Map Text Value
-- A JSON "array" (sequence).
type Array = Vector Value
-- A JSON value represented as a Haskell value.
data Value = Object Object
| Array Array
| String Text
| Number Number
| Bool !Bool
| Null
OK, so we can parse a JSON string and produce a Haskell value in JSON’s abstract syntax. What we want, however, is to go to/from user-defined datatypes and the JSON representation. How do we do this?
class ToJSON a where
toJSON :: a -> Value
class FromJSON a where
parseJSON :: Value -> Parser a
Consider the following Haskell datatype:
data Coord = Coord { x :: Double, y :: Double }
We can use the “object :: [Pair] -> Value
” helper function (where type Pair = (Text,Value)
) to produce a JSON object in abstract syntax from this:
instance ToJSON Coord where
toJSON (Coord x y) = object ["x" .= x, "y" .= y]
For parsing from JSON abstract syntax into a Haskell datatype, aeson provides you with a Parser
type with Applicative
/Monad
/MonadPlus
instances. We can use it with some of the aeson convenience functions to easily convert JSON to our datatype:
-- Note: (.:) :: FromJSON a => Object -> Text -> Parser a
instance FromJSON Coord where
parseJSON (Object v) = Coord <$>
v .: "x" <*>
v .: "y"
-- A non-Object value is of the wrong type, so use mzero to fail.
parseJSON _ = mzero
See examples/JsonExample.hs
in the CUFP 2011 tutorial code for a working example of converting to/from ByteStrings:
example1 :: ByteString -> Either String Coord
example1 bs = parseOnly json bs >>= convert
where
convert value = case fromJSON value of
(Error e) -> Left e
(Success a) -> Right a
example2 :: Coord -> ByteString
example2 c = S.concat $ L.toChunks $ encode c
To follow along with the source code, visit https://github.com/snapframework/cufp2011
(or open it in your text editor).
You can also build the API docs using cabal haddock
, which will put them in dist/doc/html/snap-chat/index.html
.
By the way, if you get completely stuck, there is a sample implementation in the sample-implementation/
directory of the tutorial project.
Please don’t peek unless you really have to! It will work a lot better if you ask me for help instead.
The specifications for the JSON API you’ll need to implement are included in the cufp2011
repository’s README.md
file (see also the github project page).
The ToJSON/FromJSON instances for the chat app have been stubbed out for you; you just need to implement them according to the specification. The places where you need to do so are marked with “toBeImplemented”:
$ find src -name '*hs' | xargs grep -l toBeImplemented
src/Snap/Chat/API/Handlers.hs
src/Snap/Chat/Internal/API/Types.hs
src/Snap/Chat/Internal/Types.hs
The tutorial has a test suite that will check that your JSON instances are correct; to run it:
$ cd test
$ cabal install
$ ./runTestsAndCoverage.sh
The Snap Chat API works by having clients POST JSON messages in the pre-determined schema to the URL endpoints /api/join
, /api/write
, etc.
Once authenticated, users are identified by an encrypted token which gets passed back to the API verbatim by the Javascript frontend. To accomplish this cleanly, we’ll use the clientsession (http://hackage.haskell.org/package/clientsession
) library to encrypt/decrypt some JSON-encoded session data.
Now that you’ve (hopefully) correctly implemented the JSON instances, the only thing that remains is to complete the code in src/Snap/Chat/API/Handlers.hs
.
Again, the places where you need to write code are marked by toBeImplemented
.
Thank you!
Gregory Collins CUFP 2011, Tokyo, Japan Friday, September 23, 2011 |