postrust_core/
error.rs

1//! Error types for Postrust.
2//!
3//! Provides comprehensive error handling with HTTP status code mapping.
4
5use http::StatusCode;
6use thiserror::Error;
7
8/// Result type for Postrust operations.
9pub type Result<T> = std::result::Result<T, Error>;
10
11/// Main error type for Postrust.
12#[derive(Error, Debug)]
13pub enum Error {
14    // ========================================================================
15    // Request Parsing Errors (4xx)
16    // ========================================================================
17    #[error("Invalid path: {0}")]
18    InvalidPath(String),
19
20    #[error("Invalid query parameter: {0}")]
21    InvalidQueryParam(String),
22
23    #[error("Invalid header: {0}")]
24    InvalidHeader(&'static str),
25
26    #[error("Invalid request body: {0}")]
27    InvalidBody(String),
28
29    #[error("Unsupported HTTP method: {0}")]
30    UnsupportedMethod(String),
31
32    #[error("Unacceptable schema: {0}")]
33    UnacceptableSchema(String),
34
35    #[error("Unknown column: {0}")]
36    UnknownColumn(String),
37
38    #[error("Invalid range: {0}")]
39    InvalidRange(String),
40
41    #[error("Invalid media type: {0}")]
42    InvalidMediaType(String),
43
44    #[error("Missing required parameter: {0}")]
45    MissingParameter(String),
46
47    #[error("Ambiguous request: {0}")]
48    AmbiguousRequest(String),
49
50    // ========================================================================
51    // Authentication/Authorization Errors (401/403)
52    // ========================================================================
53    #[error("Invalid JWT: {0}")]
54    InvalidJwt(String),
55
56    #[error("JWT expired")]
57    JwtExpired,
58
59    #[error("Missing authentication")]
60    MissingAuth,
61
62    #[error("Insufficient permissions: {0}")]
63    InsufficientPermissions(String),
64
65    // ========================================================================
66    // Resource Errors (404)
67    // ========================================================================
68    #[error("Resource not found: {0}")]
69    NotFound(String),
70
71    #[error("Table not found: {0}")]
72    TableNotFound(String),
73
74    #[error("Function not found: {0}")]
75    FunctionNotFound(String),
76
77    #[error("Column not found: {0}")]
78    ColumnNotFound(String),
79
80    #[error("Relationship not found: {0}")]
81    RelationshipNotFound(String),
82
83    // ========================================================================
84    // Schema Cache Errors
85    // ========================================================================
86    #[error("Schema cache not loaded")]
87    SchemaCacheNotLoaded,
88
89    #[error("Schema cache load failed: {0}")]
90    SchemaCacheLoadFailed(String),
91
92    // ========================================================================
93    // Database Errors (500/4xx depending on type)
94    // ========================================================================
95    #[error("Database error: {0}")]
96    Database(#[from] DatabaseError),
97
98    #[error("Connection pool error: {0}")]
99    ConnectionPool(String),
100
101    // ========================================================================
102    // Internal Errors (500)
103    // ========================================================================
104    #[error("Internal error: {0}")]
105    Internal(String),
106
107    #[error("Configuration error: {0}")]
108    Config(String),
109
110    // ========================================================================
111    // Plan Errors
112    // ========================================================================
113    #[error("Invalid plan: {0}")]
114    InvalidPlan(String),
115
116    #[error("Embedding error: {0}")]
117    EmbeddingError(String),
118}
119
120impl Error {
121    /// Get the HTTP status code for this error.
122    pub fn status_code(&self) -> StatusCode {
123        match self {
124            // 400 Bad Request
125            Self::InvalidPath(_)
126            | Self::InvalidQueryParam(_)
127            | Self::InvalidHeader(_)
128            | Self::InvalidBody(_)
129            | Self::InvalidRange(_)
130            | Self::InvalidMediaType(_)
131            | Self::MissingParameter(_)
132            | Self::AmbiguousRequest(_)
133            | Self::UnknownColumn(_)
134            | Self::InvalidPlan(_)
135            | Self::EmbeddingError(_) => StatusCode::BAD_REQUEST,
136
137            // 401 Unauthorized
138            Self::InvalidJwt(_) | Self::JwtExpired | Self::MissingAuth => StatusCode::UNAUTHORIZED,
139
140            // 403 Forbidden
141            Self::InsufficientPermissions(_) => StatusCode::FORBIDDEN,
142
143            // 404 Not Found
144            Self::NotFound(_)
145            | Self::TableNotFound(_)
146            | Self::FunctionNotFound(_)
147            | Self::ColumnNotFound(_)
148            | Self::RelationshipNotFound(_) => StatusCode::NOT_FOUND,
149
150            // 405 Method Not Allowed
151            Self::UnsupportedMethod(_) => StatusCode::METHOD_NOT_ALLOWED,
152
153            // 406 Not Acceptable
154            Self::UnacceptableSchema(_) => StatusCode::NOT_ACCEPTABLE,
155
156            // 500 Internal Server Error
157            Self::SchemaCacheNotLoaded
158            | Self::SchemaCacheLoadFailed(_)
159            | Self::ConnectionPool(_)
160            | Self::Internal(_)
161            | Self::Config(_) => StatusCode::INTERNAL_SERVER_ERROR,
162
163            // Database errors map based on type
164            Self::Database(db_err) => db_err.status_code(),
165        }
166    }
167
168    /// Get the error code for API responses.
169    pub fn code(&self) -> &'static str {
170        match self {
171            Self::InvalidPath(_) => "PGRST100",
172            Self::InvalidQueryParam(_) => "PGRST101",
173            Self::InvalidHeader(_) => "PGRST102",
174            Self::InvalidBody(_) => "PGRST103",
175            Self::UnsupportedMethod(_) => "PGRST104",
176            Self::UnacceptableSchema(_) => "PGRST105",
177            Self::UnknownColumn(_) => "PGRST106",
178            Self::InvalidRange(_) => "PGRST107",
179            Self::InvalidMediaType(_) => "PGRST108",
180            Self::MissingParameter(_) => "PGRST109",
181            Self::AmbiguousRequest(_) => "PGRST110",
182
183            Self::InvalidJwt(_) => "PGRST200",
184            Self::JwtExpired => "PGRST201",
185            Self::MissingAuth => "PGRST202",
186            Self::InsufficientPermissions(_) => "PGRST203",
187
188            Self::NotFound(_) => "PGRST300",
189            Self::TableNotFound(_) => "PGRST301",
190            Self::FunctionNotFound(_) => "PGRST302",
191            Self::ColumnNotFound(_) => "PGRST303",
192            Self::RelationshipNotFound(_) => "PGRST304",
193
194            Self::SchemaCacheNotLoaded => "PGRST400",
195            Self::SchemaCacheLoadFailed(_) => "PGRST401",
196
197            Self::Database(e) => e.code(),
198            Self::ConnectionPool(_) => "PGRST500",
199
200            Self::Internal(_) => "PGRST900",
201            Self::Config(_) => "PGRST901",
202
203            Self::InvalidPlan(_) => "PGRST600",
204            Self::EmbeddingError(_) => "PGRST601",
205        }
206    }
207
208    /// Convert to JSON error response.
209    pub fn to_json(&self) -> serde_json::Value {
210        serde_json::json!({
211            "code": self.code(),
212            "message": self.to_string(),
213            "details": self.details(),
214            "hint": self.hint(),
215        })
216    }
217
218    /// Get additional details for the error.
219    fn details(&self) -> Option<String> {
220        match self {
221            Self::Database(db_err) => db_err.details.clone(),
222            _ => None,
223        }
224    }
225
226    /// Get a hint for resolving the error.
227    fn hint(&self) -> Option<String> {
228        match self {
229            Self::InvalidJwt(_) => Some("Check that the JWT is properly signed and not expired".into()),
230            Self::MissingAuth => Some("Provide a valid JWT in the Authorization header".into()),
231            Self::TableNotFound(_) => Some("Check the table name and schema".into()),
232            Self::UnknownColumn(_) => Some("Check column names against the table schema".into()),
233            Self::Database(db_err) => db_err.hint.clone(),
234            _ => None,
235        }
236    }
237}
238
239/// Database-specific error type.
240#[derive(Error, Debug)]
241#[error("Database error [{code}]: {message}")]
242pub struct DatabaseError {
243    pub code: String,
244    pub message: String,
245    pub details: Option<String>,
246    pub hint: Option<String>,
247    pub constraint: Option<String>,
248    pub table: Option<String>,
249    pub column: Option<String>,
250}
251
252impl DatabaseError {
253    /// Get HTTP status code based on PostgreSQL error code.
254    pub fn status_code(&self) -> StatusCode {
255        // PostgreSQL error codes: https://www.postgresql.org/docs/current/errcodes-appendix.html
256        match self.code.as_str() {
257            // Class 23 - Integrity Constraint Violation
258            c if c.starts_with("23") => StatusCode::CONFLICT,
259            // Class 42 - Syntax Error or Access Rule Violation
260            c if c.starts_with("42") => StatusCode::BAD_REQUEST,
261            // Class 28 - Invalid Authorization Specification
262            c if c.starts_with("28") => StatusCode::FORBIDDEN,
263            // Class 40 - Transaction Rollback
264            c if c.starts_with("40") => StatusCode::CONFLICT,
265            // Class 53 - Insufficient Resources
266            c if c.starts_with("53") => StatusCode::SERVICE_UNAVAILABLE,
267            // Class 54 - Program Limit Exceeded
268            c if c.starts_with("54") => StatusCode::PAYLOAD_TOO_LARGE,
269            // Class P0 - PL/pgSQL Errors (custom raised errors)
270            "P0001" => StatusCode::BAD_REQUEST, // RAISE EXCEPTION
271            // Default to internal server error
272            _ => StatusCode::INTERNAL_SERVER_ERROR,
273        }
274    }
275
276    /// Get error code for API response.
277    pub fn code(&self) -> &'static str {
278        match self.code.as_str() {
279            c if c.starts_with("23") => "PGRST503", // Constraint violation
280            c if c.starts_with("42") => "PGRST504", // SQL error
281            c if c.starts_with("28") => "PGRST505", // Auth error
282            _ => "PGRST500", // Generic database error
283        }
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn test_error_status_codes() {
293        assert_eq!(
294            Error::InvalidQueryParam("test".into()).status_code(),
295            StatusCode::BAD_REQUEST
296        );
297        assert_eq!(Error::MissingAuth.status_code(), StatusCode::UNAUTHORIZED);
298        assert_eq!(
299            Error::TableNotFound("users".into()).status_code(),
300            StatusCode::NOT_FOUND
301        );
302        assert_eq!(
303            Error::UnsupportedMethod("TRACE".into()).status_code(),
304            StatusCode::METHOD_NOT_ALLOWED
305        );
306    }
307
308    #[test]
309    fn test_error_codes() {
310        assert_eq!(Error::InvalidQueryParam("test".into()).code(), "PGRST101");
311        assert_eq!(Error::MissingAuth.code(), "PGRST202");
312        assert_eq!(Error::TableNotFound("users".into()).code(), "PGRST301");
313    }
314
315    #[test]
316    fn test_database_error_status() {
317        let constraint_error = DatabaseError {
318            code: "23505".into(), // unique_violation
319            message: "Duplicate key".into(),
320            details: None,
321            hint: None,
322            constraint: Some("users_pkey".into()),
323            table: Some("users".into()),
324            column: None,
325        };
326        assert_eq!(constraint_error.status_code(), StatusCode::CONFLICT);
327    }
328
329    #[test]
330    fn test_error_to_json() {
331        let error = Error::InvalidQueryParam("bad filter".into());
332        let json = error.to_json();
333        assert_eq!(json["code"], "PGRST101");
334        assert!(json["message"].as_str().unwrap().contains("bad filter"));
335    }
336}