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.4 of Rosencrantz is tested with Nim 1.0.0, but is compatible with versions of Nim from 0.19.0 on.
- Rosencrantz
- Introduction
- Composing handlers
- Starting a server
- Structure of the package
- An example
- Basic handlers
- Path handling
- HTTP methods
- Failure containment
- Logging
- Working with headers
- Writing custom handlers
- JSON support
- Form and querystring support
- Static file support
- CORS support
- API stability
- 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
complete
function or one derived from it.
Rosencrantz provides many of those handlers, which are described below. For the complete API, check here.
The nice thing about handlers is that they are composable. There are two ways
to compose two headers h1
and h2
:
h1 -> h2
(readh1
andh2
) returns a handler that passes the request throughh1
to update the context; then, ifh1
does not reject the request, it passes it, together with the new context, toh2
. Think filtering first by HTTP method, then by path.h1 ~ h2
(readh1
orh2
) 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.
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)
Rosencrantz can be fully imported with just
import rosencrantz
The 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.
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.
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. Herecode
is an instance ofHttpCode
fromasynchttpserver
,body
is astring
andheaders
are an instance ofStringTableRef
.ok(body)
, which is a specialization ofcomplete
for a response of200 Ok
with a content type oftext/plain
.notFound(body)
, which is a specialization ofcomplete
for a response of404 Not Found
with a content type oftext/plain
.body(p)
extracts the body of the request. Herep
is aproc(s: string): Handler
which 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)
)
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
. Herep
is aproc(s: string): Handler
that takes the final part of the path and returns a handler.pathEnd(s)
filters the requests where the remaining path is equal tos
. Defaults to case sensitive matching, but you can usepathEnd(s, caseSensitive=false)
to do a case insensitive match.segment(p)
, that extracts a segment of path among two/
signs. Herep
is aproc(s: string): Handler
that takes the matched segment and return a handler. This fails if the position is not just before a/
sign.segment(s)
filters the requests where the current path segment is equal tos
. Defaults to case sensitive matching, but you can usesegment(s, caseSensitive=false)
to do a case insensitive match. 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. Herep
is 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
)
)
]
To filter by HTTP method, one can use
verb(m)
, wherem
is a member of theHttpMethod
enum defined in the standard libraryhttpcore
. There are corresponding specializationsget
,post
,put
,delete
,head
,patch
,options
,trace
andconnect
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
]
)
Similarly, you may want to customize the behaviour of Rosencrantz when the application crashes.
crashWith(code, s, logError)
can be used to wrap your handler:
crashWith(Http500, "Sorry :-(")(
accept("application/json")[
someResponse
]
)
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)
, wheres
is a format string. The string is used inside the systemformat
function, 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)
, wheres
is 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
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-Type
header, so is is equivalent toheaders(("Content-Type", s))
.readAllHeaders(p)
extract the headers as a string table. Herep
is aproc(hs: HttpHeaders): Handler
.readHeaders(s1, p)
extracts the value of the header with keys1
and passes it top
, which is of typeproc(h1: string): Handler
. It rejects the request if the headers1
is not defined. There are overloadsreadHeaders(s1, s2, p)
andreadHeaders(s1, s2, s3, p)
, wherep
is a function of two arguments (resp. three arguments). To extract more than three headers, one can usereadAllHeaders
or nestreadHeaders
calls.tryReadHeaders(s1, p)
works the same asreadHeaders
, but it does not reject the request if headers
is missing; instead,p
receives an empty string as default. Again, there are overloads for two and three arguments.checkHeaders(h1, h2, ...)
filters the request for the header value. Hereh1
and 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 theDate
header, 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)
]
]
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)
, wherep
is aproc(req: ref Request): Handler
. This allows you to access the wholeRequest
object, and as such allows more flexibility.scope
is 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.scopeAsync
is like scope, but allows asyncronous logic (for instance waiting on futures) in it.makeHandler
is 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.
Rosencrantz has support to parse and respond with JSON, under the
rosencrantz/jsonsupport
module. It defines two typeclasses:
- a type
T
isJsonReadable
if there is functionreadFromJson(json, T): T
wherejson
is of typeJsonNode
; - a type
T
isJsonWritable
if there is a functionrenderToJson(t: T): JsonNode
.
The module rosencrantz/core
contains the following handlers:
ok(j)
, wherej
is of typeJsonNode
, that will respond with a content type ofapplication/json
.ok(t)
, wheret
has a typeT
that isJsonWritable
, that will respond with the JSON representation oft
and a content type ofapplication/json
.jsonBody(p)
, wherep
is aproc(j: JsonNode): Handler
, that extracts the body as aJsonNode
and passes it top
, failing if the body is not valid JSON.jsonBody(p)
, wherep
is aproc(t: T): Handler
, whereT
is a type that isJsonReadable
; it extracts the body as aT
and passes it top
, failing if the body is not valid JSON or cannot be converted toT
.
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
T
isUrlDecodable
if there is functionparseFromUrl(s, T): T
wheres
is of typeStringTableRef
; - a type
T
isUrlMultiDecodable
if there is a functionparseFromUrl(s, T): T
wheres
is of typeTableRef[string, seq[string]]
.
The module rosencrantz/formsupport
defines the following handlers:
formBody(p)
wherep
is 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)
wheret
has a typeT
that 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 toT
fails.formBody(p)
wherep
is 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)
wheret
has a typeT
that 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 toT
fails.
There are similar handlers to extract the querystring from a request:
queryString(p)
, wherep
is aproc(s: string): Handler
allows to generate a handler from the raw querystring (not parsed into parameters yet)queryString(p)
, wherep
is aproc(s: StringTableRef): Handler
allows to generate a handler from the querystring parameters, parsed as a string table.queryString(t)
wheret
has a typeT
that isUrlDecodable
; works the same asformBody
.queryString(p)
, wherep
is aproc(s: TableRef[string, seq[string]]): Handler
allows to generate a handler from the querystring with repeated parameters, parsed as a table.queryString(t)
wheret
has a typeT
that 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)
, wherep
is aproc(m: MultiPart): Handler
is 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
.
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)
, wherepath
is 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)
, wherepath
is 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.
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-Origin
with the providedorigin
value.accessControlAllowAllOrigins
produces the headerAccess-Control-Allow-Origin
with 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)
, whereb
is 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)
, wheremethods
is 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. Herep
is 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.
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.