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/// Base URI for every problem `type`:
9/// `https://factory0.ventures/problems/<slug>`.
10pub const PROBLEM_TYPE_BASE: &str = "https://factory0.ventures/problems/";
11
12/// An API error, serialized as `application/problem+json`.
13///
14/// `type` is a stable URI under `PROBLEM_TYPE_BASE`, `instance` is the
15/// request id, and the body never leaks internals: 500s carry no stack, no
16/// source error, nothing but the generic `internal` slug.
17#[derive(Debug, Clone)]
18pub struct Problem {
19    pub slug: &'static str,
20    pub status: StatusCode,
21    pub title: &'static str,
22    pub detail: Option<String>,
23    pub instance: Option<String>,
24}
25
26impl Problem {
27    pub fn new(def: &crate::problems::ProblemDef) -> Self {
28        Self {
29            slug: def.slug,
30            status: def.status,
31            title: def.title,
32            detail: None,
33            instance: None,
34        }
35    }
36
37    #[must_use]
38    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
39        self.detail = Some(detail.into());
40        self
41    }
42
43    /// Sets `instance` to the request's id.
44    #[must_use]
45    pub fn instance(mut self, request_id: &str) -> Self {
46        self.instance = Some(request_id.to_string());
47        self
48    }
49
50    pub fn internal() -> Self {
51        Self::new(&crate::problems::SLUGS.internal)
52    }
53
54    pub fn validation_failed(detail: impl Into<String>) -> Self {
55        Self::new(&crate::problems::SLUGS.validation_failed).with_detail(detail)
56    }
57
58    pub fn request_too_large() -> Self {
59        Self::new(&crate::problems::SLUGS.request_too_large)
60    }
61
62    pub fn not_ready(detail: impl Into<String>) -> Self {
63        Self::new(&crate::problems::SLUGS.not_ready).with_detail(detail)
64    }
65
66    pub fn not_found() -> Self {
67        Self::new(&crate::problems::SLUGS.not_found)
68    }
69
70    pub fn type_uri(&self) -> String {
71        format!("{PROBLEM_TYPE_BASE}{}", self.slug)
72    }
73}
74
75impl IntoResponse for Problem {
76    fn into_response(self) -> Response {
77        let mut body = json!({
78            "type": self.type_uri(),
79            "title": self.title,
80            "status": self.status.as_u16(),
81        });
82        if let Some(detail) = &self.detail {
83            body["detail"] = json!(detail);
84        }
85        if let Some(instance) = &self.instance {
86            body["instance"] = json!(instance);
87        }
88        let mut response = (self.status, Json(body)).into_response();
89        response.headers_mut().insert(
90            header::CONTENT_TYPE,
91            header::HeaderValue::from_static("application/problem+json"),
92        );
93        response
94    }
95}
96
97impl std::fmt::Display for Problem {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        write!(f, "{} ({})", self.type_uri(), self.status.as_u16())
100    }
101}
102
103impl std::error::Error for Problem {}
104
105// Handler ergonomics: `?` on a port error inside a `Result<_, Problem>`
106// handler maps to a generic 500 — the underlying error is logged by the
107// caller, never exposed in the body (architecture section 6).
108impl From<crate::ports::DbError> for Problem {
109    fn from(error: crate::ports::DbError) -> Self {
110        tracing::error!(error = %error, "database error mapped to internal problem");
111        Self::internal()
112    }
113}