View Source nine
A data-driven routing library for elli web servers.
nine
consists of a router compiler, middleware, and utility functions. You can think of it as a micro web framework.
The goal of nine
is to allow developers to precisely compose middleware in an intuitive way.
A side effect of this is that it makes writing web servers with elli more accessible. Despite elli's
performance, it is not as widely used. nine
allows developers to not have to choose between performance and ease of use.
build
Build
rebar3 compile
demo
Demo
cd example
rebar3 compile
rebar3 shell
Navigate to localhost:3000. Please see example/README.md
for more details.
how-it-works
How It Works
nine:compile
takes a router config and compiles it into an Erlang module at runtime using forms.
The generated module will have two functions: handle/2
and handle_event/3
, in accordance with the elli_callback
behaviour.
The handle function generated has a few important details. It will wrap the elli req and create Context map that will always have these keys:
#{path => elli_request:path(Req),
method => Req#req.method,
req => Req}
If URL params are specified as part of the route then params
key will also be included with the corresponding named params in map.
So if the path was /foo/:name/:id, then the Context map will look like this:
#{params => #{name => <<"hackercat">>, id => <<"42">>},
path => ...,
method => ...,
req => ...}
Halting is implemented by wrapping each call to middleware and handlers in a case statement checking for a response
key.
If such a key exists, then response is immediately sent, instead of triggering further middleware.
A middleware chain like this:
[{nine_mid, json_request}, {todo_handler, post_todo}]
Will generate Erlang forms along the lines of:
case nine_mid:json_request(Req) of
#{response := _} = Resp ->
Resp;
Req1 ->
case todo_handler:post_todo(Req1) of
#{response := _} = Resp1 ->
Resp1;
Req2 ->
Req2
end
end
basic-usage
Basic Usage
Write a request handler:
-module(basic).
-export([index/1]).
index(_Context) ->
{200, [], <<"Hello World!">>}.
The index function returns a standard elli response.
Compile a router config:
nine:compile(example_router, #{<<"/">> => #{<<"GET">> => {basic, index}}}).
This will generate a module example_router
that provides two functions handle/2
, and handle_event/3
.
Then you can initialize your elli web server in your apps supervisor like so:
init([]) ->
SupFlags =
#{strategy => one_for_all,
intensity => 0,
period => 1},
ElliOpts = [{callback, example_router}, {port, 3000}],
ChildSpecs =
[{example_http, {elli, start_link, [ElliOpts]}, permanent, 5000, worker, [elli]}],
{ok, {SupFlags, ChildSpecs}}.
Notice how example_router
is specified as the callback.
router-config
Router Config
handler
Handler
A handler is specified as {module, function}. Example:
{basic_handlers, get_todo}
basic_handlers
being the module, and get_todo
is the function. nine
compiler will take this handler
config and generate a function call.
method-configs
Method Configs
A method config is a map with keys corresponding to HTTP methods and a value that is a handler.
#{<<"GET">> => {todo_handler, get_todo},
<<"POST">> => {todo_handler, post_todo},
<<"PUT">> => {todo_handler, put_todo},
<<"PATCH">> => {todo_handler, patch_todo},
<<"DELETE">> => {todo_handler, delete_todo},
<<"_">> => {todo_handler, any_todo}}
The example above demonstrates the set of keys possible in a method config. _ will match any method.
path-configs
Path Configs
A path config is a map with url paths or segments as keys and method configs or path configs as values.
#{<<"/">> => #{<<"GET">> => {basic_handler, index}},
<<"/foo">> => #{<<"POST">> => {basic_handler, post_foo}}}.
This Path Config will route to the paths / and /foo.
Nested Path Configs
A value in a path config can also be another path config. This example demonstrates nested path configs:
#{<<"/api">> => #{<<"/v1">> => #{<<"GET">> => {basic_handler, index}},
<<"/v2">> => #{<<"GET">> => {basic_handler2, index}}}}.
This demonstrates how one can describe multiple api versions with a nested path config. The paths for these are "/api/v1" and "/api/v2".
Due to implementation details the above config is equivalent to this:
#{<<"/api/v1">> => #{<<"GET">> => {basic_handler, index}},
<<"/api/v2">> => #{<<"GET">> => {basic_handler2, index}}}.
Despite the equivalence, we will see how nesting is key to precisely composing middleware.
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.
Path Sorting
A number of nine
's features conflict with the linear nature of Erlang's pattern matching. How do wildcards and path params not shadow other requests? More importantly, how is order guaranteed when the config is a map?
After flattening the router config, nine
sorts the routes by the aggregated path value. The sorting function works alphabetically in addition to these rules:
<<"/foo/bar">> < <<"/foo/:id">> < <<"/foo/*">> < <<"/*">>
If a path is less than another it will precede it in the list. The sorting function is designed to minimize shadowing.
The sorting function is a bit complicated so its possible edge cases are there, but for the most part, this works well. It even allows router configs like this:
#{<<"/todo/:id">> => #{<<"GET">> => {basic_handler, get_todo}},
<<"/todo/foo">> => #{<<"GET">> => {basic_handler, get_foo}}}.
This means requests at /todo/foo will always hit the handler basic_handler:get_foo
, despite the /todo/:id being a path param.
middleware
Middleware
Middleware are specified just like handlers, in fact they are the same thing! An example middleware might look like:
{nine_mid, json_response}
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 lists in the router config. Middleware chains can wrap path configs, method configs, and handlers.
The order specified in the chain determines the order of execution.
For example:
[{nine_mid, urlencoded_params}, {todo_handler, post_todo}]
Will generate a sequence of function calls where nine_mid:urlencoded_params
is called first, and the result is passed to
todo_handler:post_todo
.
Allowing post_todo
to be implemented like so:
post_todo(Context=#{params := #{<<"body">> := Body}}) ->
todo_db:insert(Body),
nine_util:redirect(Context, <<"/">>).
post_todo
can expect the params key to be filled with data because urlencoded_params
is called first.
Middleware chains can wrap method configs and path configs. Middleware chains can even wrap the entire router config!
#{<<"/api">> => [{nine_mid, json_request},
#{<<"POST">> => {todo_handler, post_todo_json},
<<"DELETE">> =>{todo_handler, delete_todo_json}}]}.
The middleware nine_mid:json_request
is specified to occur before all the handlers in the method config.
Here is an example of a middleware chain around the entire config:
[{example_mid, log_request},
#{<<"/">> => #{<<"GET">> => {example_handler, stuff}}},
{example_mid, log_response}].
Here any request will trigger log_request
at the beginning of the pipeline, and log_response
at the end of the pipeline.
We can combine middleware chaining with nested path configs:
#{<<"/api">> => [{example_mid, v1},
#{<<"/v1">> => #{<<"GET">> => {example_handler, get_thing}},
<<"/other">> => #{<<"GET">> => {example_handler, other}}}],
<<"*">> => #{<<"_">> => {nine_mid, not_found}}}.
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 response
key.
If a handler or middleware returns a Context map with the response
key it will immediately be sent without triggering further middleware.
inspirations
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
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.