defmodule Nex.Handler.Errors do
@moduledoc false
import Plug.Conn
def send_json_error(conn, status, message) do
conn
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(%{error: message}))
end
def send_error_page(conn, status, message, error) do
is_htmx = get_req_header(conn, "hx-request") != []
is_json =
match?(["api" | _], conn.path_info) or
get_req_header(conn, "accept") |> Enum.any?(&String.contains?(&1, "application/json"))
cond do
is_json ->
send_json_error(conn, status, message)
is_htmx ->
html = """
Error #{status}: #{html_escape(message)}
"""
conn
|> put_resp_content_type("text/html")
|> send_resp(status, html)
true ->
html = build_error_page(conn, status, message, error)
conn
|> put_resp_content_type("text/html")
|> send_resp(status, html)
end
end
defp build_error_page(conn, status, message, error) do
case resolve_convention_error_module(status) do
{:ok, module} ->
try do
render_convention_error(module, status, message)
rescue
_ -> fallback_error_page(conn, status, message, error)
end
:none ->
fallback_error_page(conn, status, message, error)
end
end
defp resolve_convention_error_module(status) do
app_module = Nex.Config.app_module()
module_name = "#{app_module}.Pages.Error#{status}"
case Nex.Utils.safe_to_existing_module(module_name) do
{:ok, module} -> {:ok, module}
:error -> :none
end
end
defp render_convention_error(module, status, message) do
assigns = %{
status: status,
message: message,
title: "#{status} — #{message}"
}
if function_exported?(module, :render, 1) do
assigns
|> module.render()
|> to_html_binary()
|> wrap_with_error_shell(module, assigns)
else
raise "Convention error module missing render/1"
end
end
defp wrap_with_error_shell(content_html, module, assigns) do
content_html
|> wrap_with_app(module, assigns)
|> wrap_with_document(assigns)
end
defp wrap_with_app(content_html, error_module, assigns) do
case resolve_app_module(error_module) do
nil ->
content_html
layout_module ->
layout_assigns =
assigns
|> Map.put(:inner_content, content_html)
|> Map.put_new(:title, "Nex App")
layout_module.render(layout_assigns) |> to_html_binary()
end
end
defp wrap_with_document(inner_html, assigns) do
case resolve_document_module() do
nil ->
default_document(inner_html, Map.get(assigns, :title, "Nex App"))
document_module ->
document_assigns = %{
inner_content: inner_html,
title: Map.get(assigns, :title, "Nex App")
}
document_module.render(document_assigns) |> to_html_binary()
end
end
defp resolve_app_module(error_module) do
case layout_override(error_module) do
:none -> nil
nil -> resolve_default_app_module()
layout_module -> layout_module
end
end
defp layout_override(error_module) do
if function_exported?(error_module, :layout, 0) do
case error_module.layout() do
:none -> :none
module when is_atom(module) -> module
_ -> nil
end
else
nil
end
end
defp resolve_default_app_module do
app_module = Nex.Config.app_module()
with :error <- Nex.Utils.safe_to_existing_module("#{app_module}.Pages.App"),
:error <- Nex.Utils.safe_to_existing_module("#{app_module}.Layouts") do
nil
else
{:ok, module} -> module
end
end
defp resolve_document_module do
app_module = Nex.Config.app_module()
case Nex.Utils.safe_to_existing_module("#{app_module}.Pages.Document") do
{:ok, module} -> module
:error -> nil
end
end
defp default_document(inner_html, title) do
"""
#{title}
#{inner_html}
"""
end
defp to_html_binary(html) when is_binary(html), do: html
defp to_html_binary(html), do: Phoenix.HTML.Safe.to_iodata(html) |> IO.iodata_to_binary()
defp fallback_error_page(conn, status, message, error) do
case Application.get_env(:nex_core, :error_page_module) do
nil ->
build_default_error_page(conn, status, message, error)
module ->
try do
apply(module, :render_error, [conn, status, message, error])
rescue
_ -> build_default_error_page(conn, status, message, error)
end
end
end
defp build_default_error_page(conn, status, message, error) do
is_dev = dev_env?()
{exception_section, stacktrace_section, request_section} =
if is_dev && error != nil do
{build_exception_section(error), build_stacktrace_section(), build_request_section(conn)}
else
{"", "", ""}
end
"""
#{status} — #{html_escape(message)}
#{exception_section}
#{stacktrace_section}
#{request_section}
← Back to Home
"""
end
defp build_exception_section(error) do
{type, msg} =
case error do
%{__struct__: mod, message: m} -> {inspect(mod), m}
%{__struct__: mod} -> {inspect(mod), inspect(error)}
_ -> {"RuntimeError", inspect(error, pretty: true)}
end
"""
Exception
#{html_escape(type)}
#{html_escape(msg)}
"""
end
defp build_stacktrace_section do
stacktrace = Process.get(:nex_last_stacktrace, [])
if stacktrace == [] do
""
else
frames =
stacktrace
|> Enum.take(15)
|> Enum.map_join("", fn
{mod, fun, arity, info} ->
file = Keyword.get(info, :file, "unknown") |> to_string()
line = Keyword.get(info, :line, 0)
mod_str = inspect(mod)
fun_str = "#{fun}/#{arity}"
is_app =
not String.starts_with?(mod_str, "Elixir.Phoenix") and
not String.starts_with?(mod_str, "Elixir.Plug") and
not String.starts_with?(mod_str, "Elixir.Bandit") and
not String.starts_with?(mod_str, "Elixir.Nex.")
frame_class = if is_app, do: "frame frame-app", else: "frame"
"""
#{html_escape(file)}
:#{line}
#{html_escape(mod_str)}.#{html_escape(fun_str)}
"""
entry ->
"#{html_escape(inspect(entry))}
"
end)
"""
Stacktrace (app frames highlighted)
#{frames}
"""
end
end
defp build_request_section(conn) do
params_str =
case conn.params do
%Plug.Conn.Unfetched{} -> "(unfetched)"
params -> inspect(params, pretty: true, limit: 10)
end
rows = [
{"Method", conn.method},
{"Path", conn.request_path},
{"Params", params_str},
{"Host", conn.host}
]
rows_html =
Enum.map_join(rows, "", fn {key, value} ->
"""
#{key}
#{html_escape(to_string(value))}
"""
end)
"""
"""
end
defp dev_env?, do: Nex.Config.dev?()
defp html_escape(text) when is_binary(text) do
Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string()
end
defp html_escape(text) do
text |> to_string() |> html_escape()
end
end