Skip to content

Architecture: resource

Edsko de Vries edited this page Aug 22, 2013 · 3 revisions

The way to route a given URI to a given page is through a Resource. Each HackageModule has a list of them it expects the server to serve. Routing is done particularly through a URI specification string, similar to the kind used by Ruby on Rails, Pylons, and other web frameworks.

Defining a resource

Consider a URI path like "/blog/post/7", which the client would access as "http://website.org/blog/post/7". It has three path components: "blog", "post", and "7". The first two are static components: say that when you're serving a blog post, the URI must always start with "/blog/post". However, the third component is dynamic, meaning that you might want to take an arbitrary number there, 7 or 12 or 81, even though not all numbers have their own blog post (in which case you'd return a 404 page, "post not found"). Knowing this, you would use the string "/blog/post/:id" to indicate the structure of the URI, and you could make a resource from it using resourceAt :: String -> Resource. Any path components starting with a colon are dynamic. Any other ones are static.

A Resource defines not only the structure of URIs it will respond to, but also the functions themselves that do the responding. It can respond to the four most common HTTP methods, GET, POST, PUT, and DELETE, using the Happstack web framework. Whenever you want to respond to an HTTP request from a given Resource, you have to provide a function of the type DynamicPath -> ServerPart Response. ServerPart Response is the Happstack type for a procedure in the server monad that produces a Response object. DynamicPath, however, is defined as a mapping from the names of the dynamic path components to their values. In particular, it's an association list, [(String, String)].

To define a basic blog post resource, you would write:

blogPost :: Resource
blogPost = (resourceAt "/blog/post/:id") { resourceGet = [("txt", serveBlogPost)], resourcePut = [("txt", setBlogPost)]}

serveBlogPost :: DynamicPath -> ServerPart Response
serveBlogPost dpath = case fromReqURI =<< lookup "id" dpath of
    Nothing  -> mzero  --invalid number
    Just pid -> do
        mcontents <- query $ LookupPost pid
        case mcontents of
            Nothing -> notFound . toResponse $ "Post #" ++ show pid ++ " not found"
            Just contents -> ok . toResponse $ contents

setBlogPost :: DynamicPath -> ServerPart Response
setBlogPost dpath = case fromReqURI =<< lookup "id" dpath of
    Nothing -> mzero  --invalid number
    Just pid -> do
        mcontents <- getDataFn $ look "contents"
        case mcontents of
            Nothing -> badRequest . toResponse $ "Bad input, couldn't find text"
            Just contents -> do
                update $ SetPost pid contents
                ok . toResponse $ contents

At the very top, the Resource object is created, then GET/PUT methods are added using record update notation. Both resourceGet and resourcePut expect a [(Content, DynamicPath -> ServerPart Response)] object, where the first argument is a content-type in case multiple formats are wanted. They each return a text/plain response, so "txt" is used.

For "/blog/post/:id", the functions can expect a DynamicPath of [("id", string)]. The string is whatever is requested with the URI; it might be "4" or "0020" or "banana", but not blank. If the function cannot parse the URI, it should return mzero, meaning it can potentially be parsed by other Resources. If it parses successfully and cannot find the Resource, however, it should return a 404.

For "/blog/:id/user/:user/post/:id", the function can expect a DynamicPath looking something like [("id", postId), ("user", userName), ("id", blogId)]. It's best to keep dynamic path names unique for this reason.

This is just a basic example of a stand-alone resource backed up by happstack-state functions. Features often define several resources, and they expose those resources to other features.

Ending slashes

Resources favor either having a slash or not having a slash at the end. In the case of a GET or HEAD request, the server will automatically redirect requests to match the canonical slash ending. Otherwise, it won't.

Formats

Resources can define multiple representations for a given URI.

resourceGet = [("html", htmlRep), ("txt", textRep)]

These format strings are not content-types but rather like file endings. Content negotiation, using the Accept header, would require a mapping between them, but that's not too difficult to come up with. By default, the first argument is served if no format is specified.

The complex part is that formats can be specified in the URI. Unfortunately, this isn't RESTful, since it's giving the same resource one representation per URI when it should have one URI. However, this doesn't really disrupt the concept of hypertext as the engine of application state, and it's a necessary hack to deal with the shortcomings of web browsers.

The syntax is ".:format", which can only appear at the end of a URI. So with resourceAt "/path/to/uri.:format", retrieving /path/to/uri.json would look for json in the Resource's list, and if it couldn't be found, yield mzero. Retrieving /path/to/uri would look for the default format in the Resource's list (the head of the list). The format string is also added to the DynamicPath under the string "format". In the URI "/index/:page.:format", requesting "/index/help.html" would result in [("page", "help"), ("format", "html")]. To add a format to a Resource with a slash at the end, add the format after the last slash: "/packages/tags/.:format".

A second amenity of the format syntax is being able to vary a string over a static format. For example, "/package/:package/:tarball.tar.gz", where the format starts at the first full stop. Requesting "/package/parsec/parsec-3.1.0.tar.gz" would yield [("tarball", "parsec-3.1.0"), ("package", "parsec")].

Ruby seems to provide a similar thing, though I don't know much about it other than that the syntax is close.

Combining resources

Resource is an instance of Monoid. Combining favors the first argument, if there's anything to be replaced. So, for instance:

resourceAt "/other/path"
  `mappend`
resourceAt "/path/to/uri.:format"
  ==
resourceAt "/other/path"

Combining Resources with different URIs will yield the URI of the first (unless the URI of the first is "/", to satisfy Monoid laws). This isn't so much of a concern with the ServerTree approach Hackage uses.

(resourceAt "/the/uri") { resourcePost = [("txt", postUri)], resourceGet = [] }
  `mappend`
(resourceAt "/the/uri/") { resourceGet = [("html", getUri)] }
  ==
(resourceAt "/the/uri") { resourceGet = [("html", getUri)], resourcePost = [("txt", postUri)] }
(resourceAt "/second/uri") { resourceGet = [("txt", getUriText), ("html", getUriNew)] }
  `mappend`
(resourceAt "/second/uri.:format") { resourceGet = [("html", getUri)] }
  == 
(resourceAt "/second/uri") { resourceGet = [("txt", getUriText), ("html", getUriNew)] }

mappend combines the list of formats for a method using unionBy fst arg arg2, arg being the format list in the first Resource. In order to extend resources more precisely, particularly across features, there are two helper functions:

-- | Creates a new resource at the same location, but without any of the request
-- handlers of the original. When mappend'd to the original, their methods
-- will be combined. This can be useful for extending an existing resource
-- with new representations and new functionality.
extendResource :: Resource -> Resource
extendResource resource = resource { resourceGet = [], resourcePut = [], resourcePost = [], resourceDelete = [] }

-- | Creates a new resource that is at a subdirectory of an existing resource. This function takes care of formats
-- as best as it can.
--
-- extendResourcePath "/bar/.:format" (resourceAt "/data/:foo.:format") == resourceAt "/data/:foo/bar/:.format"
--
-- Extending static formats with this method is not recommended. (extending "/:tarball.tar.gz"
-- with "/data" will give "/:tarball/data", with the format stripped, and extending
-- "/help/.json" with "/tree" will give "/help/.json/tree")
extendResourcePath :: String -> Resource -> Resource
extendResourcePath arg resource = ...

Serving resources

There is one particular structure, the ServerTree, that's used to combine Resources from different sources using their Monoid instance. It's defined as:

data ServerTree a = ServerTree {
    nodeResponse :: Maybe a,
    nodeForest :: Map BranchComponent (ServerTree a)
}
addServerNode :: Monoid a => [BranchComponent] -> a -> ServerTree a -> ServerTree a

Since each feature provides a [Resource], which are folded up into a ServerTree Response. Using the Functor instance of ServerTree and the Monoid instance of Resource, a ServerPart Response can be obtained from these functions:

type ServerResponse = DynamicPath -> ServerPart Response
serveResource :: Resource -> ServerResponse
renderServerTree :: DynamicPath -> ServerTree ServerResponse -> ServerPart Response

Resources that are added from later features are preferred over those from earlier features.

The resource system is flexible enough, I think, to handle Hackage's needs.