Rosencrantz

shakespeare

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:

A handler usually does one or more of the following:

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:

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 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.

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:

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:

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

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(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:

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.

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.

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:

The module rosencrantz/core contains the following handlers:

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:

The module rosencrantz/formsupport defines the following handlers:

There are similar handlers to extract the querystring from a request:

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:

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:

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:

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.