diff --git a/Cargo.lock b/Cargo.lock index d483ac59..228e132f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/hive-c0re/Cargo.toml b/hive-c0re/Cargo.toml index f57c3a4d..f97ef7b0 100644 --- a/hive-c0re/Cargo.toml +++ b/hive-c0re/Cargo.toml @@ -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" diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 4b4d7507..944ab5de 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -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() }