diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 121e6be0..b19855cd 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -1131,6 +1131,23 @@ struct RequestSpawnForm { #[cfg(test)] 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"); + } + #[test] fn walk_meta_inputs_keeps_nixpkgs_under_hyperhive_post_follows_refactor() { // Reproduce the shape where meta has @@ -1638,8 +1655,44 @@ 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. +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, + ) + .into_response() +} + +/// Convenience wrapper for the common internal-error case: a 500 +/// problem-details response (see [`problem_response`]). Most dashboard +/// handlers funnel their errors through here; handlers with a more +/// specific failure (bad input, not found) call [`problem_response`] +/// directly with the right status. fn error_response(message: &str) -> Response { - // Plain text — the JS app surfaces this in an alert(), so HTML - // wrapping would just clutter the message. - (StatusCode::INTERNAL_SERVER_ERROR, message.to_owned()).into_response() + problem_response(StatusCode::INTERNAL_SERVER_ERROR, message) }