prop/lib/microwaveprop_web/api/error_json.ex
Graham McIntire c6d2c48264
feat: secure /api/v1 REST API for regular-user actions
Adds bearer-token authenticated REST API at /api/v1 covering every
action a non-admin user can perform on the website: contact + beacon
submission, beacon-monitor management, propagation queries, profile
read/update, and self-service API token issuance/revocation.

Security: SHA-256-hashed bearer tokens (mwp_ prefix, plaintext shown
once at creation), RFC 9457 problem+json error responses, RFC 9651
RateLimit-* headers backed by an ETS bucket (600/min per token,
60/min per anonymous IP, 30/min on /auth/tokens), private-contact
filtering by viewer.

Docs at docs/api/README.md (prose reference) and docs/api/openapi.yaml
(OpenAPI 3.1 spec covering every endpoint, response, and schema).

Tests: 124 new tests across schema, plug, error renderer, rate
limiter, fallback, and every controller. 16/17 API modules at 100%
line coverage; FallbackController at 87.5% (one defmodule line, an
Erlang-cover artifact for action_fallback-only modules).
2026-05-09 08:59:54 -05:00

85 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
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