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