Rosencrantz

Rosencrantz is a DSL to write web servers, inspired by Spray and its successor Akka HTTP.
It sits on top of asynchttpserver and provides a composable way to write HTTP handlers.
Version 0.3 of Rosencrantz requires at least Nim 0.15. Use versions up to 0.2.8 for older versions of Nim.
Table of contents
Introduction
The core abstraction in Rosencrantz is the Handler, which is just an alias
for a proc(req: ref Request, ctx: Context): Future[Context]. Here Request
is the HTTP request from asynchttpserver, while Context is a place where
we accumulate information such as:
- what part of the path has been matched so far;
- what headers to emit with the response;
- whether the request has matched a route so far.
A handler usually does one or more of the following:
- filter the request, by returning
ctx.reject()if some condition is not satisfied; - accumulate some headers;
- actually respond to the request, by calling the
completefunction or one derived from it.
Rosencrantz provides many of those handlers, which are described below. For the complete API, check here.
Composing handlers
The nice thing about handlers is that they are composable. There are two ways
to compose two headers h1 and h2:
-
h1 -> h2(readh1andh2) returns a handler that passes the request throughh1to update the context; then, ifh1does not reject the request, it passes it, together with the new context, toh2. Think filtering first by HTTP method, then by path. -
h1 ~ h2(readh1orh2) returns a handler that passes the request throughh1; if it rejects the request, it tries again withh2. Think matching on two alternative paths.
The combination h1 -> h2 can also be written h1[h2], which makes it nicer
when composing many handlers one inside each other. Also remember that,
according to Nim rules, ~ has higher precedence than -> - use parentheses
if necessary to compose your handlers.
Starting a server
Once you have a handler, you can serve it using a server from asynchttpserver,
like this:
let server = newAsyncHttpServer()
waitFor server.serve(Port(8080), handler)Structure of the package
Rosencrantz can be fully imported with just
import rosencrantzThe rosencrantz module just re-exports functionality from the submodules
rosencrantz/core, rosencrantz/handlers, rosencrantz/jsonsupport and so
on. These modules can be imported separately. The API is available
here.
An example
The following uses some of the predefined handlers and composes them together.
We write a small piece of a fictionary API to save and retrieve messages, and
we assume we have functions such as getMessageById that perform the actual
business logic. This should give a feel of how the DSL looks like:
let handler = get[
path("/api/status")[
ok(getStatus())
] ~
pathChunk("/api/message")[
accept("application/json")[
intSegment(proc(id: int): auto =
let message = getMessageById(id)
ok(message)
)
]
]
] ~ post [
path("/api/new-message")[
jsonBody(proc(msg: Message): auto =
let
id = generateId()
saved = saveMessage(id, msg)
if saved: ok(id)
else: complete(Http500, "save failed")
)
]
]For more (actually working) examples, check the tests directory. In particular,
the server example
tests every handler defined in Rosencrantz, while
the todo example
implements a server compliant with the TODO backend project
specs.
Basic handlers
In order to work with Rosencrantz, you can import rosencrantz. If you prefer
a more fine-grained control, there are packages rosencrantz/core (which
contains the definitions common to all handlers), rosencrantz/handlers (for
the handlers we are about to show), and then more specialized handlers under
rosencrantz/jsonsupport, rosencrantz/formsupport and so on.
The simplest handlers are:
-
complete(code, body, headers)that actually responds to the request. Herecodeis an instance ofHttpCodefromasynchttpserver,bodyis astringandheadersare an instance ofStringTableRef. -
ok(body), which is a specialization ofcompletefor a response of200 Okwith a content type oftext/plain. -
notFound(body), which is a specialization ofcompletefor a response of404 Not Foundwith a content type oftext/plain. -
body(p)extracts the body of the request. Herepis aproc(s: string): Handlerwhich takes the extracted body as input and returns a handler.
For instance, a simple handler that echoes back the body of the request would look like
body(proc(s: string): auto =
ok(s)
)Path handling
There are a few handlers to filter by path and extract path parameters:
-
path(s)filters the requests where the path is equal tos. -
pathChunk(s)does the same but only for a prefix of the path. This means that one can nest more path handlers after it, unlikepath, that matches and consumes the whole path. -
pathEnd(p)extracts whatever is not matched yet of the path and passes it top. Herepis aproc(s: string): Handlerthat takes the final part of the path and returns a handler. -
segment(p), that extracts a segment of path among two/signs. Herepis aproc(s: string): Handlerthat takes the matched segment and return a handler. This fails if the position is not just before a/sign. -
intSegment(p), works the same assegment, but extracts and parses an integer number. It fails if the segment does not represent an integer. Herepis aproc(s: int): Handler.
For instance, to match and extract parameters out of a route like
repeat/$msg/$n, one would nest the above to get
pathChunk("/repeat")[
segment(proc(msg: string): auto =
intSegment(proc(n: int): auto =
someHandler
)
)
]HTTP methods
To filter by HTTP method, one can use
-
verb(m), wheremis a member of theHttpMethodenum defined in the standard libraryhttpcore. There are corresponding specializations -
get,post,put,delete,head,patch,options,traceandconnect
Failure containment
When a requests falls through all routes without matching, Rosencrantz will
return a standard response of 404 Not Found. Similarly, whenever an
exception arises, Rosencrantz will respond with 500 Server Error.
Sometimes, it can be useful to have more control over failure cases. For
instance, you are able only to generate responses with type application/json:
if the Accept header does not match it, you may want to return a status code
of 406 Not Accepted.
One way to do this is to put the 406 response as an alternative, like this:
accept("application/json")[
someResponse
] ~ complete(Http406, "JSON endpoint")However, it can be more clear to use an equivalent combinators that wraps an existing handler and it returns a given failure message in case the inner handler fails to match. For this, there is
-
failWith(code, s), to be used like this:
failWith(Http406, "JSON endpoint")(
accept("application/json")[
someResponse
]
)Logging
Rosencrantz supports logging in two different moments: when a request arrives, or when a response is produced (of course you can also manually log at any other moment). In the first case, you will only have available the information about the current request, while in the latter both the request and the response will be available.
The two basic handlers for logging are:
-
logRequest(s), wheresis a format string. The string is used inside the systemformatfunction, and it is passed the following arguments in order:- the HTTP method of the request
- the path of the resource
- the headers, as a table
- the body of the request, if any.
-
logResponse(s), wheresis a format string. The first four arguments are the same as inlogRequest; then there are- the HTTP code of the response
- the headers of the response, as a table
- the body of the response, if any.
So for instance, in order to log the incoming method and path, as well as the HTTP code of the response, you can use the following handler:
logResponse("$1 $2 - $5")which will produce log strings such as
GET /api/users/181 - 200 OK
Working with headers
Under rosencrantz/headersupport, there are various handlers to read HTTP
headers, filter requests by their values, or accumulate HTTP headers for the
response.
-
headers(h1, h2, ...)adds headers for the response. Here each argument is a tuple of two strings, which are a key/value pair. -
contentType(s)is a specialization to emit theContent-Typeheader, so is is equivalent toheaders(("Content-Type", s)). -
readAllHeaders(p)extract the headers as a string table. Herepis aproc(hs: HttpHeaders): Handler. -
readHeaders(s1, p)extracts the value of the header with keys1and passes it top, which is of typeproc(h1: string): Handler. It rejects the request if the headers1is not defined. There are overloadsreadHeaders(s1, s2, p)andreadHeaders(s1, s2, s3, p), wherepis a function of two arguments (resp. three arguments). To extract more than three headers, one can usereadAllHeadersor nestreadHeaderscalls. -
tryReadHeaders(s1, p)works the same asreadHeaders, but it does not reject the request if headersis missing; instead,preceives an empty string as default. Again, there are overloads for two and three arguments. -
checkHeaders(h1, h2, ...)filters the request for the header value. Hereh1and the other are pairs of strings, representing a key and a value. If the request does not have the corresponding headers with these values, it will be rejected. -
accept(mimetype)is equivalent tocheckHeaders(("Accept", mimetype)). -
addDate()returns a handler that adds theDateheader, formatted as a GMT date in the HTTP date format.
For example, if you can return a result both as JSON or XML, according to the request, you can do
accept("application/json")[
contentType("application/json")[
ok(someJsonValue)
]
] ~ accept("text/xml")[
contentType("text/xml")[
ok(someXmlValue)
]
]Writing custom handlers
Sometimes, the need arises to write handlers that perform a little more custom
logic than those shown above. For those cases, Rosencrantz provides a few
procedures and templates (under rosencrantz/custom) that help creating
your handlers.
-
getRequest(p), wherepis aproc(req: ref Request): Handler. This allows you to access the wholeRequestobject, and as such allows more flexibility. -
scopeis a template that creates a local scope. It us useful when one needs to define a few variables to write a little logic inline before returning an actual handler. -
scopeAsyncis like scope, but allows asyncronous logic (for instance waiting on futures) in it. -
makeHandleris a macro that removes some boilerplate in writing a custom handler. It accepts the body of a handler, and surrounds it with the proper function declaration, etc.
An example of usage of scope is the following:
path("/using-scope")[
scope do:
let x = "Hello, World!"
echo "We are returning: ", x
return ok(x)
]An example of usage of scopeAsync is the following:
path("/using-scope")[
scopeAsync do:
let x = "Hello, World!"
echo "We are returning: ", x
await sleepAsync(100)
return ok(x)
]An example of usage of makeHandler is the following:
path("/custom-handler")[
makeHandler do:
let x = "Hello, World!"
await req[].respond(Http200, x, {"Content-Type": "text/plain;charset=utf-8"}.newStringTable)
return ctx
]That is expanded into something like:
path("/custom-handler")[
proc innerProc() =
proc h(req: ref Request, ctx: Context): Future[Context] {.async.} =
let x = "Hello, World!"
await req[].respond(Http200, x, {"Content-Type": "text/plain;charset=utf-8"}.newStringTable)
return ctx
return h
innerProc()
]Notice that makeHandler is a little lower-level than other parts of
Rosencrantz, and requires you to know how to write a custom handler.
JSON support
Rosencrantz has support to parse and respond with JSON, under the
rosencrantz/jsonsupport module. It defines two typeclasses:
- a type
TisJsonReadableif there is functionreadFromJson(json, T): Twherejsonis of typeJsonNode; - a type
TisJsonWritableif there is a functionrenderToJson(t: T): JsonNode.
The module rosencrantz/core contains the following handlers:
-
ok(j), wherejis of typeJsonNode, that will respond with a content type ofapplication/json. -
ok(t), wherethas a typeTthat isJsonWritable, that will respond with the JSON representation oftand a content type ofapplication/json. -
jsonBody(p), wherepis aproc(j: JsonNode): Handler, that extracts the body as aJsonNodeand passes it top, failing if the body is not valid JSON. -
jsonBody(p), wherepis aproc(t: T): Handler, whereTis a type that isJsonReadable; it extracts the body as aTand passes it top, failing if the body is not valid JSON or cannot be converted toT.
Form and querystring support
Rosencrantz has support to read the body of a form, either of type
application/x-www-form-urlencoded or multipart. It also supports
parsing the querystring as application/x-www-form-urlencoded.
The rosencrantz/formsupport module defines two typeclasses:
- a type
TisUrlDecodableif there is functionparseFromUrl(s, T): Twheresis of typeStringTableRef; - a type
TisUrlMultiDecodableif there is a functionparseFromUrl(s, T): Twheresis of typeTableRef[string, seq[string]].
The module rosencrantz/formsupport defines the following handlers:
-
formBody(p)wherepis aproc(s: StringTableRef): Handler. It will parse the body as an URL-encoded form and pass the corresponding string table top, rejecting the request if the body is not parseable. -
formBody(t)wherethas a typeTthat isUrlDecodable. It will parse the body as an URL-encoded form, convert it toT, and pass the resulting object top. It will reject a request if the body is not parseable or if the conversion toTfails. -
formBody(p)wherepis aproc(s: TableRef[string, seq[string]]): Handler. It will parse the body as an URL-encoded form, accumulating repeated parameters into sequences, and pass table top, rejecting the request if the body is not parseable. -
formBody(t)wherethas a typeTthat isUrlMultiDecodable. It will parse the body as an URL-encoded with repeated parameters form, convert it toT, and pass the resulting object top. It will reject a request if the body is not parseable or if the conversion toTfails.
There are similar handlers to extract the querystring from a request:
-
queryString(p), wherepis aproc(s: string): Handlerallows to generate a handler from the raw querystring (not parsed into parameters yet) -
queryString(p), wherepis aproc(s: StringTableRef): Handlerallows to generate a handler from the querystring parameters, parsed as a string table. -
queryString(t)wherethas a typeTthat isUrlDecodable; works the same asformBody. -
queryString(p), wherepis aproc(s: TableRef[string, seq[string]]): Handlerallows to generate a handler from the querystring with repeated parameters, parsed as a table. -
queryString(t)wherethas a typeTthat isUrlMultiDecodable; works the same asformBody.
Finally, there is a handler to parse multipart forms. The results are
accumulated inside a MultiPart object, which is defined by
type
MultiPartFile* = object
filename*, contentType*, content*: string
MultiPart* = object
fields*: StringTableRef
files*: TableRef[string, MultiPartFile]The handler for multipart forms is:
-
multipart(p), wherepis aproc(m: MultiPart): Handleris handed the result of parsing the form as multipart. In case of parsing error, an exception is raised - you can choose whether to let it propagate it and return a 500 error, or contain it usingfailWith.
Static file support
Rosencrantz has support to serve static files or directories. For now, it is limited to small files, because it does not support streaming yet.
The module rosencrantz/staticsupport defines the following handlers:
-
file(path), wherepathis either absolute or relative to the current working directory. It will respond by serving the content of the file, if it exists and is a simple file, or reject the request if it does not exist or is a directory. -
dir(path), wherepathis either absolute or relative to the current working directory. It will respond by taking the part of the URL requested that is not matched yet, concatenate it topath, and serve the corresponding file. Again, if the file does not exist or is a directory, the handler will reject the request.
To make things concrete, consider the following handler:
path("/main")[
file("index.html")
] ~
pathChunk("/static")[
dir("public")
]This will server the file index.html when the request is for the path /main,
and it will serve the contents of the directory public under the URL static.
So, for instance, a request for /static/css/boostrap.css will return the
contents of the file ./public/css/boostrap.css.
All static handlers use the mimetypes module
to try to guess the correct content type depending on the file extension. This
should be usually enough; if you need more control, you can wrap a file
handler inside a contentType handler to override the content type.
Note Due to a bug in Nim 0.14.2, the static handlers will not work on this version. They work just fine on Nim 0.14.0 or on devel.
CORS support
Rosencrantz has support for Cross-Origin requests
under the module rosencrantz/corssupport.
The following are essentially helper functions to produce headers related to handling cross-origin HTTP requests, as well as reading common headers in preflight requests. These handlers are available:
-
accessControlAllowOrigin(origin)produces the headerAccess-Control-Allow-Originwith the providedoriginvalue. -
accessControlAllowAllOriginsproduces the headerAccess-Control-Allow-Originwith the value*, which amounts to accepting all origins. -
accessControlExposeHeaders(headers)produces the headerAccess-Control-Expose-Headers, which is used to control which headers are exposed to the client. -
accessControlMaxAge(seconds)produces the headerAccess-Control-Max-Age, which controls the time validity for the preflight request. -
accessControlAllowCredentials(b), wherebis a boolean value, produces the headerAccess-Control-Allow-Credentials, which is used to allow the client to pass cookies and headers related to HTTP authentication. -
accessControlAllowMethods(methods), wheremethodsis an openarray ofHttpMethod, produces the headerAccess-Control-Allow-Methods, which is used in preflight requests to communicate which methods are allowed on the resource. -
accessControlAllowHeaders(headers)produces the headerAccess-Control-Allow-Headers, which is used in the preflight request to control which headers can be added by the client. -
accessControlAllow(origin, methods, headers)is used in preflight requests for the common combination of specifying the origin as well as methods and headers accepted. -
readAccessControl(p)is used to extract information in the preflight request from the CORS related headers at once. Herepis aproc(origin: string, m: HttpMethod, headers: seq[string]that will receive the origin of the request, the desired method and the additional headers to be provided, and will return a suitable response.
API stability
While the basic design is not going to change, the API is not completely
stable yet. It is possible that the Context will change to accomodate some
more information, or that it will be passed as a ref to handlers.
As long as you compose the handlers defined above, everything will continue to work, but if you write your own handlers by hand, this is something to be aware of.