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