use the problem_details crate for problem+json responses

This commit is contained in:
damocles 2026-06-22 14:11:05 +02:00 committed by mara
commit cdf1bfe7db
3 changed files with 49 additions and 42 deletions

24
Cargo.lock generated
View file

@ -1367,6 +1367,7 @@ dependencies = [
"hive-sh4re",
"libc",
"listenfd",
"problem_details",
"reqwest",
"rusqlite",
"serde",
@ -1508,6 +1509,16 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "http-serde"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd"
dependencies = [
"http",
"serde",
]
[[package]]
name = "httparse"
version = "1.10.1"
@ -2627,6 +2638,19 @@ dependencies = [
"syn",
]
[[package]]
name = "problem_details"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d50e8b46a2f32e61ae82888734e24627ea0f8c9bc7c5fc8d0c3e0eb7ed0ff5ab"
dependencies = [
"axum",
"http",
"http-serde",
"serde",
"serde_json",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"

View file

@ -24,6 +24,7 @@ tokio.workspace = true
tokio-stream.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
problem_details = { version = "0.9.0", features = ["axum"] }
[dev-dependencies]
tempfile = "3"

View file

@ -1133,19 +1133,21 @@ mod tests {
use super::*;
#[test]
fn problem_body_has_rfc9457_members() {
// about:blank type → title is the canonical status reason phrase,
// status is the numeric code, detail is the caller message.
let body = problem_body(StatusCode::BAD_REQUEST, "bad input");
assert_eq!(body["type"], "about:blank");
assert_eq!(body["title"], "Bad Request");
assert_eq!(body["status"], 400);
assert_eq!(body["detail"], "bad input");
// error_response (the 500 wrapper) carries the same shape with the
// internal-error status.
let five = problem_body(StatusCode::INTERNAL_SERVER_ERROR, "boom");
assert_eq!(five["status"], 500);
assert_eq!(five["title"], "Internal Server Error");
fn problem_details_carry_rfc9457_status_and_detail() {
// Contract the frontend depends on: the problem_details crate
// serialises the RFC 9457 members we rely on — `status` (numeric)
// and `detail` (the caller message; the FE reads `.detail`).
let pd = problem_details::ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail("bad input");
let v = serde_json::to_value(&pd).expect("problem details serialise");
assert_eq!(v["status"], 400);
assert_eq!(v["detail"], "bad input");
// The 500 wrapper path carries the internal-error status.
let five =
problem_details::ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR)
.with_detail("boom");
let fv = serde_json::to_value(&five).expect("problem details serialise");
assert_eq!(fv["status"], 500);
}
#[test]
@ -1655,36 +1657,16 @@ fn strip_container_prefix(name: &str) -> String {
.to_owned()
}
/// The RFC 9457 problem-details media type.
const PROBLEM_JSON_CONTENT_TYPE: &str = "application/problem+json";
/// Build the RFC 9457 problem-details body for `status` + `detail`. The
/// object carries the standard members: `type` ("about:blank", i.e. no
/// problem-specific type), `title` (the HTTP status reason phrase),
/// `status` (numeric code) and `detail` (the caller-supplied message).
/// Split from [`problem_response`] so the member shape is unit-testable
/// without axum response plumbing.
fn problem_body(status: StatusCode, detail: &str) -> serde_json::Value {
serde_json::json!({
"type": "about:blank",
"title": status.canonical_reason().unwrap_or("Error"),
"status": status.as_u16(),
"detail": detail,
})
}
/// Build an RFC 9457 (`application/problem+json`) error response.
/// Centralising this keeps every dashboard error on one machine-readable
/// shape the frontend can parse (read `detail` for display) instead of
/// guessing between plain text and JSON.
/// Build an RFC 9457 (`application/problem+json`) error response via the
/// `problem_details` crate. `from_status_code` sets `status` + `title`
/// (the canonical reason phrase) and leaves `type` as the default
/// `about:blank`; `with_detail` carries the caller message. The crate's
/// axum `IntoResponse` emits the body with the `application/problem+json`
/// content type. Centralising this keeps every dashboard error on one
/// machine-readable shape the frontend parses (it reads `detail`).
fn problem_response(status: StatusCode, detail: &str) -> Response {
let body = serde_json::to_string(&problem_body(status, detail))
.expect("problem+json body is always serialisable");
(
status,
[(axum::http::header::CONTENT_TYPE, PROBLEM_JSON_CONTENT_TYPE)],
body,
)
problem_details::ProblemDetails::from_status_code(status)
.with_detail(detail)
.into_response()
}