rosencrantz

A web DSL for Nim


Keywords
web, server, DSL, combinators
License
MIT
Install
nimble install rosencrantz

Documentation

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.

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 complete function or one derived from it.

Rosencrantz provides many of those handlers, which are described below.

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 (read h1 and h2) returns a handler that passes the request through h1 to update the context; then, if h1 does not reject the request, it passes it, together with the new context, to h2. Think filtering first by HTTP method, then by path.
  • h1 ~ h2 (read h1 or h2) returns a handler that passes the request through h1; if it rejects the request, it tries again with h2. 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.

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)

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")
    )
  ]
]

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.

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. Here code is an instance of HttpCode from asynchttpserver, body is a string and headers are an instance of StringTableRef.
  • ok(body), which is a specialization of complete for a response of 200 Ok with a content type of text/plain.
  • notFound(body), which is a specialization of complete for a response of 404 Not Found with a content type of text/plain.
  • body(p) extracts the body of the request. Here p is a proc(s: string): Handler which takes the extracted body as input and returns a handler.
  • logging(handler) takes a handler and returns the same handler, but logs some information when a request passes through.

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 to s.
  • pathChunk(s) does the same but only for a prefix of the path. This means that one can nest more path handlers after it, unlike path, that matches and consumes the whole path.
  • pathEnd(p) extracts whatever is not matched yet of the path and passes it to p. Here p is a proc(s: string): Handler that takes the final part of the path and returns a handler.
  • segment(p), that extracts a segment of path among two / signs. Here p is a proc(s: string): Handler that takes the matched segment and return a handler. This fails if the position is not just before a / sign.
  • intSegment(p), works the same as segment, but extracts and parses an integer number. It fails if the segment does not represent an integer. Here p is a proc(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), where m is a member of the HttpMethod enum defined in rosencrantz/core. There are corresponding specializations
  • get, post, put, delete, head, patch, options, trace and connect

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
  ]
)

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 the Content-Type header, so is is equivalent to headers(("Content-Type", s)).
  • readAllHeaders(p) extract the headers as a string table. Here p is a proc(hs: StringTableRef): Handler.
  • readHeaders(s1, p) extracts the value of the header with key s1 and passes it to p, which is of type proc(h1: string): Handler. It rejects the request if the header s1 is not defined. There are overloads readHeaders(s1, s2, p) and readHeaders(s1, s2, s3, p), where p is a function of two arguments (resp. three arguments). To extract more than three headers, one can use readAllHeaders or nest readHeaders calls.
  • tryReadHeaders(s1, p) works the same as readHeaders, but it does not reject the request if header s 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. Here h1 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 to checkHeaders(("Accept", mimetype)).
  • addDate() returns a handler that adds the Date 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)
  ]
]

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), where p is a proc(req: ref Request): Handler. This allows you to access the whole Request 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.
  • 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 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 T is JsonReadable if there is function readFromJson(json, T): T where json is of type JsonNode;
  • a type T is JsonWritable if there is a function renderToJson(t: T): JsonNode.

The module rosencrantz/core contains the following handlers:

  • ok(j), where j is of type JsonNode, that will respond with a content type of application/json.
  • ok(t), where t has a type T that is JsonWritable, that will respond with the JSON representation of t and a content type of application/json.
  • jsonBody(p), where p is a proc(j: JsonNode): Handler, that extracts the body as a JsonNode and passes it to p, failing if the body is not valid JSON.
  • jsonBody(p), where p is a proc(t: T): Handler, where T is a type that is JsonReadable; it extracts the body as a T and passes it to p, failing if the body is not valid JSON or cannot be converted to T.

Form and querystring support

Rosencrantz has support to read the body of a form, either of type application/x-www-form-urlencoded or multipart (to be done). It also supports parsing the querystring as application/x-www-form-urlencoded.

The rosencrantz/formsupport module defines two typeclasses:

  • a type T is UrlDecodable if there is function parseFromUrl(s, T): T where s is of type StringTableRef;
  • a type T is UrlMultiDecodable if there is a function parseFromUrl(s, T): T where s is of type TableRef[string, seq[string]].

The module rosencrantz/formsupport defines the following handlers:

  • formBody(p) where p is a proc(s: StringTableRef): Handler. It will parse the body as an URL-encoded form and pass the corresponding string table to p, rejecting the request if the body is not parseable.
  • formBody(t) where t has a type T that is UrlDecodable. It will parse the body as an URL-encoded form, convert it to T, and pass the resulting object to p. It will reject a request if the body is not parseable or if the conversion to T fails.
  • formBody(p) where p is a proc(s: TableRef[string, seq[string]]): Handler. It will parse the body as an URL-encoded form, accumulating repeated parameters into sequences, and pass table to p, rejecting the request if the body is not parseable.
  • formBody(t) where t has a type T that is UrlMultiDecodable. It will parse the body as an URL-encoded with repeated parameters form, convert it to T, and pass the resulting object to p. It will reject a request if the body is not parseable or if the conversion to T fails.

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

  • queryString(p), where p is a proc(s: string): Handler allows to generate a handler from the raw querystring (not parsed into parameters yet)
  • queryString(p), where p is a proc(s: StringTableRef): Handler allows to generate a handler from the querystring parameters, parsed as a string table.
  • queryString(t) where t has a type T that is UrlDecodable; works the same as formBody.
  • queryString(p), where p is a proc(s: TableRef[string, seq[string]]): Handler allows to generate a handler from the querystring with repeated parameters, parsed as a table.
  • queryString(t) where t has a type T that is UrlMultiDecodable; works the same as formBody.

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), where path 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), where path 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 to path, 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.

CORS support

To be done.

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.