Skip to main content

cratefield_core/
problem.rs

1//! RFC 9457 problem+json errors (architecture section 6, issue #2).
2
3use axum::Json;
4use axum::http::{StatusCode, header};
5use axum::response::{IntoResponse, Response};
6use serde_json::json;
7
8/// The `type` URI of a problem that has no URI of its own to name (RFC
9/// 9457 §4.2.1): a venture with no public URL, or a response rendered
10/// outside any venture's context. The slug stays in the body's title and
11/// in logs; callers branch on it where one exists.
12pub const ABOUT_BLANK: &str = "about:blank";
13
14/// An API error, serialized as `application/problem+json`.
15///
16/// `type` is the venture's stable slug URI (see
17/// [`Problem::type_uri`]), `instance` is the request id, and the body
18/// never leaks internals: 500s carry no stack, no source error, nothing
19/// but the generic `internal` slug.
20#[derive(Debug, Clone)]
21pub struct Problem {
22    pub slug: &'static str,
23    pub status: StatusCode,
24    pub title: &'static str,
25    pub detail: Option<String>,
26    pub instance: Option<String>,
27    /// RFC 9457 §3.2 extension members, copied into the body beside the
28    /// standard fields. A slug that carries structured data a caller acts
29    /// on — a metered usage problem names its `meter`, `used` and
30    /// `limit` (issue #588) — keeps it here, so it survives the harness's
31    /// re-render under the serving venture's base, which rebuilds the body
32    /// from the problem and nothing else. Boxed: inlined, the map would
33    /// push `Problem` past clippy's 128-byte `result_large_err` limit and
34    /// every `Result<_, Problem>` in the workspace would grow with it.
35    extensions: Box<serde_json::Map<String, serde_json::Value>>,
36}
37
38impl Problem {
39    pub fn new(def: &crate::problems::ProblemDef) -> Self {
40        Self {
41            slug: def.slug,
42            status: def.status,
43            title: def.title,
44            detail: None,
45            instance: None,
46            extensions: Box::new(serde_json::Map::new()),
47        }
48    }
49
50    #[must_use]
51    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
52        self.detail = Some(detail.into());
53        self
54    }
55
56    /// Adds an RFC 9457 §3.2 extension member to the body.
57    #[must_use]
58    pub fn with_extension(
59        mut self,
60        name: impl Into<String>,
61        value: impl Into<serde_json::Value>,
62    ) -> Self {
63        self.extensions.insert(name.into(), value.into());
64        self
65    }
66
67    /// Sets `instance` to the request's id.
68    #[must_use]
69    pub fn instance(mut self, request_id: &str) -> Self {
70        self.instance = Some(request_id.to_string());
71        self
72    }
73
74    pub fn internal() -> Self {
75        Self::new(&crate::problems::SLUGS.internal)
76    }
77
78    pub fn validation_failed(detail: impl Into<String>) -> Self {
79        Self::new(&crate::problems::SLUGS.validation_failed).with_detail(detail)
80    }
81
82    pub fn request_too_large() -> Self {
83        Self::new(&crate::problems::SLUGS.request_too_large)
84    }
85
86    pub fn not_ready(detail: impl Into<String>) -> Self {
87        Self::new(&crate::problems::SLUGS.not_ready).with_detail(detail)
88    }
89
90    pub fn not_found() -> Self {
91        Self::new(&crate::problems::SLUGS.not_found)
92    }
93
94    /// The problem's `type` URI: the venture's base followed by the slug.
95    ///
96    /// The base is the *serving* venture's — [`crate::Venture::problem_type_base`]
97    /// — never a constant of this crate: every venture names its problems
98    /// under its own domain. A base of [`ABOUT_BLANK`] (the context-free
99    /// default, and a venture with no public URL) yields `about:blank`
100    /// itself: the slug has no URI to live under, and RFC 9457 §4.2.1
101    /// reserves exactly that value.
102    pub fn type_uri(&self, base: &str) -> String {
103        if base == ABOUT_BLANK {
104            return ABOUT_BLANK.to_owned();
105        }
106        format!("{base}{}", self.slug)
107    }
108
109    /// The RFC 9457 body, with `type` built under `base`. Shared by
110    /// [`IntoResponse`] (the context-free default) and the harness layer
111    /// that re-renders under the serving venture's base.
112    pub(crate) fn body(&self, base: &str) -> serde_json::Value {
113        let mut body = json!({
114            "type": self.type_uri(base),
115            "title": self.title,
116            "status": self.status.as_u16(),
117        });
118        if let Some(detail) = &self.detail {
119            body["detail"] = json!(detail);
120        }
121        if let Some(instance) = &self.instance {
122            body["instance"] = json!(instance);
123        }
124        // Extension members ride the body last, so they cannot be mistaken
125        // for (or overwrite) a standard field: the four above are the only
126        // keys this crate writes, and a name colliding with one is the
127        // caller's to avoid (RFC 9457 §3.2 keeps them disjoint).
128        if let Some(object) = body.as_object_mut() {
129            for (name, value) in self.extensions.iter() {
130                object.insert(name.clone(), value.clone());
131            }
132        }
133        body
134    }
135
136    /// Renders this problem as a response whose `type` names `base` — the
137    /// serving venture's [`crate::Venture::problem_type_base`]. For a
138    /// response built *outside* a harness router, where no layer stands
139    /// behind it to name the venture: a runtime's pre-router short-circuit
140    /// (the native host refusal, the Worker's body ceiling).
141    pub fn into_response_with_base(self, base: &str) -> Response {
142        let mut response = (self.status, Json(self.body(base))).into_response();
143        response.headers_mut().insert(
144            header::CONTENT_TYPE,
145            header::HeaderValue::from_static("application/problem+json"),
146        );
147        // For the serving venture to re-render under its own base: a
148        // response built here cannot know it. The harness's outermost
149        // layer reads this extension; a response that reaches a caller
150        // without one names `about:blank`, never another venture's domain.
151        response.extensions_mut().insert(self);
152        response
153    }
154}
155
156impl IntoResponse for Problem {
157    fn into_response(self) -> Response {
158        self.into_response_with_base(ABOUT_BLANK)
159    }
160}
161
162impl std::fmt::Display for Problem {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        write!(
165            f,
166            "{}: {} ({})",
167            self.slug,
168            self.title,
169            self.status.as_u16()
170        )
171    }
172}
173
174impl std::error::Error for Problem {}
175
176// Handler ergonomics: `?` on a port error inside a `Result<_, Problem>`
177// handler maps to a generic 500 — the underlying error is logged by the
178// caller, never exposed in the body (architecture section 6).
179impl From<crate::ports::DbError> for Problem {
180    fn from(error: crate::ports::DbError) -> Self {
181        tracing::error!(error = %error, "database error mapped to internal problem");
182        // wasm has no tracing dispatcher (it hangs the isolate), so also
183        // forward the diagnostic to the runtime's sink — otherwise this 500 is
184        // invisible on Workers (issue #107). DbError is sanitized for logs.
185        crate::logging::forward_internal_error(&format!(
186            "database error mapped to internal problem: {error}"
187        ));
188        Self::internal()
189    }
190}