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}