prop/lib/microwaveprop_web/api/error_json.ex
Graham McIntire cb8445f329
fix: resolve 158/183 credo --strict warnings
- Add 17 missing @spec annotations (layouts, error_json, error_html, skewt_svg)
- Move 12+ nested alias/import/require to module top level
- Add phx-change/id attributes to 11 raw HTML <form> tags
- Remove 4 unused LiveView assigns (:bounds, :data_provider)
- Add 3 missing doctest references (HrrrNativeClient, BulkFetch, Accounts)
- Break 2 long lines (path_compute.ex:382)
- Strengthen weak test assertions (is_binary→byte_size, is_list→!=[])
- Replace Module.concat with Module.safe_concat (2 occurrences)
- Replace length/1 > 0 with list != [] (9 occurrences)
- Remove no-op assert true, fix no-assertion tests

Remaining: 24 socket.assigns introspection warnings (deliberate test
pattern for observable behavior testing), 1 formatter-resistant long
line, 3 app-code usage warnings.
2026-06-12 15:47:15 -05:00

86 lines
2.9 KiB
Elixir

defmodule MicrowavepropWeb.Api.ErrorJSON do
@moduledoc """
RFC 9457 problem+json error responses for `/api/v1`.
All error bodies share the shape:
{
"type": "about:blank",
"title": "<short slug>",
"status": <int>,
"detail": "<human-readable explanation>",
"errors": <optional changeset error map>
}
"""
import Plug.Conn
alias Ecto.Changeset
alias Plug.Conn.Status
@content_type "application/problem+json"
@doc "Halts the connection with a problem+json body."
@spec send_problem(Plug.Conn.t(), pos_integer(), String.t(), String.t(), map()) :: Plug.Conn.t()
def send_problem(conn, status, title, detail, extra \\ %{}) do
body =
%{
type: "about:blank",
title: title,
status: status,
detail: detail
}
|> Map.merge(extra)
|> Jason.encode!()
conn
|> put_resp_content_type(@content_type)
|> send_resp(status, body)
|> halt()
end
@doc "Halts with a 422 problem+json including a flattened changeset error map."
@spec send_changeset(Plug.Conn.t(), Changeset.t()) :: Plug.Conn.t()
def send_changeset(conn, %Changeset{} = changeset) do
send_problem(
conn,
422,
"validation_failed",
"One or more fields are invalid.",
%{errors: translate_errors(changeset)}
)
end
@doc "Translates a changeset's errors into a flat map of field -> [messages]."
@spec translate_errors(Changeset.t()) :: map()
def translate_errors(%Changeset{} = changeset) do
Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
end
## Default Phoenix error renderer hooks ----------------------------
@doc false
@spec render(String.t(), map()) :: map()
def render("400.json", _assigns), do: error_body(400, "bad_request", "Malformed request.")
def render("401.json", _assigns), do: error_body(401, "unauthorized", "Authentication is required.")
def render("403.json", _assigns), do: error_body(403, "forbidden", "You may not access this resource.")
def render("404.json", _assigns), do: error_body(404, "not_found", "Resource not found.")
def render("405.json", _assigns), do: error_body(405, "method_not_allowed", "HTTP method not allowed.")
def render("415.json", _assigns), do: error_body(415, "unsupported_media_type", "Unsupported media type.")
def render("429.json", _assigns), do: error_body(429, "too_many_requests", "Rate limit exceeded.")
def render("500.json", _assigns), do: error_body(500, "internal_server_error", "Something went wrong.")
def render(template, _assigns) do
[code | _] = String.split(template, ".")
status = String.to_integer(code)
error_body(status, status |> Status.reason_atom() |> Atom.to_string(), "")
end
defp error_body(status, title, detail) do
%{type: "about:blank", title: title, status: status, detail: detail}
end
end