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 and sent over the wire.
260///
261/// Produced by protocol macros when a handler returns an `Err(_)` value.
262/// Serializes to `{"code": "NOT_FOUND", "message": "..."}` (details omitted when absent).
263#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
264pub struct ErrorResponse {
265    /// Machine-readable error code (e.g. `"NOT_FOUND"`, `"INVALID_PARAMS"`).
266    pub code: String,
267    /// Human-readable error message.
268    pub message: String,
269    /// Optional structured details about the error (omitted from serialization when absent).
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub details: Option<serde_json::Value>,
272}
273
274impl ErrorResponse {
275    /// Create a new `ErrorResponse` from an `ErrorCode` and a message.
276    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
277        Self {
278            code: format!("{:?}", code).to_uppercase(),
279            message: message.into(),
280            details: None,
281        }
282    }
283
284    /// Attach structured details to this error response.
285    pub fn with_details(mut self, details: serde_json::Value) -> Self {
286        self.details = Some(details);
287        self
288    }
289}
290
291impl fmt::Display for ErrorResponse {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        write!(f, "{}: {}", self.code, self.message)
294    }
295}
296
297impl std::error::Error for ErrorResponse {}
298
299/// Error type for schema validation failures.
300///
301/// Used by schema validation methods (validate_schema) in generated code.
302#[derive(Debug, Clone)]
303pub struct SchemaValidationError {
304    /// Schema type (proto, capnp, thrift, smithy, etc.)
305    pub schema_type: String,
306    /// Lines present in expected schema but missing from generated
307    pub missing_lines: Vec<String>,
308    /// Lines present in generated schema but not in expected
309    pub extra_lines: Vec<String>,
310}
311
312impl SchemaValidationError {
313    /// Create a new schema validation error
314    pub fn new(schema_type: impl Into<String>) -> Self {
315        Self {
316            schema_type: schema_type.into(),
317            missing_lines: Vec::new(),
318            extra_lines: Vec::new(),
319        }
320    }
321
322    /// Add a line that's missing from the generated schema
323    pub fn add_missing(&mut self, line: impl Into<String>) {
324        self.missing_lines.push(line.into());
325    }
326
327    /// Add a line that's extra in the generated schema
328    pub fn add_extra(&mut self, line: impl Into<String>) {
329        self.extra_lines.push(line.into());
330    }
331
332    /// Check if there are any differences
333    pub fn has_differences(&self) -> bool {
334        !self.missing_lines.is_empty() || !self.extra_lines.is_empty()
335    }
336}
337
338impl fmt::Display for SchemaValidationError {
339    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340        writeln!(f, "{} schema validation failed:", self.schema_type)?;
341
342        if !self.missing_lines.is_empty() {
343            writeln!(f, "\nExpected methods/messages not found in generated:")?;
344            for line in &self.missing_lines {
345                writeln!(f, "  - {}", line)?;
346            }
347        }
348
349        if !self.extra_lines.is_empty() {
350            writeln!(f, "\nGenerated methods/messages not in expected:")?;
351            for line in &self.extra_lines {
352                writeln!(f, "  + {}", line)?;
353            }
354        }
355
356        // Add helpful hints
357        writeln!(f)?;
358        writeln!(f, "Hints:")?;
359
360        if !self.missing_lines.is_empty() && !self.extra_lines.is_empty() {
361            writeln!(
362                f,
363                "  - Method signature or type may have changed. Check parameter names and types."
364            )?;
365        }
366
367        if !self.missing_lines.is_empty() {
368            writeln!(
369                f,
370                "  - Missing items may indicate removed or renamed methods in Rust code."
371            )?;
372        }
373
374        if !self.extra_lines.is_empty() {
375            writeln!(
376                f,
377                "  - Extra items may indicate new methods added. Update the schema file."
378            )?;
379        }
380
381        writeln!(
382            f,
383            "  - Run `write_{schema}()` to regenerate the schema file.",
384            schema = self.schema_type.to_lowercase()
385        )?;
386
387        Ok(())
388    }
389}
390
391impl std::error::Error for SchemaValidationError {}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_error_code_inference() {
399        assert_eq!(ErrorCode::infer_from_name("NotFound"), ErrorCode::NotFound);
400        assert_eq!(
401            ErrorCode::infer_from_name("UserNotFound"),
402            ErrorCode::NotFound
403        );
404        assert_eq!(
405            ErrorCode::infer_from_name("InvalidEmail"),
406            ErrorCode::InvalidInput
407        );
408        assert_eq!(
409            ErrorCode::infer_from_name("Forbidden"),
410            ErrorCode::Forbidden
411        );
412        assert_eq!(
413            ErrorCode::infer_from_name("AlreadyExists"),
414            ErrorCode::Conflict
415        );
416    }
417
418    #[test]
419    fn test_http_status_codes() {
420        assert_eq!(ErrorCode::NotFound.http_status(), 404);
421        assert_eq!(ErrorCode::InvalidInput.http_status(), 400);
422        assert_eq!(ErrorCode::Internal.http_status(), 500);
423    }
424}