Skip to main content

webx_core/
error.rs

1//! Unified error type for the rust-webx framework.
2
3use thiserror::Error;
4
5/// Framework-wide error type.
6#[derive(Error, Debug)]
7pub enum Error {
8    #[error("HTTP error: {0}")]
9    Http(String),
10
11    #[error("Status {0}: {1}")]
12    Status(u16, String),
13
14    #[error("Unauthorized: {0}")]
15    Unauthorized(String),
16
17    #[error("Forbidden: {0}")]
18    Forbidden(String),
19
20    #[error("DI error: {0}")]
21    Di(String),
22
23    #[error("Routing error: {0}")]
24    Routing(String),
25
26    #[error("Serialization error: {0}")]
27    Serialization(#[from] serde_json::Error),
28
29    /// Serializing the **response** failed — a server-side defect, not a bad
30    /// request.
31    ///
32    /// Kept separate from [`Error::Serialization`] (which stays on the request
33    /// parsing path and maps to 400) so that a DTO which cannot be serialized
34    /// is reported as 500 instead of blaming the client. There is no `#[from]`
35    /// because that conversion is already taken by `Serialization`; call sites
36    /// convert explicitly with `.map_err(Error::ResponseSerialization)`.
37    #[error("response serialization failed: {0}")]
38    ResponseSerialization(#[source] serde_json::Error),
39
40    /// A server-side state-machine / programming error, e.g. the response body
41    /// state does not allow the requested operation. Maps to 500.
42    #[error("{0}")]
43    State(String),
44
45    #[error("Internal error: {0}")]
46    Internal(String),
47
48    #[error("{0}")]
49    Message(String),
50
51    /// Validation error with user-readable message.
52    #[error("{0}")]
53    Validation(String),
54
55    /// Resource not found.
56    #[error("{0}")]
57    NotFound(String),
58
59    /// Optimistic concurrency or state conflict.
60    #[error("{0}")]
61    Conflict(String),
62
63    /// Request body (or one uploaded file) exceeded the configured limit.
64    #[error("{0}")]
65    PayloadTooLarge(String),
66
67    /// Request content type is not supported by the target endpoint.
68    #[error("{0}")]
69    UnsupportedMediaType(String),
70
71    /// A fully-formed problem response (validation, binding failure).
72    ///
73    /// Carried as-is instead of being flattened into a message so the host can
74    /// write the status and the extension members (`errors`) verbatim.
75    #[error("request problem details")]
76    Problem(Box<crate::problem::ProblemDetails>),
77}
78
79impl Error {
80    /// Map the error to an appropriate HTTP status code.
81    ///
82    /// Used by the built-in exception middleware to produce
83    /// well-formed HTTP error responses.
84    pub fn status_code(&self) -> u16 {
85        match self {
86            Error::Http(_) => 400,
87            Error::Status(code, _) => *code,
88            Error::Unauthorized(_) => 401,
89            Error::Forbidden(_) => 403,
90            Error::Di(_) => 500,
91            Error::Routing(_) => 404,
92            Error::Serialization(_) => 400,
93            Error::ResponseSerialization(_) => 500,
94            Error::State(_) => 500,
95            Error::Internal(_) => 500,
96            Error::Message(_) => 500,
97            Error::Validation(_) => 400,
98            Error::NotFound(_) => 404,
99            Error::Conflict(_) => 409,
100            Error::PayloadTooLarge(_) => 413,
101            Error::UnsupportedMediaType(_) => 415,
102            Error::Problem(problem) => problem.status,
103        }
104    }
105
106    /// The client-facing detail for a 4xx response.
107    ///
108    /// `Display` is written for operators (`"Unauthorized: …"`,
109    /// `"Internal error: …"`), so serving it verbatim would leak variant names
110    /// and internals. This returns the variant's own message for the errors
111    /// that are safe to show, and `None` for 5xx/internal variants — the host
112    /// then serves a stable detail and logs the original text.
113    pub fn public_detail(&self) -> Option<&str> {
114        match self {
115            Error::Http(detail)
116            | Error::Unauthorized(detail)
117            | Error::Forbidden(detail)
118            | Error::Routing(detail)
119            | Error::Validation(detail)
120            | Error::NotFound(detail)
121            | Error::Conflict(detail)
122            | Error::PayloadTooLarge(detail)
123            | Error::UnsupportedMediaType(detail) => Some(detail),
124            Error::Status(_, detail) => Some(detail),
125            // Internal / DI / state / response serialization / already-written
126            // problem: nothing here is safe or meaningful for a client.
127            Error::Di(_)
128            | Error::Serialization(_)
129            | Error::ResponseSerialization(_)
130            | Error::State(_)
131            | Error::Internal(_)
132            | Error::Message(_)
133            | Error::Problem(_) => None,
134        }
135    }
136}
137
138/// Shorthand for `Result<T, Error>` — the general fallible type used by
139/// infrastructure code, helpers and middleware.
140pub type Result<T> = std::result::Result<T, Error>;
141
142/// The result of a request handler — the framework-level counterpart of
143/// ASP.NET Core's `ActionResult<T>`.
144///
145/// Same underlying type as [`Result`]; the name is the handler-facing one:
146/// `IRequestHandler::handle`, `IPipelineBehavior::handle` and `Next::run`
147/// return it, so action code reads like the ASP.NET Core action it
148/// corresponds to. `T` defaults to `()` for an action without a body.
149pub type ActionResult<T = ()> = std::result::Result<T, Error>;
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    /// Any `serde_json::Error`; direction does not matter for the mapping test.
156    fn serde_error() -> serde_json::Error {
157        serde_json::from_str::<serde_json::Value>("{").unwrap_err()
158    }
159
160    #[test]
161    fn response_serialization_maps_to_500_and_hides_detail() {
162        let err = Error::ResponseSerialization(serde_error());
163        assert_eq!(err.status_code(), 500);
164        assert_eq!(err.public_detail(), None);
165        assert!(err
166            .to_string()
167            .starts_with("response serialization failed:"));
168    }
169
170    #[test]
171    fn state_error_maps_to_500_and_hides_detail() {
172        let err = Error::State("response body already written".into());
173        assert_eq!(err.status_code(), 500);
174        assert_eq!(err.public_detail(), None);
175        assert_eq!(err.to_string(), "response body already written");
176    }
177
178    #[test]
179    fn request_serialization_stays_a_client_error() {
180        // Request parsing keeps its `#[from]` conversion and its 400 mapping;
181        // the new response-side variant must not change that.
182        let err: Error = serde_error().into();
183        assert!(matches!(err, Error::Serialization(_)));
184        assert_eq!(err.status_code(), 400);
185        assert_eq!(err.public_detail(), None);
186    }
187}