Current section

Files

Jump to
trot lib router.ex
Raw

lib/router.ex

defmodule Trot.Router do
@moduledoc """
Module for creating routes based on the URL path.
Routes are specified using one of the HTTP method macros:
`get/2 post/2 put/2 patch/2 delete/2 options/2`.
The first argument is a the path to route to, and the second argument is the
block of code to execute. See examples below.
## Module attributes
`@path_root`: URL path to prefix to all routes in the module. Defaults to "/".
`@static_root`: File path to use as the root when looking for static files.
Defaults to "priv/static".
"""
alias Plug.Conn.Status
require Logger
@doc false
defmacro __using__(_opts) do
quote do
import Trot.Router
import Plug.Builder, only: [plug: 1, plug: 2]
import Plug.Conn
@behaviour Plug
@plug_builder_opts []
Module.register_attribute(__MODULE__, :plugs, accumulate: true)
def match(conn = %Plug.Conn{state: :unset}, _opts) do
matcher = conn.req_headers |> Enum.into(%{}) |> Dict.merge(conn.assigns) |> Dict.put(:host, conn.host)
fun = do_match(conn.method, Enum.map(conn.path_info, &URI.decode/1), matcher)
fun.(conn)
end
def match(conn, _opts), do: conn
def init(opts), do: opts
def call(conn, opts) do
plug_builder_call(conn, opts)
end
@static_root Path.relative_to_cwd("priv/static")
@path_root "/"
plug Plug.Logger
plug :match
@before_compile Trot.Router
@before_compile Plug.Builder
end
end
@doc false
defmacro __before_compile__(_env) do
quote do
@doc """
Pass-through route that matches all parametes. This ensures that the plug
pipeline won't die if there are more plugs after this module.
"""
def do_match(_method, _path, _matcher) do
fn(conn) -> conn end
end
end
end
@doc """
Sets up routes from other modules my plugging into the `match/2` function
in the module.
"""
defmacro import_routes(module) do
quote do
defp external_match(conn, [module: unquote(module)]) do
unquote(module).match(conn, [])
end
plug :external_match, module: unquote(module)
end
end
@doc ~S"""
Encodes HTTP responses as appropriate and passes them to Plug.Conn.
## Examples
# Sets status code to 200 with an empty body
get "/" do
200
end
# Returns an empty body with a status code of 404
get "/bad" do
:bad_request
end
# Sets the status code to 200 with a text body
get "/text" do
"Thank you for your question."
end
# Sets the status code to 201 with a text body
get "/text/body" do
{201, "Thank you for your question."}
end
# Sets status code to 200 with a JSON-encoded body
get "/json" do
%{"hyper" => "social"}
end
# Sets the status code to 201 with a JSON-encoded body
get "/json/code" do
{201, %{"hyper" => "social"}}
end
# Set the response manually as when using Plug directly
get "/conn" do
send_resp(conn, 200, "optimal tip-to-tip efficiency")
end
# Pattern match part of the path into a variable
get "/presenter/:name" do
"The presenter is #{name}"
end
# Redirect the incoming request
get "/redirect" do
{:redirect, "/text/body"}
end
"""
def make_response(%Plug.Conn{state: :set}, _conn) do
raise ArgumentError, message: "conn must be sent before being returned"
end
def make_response(conn = %Plug.Conn{}, _conn), do: conn
def make_response({:redirect, to}, conn), do: do_redirect(to, conn)
def make_response(body, conn) when is_binary(body), do: make_response({:ok, body}, conn)
def make_response(code, conn) when is_number(code), do: make_response({code, ""}, conn)
def make_response(code, conn) when is_atom(code), do: make_response({Status.code(code), ""}, conn)
def make_response({code, body}, conn) when is_atom(code), do: make_response({Status.code(code), body}, conn)
def make_response({code, body}, conn) when is_number(code) and is_binary(body) do
Plug.Conn.send_resp(conn, code, body)
end
def make_response({code, body, headers}, conn) do
conn = headers
|> Enum.map(&format_header/1)
|> Enum.reduce(conn, fn({header, value}, conn) -> Plug.Conn.put_resp_header(conn, header, value) end)
make_response({code, body}, conn)
end
def make_response({code, body}, conn) when is_number(code) do
body = Poison.encode!(body)
make_response({code, body}, conn)
end
def make_response(body, conn) do
body = Poison.encode!(body)
make_response({:ok, body}, conn)
end
@doc """
Redirect the request to another location.
"""
def do_redirect(path, conn) when is_binary(path) do
Logger.info "Redirecting to #{path}"
URI.parse(path) |> do_redirect(conn)
end
def do_redirect(uri = %URI{}, conn) do
conn
|> Plug.Conn.put_resp_header("Location", to_string(uri))
|> Plug.Conn.send_resp(Status.code(:temporary_redirect), "")
end
defmacro get(path, options, contents \\ []), do: compile(:get, path, options, contents)
defmacro post(path, options, contents \\ []), do: compile(:post, path, options, contents)
defmacro put(path, options, contents \\ []), do: compile(:put, path, options, contents)
defmacro patch(path, options, contents \\ []), do: compile(:patch, path, options, contents)
defmacro delete(path, options, contents \\ []), do: compile(:delete, path, options, contents)
defmacro options(path, options, contents \\ []), do: compile(:options, path, options, contents)
@doc """
Redirects all incoming requests for "from" to "to". The value of "to" will be put into the Location response header.
"""
defmacro redirect(from, to) do
body = quote do: {:redirect, unquote(to)}
compile(:get, from, [], do: body)
end
@doc """
Sets up a route to static assets. All requests beginning with "at" will look for a matching file under "from".
@static_root will be prepended to "from" and defaults to "priv/static".
## Examples
static "/js", "assets/js"
@static_root "priv/assets/static"
static "/css", "css"
"""
defmacro static(at, from), do: static_plug(at, from)
@doc """
Sets up a route for static to path at the @static_root, which defaults to "priv/static".
"""
defmacro static(at), do: static_plug(at, "/")
defp static_plug(at, from) do
quote do
path = Path.join(@static_root, unquote(from))
@plugs {Plug.Static, [at: unquote(at), from: path], true}
end
end
# Entry point for both forward and match that is actually
# responsible to compile the route.
defp compile(method, expr, options, contents) do
{body, options} =
cond do
b = contents[:do] ->
{b, options}
options[:do] ->
Keyword.pop(options, :do)
true ->
raise ArgumentError, message: "expected :do to be given as option"
end
options = sanitize_options(options)
quote bind_quoted: [method: method,
options: options,
expr: expr,
body: Macro.escape(body, unquote: true)] do
path = Path.join(@path_root, expr)
{path, guards} = Trot.Router.extract_path_and_guards(path)
{method, match, _host, guards} = Plug.Router.__route__(method, path, guards, options)
match_opts = %{}
|> Trot.Versioning.build_version_match(options[:version])
|> Trot.Router.build_headers_match(options[:headers])
|> Trot.Router.build_host_match(options[:host])
|> Macro.escape
def do_match(unquote(method), unquote(match), unquote(match_opts)) when unquote(guards) do
fn var!(conn) -> unquote(body) |> Trot.Router.make_response(var!(conn)) end
end
end
end
defp sanitize_options(options), do: Enum.map(options, &default_keyword/1)
defp default_keyword(item = {_key, _value}), do: item
defp default_keyword(key) when is_atom(key), do: {key, true}
@doc ~S"""
Builds the pattern that will be used to match against the request's host.
## Examples
iex> Trot.Router.build_host_match(nil)
%{}
iex> Trot.Router.build_host_match("foo.com")
%{host: "foo.com"}
"""
def build_host_match(host), do: build_host_match(%{}, host)
def build_host_match(matcher, nil), do: matcher
def build_host_match(matcher, host), do: Dict.put(matcher, :host, host)
@doc ~S"""
Extracts the request headers to be used in route matches. All headers are sanitized
to lower case strings.
## Examples
iex> Trot.Router.build_headers_match(nil)
%{}
iex> Trot.Router.build_headers_match(["X-Tasty-Header": "bacon"])
%{"x-tasty-header" => "bacon"}
"""
def build_headers_match(headers), do: build_headers_match(%{}, headers)
def build_headers_match(matcher, nil), do: matcher
def build_headers_match(matcher, headers) do
headers
|> Enum.map(fn({header, value}) -> {format_header_name(header), value} end)
|> Enum.into(%{})
|> Dict.merge(matcher)
end
defp format_header_name(name) do
name
|> to_string
|> String.downcase
end
@doc """
Extract the path and guards from the path.
"""
def extract_path_and_guards({:when, _, [path, guards]}), do: {extract_path(path), guards}
def extract_path_and_guards(path), do: {extract_path(path), true}
defp extract_path({:_, _, var}) when is_atom(var), do: "/*_path"
defp extract_path(path), do: path
defp format_header({header, value}) do
name = header |> to_string |> String.downcase
{name, value}
end
end