Skip to main content

ullun/
problem.rs

1//! RFC 9457 problem details for error responses
2//!
3//! By default errors are `{"error": "...", "status": 404}`. Add
4//! [`ProblemDetails`] to answer in the standard format instead, which API
5//! clients and gateways understand without knowing your conventions:
6//!
7//! ```json
8//! {
9//!   "type": "https://api.example.com/problems/not-found",
10//!   "title": "Not Found",
11//!   "status": 404,
12//!   "detail": "Order 17 does not exist",
13//!   "instance": "/orders/17",
14//!   "errors": { "quantity": ["must be 1-50"] }
15//! }
16//! ```
17//!
18//! sent as `application/problem+json`. Validation failures (422) list their
19//! fields under `errors`.
20//!
21//! ```no_run
22//! use ullun::prelude::*;
23//! use ullun::problem::ProblemDetails;
24//!
25//! # async fn run() {
26//! App::new()
27//!     .middleware(ProblemDetails::new().type_base("https://api.example.com/problems/"))
28//!     .get("/orders/{id}", |_req: Request| async {
29//!         Err::<Response, _>(Error::not_found("Order does not exist"))
30//!     })
31//!     .run("127.0.0.1:3000")
32//!     .await
33//!     .unwrap();
34//! # }
35//! ```
36//!
37//! Only the framework's own error bodies are converted (a JSON object with
38//! exactly `error` and `status`, plus `fields` for validation); responses your
39//! handlers build themselves are left alone. Without
40//! [`type_base`](ProblemDetails::type_base) the `type` is `about:blank`, as the RFC
41//! allows for plain HTTP status problems.
42
43use crate::error::Result;
44use crate::middleware::{Middleware, Next};
45use crate::request::Request;
46use crate::response::Response;
47use async_trait::async_trait;
48
49/// Middleware turning error responses into `application/problem+json`
50#[derive(Clone, Debug, Default)]
51pub struct ProblemDetails {
52    type_base: Option<String>,
53    include_instance: bool,
54}
55
56impl ProblemDetails {
57    pub fn new() -> Self {
58        Self {
59            type_base: None,
60            include_instance: true,
61        }
62    }
63
64    /// Prefix for the `type` URI: `{base}not-found`, `{base}validation-failed`, ...
65    pub fn type_base(mut self, base: &str) -> Self {
66        self.type_base = Some(base.to_string());
67        self
68    }
69
70    /// Leave out `instance` (the request path)
71    pub fn without_instance(mut self) -> Self {
72        self.include_instance = false;
73        self
74    }
75
76    fn convert(&self, res: Response, path: &str) -> Response {
77        if !res.status.is_client_error() && !res.status.is_server_error() {
78            return res;
79        }
80        let is_json = res
81            .headers
82            .get(http::header::CONTENT_TYPE)
83            .and_then(|v| v.to_str().ok())
84            .is_some_and(|v| v.starts_with("application/json"));
85        if !is_json || res.is_streaming() {
86            return res;
87        }
88        let Ok(serde_json::Value::Object(old)) = serde_json::from_slice(&res.body) else {
89            return res;
90        };
91        let known = old
92            .keys()
93            .all(|k| matches!(k.as_str(), "error" | "status" | "fields"));
94        let (Some(detail), Some(status)) = (
95            old.get("error").and_then(|v| v.as_str()),
96            old.get("status").and_then(|v| v.as_u64()),
97        ) else {
98            return res;
99        };
100        if !known || status != u64::from(res.status.as_u16()) {
101            return res;
102        }
103
104        let reason = res.status.canonical_reason().unwrap_or("Error");
105        let slug = if old.contains_key("fields") {
106            "validation-failed".to_string()
107        } else {
108            reason.to_ascii_lowercase().replace(' ', "-")
109        };
110        let mut problem = serde_json::json!({
111            "type": match &self.type_base {
112                Some(base) => format!("{base}{slug}"),
113                None => "about:blank".to_string(),
114            },
115            "title": reason,
116            "status": status,
117            "detail": detail,
118        });
119        if self.include_instance {
120            problem["instance"] = path.into();
121        }
122        if let Some(fields) = old.get("fields") {
123            problem["errors"] = fields.clone();
124        }
125        let mut out = res;
126        out.body = serde_json::to_vec(&problem).unwrap_or_default().into();
127        out.headers.remove(http::header::CONTENT_LENGTH);
128        out.with_header("content-type", "application/problem+json")
129    }
130}
131
132#[async_trait]
133impl Middleware for ProblemDetails {
134    async fn handle(&self, req: Request, next: Next) -> Result<Response> {
135        let path = req.uri.path().to_string();
136        let res = next.run(req).await.unwrap_or_else(Response::from_error);
137        Ok(self.convert(res, &path))
138    }
139}