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).
85 lines
2.9 KiB
Elixir
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
|