~fancycade/nine

Generic data driven routing compiler for Erlang web servers
7a293b66 — Harley Swick a month ago
Bump version
9d002cbe — Harley Swick a month ago
Throw exception if key is not atom or binary
5a02d828 — Harley Swick a month ago
Throw error if missing handle key

clone

read-only
https://git.sr.ht/~fancycade/nine
read/write
git@git.sr.ht:~fancycade/nine

You can also use your local clone with git send-email.

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