Skip to main content

server_less_core/
error.rs

1//! Error handling and protocol-specific error mapping.
2
3use std::fmt;
4
5/// Protocol-agnostic error code that maps to HTTP status, gRPC code, CLI exit code, etc.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ErrorCode {
8    /// 400 Bad Request / INVALID_ARGUMENT / exit 1
9    InvalidInput,
10    /// 401 Unauthorized / UNAUTHENTICATED / exit 1
11    Unauthenticated,
12    /// 403 Forbidden / PERMISSION_DENIED / exit 1
13    Forbidden,
14    /// 404 Not Found / NOT_FOUND / exit 1
15    NotFound,
16    /// 409 Conflict / ALREADY_EXISTS / exit 1
17    Conflict,
18    /// 422 Unprocessable Entity / FAILED_PRECONDITION / exit 1
19    FailedPrecondition,
20    /// 429 Too Many Requests / RESOURCE_EXHAUSTED / exit 1
21    RateLimited,
22    /// 500 Internal Server Error / INTERNAL / exit 1
23    Internal,
24    /// 501 Not Implemented / UNIMPLEMENTED / exit 1
25    NotImplemented,
26    /// 503 Service Unavailable / UNAVAILABLE / exit 1
27    Unavailable,
28}
29
30impl ErrorCode {
31    /// Convert to HTTP status code
32    pub fn http_status(&self) -> u16 {
33        match self {
34            ErrorCode::InvalidInput => 400,
35            ErrorCode::Unauthenticated => 401,
36            ErrorCode::Forbidden => 403,
37            ErrorCode::NotFound => 404,
38            ErrorCode::Conflict => 409,
39            ErrorCode::FailedPrecondition => 422,
40            ErrorCode::RateLimited => 429,
41            ErrorCode::Internal => 500,
42            ErrorCode::NotImplemented => 501,
43            ErrorCode::Unavailable => 503,
44        }
45    }
46
47    /// Convert to CLI exit code
48    pub fn exit_code(&self) -> i32 {
49        match self {
50            ErrorCode::NotFound => 1,
51            ErrorCode::InvalidInput => 2,
52            ErrorCode::Unauthenticated | ErrorCode::Forbidden => 3,
53            ErrorCode::Conflict | ErrorCode::FailedPrecondition => 4,
54            ErrorCode::RateLimited => 5,
55            ErrorCode::Internal | ErrorCode::Unavailable => 1,
56            ErrorCode::NotImplemented => 1,
57        }
58    }
59
60    /// Convert to gRPC status code name
61    pub fn grpc_code(&self) -> &'static str {
62        match self {
63            ErrorCode::InvalidInput => "INVALID_ARGUMENT",
64            ErrorCode::Unauthenticated => "UNAUTHENTICATED",
65            ErrorCode::Forbidden => "PERMISSION_DENIED",
66            ErrorCode::NotFound => "NOT_FOUND",
67            ErrorCode::Conflict => "ALREADY_EXISTS",
68            ErrorCode::FailedPrecondition => "FAILED_PRECONDITION",
69            ErrorCode::RateLimited => "RESOURCE_EXHAUSTED",
70            ErrorCode::Internal => "INTERNAL",
71            ErrorCode::NotImplemented => "UNIMPLEMENTED",
72            ErrorCode::Unavailable => "UNAVAILABLE",
73        }
74    }
75
76    /// Convert to JSON-RPC error code.
77    ///
78    /// Standard codes: -32700 parse error, -32600 invalid request,
79    /// -32601 method not found, -32602 invalid params, -32603 internal error.
80    /// Server-defined codes are in the range -32000 to -32099.
81    pub fn jsonrpc_code(&self) -> i32 {
82        match self {
83            ErrorCode::InvalidInput => -32602,
84            ErrorCode::Unauthenticated => -32000,
85            ErrorCode::Forbidden => -32001,
86            ErrorCode::NotFound => -32002,
87            ErrorCode::Conflict => -32003,
88            ErrorCode::FailedPrecondition => -32004,
89            ErrorCode::RateLimited => -32005,
90            ErrorCode::Internal => -32603,
91            ErrorCode::NotImplemented => -32601,
92            ErrorCode::Unavailable => -32006,
93        }
94    }
95
96    /// Infer error code from type/variant name (convention-based)
97    pub fn infer_from_name(name: &str) -> Self {
98        let name_lower = name.to_lowercase();
99
100        if name_lower.contains("notfound")
101            || name_lower.contains("not_found")
102            || name_lower.contains("missing")
103        {
104            ErrorCode::NotFound
105        } else if name_lower.contains("invalid")
106            || name_lower.contains("validation")
107            || name_lower.contains("parse")
108        {
109            ErrorCode::InvalidInput
110        } else if name_lower.contains("unauthorized") || name_lower.contains("unauthenticated") {
111            ErrorCode::Unauthenticated
112        } else if name_lower.contains("forbidden")
113            || name_lower.contains("permission")
114            || name_lower.contains("denied")
115        {
116            ErrorCode::Forbidden
117        } else if name_lower.contains("conflict")
118            || name_lower.contains("exists")
119            || name_lower.contains("duplicate")
120        {
121            ErrorCode::Conflict
122        } else if name_lower.contains("ratelimit")
123            || name_lower.contains("rate_limit")
124            || name_lower.contains("throttle")
125        {
126            ErrorCode::RateLimited
127        } else if name_lower.contains("unavailable") || name_lower.contains("temporarily") {
128            ErrorCode::Unavailable
129        } else if name_lower.contains("unimplemented") || name_lower.contains("not_implemented") {
130            ErrorCode::NotImplemented
131        } else {
132            ErrorCode::Internal
133        }
134    }
135}
136
137/// Trait for converting errors to protocol-agnostic error codes.
138///
139/// Implement this for your error types, or use the derive macro.
140pub trait IntoErrorCode {
141    /// Get the error code for this error
142    fn error_code(&self) -> ErrorCode;
143
144    /// Get a human-readable message
145    fn message(&self) -> String;
146
147    /// Get the JSON-RPC numeric error code for this error.
148    ///
149    /// Defaults to the code derived from `error_code()`. Override this for
150    /// per-variant JSON-RPC codes (e.g. `-32602` for invalid params).
151    fn jsonrpc_code(&self) -> i32 {
152        self.error_code().jsonrpc_code()
153    }
154}
155
156/// Fallback trait used by [`HttpStatusHelper`] when the concrete error type
157/// does not implement [`IntoErrorCode`].
158///
159/// This is part of the autoref specialization pattern. Generated HTTP handler
160/// code brings this trait into scope with `use ... as _` so that
161/// `HttpStatusHelper(&err).http_status_code()` resolves to 500 when the error
162/// type does not implement `IntoErrorCode`, without requiring specialization.
163///
164/// **Not intended for direct use.** Call `HttpStatusHelper(&err).http_status_code()`
165/// from generated code instead.
166pub trait HttpStatusFallback {
167    /// Returns the HTTP status code for this error, defaulting to 500.
168    fn http_status_code(&self) -> u16;
169}
170
171/// Helper wrapper used by generated HTTP handler code to map error values to
172/// HTTP status codes.
173///
174/// Method resolution picks the inherent impl (using [`IntoErrorCode`]) when the
175/// wrapped type implements [`IntoErrorCode`], and falls back to the
176/// [`HttpStatusFallback`] trait impl (which returns 500) otherwise.
177///
178/// # Example (generated code pattern)
179///
180/// ```ignore
181/// use ::server_less::HttpStatusFallback as _;
182/// let status_u16 = ::server_less::HttpStatusHelper(&err).http_status_code();
183/// ```
184pub struct HttpStatusHelper<'a, T>(pub &'a T);
185
186impl<T: IntoErrorCode> HttpStatusHelper<'_, T> {
187    /// Returns the HTTP status code derived from [`IntoErrorCode::error_code`].
188    pub fn http_status_code(&self) -> u16 {
189        self.0.error_code().http_status()
190    }
191}
192
193impl<T> HttpStatusFallback for HttpStatusHelper<'_, T> {
194    /// Fallback: returns 500 Internal Server Error for types that do not
195    /// implement [`IntoErrorCode`].
196    fn http_status_code(&self) -> u16 {
197        500
198    }
199}
200
201// Implement for common error types
202impl IntoErrorCode for std::io::Error {
203    fn error_code(&self) -> ErrorCode {
204        match self.kind() {
205            std::io::ErrorKind::NotFound => ErrorCode::NotFound,
206            std::io::ErrorKind::PermissionDenied => ErrorCode::Forbidden,
207            std::io::ErrorKind::InvalidInput | std::io::ErrorKind::InvalidData => {
208                ErrorCode::InvalidInput
209            }
210            _ => ErrorCode::Internal,
211        }
212    }
213
214    fn message(&self) -> String {
215        self.to_string()
216    }
217}
218
219impl IntoErrorCode for String {
220    fn error_code(&self) -> ErrorCode {
221        ErrorCode::Internal
222    }
223
224    fn message(&self) -> String {
225        self.clone()
226    }
227}
228
229impl IntoErrorCode for &str {
230    fn error_code(&self) -> ErrorCode {
231        ErrorCode::Internal
232    }
233
234    fn message(&self) -> String {
235        self.to_string()
236    }
237}
238
239impl IntoErrorCode for Box<dyn std::error::Error> {
240    fn error_code(&self) -> ErrorCode {
241        ErrorCode::Internal
242    }
243
244    fn message(&self) -> String {
245        self.to_string()
246    }
247}
248
249impl IntoErrorCode for Box<dyn std::error::Error + Send + Sync> {
250    fn error_code(&self) -> ErrorCode {
251        ErrorCode::Internal
252    }
253
254    fn message(&self) -> String {
255        self.to_string()
256    }
257}
258
259/// A generic error response that can be serialized
260#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
261pub struct ErrorResponse {
262    pub code: String,
263    pub message: String,
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub details: Option<serde_json::Value>,
266}
267
268impl ErrorResponse {
269    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
270        Self {
271            code: format!("{:?}", code).to_uppercase(),
272            message: message.into(),
273            details: None,
274        }
275    }
276
277    pub fn with_details(mut self, details: serde_json::Value) -> Self {
278        self.details = Some(details);
279        self
280    }
281}
282
283impl fmt::Display for ErrorResponse {
284    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285        write!(f, "{}: {}", self.code, self.message)
286    }
287}
288
289impl std::error::Error for ErrorResponse {}
290
291/// Error type for schema validation failures.
292///
293/// Used by schema validation methods (validate_schema) in generated code.
294#[derive(Debug, Clone)]
295pub struct SchemaValidationError {
296    /// Schema type (proto, capnp, thrift, smithy, etc.)
297    pub schema_type: String,
298    /// Lines present in expected schema but missing from generated
299    pub missing_lines: Vec<String>,
300    /// Lines present in generated schema but not in expected
301    pub extra_lines: Vec<String>,
302}
303
304impl SchemaValidationError {
305    /// Create a new schema validation error
306    pub fn new(schema_type: impl Into<String>) -> Self {
307        Self {
308            schema_type: schema_type.into(),
309            missing_lines: Vec::new(),
310            extra_lines: Vec::new(),
311        }
312    }
313
314    /// Add a line that's missing from the generated schema
315    pub fn add_missing(&mut self, line: impl Into<String>) {
316        self.missing_lines.push(line.into());
317    }
318
319    /// Add a line that's extra in the generated schema
320    pub fn add_extra(&mut self, line: impl Into<String>) {
321        self.extra_lines.push(line.into());
322    }
323
324    /// Check if there are any differences
325    pub fn has_differences(&self) -> bool {
326        !self.missing_lines.is_empty() || !self.extra_lines.is_empty()
327    }
328}
329
330impl fmt::Display for SchemaValidationError {
331    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332        writeln!(f, "{} schema validation failed:", self.schema_type)?;
333
334        if !self.missing_lines.is_empty() {
335            writeln!(f, "\nExpected methods/messages not found in generated:")?;
336            for line in &self.missing_lines {
337                writeln!(f, "  - {}", line)?;
338            }
339        }
340
341        if !self.extra_lines.is_empty() {
342            writeln!(f, "\nGenerated methods/messages not in expected:")?;
343            for line in &self.extra_lines {
344                writeln!(f, "  + {}", line)?;
345            }
346        }
347
348        // Add helpful hints
349        writeln!(f)?;
350        writeln!(f, "Hints:")?;
351
352        if !self.missing_lines.is_empty() && !self.extra_lines.is_empty() {
353            writeln!(
354                f,
355                "  - Method signature or type may have changed. Check parameter names and types."
356            )?;
357        }
358
359        if !self.missing_lines.is_empty() {
360            writeln!(
361                f,
362                "  - Missing items may indicate removed or renamed methods in Rust code."
363            )?;
364        }
365
366        if !self.extra_lines.is_empty() {
367            writeln!(
368                f,
369                "  - Extra items may indicate new methods added. Update the schema file."
370            )?;
371        }
372
373        writeln!(
374            f,
375            "  - Run `write_{schema}()` to regenerate the schema file.",
376            schema = self.schema_type.to_lowercase()
377        )?;
378
379        Ok(())
380    }
381}
382
383impl std::error::Error for SchemaValidationError {}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    #[test]
390    fn test_error_code_inference() {
391        assert_eq!(ErrorCode::infer_from_name("NotFound"), ErrorCode::NotFound);
392        assert_eq!(
393            ErrorCode::infer_from_name("UserNotFound"),
394            ErrorCode::NotFound
395        );
396        assert_eq!(
397            ErrorCode::infer_from_name("InvalidEmail"),
398            ErrorCode::InvalidInput
399        );
400        assert_eq!(
401            ErrorCode::infer_from_name("Forbidden"),
402            ErrorCode::Forbidden
403        );
404        assert_eq!(
405            ErrorCode::infer_from_name("AlreadyExists"),
406            ErrorCode::Conflict
407        );
408    }
409
410    #[test]
411    fn test_http_status_codes() {
412        assert_eq!(ErrorCode::NotFound.http_status(), 404);
413        assert_eq!(ErrorCode::InvalidInput.http_status(), 400);
414        assert_eq!(ErrorCode::Internal.http_status(), 500);
415    }
416}