Skip to main content

rustapi_core/
error.rs

1//! Error types for RustAPI
2//!
3//! This module provides structured error handling with environment-aware
4//! error masking for production safety.
5//!
6//! # Error Response Format
7//!
8//! All errors are returned as JSON with a consistent structure:
9//!
10//! ```json
11//! {
12//!   "error": {
13//!     "type": "not_found",
14//!     "message": "User not found",
15//!     "fields": null
16//!   },
17//!   "error_id": "err_a1b2c3d4e5f6"
18//! }
19//! ```
20//!
21//! # Environment-Aware Error Masking
22//!
23//! In production mode (`RUSTAPI_ENV=production`), internal server errors (5xx)
24//! are masked to prevent information leakage:
25//!
26//! - **Production**: Generic "An internal error occurred" message
27//! - **Development**: Full error details for debugging
28//!
29//! Validation errors always include field details regardless of environment.
30//!
31//! # Example
32//!
33//! ```rust,ignore
34//! use rustapi_core::{ApiError, Result};
35//! use http::StatusCode;
36//!
37//! async fn get_user(id: i64) -> Result<Json<User>> {
38//!     let user = db.find_user(id)
39//!         .ok_or_else(|| ApiError::not_found("User not found"))?;
40//!     Ok(Json(user))
41//! }
42//!
43//! // Create custom errors
44//! let error = ApiError::new(StatusCode::CONFLICT, "duplicate", "Email already exists");
45//!
46//! // Convenience constructors
47//! let bad_request = ApiError::bad_request("Invalid input");
48//! let unauthorized = ApiError::unauthorized("Invalid token");
49//! let forbidden = ApiError::forbidden("Access denied");
50//! let not_found = ApiError::not_found("Resource not found");
51//! let internal = ApiError::internal("Something went wrong");
52//! ```
53//!
54//! # Error ID Correlation
55//!
56//! Every error response includes a unique `error_id` (format: `err_{uuid}`) that
57//! appears in both the response and server logs, enabling easy correlation for
58//! debugging.
59
60use http::StatusCode;
61use serde::Serialize;
62use std::fmt;
63use std::sync::OnceLock;
64use uuid::Uuid;
65
66/// Result type alias for RustAPI operations
67pub type Result<T, E = ApiError> = std::result::Result<T, E>;
68
69/// Environment configuration for error handling behavior
70///
71/// Controls whether internal error details are exposed in API responses.
72/// In production, internal details are masked to prevent information leakage.
73/// In development, full error details are shown for debugging.
74///
75/// # Example
76///
77/// ```
78/// use rustapi_core::Environment;
79///
80/// let dev = Environment::Development;
81/// assert!(dev.is_development());
82/// assert!(!dev.is_production());
83///
84/// let prod = Environment::Production;
85/// assert!(prod.is_production());
86/// assert!(!prod.is_development());
87/// ```
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
89pub enum Environment {
90    /// Development mode - shows full error details in responses
91    #[default]
92    Development,
93    /// Production mode - masks internal error details in responses
94    Production,
95}
96
97impl Environment {
98    /// Detect environment from `RUSTAPI_ENV` environment variable
99    ///
100    /// Returns `Production` if `RUSTAPI_ENV` is set to "production" or "prod" (case-insensitive).
101    /// Returns `Development` for all other values or if the variable is not set.
102    ///
103    /// # Example
104    ///
105    /// ```bash
106    /// # Production mode
107    /// RUSTAPI_ENV=production cargo run
108    /// RUSTAPI_ENV=prod cargo run
109    ///
110    /// # Development mode (default)
111    /// RUSTAPI_ENV=development cargo run
112    /// cargo run  # No env var set
113    /// ```
114    pub fn from_env() -> Self {
115        match std::env::var("RUSTAPI_ENV")
116            .map(|s| s.to_lowercase())
117            .as_deref()
118        {
119            Ok("production") | Ok("prod") => Environment::Production,
120            _ => Environment::Development,
121        }
122    }
123
124    /// Check if this is production environment
125    pub fn is_production(&self) -> bool {
126        matches!(self, Environment::Production)
127    }
128
129    /// Check if this is development environment
130    pub fn is_development(&self) -> bool {
131        matches!(self, Environment::Development)
132    }
133}
134
135impl fmt::Display for Environment {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        match self {
138            Environment::Development => write!(f, "development"),
139            Environment::Production => write!(f, "production"),
140        }
141    }
142}
143
144/// Global environment setting, cached on first access
145static ENVIRONMENT: OnceLock<Environment> = OnceLock::new();
146
147/// Get the current environment (cached)
148///
149/// This function caches the environment on first call for performance.
150/// The environment is detected from the `RUSTAPI_ENV` environment variable.
151pub fn get_environment() -> Environment {
152    *ENVIRONMENT.get_or_init(Environment::from_env)
153}
154
155/// Set the environment explicitly (for testing purposes)
156///
157/// Note: This only works if the environment hasn't been accessed yet.
158/// Returns `Ok(())` if successful, `Err(env)` if already set.
159#[cfg(test)]
160#[allow(dead_code)]
161pub fn set_environment_for_test(env: Environment) -> Result<(), Environment> {
162    ENVIRONMENT.set(env)
163}
164
165/// Generate a unique error ID using UUID v4 format
166///
167/// Returns a string in the format `err_{uuid}` where uuid is a 32-character
168/// hexadecimal string (UUID v4 simple format).
169///
170/// # Example
171///
172/// ```rust,ignore
173/// use rustapi_core::error::generate_error_id;
174///
175/// let id = generate_error_id();
176/// assert!(id.starts_with("err_"));
177/// assert_eq!(id.len(), 36); // "err_" (4) + uuid (32)
178/// ```
179pub fn generate_error_id() -> String {
180    format!("err_{}", Uuid::new_v4().simple())
181}
182
183/// Standard API error type
184///
185/// Provides structured error responses following a consistent JSON format.
186///
187/// # Example
188///
189/// ```
190/// use rustapi_core::ApiError;
191/// use http::StatusCode;
192///
193/// // Create a custom error
194/// let error = ApiError::new(StatusCode::CONFLICT, "duplicate", "Email already exists");
195/// assert_eq!(error.status, StatusCode::CONFLICT);
196/// assert_eq!(error.error_type, "duplicate");
197///
198/// // Use convenience constructors
199/// let not_found = ApiError::not_found("User not found");
200/// assert_eq!(not_found.status, StatusCode::NOT_FOUND);
201///
202/// let bad_request = ApiError::bad_request("Invalid input");
203/// assert_eq!(bad_request.status, StatusCode::BAD_REQUEST);
204/// ```
205#[derive(Debug, Clone)]
206pub struct ApiError {
207    /// HTTP status code
208    pub status: StatusCode,
209    /// Error type identifier
210    pub error_type: String,
211    /// Human-readable error message
212    pub message: String,
213    /// Optional field-level validation errors
214    pub fields: Option<Vec<FieldError>>,
215    /// Internal details (hidden in production)
216    pub(crate) internal: Option<String>,
217}
218
219/// Field-level validation error
220#[derive(Debug, Clone, Serialize)]
221pub struct FieldError {
222    /// Field name (supports nested: "address.city")
223    pub field: String,
224    /// Error code (e.g., "email", "length", "required")
225    pub code: String,
226    /// Human-readable message
227    pub message: String,
228}
229
230impl ApiError {
231    /// Create a new API error
232    pub fn new(
233        status: StatusCode,
234        error_type: impl Into<String>,
235        message: impl Into<String>,
236    ) -> Self {
237        Self {
238            status,
239            error_type: error_type.into(),
240            message: message.into(),
241            fields: None,
242            internal: None,
243        }
244    }
245
246    /// Create a validation error with field details
247    pub fn validation(fields: Vec<FieldError>) -> Self {
248        Self {
249            status: StatusCode::UNPROCESSABLE_ENTITY,
250            error_type: "validation_error".to_string(),
251            message: "Request validation failed".to_string(),
252            fields: Some(fields),
253            internal: None,
254        }
255    }
256
257    /// Create a 400 Bad Request error
258    pub fn bad_request(message: impl Into<String>) -> Self {
259        Self::new(StatusCode::BAD_REQUEST, "bad_request", message)
260    }
261
262    /// Create a 401 Unauthorized error
263    pub fn unauthorized(message: impl Into<String>) -> Self {
264        Self::new(StatusCode::UNAUTHORIZED, "unauthorized", message)
265    }
266
267    /// Create a 403 Forbidden error
268    pub fn forbidden(message: impl Into<String>) -> Self {
269        Self::new(StatusCode::FORBIDDEN, "forbidden", message)
270    }
271
272    /// Create a 404 Not Found error
273    pub fn not_found(message: impl Into<String>) -> Self {
274        Self::new(StatusCode::NOT_FOUND, "not_found", message)
275    }
276
277    /// Create a 409 Conflict error
278    pub fn conflict(message: impl Into<String>) -> Self {
279        Self::new(StatusCode::CONFLICT, "conflict", message)
280    }
281
282    /// Create a 500 Internal Server Error
283    pub fn internal(message: impl Into<String>) -> Self {
284        Self::new(StatusCode::INTERNAL_SERVER_ERROR, "internal_error", message)
285    }
286
287    /// Add internal details (for logging, hidden from response in prod)
288    pub fn with_internal(mut self, details: impl Into<String>) -> Self {
289        self.internal = Some(details.into());
290        self
291    }
292}
293
294impl fmt::Display for ApiError {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        write!(f, "{}: {}", self.error_type, self.message)
297    }
298}
299
300impl std::error::Error for ApiError {}
301
302/// JSON representation of API error response
303#[derive(Serialize)]
304pub struct ErrorResponse {
305    pub error: ErrorBody,
306    /// Unique error ID for log correlation (format: err_{uuid})
307    pub error_id: String,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub request_id: Option<String>,
310}
311
312#[derive(Serialize)]
313pub struct ErrorBody {
314    #[serde(rename = "type")]
315    pub error_type: String,
316    pub message: String,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub fields: Option<Vec<FieldError>>,
319}
320
321impl ErrorResponse {
322    /// Create an ErrorResponse from an ApiError with environment-aware masking
323    ///
324    /// In production mode:
325    /// - Internal server errors (5xx) show generic messages
326    /// - Validation errors always include field details
327    /// - Client errors (4xx) show their messages
328    ///
329    /// In development mode:
330    /// - All error details are shown
331    pub fn from_api_error(err: ApiError, env: Environment) -> Self {
332        let error_id = generate_error_id();
333
334        // Always log the full error details with error_id for correlation
335        if err.status.is_server_error() {
336            crate::trace_error!(
337                error_id = %error_id,
338                error_type = %err.error_type,
339                message = %err.message,
340                status = %err.status.as_u16(),
341                internal = ?err.internal,
342                environment = %env,
343                "Server error occurred"
344            );
345        } else if err.status.is_client_error() {
346            crate::trace_warn!(
347                error_id = %error_id,
348                error_type = %err.error_type,
349                message = %err.message,
350                status = %err.status.as_u16(),
351                environment = %env,
352                "Client error occurred"
353            );
354        } else {
355            crate::trace_info!(
356                error_id = %error_id,
357                error_type = %err.error_type,
358                message = %err.message,
359                status = %err.status.as_u16(),
360                environment = %env,
361                "Error response generated"
362            );
363        }
364
365        // Determine the message and fields based on environment and error type
366        let (message, fields) = if env.is_production() && err.status.is_server_error() {
367            // In production, mask internal server error details
368            // But preserve validation error fields (they're always shown per requirement 3.5)
369            let masked_message = "An internal error occurred".to_string();
370            // Validation errors keep their fields even in production
371            let fields = if err.error_type == "validation_error" {
372                err.fields
373            } else {
374                None
375            };
376            (masked_message, fields)
377        } else {
378            // In development or for non-5xx errors, show full details
379            (err.message, err.fields)
380        };
381
382        Self {
383            error: ErrorBody {
384                error_type: err.error_type,
385                message,
386                fields,
387            },
388            error_id,
389            request_id: None,
390        }
391    }
392}
393
394impl From<ApiError> for ErrorResponse {
395    fn from(err: ApiError) -> Self {
396        // Use the cached environment
397        let env = get_environment();
398        Self::from_api_error(err, env)
399    }
400}
401
402// Conversion from common error types
403impl From<serde_json::Error> for ApiError {
404    fn from(err: serde_json::Error) -> Self {
405        ApiError::bad_request(format!("Invalid JSON: {}", err))
406    }
407}
408
409impl From<crate::json::JsonError> for ApiError {
410    fn from(err: crate::json::JsonError) -> Self {
411        ApiError::bad_request(format!("Invalid JSON: {}", err))
412    }
413}
414
415impl From<std::io::Error> for ApiError {
416    fn from(err: std::io::Error) -> Self {
417        ApiError::internal("I/O error").with_internal(err.to_string())
418    }
419}
420
421impl From<hyper::Error> for ApiError {
422    fn from(err: hyper::Error) -> Self {
423        ApiError::internal("HTTP error").with_internal(err.to_string())
424    }
425}
426
427impl From<rustapi_validate::ValidationError> for ApiError {
428    fn from(err: rustapi_validate::ValidationError) -> Self {
429        let fields = err
430            .fields
431            .into_iter()
432            .map(|f| FieldError {
433                field: f.field,
434                code: f.code,
435                message: f.message,
436            })
437            .collect();
438
439        ApiError::validation(fields)
440    }
441}
442
443impl From<rustapi_validate::v2::ValidationErrors> for ApiError {
444    fn from(err: rustapi_validate::v2::ValidationErrors) -> Self {
445        let fields = err
446            .fields
447            .into_iter()
448            .flat_map(|(field, errors)| {
449                errors.into_iter().map(move |e| {
450                    let message = e.interpolate_message();
451                    FieldError {
452                        field: field.clone(),
453                        code: e.code,
454                        message,
455                    }
456                })
457            })
458            .collect();
459
460        ApiError::validation(fields)
461    }
462}
463
464impl ApiError {
465    /// Create a validation error from a ValidationError
466    pub fn from_validation_error(err: rustapi_validate::ValidationError) -> Self {
467        err.into()
468    }
469
470    /// Create a 503 Service Unavailable error
471    pub fn service_unavailable(message: impl Into<String>) -> Self {
472        Self::new(
473            StatusCode::SERVICE_UNAVAILABLE,
474            "service_unavailable",
475            message,
476        )
477    }
478}
479
480// SQLx error conversion (feature-gated)
481#[cfg(feature = "sqlx")]
482impl From<sqlx::Error> for ApiError {
483    fn from(err: sqlx::Error) -> Self {
484        match &err {
485            // Pool timeout or connection acquisition failure → 503
486            sqlx::Error::PoolTimedOut => {
487                ApiError::service_unavailable("Database connection pool exhausted")
488                    .with_internal(err.to_string())
489            }
490
491            // Pool closed → 503
492            sqlx::Error::PoolClosed => {
493                ApiError::service_unavailable("Database connection pool is closed")
494                    .with_internal(err.to_string())
495            }
496
497            // Row not found → 404
498            sqlx::Error::RowNotFound => ApiError::not_found("Resource not found"),
499
500            // Database-specific errors need deeper inspection
501            sqlx::Error::Database(db_err) => {
502                // Check for unique constraint violation
503                // PostgreSQL: 23505, MySQL: 1062, SQLite: 2067
504                if let Some(code) = db_err.code() {
505                    let code_str = code.as_ref();
506                    if code_str == "23505" || code_str == "1062" || code_str == "2067" {
507                        return ApiError::conflict("Resource already exists")
508                            .with_internal(db_err.to_string());
509                    }
510
511                    // Foreign key violation
512                    // PostgreSQL: 23503, MySQL: 1452, SQLite: 787
513                    if code_str == "23503" || code_str == "1452" || code_str == "787" {
514                        return ApiError::bad_request("Referenced resource does not exist")
515                            .with_internal(db_err.to_string());
516                    }
517
518                    // Check constraint violation
519                    // PostgreSQL: 23514
520                    if code_str == "23514" {
521                        return ApiError::bad_request("Data validation failed")
522                            .with_internal(db_err.to_string());
523                    }
524                }
525
526                // Generic database error
527                ApiError::internal("Database error").with_internal(db_err.to_string())
528            }
529
530            // Connection errors → 503
531            sqlx::Error::Io(_) => ApiError::service_unavailable("Database connection error")
532                .with_internal(err.to_string()),
533
534            // TLS errors → 503
535            sqlx::Error::Tls(_) => {
536                ApiError::service_unavailable("Database TLS error").with_internal(err.to_string())
537            }
538
539            // Protocol errors → 500
540            sqlx::Error::Protocol(_) => {
541                ApiError::internal("Database protocol error").with_internal(err.to_string())
542            }
543
544            // Type/decode errors → 500
545            sqlx::Error::TypeNotFound { .. } => {
546                ApiError::internal("Database type error").with_internal(err.to_string())
547            }
548
549            sqlx::Error::ColumnNotFound(_) => {
550                ApiError::internal("Database column not found").with_internal(err.to_string())
551            }
552
553            sqlx::Error::ColumnIndexOutOfBounds { .. } => {
554                ApiError::internal("Database column index error").with_internal(err.to_string())
555            }
556
557            sqlx::Error::ColumnDecode { .. } => {
558                ApiError::internal("Database decode error").with_internal(err.to_string())
559            }
560
561            // Configuration errors → 500
562            sqlx::Error::Configuration(_) => {
563                ApiError::internal("Database configuration error").with_internal(err.to_string())
564            }
565
566            // Migration errors → 500
567            sqlx::Error::Migrate(_) => {
568                ApiError::internal("Database migration error").with_internal(err.to_string())
569            }
570
571            // Any other errors → 500
572            _ => ApiError::internal("Database error").with_internal(err.to_string()),
573        }
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use proptest::prelude::*;
581    use std::collections::HashSet;
582
583    // **Feature: phase4-ergonomics-v1, Property 6: Error ID Uniqueness**
584    //
585    // For any sequence of N errors generated by the system, all N error IDs
586    // should be unique. The error ID should appear in both the HTTP response
587    // and the corresponding log entry.
588    //
589    // **Validates: Requirements 3.3**
590    proptest! {
591        #![proptest_config(ProptestConfig::with_cases(100))]
592
593        #[test]
594        fn prop_error_id_uniqueness(
595            // Generate a random number of errors between 10 and 200
596            num_errors in 10usize..200,
597        ) {
598            // Generate N error IDs
599            let error_ids: Vec<String> = (0..num_errors)
600                .map(|_| generate_error_id())
601                .collect();
602
603            // Collect into a HashSet to check uniqueness
604            let unique_ids: HashSet<&String> = error_ids.iter().collect();
605
606            // All IDs should be unique
607            prop_assert_eq!(
608                unique_ids.len(),
609                error_ids.len(),
610                "Generated {} error IDs but only {} were unique",
611                error_ids.len(),
612                unique_ids.len()
613            );
614
615            // All IDs should follow the format err_{uuid}
616            for id in &error_ids {
617                prop_assert!(
618                    id.starts_with("err_"),
619                    "Error ID '{}' does not start with 'err_'",
620                    id
621                );
622
623                // The UUID part should be 32 hex characters (simple format)
624                let uuid_part = &id[4..];
625                prop_assert_eq!(
626                    uuid_part.len(),
627                    32,
628                    "UUID part '{}' should be 32 characters, got {}",
629                    uuid_part,
630                    uuid_part.len()
631                );
632
633                // All characters should be valid hex
634                prop_assert!(
635                    uuid_part.chars().all(|c| c.is_ascii_hexdigit()),
636                    "UUID part '{}' contains non-hex characters",
637                    uuid_part
638                );
639            }
640        }
641    }
642
643    // **Feature: phase4-ergonomics-v1, Property 6: Error ID in Response**
644    //
645    // For any ApiError converted to ErrorResponse, the error_id field should
646    // be present and follow the correct format.
647    //
648    // **Validates: Requirements 3.3**
649    proptest! {
650        #![proptest_config(ProptestConfig::with_cases(100))]
651
652        #[test]
653        fn prop_error_response_contains_error_id(
654            error_type in "[a-z_]{1,20}",
655            message in "[a-zA-Z0-9 ]{1,100}",
656        ) {
657            let api_error = ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, error_type, message);
658            let error_response = ErrorResponse::from(api_error);
659
660            // error_id should be present and follow format
661            prop_assert!(
662                error_response.error_id.starts_with("err_"),
663                "Error ID '{}' does not start with 'err_'",
664                error_response.error_id
665            );
666
667            let uuid_part = &error_response.error_id[4..];
668            prop_assert_eq!(uuid_part.len(), 32);
669            prop_assert!(uuid_part.chars().all(|c| c.is_ascii_hexdigit()));
670        }
671    }
672
673    #[test]
674    fn test_error_id_format() {
675        let error_id = generate_error_id();
676
677        // Should start with "err_"
678        assert!(error_id.starts_with("err_"));
679
680        // Total length should be 4 (prefix) + 32 (uuid simple format) = 36
681        assert_eq!(error_id.len(), 36);
682
683        // UUID part should be valid hex
684        let uuid_part = &error_id[4..];
685        assert!(uuid_part.chars().all(|c| c.is_ascii_hexdigit()));
686    }
687
688    #[test]
689    fn test_error_response_includes_error_id() {
690        let api_error = ApiError::bad_request("test error");
691        let error_response = ErrorResponse::from(api_error);
692
693        // error_id should be present
694        assert!(error_response.error_id.starts_with("err_"));
695        assert_eq!(error_response.error_id.len(), 36);
696    }
697
698    #[test]
699    fn test_error_id_in_json_serialization() {
700        let api_error = ApiError::internal("test error");
701        let error_response = ErrorResponse::from(api_error);
702
703        let json = serde_json::to_string(&error_response).unwrap();
704
705        // JSON should contain error_id field
706        assert!(json.contains("\"error_id\":"));
707        assert!(json.contains("err_"));
708    }
709
710    #[test]
711    fn test_multiple_error_ids_are_unique() {
712        let ids: Vec<String> = (0..1000).map(|_| generate_error_id()).collect();
713        let unique: HashSet<_> = ids.iter().collect();
714
715        assert_eq!(ids.len(), unique.len(), "All error IDs should be unique");
716    }
717
718    // **Feature: phase4-ergonomics-v1, Property 4: Production Error Masking**
719    //
720    // For any internal error (5xx) when RUSTAPI_ENV=production, the response body
721    // should contain only a generic error message and error ID, without stack traces,
722    // internal details, or sensitive information.
723    //
724    // **Validates: Requirements 3.1**
725    proptest! {
726        #![proptest_config(ProptestConfig::with_cases(100))]
727
728        #[test]
729        fn prop_production_error_masking(
730            // Generate random error messages that could contain sensitive info
731            // Use longer strings to avoid false positives where short strings appear in masked message
732            sensitive_message in "[a-zA-Z0-9_]{10,200}",
733            internal_details in "[a-zA-Z0-9_]{10,200}",
734            // Generate random 5xx status codes
735            status_code in prop::sample::select(vec![500u16, 501, 502, 503, 504, 505]),
736        ) {
737            // Create an internal error with potentially sensitive details
738            let api_error = ApiError::new(
739                StatusCode::from_u16(status_code).unwrap(),
740                "internal_error",
741                sensitive_message.clone()
742            ).with_internal(internal_details.clone());
743
744            // Convert to ErrorResponse in production mode
745            let error_response = ErrorResponse::from_api_error(api_error, Environment::Production);
746
747            // The message should be masked to a generic message
748            prop_assert_eq!(
749                &error_response.error.message,
750                "An internal error occurred",
751                "Production 5xx error should have masked message, got: {}",
752                &error_response.error.message
753            );
754
755            // The original sensitive message should NOT appear in the response
756            // (only check if the message is long enough to be meaningful)
757            if sensitive_message.len() >= 10 {
758                prop_assert!(
759                    !error_response.error.message.contains(&sensitive_message),
760                    "Production error response should not contain original message"
761                );
762            }
763
764            // Internal details should NOT appear anywhere in the serialized response
765            let json = serde_json::to_string(&error_response).unwrap();
766            if internal_details.len() >= 10 {
767                prop_assert!(
768                    !json.contains(&internal_details),
769                    "Production error response should not contain internal details"
770                );
771            }
772
773            // Error ID should still be present
774            prop_assert!(
775                error_response.error_id.starts_with("err_"),
776                "Error ID should be present in production error response"
777            );
778        }
779    }
780
781    // **Feature: phase4-ergonomics-v1, Property 5: Development Error Details**
782    //
783    // For any error when RUSTAPI_ENV=development, the response body should contain
784    // detailed error information including the original error message and any
785    // available context.
786    //
787    // **Validates: Requirements 3.2**
788    proptest! {
789        #![proptest_config(ProptestConfig::with_cases(100))]
790
791        #[test]
792        fn prop_development_error_details(
793            // Generate random error messages
794            error_message in "[a-zA-Z0-9 ]{1,100}",
795            error_type in "[a-z_]{1,20}",
796            // Generate random status codes (both 4xx and 5xx)
797            status_code in prop::sample::select(vec![400u16, 401, 403, 404, 500, 502, 503]),
798        ) {
799            // Create an error with details
800            let api_error = ApiError::new(
801                StatusCode::from_u16(status_code).unwrap(),
802                error_type.clone(),
803                error_message.clone()
804            );
805
806            // Convert to ErrorResponse in development mode
807            let error_response = ErrorResponse::from_api_error(api_error, Environment::Development);
808
809            // The original message should be preserved
810            prop_assert_eq!(
811                error_response.error.message,
812                error_message,
813                "Development error should preserve original message"
814            );
815
816            // The error type should be preserved
817            prop_assert_eq!(
818                error_response.error.error_type,
819                error_type,
820                "Development error should preserve error type"
821            );
822
823            // Error ID should be present
824            prop_assert!(
825                error_response.error_id.starts_with("err_"),
826                "Error ID should be present in development error response"
827            );
828        }
829    }
830
831    // **Feature: phase4-ergonomics-v1, Property 7: Validation Error Field Details**
832    //
833    // For any validation error in any environment (production or development),
834    // the response should include field-level error details with field name,
835    // error code, and message.
836    //
837    // **Validates: Requirements 3.5**
838    proptest! {
839        #![proptest_config(ProptestConfig::with_cases(100))]
840
841        #[test]
842        fn prop_validation_error_field_details(
843            // Generate random field errors
844            field_name in "[a-z_]{1,20}",
845            field_code in "[a-z_]{1,15}",
846            field_message in "[a-zA-Z0-9 ]{1,50}",
847            // Test in both environments
848            is_production in proptest::bool::ANY,
849        ) {
850            let env = if is_production {
851                Environment::Production
852            } else {
853                Environment::Development
854            };
855
856            // Create a validation error with field details
857            let field_error = FieldError {
858                field: field_name.clone(),
859                code: field_code.clone(),
860                message: field_message.clone(),
861            };
862            let api_error = ApiError::validation(vec![field_error]);
863
864            // Convert to ErrorResponse
865            let error_response = ErrorResponse::from_api_error(api_error, env);
866
867            // Fields should always be present for validation errors
868            prop_assert!(
869                error_response.error.fields.is_some(),
870                "Validation error should always include fields in {} mode",
871                env
872            );
873
874            let fields = error_response.error.fields.as_ref().unwrap();
875            prop_assert_eq!(
876                fields.len(),
877                1,
878                "Should have exactly one field error"
879            );
880
881            let field = &fields[0];
882
883            // Field name should be preserved
884            prop_assert_eq!(
885                &field.field,
886                &field_name,
887                "Field name should be preserved in {} mode",
888                env
889            );
890
891            // Field code should be preserved
892            prop_assert_eq!(
893                &field.code,
894                &field_code,
895                "Field code should be preserved in {} mode",
896                env
897            );
898
899            // Field message should be preserved
900            prop_assert_eq!(
901                &field.message,
902                &field_message,
903                "Field message should be preserved in {} mode",
904                env
905            );
906
907            // Verify JSON serialization includes all field details
908            let json = serde_json::to_string(&error_response).unwrap();
909            prop_assert!(
910                json.contains(&field_name),
911                "JSON should contain field name in {} mode",
912                env
913            );
914            prop_assert!(
915                json.contains(&field_code),
916                "JSON should contain field code in {} mode",
917                env
918            );
919            prop_assert!(
920                json.contains(&field_message),
921                "JSON should contain field message in {} mode",
922                env
923            );
924        }
925    }
926
927    // Unit tests for Environment enum
928    // Note: These tests verify the Environment::from_env() logic by testing the parsing
929    // directly rather than modifying global environment variables (which causes race conditions
930    // in parallel test execution).
931
932    #[test]
933    fn test_environment_from_env_production() {
934        // Test the parsing logic directly by simulating what from_env() does
935        // This avoids race conditions with parallel tests
936
937        // Test "production" variants
938        assert!(matches!(
939            match "production".to_lowercase().as_str() {
940                "production" | "prod" => Environment::Production,
941                _ => Environment::Development,
942            },
943            Environment::Production
944        ));
945
946        assert!(matches!(
947            match "prod".to_lowercase().as_str() {
948                "production" | "prod" => Environment::Production,
949                _ => Environment::Development,
950            },
951            Environment::Production
952        ));
953
954        assert!(matches!(
955            match "PRODUCTION".to_lowercase().as_str() {
956                "production" | "prod" => Environment::Production,
957                _ => Environment::Development,
958            },
959            Environment::Production
960        ));
961
962        assert!(matches!(
963            match "PROD".to_lowercase().as_str() {
964                "production" | "prod" => Environment::Production,
965                _ => Environment::Development,
966            },
967            Environment::Production
968        ));
969    }
970
971    #[test]
972    fn test_environment_from_env_development() {
973        // Test the parsing logic directly by simulating what from_env() does
974        // This avoids race conditions with parallel tests
975
976        // Test "development" and other variants that should default to Development
977        assert!(matches!(
978            match "development".to_lowercase().as_str() {
979                "production" | "prod" => Environment::Production,
980                _ => Environment::Development,
981            },
982            Environment::Development
983        ));
984
985        assert!(matches!(
986            match "dev".to_lowercase().as_str() {
987                "production" | "prod" => Environment::Production,
988                _ => Environment::Development,
989            },
990            Environment::Development
991        ));
992
993        assert!(matches!(
994            match "test".to_lowercase().as_str() {
995                "production" | "prod" => Environment::Production,
996                _ => Environment::Development,
997            },
998            Environment::Development
999        ));
1000
1001        assert!(matches!(
1002            match "anything_else".to_lowercase().as_str() {
1003                "production" | "prod" => Environment::Production,
1004                _ => Environment::Development,
1005            },
1006            Environment::Development
1007        ));
1008    }
1009
1010    #[test]
1011    fn test_environment_default_is_development() {
1012        // Test that the default is Development
1013        assert_eq!(Environment::default(), Environment::Development);
1014    }
1015
1016    #[test]
1017    fn test_environment_display() {
1018        assert_eq!(format!("{}", Environment::Development), "development");
1019        assert_eq!(format!("{}", Environment::Production), "production");
1020    }
1021
1022    #[test]
1023    fn test_environment_is_methods() {
1024        assert!(Environment::Production.is_production());
1025        assert!(!Environment::Production.is_development());
1026        assert!(Environment::Development.is_development());
1027        assert!(!Environment::Development.is_production());
1028    }
1029
1030    #[test]
1031    fn test_production_masks_5xx_errors() {
1032        let error =
1033            ApiError::internal("Sensitive database connection string: postgres://user:pass@host");
1034        let response = ErrorResponse::from_api_error(error, Environment::Production);
1035
1036        assert_eq!(response.error.message, "An internal error occurred");
1037        assert!(!response.error.message.contains("postgres"));
1038    }
1039
1040    #[test]
1041    fn test_production_shows_4xx_errors() {
1042        let error = ApiError::bad_request("Invalid email format");
1043        let response = ErrorResponse::from_api_error(error, Environment::Production);
1044
1045        // 4xx errors should show their message even in production
1046        assert_eq!(response.error.message, "Invalid email format");
1047    }
1048
1049    #[test]
1050    fn test_development_shows_all_errors() {
1051        let error = ApiError::internal("Detailed error: connection refused to 192.168.1.1:5432");
1052        let response = ErrorResponse::from_api_error(error, Environment::Development);
1053
1054        assert_eq!(
1055            response.error.message,
1056            "Detailed error: connection refused to 192.168.1.1:5432"
1057        );
1058    }
1059
1060    #[test]
1061    fn test_validation_errors_always_show_fields() {
1062        let fields = vec![
1063            FieldError {
1064                field: "email".to_string(),
1065                code: "invalid_format".to_string(),
1066                message: "Invalid email format".to_string(),
1067            },
1068            FieldError {
1069                field: "age".to_string(),
1070                code: "min".to_string(),
1071                message: "Must be at least 18".to_string(),
1072            },
1073        ];
1074
1075        let error = ApiError::validation(fields.clone());
1076
1077        // Test in production
1078        let prod_response = ErrorResponse::from_api_error(error.clone(), Environment::Production);
1079        assert!(prod_response.error.fields.is_some());
1080        let prod_fields = prod_response.error.fields.unwrap();
1081        assert_eq!(prod_fields.len(), 2);
1082        assert_eq!(prod_fields[0].field, "email");
1083        assert_eq!(prod_fields[1].field, "age");
1084
1085        // Test in development
1086        let dev_response = ErrorResponse::from_api_error(error, Environment::Development);
1087        assert!(dev_response.error.fields.is_some());
1088        let dev_fields = dev_response.error.fields.unwrap();
1089        assert_eq!(dev_fields.len(), 2);
1090    }
1091}