Skip to main content

webx_host/
problem_response.rs

1//! RFC 7807 Problem Details helpers for consistent HTTP error responses.
2
3use std::collections::HashMap;
4use webx_core::http::IHttpContext;
5use webx_core::problem::ProblemDetails;
6
7/// Standard HTTP status phrase for Problem Details `title`.
8pub fn problem_status_title(status: u16) -> &'static str {
9    match status {
10        400 => "Bad Request",
11        401 => "Unauthorized",
12        403 => "Forbidden",
13        404 => "Not Found",
14        405 => "Method Not Allowed",
15        409 => "Conflict",
16        413 => "Payload Too Large",
17        415 => "Unsupported Media Type",
18        422 => "Unprocessable Entity",
19        429 => "Too Many Requests",
20        501 => "Not Implemented",
21        500 => "Internal Server Error",
22        502 => "Bad Gateway",
23        503 => "Service Unavailable",
24        504 => "Gateway Timeout",
25        _ => "Error",
26    }
27}
28
29/// Build a Problem Details value with type URI and detail.
30pub fn build_problem(status: u16, detail: impl Into<String>) -> ProblemDetails {
31    ProblemDetails {
32        typ: Some(format!("https://httpstatuses.com/{}", status)),
33        title: problem_status_title(status).into(),
34        status,
35        detail: Some(detail.into()),
36        instance: None,
37        extensions: None,
38    }
39}
40
41/// Serialize problem details to JSON bytes (for sync response setup).
42pub fn problem_to_bytes(problem: &ProblemDetails) -> Vec<u8> {
43    serde_json::to_vec(problem).unwrap_or_default()
44}
45
46/// Write RFC 7807 problem+json to the response.
47pub async fn write_problem_response(ctx: &mut dyn IHttpContext, problem: ProblemDetails) {
48    let status = problem.status;
49    ctx.response_mut().set_status(status);
50    ctx.response_mut()
51        .set_header("content-type", "application/problem+json");
52    let _ = ctx
53        .response_mut()
54        .write_bytes(problem_to_bytes(&problem))
55        .await;
56}
57
58/// Convenience: status + detail string.
59pub async fn write_problem(ctx: &mut dyn IHttpContext, status: u16, detail: impl Into<String>) {
60    write_problem_response(ctx, build_problem(status, detail)).await;
61}
62
63/// Realm advertised in the `WWW-Authenticate` challenge of 401 responses.
64///
65/// RFC 7235 ยง3.1 requires a 401 to carry at least one challenge so clients can
66/// discover how to authenticate; RFC 6750 uses the `Bearer` scheme.
67pub const AUTH_REALM: &str = "rust-webx";
68
69/// Write the single 401 response of the framework.
70///
71/// This is the only place a 401 is produced: every caller (declarative
72/// authorization, dynamic authorization, JWT middleware, identity binding)
73/// funnels its `Error::Unauthorized` through here, so the status, the
74/// `application/problem+json` body and the `WWW-Authenticate` challenge stay
75/// consistent.
76pub async fn write_unauthorized(ctx: &mut dyn IHttpContext, detail: impl Into<String>) {
77    let problem = build_problem(401, detail);
78    let challenge = format!("Bearer realm=\"{AUTH_REALM}\"");
79    ctx.response_mut().set_status(problem.status);
80    ctx.response_mut()
81        .set_header("content-type", "application/problem+json");
82    ctx.response_mut()
83        .set_header("www-authenticate", &challenge);
84    let _ = ctx
85        .response_mut()
86        .write_bytes(problem_to_bytes(&problem))
87        .await;
88}
89
90/// 403 with optional extension fields (e.g. `required_roles`).
91pub async fn write_forbidden(
92    ctx: &mut dyn IHttpContext,
93    detail: impl Into<String>,
94    extensions: HashMap<String, serde_json::Value>,
95) {
96    let mut problem = build_problem(403, detail);
97    if !extensions.is_empty() {
98        problem.extensions = Some(extensions);
99    }
100    write_problem_response(ctx, problem).await;
101}