defmodule Francis do
@moduledoc """
Module responsible for starting the Francis server and to wrap the Plug functionality
This module performs multiple tasks:
* Uses the Application module to start the Francis server
* Defines the Francis.Router which uses Francis.Plug.Router, :match and :dispatch
* Defines the macros get, post, put, delete, patch and ws to define routes for each operation
* Setups Plug.Static with the given options
* Sets up Plug.Parsers with the default configuration of:
* ```elixir
plug(Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
json_decoder: Jason
)
```
* Defines a default error handler that returns a 500 status code and a generic error message. You can override this by passing the function name on `:error_handler` option to the `use Francis` macro which will override the default error handler.
You can also set the following options:
* :bandit_opts - Options to be passed to Bandit
* :static - Configure Plug.Static to serve static files
* :parser - Overrides the default configuration for Plug.Parsers
* :error_handler - Defines a custom error handler for the server
* :log_level - Sets the log level for Plug.Logger (default is `:info`)
"""
require Logger
import Plug.Conn
defmacro __using__(opts \\ []) do
quote location: :keep do
use Application
use Plug.ErrorHandler
use Francis.Plug.Router
require Logger
import Francis.ResponseHandlers
def start, do: start(:normal, [])
static = get_configuration(:static, unquote(opts), from: "priv/static", at: "/")
parser =
get_configuration(:parser, unquote(opts),
parsers: [:urlencoded, :multipart, :json],
json_decoder: Jason
)
log_level = get_configuration(:log_level, unquote(opts), :info)
if static, do: plug(Plug.Static, static)
plug(Plug.Parsers, parser)
plug(Plug.Logger, log: log_level)
plug(Plug.Head)
def start(_type, _args) do
dev = Application.get_env(:francis, :dev, false)
watcher_spec = if dev, do: [{Francis.Watcher, []}], else: []
children =
[
{Bandit, [plug: __MODULE__] ++ Keyword.get(unquote(opts), :bandit_opts, [])}
] ++ watcher_spec
Supervisor.start_link(children, strategy: :one_for_one)
end
defoverridable(start: 2)
def child_spec(opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start, opts},
type: :supervisor,
restart: :permanent,
shutdown: 5000,
modules: [__MODULE__]
}
end
@spec handle_response(
(Plug.Conn.t() -> binary() | map() | Plug.Conn.t()),
Plug.Conn.t(),
integer()
) :: Plug.Conn.t()
def handle_response(handler, conn, status \\ 200) do
case handler.(conn) do
res when is_struct(res, Plug.Conn) ->
res
res when is_binary(res) ->
conn
|> send_resp(status, res)
|> halt()
res when is_map(res) or is_list(res) ->
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(res))
|> halt()
{:error, res} ->
handle_errors(conn, {:error, res})
end
rescue
e -> handle_errors(conn, e)
end
@spec handle_errors(Plug.Conn.t(), any()) :: Plug.Conn.t()
@impl true
def handle_errors(conn, reason) do
error_handler = Keyword.get(unquote(opts), :error_handler)
case error_handler do
nil ->
Logger.error("Unhandled error: #{inspect(reason)}")
internal_server_error(conn)
handler ->
handler.(conn, reason)
end
rescue
e ->
Logger.error("Unhandled error: #{inspect(e)}")
internal_server_error(conn)
end
defp internal_server_error(conn) do
conn |> put_status(500) |> send_resp(500, "Internal Server Error") |> halt()
end
end
end
@http_methods [:get, :post, :put, :delete, :patch]
for method <- @http_methods do
@doc """
Defines a #{String.upcase(to_string(method))} route
## Examples
```elixir
defmodule Example.Router do
use Francis
#{method} "/hello", fn conn ->
"Hello World!"
end
end
```
"""
@spec unquote(method)(String.t(), (Plug.Conn.t() -> binary() | map() | Plug.Conn.t())) ::
Macro.t()
defmacro unquote(method)(path, handler) do
method = unquote(method)
quote location: :keep do
Plug.Router.unquote(method)(
unquote(path),
do: handle_response(unquote(handler), var!(conn))
)
end
end
end
@doc """
Defines a WebSocket route with a unified event handler.
The handler function uses pattern matching on events, providing an idiomatic Elixir approach.
All events flow through a single function with distinct shapes for easy pattern matching.
## Events
The handler receives different event types that can be pattern matched:
- `:join` - Sent when a client connects. Return `{:reply, message}` to send a welcome message.
- `{:close, reason}` - Sent when the connection closes. Return `:ok` or `:noreply`.
- `{:received, message}` - Regular WebSocket text messages from the client.
Messages sent via `send(socket.transport, message)` are automatically forwarded to the client.
## Return Values
- `{:reply, response}` - where `response` can be a binary, a map, or a list (maps/lists will be JSON encoded)
- `:noreply` or `:ok` - to not send a response
## Socket State
The socket state map includes:
- `:transport` - The transport process that can be used to send messages back to the client using `send/2`
- `:id` - A unique identifier for the WebSocket connection that can be used to track the connection
- `:path` - The actual request path of the WebSocket connection (e.g., `/chat/general`)
- `:params` - A map of path parameters extracted from the route (e.g., `%{"room" => "general"}` for route `/:room`)
## Options
- `:timeout` - The timeout for the WebSocket connection in milliseconds (default: 60_000)
- `:heartbeat_interval` - The interval in milliseconds between ping frames for heartbeat (default: 30_000). Set to `nil` to disable heartbeat.
## Examples
```elixir
defmodule Example.Router do
use Francis
# Simple echo server
ws "/echo", fn {:received, message}, socket ->
{:reply, message}
end
# Pattern matching on specific messages
ws "/ping", fn {:received, "ping"}, socket ->
{:reply, "pong"}
end
# Full lifecycle handling with pattern matching
ws "/chat/:room", fn
:join, socket ->
room = socket.params["room"]
{:reply, %{type: "welcome", room: room, id: socket.id}}
{:close, reason}, socket ->
Logger.info("Client \#{socket.id} left: \#{inspect(reason)}")
:ok
{:received, message}, socket ->
room = socket.params["room"]
# Broadcast to self (will be forwarded to client)
send(socket.transport, "Someone said: " <> message)
{:reply, "[" <> room <> "] " <> message}
end
# JSON responses
ws "/json", fn {:received, message}, socket ->
{:reply, %{status: "ok", message: message}}
end
# No reply needed
ws "/fire-and-forget", fn {:received, message}, socket ->
Logger.info("Received: \#{message}")
:noreply
end
# Custom heartbeat interval (ping every 10 seconds)
ws "/heartbeat", fn {:received, message}, socket ->
{:reply, message}
end, heartbeat_interval: 10_000
# Disable heartbeat
ws "/no-heartbeat", fn {:received, message}, socket ->
{:reply, message}
end, heartbeat_interval: nil
end
```
"""
@spec ws(
String.t(),
(event :: :join | {:close, term()} | {:received, binary()},
socket :: %{id: binary(), transport: pid(), path: binary(), params: map()} ->
{:reply, binary() | map() | {atom(), any()}} | :noreply | :ok),
Keyword.t()
) :: Macro.t()
defmacro ws(path, handler, opts \\ []) do
module_name = generate_ws_module_name(path)
handler_ast = build_ws_handler_ast(module_name, handler)
Code.compile_quoted(handler_ast)
quote location: :keep do
get(unquote(path), fn conn ->
socket_state = %{
id: 32 |> :crypto.strong_rand_bytes() |> Base.encode16(),
path: conn.request_path,
params: conn.params
}
heartbeat_interval = Keyword.get(unquote(opts), :heartbeat_interval, 30_000)
conn
|> var!()
|> WebSockAdapter.upgrade(
unquote(module_name),
Map.put(socket_state, :heartbeat_interval, heartbeat_interval),
timeout: Keyword.get(unquote(opts), :timeout, 60_000)
)
|> halt()
end)
end
end
# Private helper functions for WebSocket macro
defp generate_ws_module_name(path) do
path
|> URI.parse()
|> Map.get(:path)
|> String.split("/")
|> Enum.map_join(".", &Macro.camelize/1)
|> then(&Module.concat([__MODULE__, &1]))
end
defp build_ws_handler_ast(module_name, handler) do
quote do
defmodule unquote(module_name) do
require Logger
def init(opts) do
state =
opts
|> Map.put(:transport, self())
|> Francis.Websocket.setup_heartbeat()
send(self(), :__francis_join__)
{:ok, state}
end
def handle_control({_payload, [opcode: :ping]}, state), do: {:ok, state}
def handle_control({_payload, [opcode: :pong]}, state), do: {:ok, state}
def handle_in({message, _opts}, state) do
unquote(handler).({:received, message}, state)
|> Francis.Websocket.format_response(state)
rescue
e ->
Logger.error("WS Handler error: #{inspect(e)}")
{:stop, :error, state}
end
def handle_info(:__francis_join__, state),
do: Francis.Websocket.call_join(unquote(handler), state)
def handle_info(:__francis_heartbeat__, state),
do: Francis.Websocket.handle_heartbeat(state)
def handle_info(msg, state), do: Francis.Websocket.format_response({:reply, msg}, state)
def terminate(reason, state) do
Francis.Websocket.cancel_heartbeat(state)
Francis.Websocket.call_close(unquote(handler), {:close, reason}, state)
:ok
end
end
end
end
@doc """
Defines an action for umatched routes and returns 404
"""
@spec unmatched((Plug.Conn.t() -> binary() | map() | Plug.Conn.t())) :: Macro.t()
defmacro unmatched(handler) do
quote location: :keep do
match _ do
handle_response(unquote(handler), var!(conn), 404)
end
end
end
@doc """
Retrieves the configuration for a given key, checking both the macro options and the application environment.
"""
@spec get_configuration(atom(), Keyword.t(), any()) :: any()
def get_configuration(key, opts, default) do
opts = Keyword.get(opts, key)
config = Application.get_env(:francis, key)
if opts && config do
Logger.warning(
"Both application configuration and macro option provided for #{key}. Using macro option."
)
opts
else
opts || config || default
end
end
end