View Source nine

A library providing a data driven router compiler.

The goal of nine is to allow developers to compose middleware and handlers. This should significantly decrease boiler plate and give a web development experience competitive with other languages and ecosystems.

Build

rebar3 compile

Usage

nine functions as a router compiler library. Meaning you give it a router config and it compiles a router module. nine does not have it's own web server, it is meant to be paired with a backend.

This is a list of implemented backends:

nine provides one single function to be used: compile. Compile can be given one map argument or 3 arguments.

nine:compile(#{routes => Routes, router => Router, generator => Generator})

OR

nine:compile(Routes, Router, Generator).

The generator is provided by the backend, and is a module required to have at least one function generate/2. The name of the Router module and the normalized routes after nine compiles the routes config given.

nine:compile takes a router config and compiles it into an Erlang module at runtime using forms.

Routes

A route is a map with these keys:

#{<<"path">> => ...,
  <<"method">> => ...,
  <<"pre">> => ...,
  <<"post">> => ...,
  <<"handle">> => ...}

Using atoms for the keys is an option as well:

#{path => ...,
  method => ...,
  pre => ...,
  post => ...,
  handle => ...}

path: The URL path for the request to be handled. Example: <<"/todo">>. method: The request method to be handled. Example: <<"GET">>. pre: The middleware to be called before the request handler. Example: [{todo_mid, json_request}] post: The middleware to be called after the request handler. Example: [{todo_mid, json_response}] handle: The request handler function.

Because of how some of nine's defaults work this is the most minimal config possible:

#{handle => {index_handler, index}}

This config will route requests with the root path ('/') with any method type to the function index_handler:index/1. The handle key is the only required key in a route config.

Use a list to compose multiple requests. The order in the list is respected as the order that the requests will be matched.

A full config for a todo application would look like this:

[#{<<"path">> => <<"/">> =>
   <<"method">> => <<"GET">>,
   <<"handle">> => {todo_handler, index}},
 #{<<"path">> => <<"/api">>,
   <<"post">> => [{todo_mid, json_response}],
   <<"handle">> => [
     #{<<"path">> => <<"/todo">>,
       <<"pre">> => [{todo_mid, json_request}],
       <<"handle">> => [
         #{<<"method">> => <<"POST">>,
           <<"handle">> => {todo_handler, post_todo}},
         #{<<"method">> => <<"DELETE">>,
           <<"handle">> => {todo_handler, delete_todo}}
       ],
     #{<<"path">> => <<"/todos">>,
       <<"method">> => <<"GET">>,
       <<"handle">> => {todo_handler, get_todos}}
   ]
 },
 #{<<"path">> => <<"*">>, 
   <<"handle">> => {todo_handler, not_found}}
]

There is a lot going on here with this config which we will break down in the next sections.

Handler

A handler is specified as {module, function}. Example:

{todo_handler, get_todos}

todo_handler being the module, and get_todos is the function.

The handler function is expected to look like this:

get_todos(Context) ->
    Resp = cowboy_req:reply(
      200,
      #{<<"Content-Type">> => <<"application/json">>},
      thoas:encode(#{hello => <<"world">>})
    ),
    Context#{resp => Resp}.

Nested Handlers

It is possible to compose complex router configs by nesting handlers. The handle key can either take a tuple or a list of route configs.

For example an api handler can branch off with multiple other request paths.

[{<<"path">> => <<"/api">>,
  <<"handle">> => [
    #{<<"path">> => <<"todo">>,
      <<"method">> => <<"POST">>,
      <<"handle">> => {todo_handler, post_todo}},
    #{<<"path">> => <<"todos">>,
      <<"method">> => <<"GET">>,
      <<"handle">> => {todo_handler, get_todos}}
]}]

This means when a POST request goes to /api/todo the function todo_handler:post_todo/1 will be called. While a request going to /api/todos will trigger the function todo_handler:get_todos/1.

URL Path Params

nine builds in a way to have named parameters in the URL.

A path like /todo/:id will result in the context map including the params key. The value of the params will be #{id => <<"id1">>}. In case you are worried about atoms coming from user data, it is okay for id to be an atom because it is a static value set at compile time.

nine also supports partial path params like /person/num:ber will result in a params map looking like #{ber => <<"2">>} for example. This is similar to how Phoenix works with routing.

Wildcard

nine supports catch all routes with * in the path. For example: <<"/*">> will match any route.

We can also put a wildcard at the suffix of a path: <<"/foo/*">> will match a route like <<"/foo/bar">>. Wildcards can only be at the end of a path. This is similar to how Phoenix works with catch all routes. The reason for this is the routing uses Erlang pattern matching, so it must follow the same rules.

Middleware

Middleware are specified just like handlers, in fact they are the same thing! An example middleware might look like:

{nine_cowboy_mid, json_request}

Middleware are functions that take a Context as input and output a Context or an elli response. One could write a logging middleware like this:

logging_middleware(Context) ->
    logger:debug(#{context => Context}),
    Context.

Or we could make a middleware that adds some data to the Context:

message_middleware(Context) ->
    Context#{message => <<"Hello, World!">>}.

Middleware are helpful in all sorts of situations and allow developers to write web apps in a DRY way.

Middleware Chains

nine specifies middleware chaining with the pre and post keys. Nesting routes will also concatenate the pre and post keys in the expected order.

For example:

#{<<"pre">> => [{todo_mid, json_request}],
  <<"handle">> => {todo_handler, post_todo}}

Will generate a sequence of function calls where todo_mid:json_request is called first, and the result is passed to todo_handler:post_todo.

Allowing post_todo to be implemented like so:

post_todo(Context=#{json := #{<<"body">> := Body}}) ->
    todo_db:insert(Body),
    nine_cowboy_util:redirect(Context, <<"/">>).

post_todo can expect the json key to be filled with data because json_request is called first.

Halting

There are situations where we want to return a response immediately without finishing the middleware chain. This is known as halting.

nine makes this possible because each middleware and handler call is wrapped in a case statement checking for the resp key.

If a handler or middleware returns a Context map with the resp key it will immediately be sent without triggering further middleware.

Inspirations

nine was inspired by other composable middleware tools.

  • ring - Standard Clojure HTTP abstraction for web servers
  • ataraxy - Data driven routing library for Clojure
  • Plug.Router - Ecosystem defining Elixir HTTP middleware
  • golang http middleware - Standard Library Golang Middleware Pattern
  • Cowboy Router - Cowboy router is compiled into a lookup table

Fun Facts

  • The name nine comes from "nine nines".
  • Middleware was originally intended to look like Ring's, but wasn't compatible with Erlang's pattern matching lookups.