Bump version
Throw exception if key is not atom or binary
Throw error if missing handle key
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.
rebar3 compile
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.
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.
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}.
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
.
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.
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 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.
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.
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.
nine
was inspired by other composable middleware tools.
nine
comes from "nine nines".