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}