talos/server/
api_error.rs

1//! Standardized API error responses for all Talos endpoints.
2//!
3//! This module provides a unified error response format across all API endpoints:
4//! - Legacy client endpoints (`/activate`, `/validate`, etc.)
5//! - Client API v1 (`/api/v1/client/*`)
6//! - Admin API (`/api/v1/licenses/*`, `/api/v1/tokens/*`)
7//!
8//! # Response Format
9//!
10//! All error responses follow this JSON structure:
11//!
12//! ```json
13//! {
14//!   "error": {
15//!     "code": "LICENSE_NOT_FOUND",
16//!     "message": "The requested license does not exist",
17//!     "details": null
18//!   }
19//! }
20//! ```
21//!
22//! The `details` field is optional and may contain additional context.
23
24use axum::{
25    http::StatusCode,
26    response::{IntoResponse, Response},
27    Json,
28};
29use serde::{Deserialize, Serialize};
30
31#[cfg(feature = "openapi")]
32use utoipa::ToSchema;
33
34use crate::errors::LicenseError;
35
36/// Machine-readable error codes for API responses.
37///
38/// These codes are stable and can be used by clients for programmatic error handling.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[cfg_attr(feature = "openapi", derive(ToSchema))]
41#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
42pub enum ErrorCode {
43    // === License State Errors (4xx) ===
44    /// License key was not found in the database
45    LicenseNotFound,
46    /// License has expired
47    LicenseExpired,
48    /// License has been revoked
49    LicenseRevoked,
50    /// License is suspended (temporary)
51    LicenseSuspended,
52    /// License has been permanently blacklisted
53    LicenseBlacklisted,
54    /// License exists but is not in active state
55    LicenseInactive,
56
57    // === Hardware Binding Errors (4xx) ===
58    /// License is already bound to a different device
59    AlreadyBound,
60    /// License is not bound to any device
61    NotBound,
62    /// Request hardware ID doesn't match bound device
63    HardwareMismatch,
64
65    // === Feature/Quota Errors (4xx) ===
66    /// Requested feature is not included in license tier
67    FeatureNotIncluded,
68    /// Usage quota has been exceeded
69    QuotaExceeded,
70
71    // === Validation Errors (400) ===
72    /// Request payload is invalid or malformed
73    InvalidRequest,
74    /// A required field is missing
75    MissingField,
76    /// A field value is invalid
77    InvalidField,
78
79    // === Authentication Errors (401/403) ===
80    /// No authentication token provided
81    MissingToken,
82    /// Authorization header is malformed
83    InvalidHeader,
84    /// Authentication token is invalid
85    InvalidToken,
86    /// Authentication token has expired
87    TokenExpired,
88    /// Token lacks required permissions
89    InsufficientScope,
90    /// Authentication is not configured on server
91    AuthDisabled,
92
93    // === Resource Errors (404/409) ===
94    /// Requested resource was not found
95    NotFound,
96    /// Operation conflicts with current state
97    Conflict,
98
99    // === Server Errors (5xx) ===
100    /// Database operation failed
101    DatabaseError,
102    /// Server configuration error
103    ConfigError,
104    /// Encryption/decryption operation failed
105    CryptoError,
106    /// External service communication failed
107    NetworkError,
108    /// Unexpected internal server error
109    InternalError,
110}
111
112impl ErrorCode {
113    /// Returns the HTTP status code for this error code.
114    pub fn status_code(&self) -> StatusCode {
115        match self {
116            // 400 Bad Request
117            ErrorCode::InvalidRequest
118            | ErrorCode::MissingField
119            | ErrorCode::InvalidField
120            | ErrorCode::InvalidHeader => StatusCode::BAD_REQUEST,
121
122            // 401 Unauthorized
123            ErrorCode::MissingToken | ErrorCode::InvalidToken | ErrorCode::TokenExpired => {
124                StatusCode::UNAUTHORIZED
125            }
126
127            // 403 Forbidden
128            ErrorCode::LicenseExpired
129            | ErrorCode::LicenseRevoked
130            | ErrorCode::LicenseSuspended
131            | ErrorCode::LicenseBlacklisted
132            | ErrorCode::LicenseInactive
133            | ErrorCode::HardwareMismatch
134            | ErrorCode::FeatureNotIncluded
135            | ErrorCode::QuotaExceeded
136            | ErrorCode::InsufficientScope => StatusCode::FORBIDDEN,
137
138            // 404 Not Found
139            ErrorCode::LicenseNotFound | ErrorCode::NotFound => StatusCode::NOT_FOUND,
140
141            // 409 Conflict
142            ErrorCode::AlreadyBound | ErrorCode::NotBound | ErrorCode::Conflict => {
143                StatusCode::CONFLICT
144            }
145
146            // 500 Internal Server Error
147            ErrorCode::DatabaseError
148            | ErrorCode::ConfigError
149            | ErrorCode::CryptoError
150            | ErrorCode::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
151
152            // 501 Not Implemented
153            ErrorCode::AuthDisabled => StatusCode::NOT_IMPLEMENTED,
154
155            // 502 Bad Gateway
156            ErrorCode::NetworkError => StatusCode::BAD_GATEWAY,
157        }
158    }
159
160    /// Returns a default human-readable message for this error code.
161    pub fn default_message(&self) -> &'static str {
162        match self {
163            ErrorCode::LicenseNotFound => "The requested license does not exist",
164            ErrorCode::LicenseExpired => "License has expired",
165            ErrorCode::LicenseRevoked => "License has been revoked",
166            ErrorCode::LicenseSuspended => "License is temporarily suspended",
167            ErrorCode::LicenseBlacklisted => "License has been permanently blacklisted",
168            ErrorCode::LicenseInactive => "License is not active",
169            ErrorCode::AlreadyBound => "License is already bound to another device",
170            ErrorCode::NotBound => "License is not bound to any device",
171            ErrorCode::HardwareMismatch => "Hardware ID does not match the bound device",
172            ErrorCode::FeatureNotIncluded => "Feature is not included in your license tier",
173            ErrorCode::QuotaExceeded => "Usage quota has been exceeded",
174            ErrorCode::InvalidRequest => "Request payload is invalid",
175            ErrorCode::MissingField => "A required field is missing",
176            ErrorCode::InvalidField => "A field value is invalid",
177            ErrorCode::MissingToken => "Authentication token is required",
178            ErrorCode::InvalidHeader => "Authorization header is malformed",
179            ErrorCode::InvalidToken => "Authentication token is invalid",
180            ErrorCode::TokenExpired => "Authentication token has expired",
181            ErrorCode::InsufficientScope => "Insufficient permissions for this operation",
182            ErrorCode::AuthDisabled => "Authentication is not configured on this server",
183            ErrorCode::NotFound => "The requested resource was not found",
184            ErrorCode::Conflict => "Operation conflicts with current resource state",
185            ErrorCode::DatabaseError => "Database operation failed",
186            ErrorCode::ConfigError => "Server configuration error",
187            ErrorCode::CryptoError => "Encryption operation failed",
188            ErrorCode::NetworkError => "Failed to communicate with external service",
189            ErrorCode::InternalError => "An unexpected error occurred",
190        }
191    }
192}
193
194/// The inner error object containing code, message, and optional details.
195#[derive(Debug, Clone, Serialize, Deserialize)]
196#[cfg_attr(feature = "openapi", derive(ToSchema))]
197pub struct ErrorBody {
198    /// Machine-readable error code
199    pub code: ErrorCode,
200    /// Human-readable error message
201    pub message: String,
202    /// Optional additional details (field name, constraint violated, etc.)
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub details: Option<serde_json::Value>,
205}
206
207/// Standardized API error response.
208///
209/// This is the top-level error response returned by all API endpoints.
210#[derive(Debug, Clone, Serialize, Deserialize)]
211#[cfg_attr(feature = "openapi", derive(ToSchema))]
212pub struct ApiError {
213    /// The error details
214    pub error: ErrorBody,
215}
216
217impl ApiError {
218    /// Creates a new API error with the given code.
219    ///
220    /// Uses the default message for the error code.
221    pub fn new(code: ErrorCode) -> Self {
222        Self {
223            error: ErrorBody {
224                code,
225                message: code.default_message().to_string(),
226                details: None,
227            },
228        }
229    }
230
231    /// Creates a new API error with a custom message.
232    pub fn with_message(code: ErrorCode, message: impl Into<String>) -> Self {
233        Self {
234            error: ErrorBody {
235                code,
236                message: message.into(),
237                details: None,
238            },
239        }
240    }
241
242    /// Creates a new API error with a custom message and details.
243    pub fn with_details(
244        code: ErrorCode,
245        message: impl Into<String>,
246        details: serde_json::Value,
247    ) -> Self {
248        Self {
249            error: ErrorBody {
250                code,
251                message: message.into(),
252                details: Some(details),
253            },
254        }
255    }
256
257    /// Adds details to an existing error.
258    pub fn details(mut self, details: serde_json::Value) -> Self {
259        self.error.details = Some(details);
260        self
261    }
262
263    /// Returns the HTTP status code for this error.
264    pub fn status_code(&self) -> StatusCode {
265        self.error.code.status_code()
266    }
267
268    // === Convenience constructors for common errors ===
269
270    /// License not found error.
271    pub fn license_not_found() -> Self {
272        Self::new(ErrorCode::LicenseNotFound)
273    }
274
275    /// License not found with key in message.
276    pub fn license_not_found_key(key: &str) -> Self {
277        Self::with_message(
278            ErrorCode::LicenseNotFound,
279            format!("License '{}' not found", key),
280        )
281    }
282
283    /// Invalid request error with field details.
284    pub fn invalid_field(field: &str, reason: &str) -> Self {
285        Self::with_details(
286            ErrorCode::InvalidField,
287            format!("Invalid value for '{}': {}", field, reason),
288            serde_json::json!({ "field": field }),
289        )
290    }
291
292    /// Missing required field error.
293    pub fn missing_field(field: &str) -> Self {
294        Self::with_details(
295            ErrorCode::MissingField,
296            format!("Required field '{}' is missing", field),
297            serde_json::json!({ "field": field }),
298        )
299    }
300
301    /// Resource not found error.
302    pub fn not_found(resource: &str) -> Self {
303        Self::with_message(ErrorCode::NotFound, format!("{} not found", resource))
304    }
305
306    /// Database error (internal details hidden from client).
307    pub fn database_error() -> Self {
308        Self::new(ErrorCode::DatabaseError)
309    }
310
311    /// Internal server error.
312    pub fn internal_error() -> Self {
313        Self::new(ErrorCode::InternalError)
314    }
315}
316
317impl IntoResponse for ApiError {
318    fn into_response(self) -> Response {
319        let status = self.status_code();
320        (status, Json(self)).into_response()
321    }
322}
323
324impl std::fmt::Display for ApiError {
325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        write!(
327            f,
328            "{}: {}",
329            self.error.code.default_message(),
330            self.error.message
331        )
332    }
333}
334
335impl std::error::Error for ApiError {}
336
337// === Conversions from existing error types ===
338
339impl From<LicenseError> for ApiError {
340    fn from(err: LicenseError) -> Self {
341        match err {
342            LicenseError::InvalidLicense(msg) => {
343                ApiError::with_message(ErrorCode::InvalidRequest, msg)
344            }
345            LicenseError::ConfigError(msg) => ApiError::with_message(ErrorCode::ConfigError, msg),
346            LicenseError::NetworkError(e) => {
347                ApiError::with_message(ErrorCode::NetworkError, e.to_string())
348            }
349            LicenseError::StorageError(e) => {
350                ApiError::with_message(ErrorCode::InternalError, e.to_string())
351            }
352            LicenseError::EncryptionError(msg)
353            | LicenseError::DecryptionError(msg)
354            | LicenseError::KeyringError(msg) => {
355                ApiError::with_message(ErrorCode::CryptoError, msg)
356            }
357            LicenseError::ServerError(msg) => ApiError::with_message(ErrorCode::InternalError, msg),
358            LicenseError::UnknownError => ApiError::new(ErrorCode::InternalError),
359            LicenseError::ClientApiError(e) => {
360                // Map client error codes to server error codes
361                use crate::client::errors::ClientErrorCode;
362                let code = match e.code {
363                    ClientErrorCode::LicenseNotFound => ErrorCode::LicenseNotFound,
364                    ClientErrorCode::LicenseExpired => ErrorCode::LicenseExpired,
365                    ClientErrorCode::LicenseRevoked => ErrorCode::LicenseRevoked,
366                    ClientErrorCode::LicenseSuspended => ErrorCode::LicenseSuspended,
367                    ClientErrorCode::LicenseBlacklisted => ErrorCode::LicenseBlacklisted,
368                    ClientErrorCode::LicenseInactive => ErrorCode::LicenseInactive,
369                    ClientErrorCode::AlreadyBound => ErrorCode::AlreadyBound,
370                    ClientErrorCode::NotBound => ErrorCode::NotBound,
371                    ClientErrorCode::HardwareMismatch => ErrorCode::HardwareMismatch,
372                    ClientErrorCode::FeatureNotIncluded => ErrorCode::FeatureNotIncluded,
373                    ClientErrorCode::QuotaExceeded => ErrorCode::QuotaExceeded,
374                    ClientErrorCode::GracePeriodExpired
375                    | ClientErrorCode::InternalError
376                    | ClientErrorCode::Unknown => ErrorCode::InternalError,
377                };
378                ApiError::with_message(code, e.message)
379            }
380        }
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn error_code_status_mapping() {
390        assert_eq!(
391            ErrorCode::LicenseNotFound.status_code(),
392            StatusCode::NOT_FOUND
393        );
394        assert_eq!(
395            ErrorCode::InvalidRequest.status_code(),
396            StatusCode::BAD_REQUEST
397        );
398        assert_eq!(
399            ErrorCode::MissingToken.status_code(),
400            StatusCode::UNAUTHORIZED
401        );
402        assert_eq!(
403            ErrorCode::LicenseExpired.status_code(),
404            StatusCode::FORBIDDEN
405        );
406        assert_eq!(ErrorCode::AlreadyBound.status_code(), StatusCode::CONFLICT);
407        assert_eq!(
408            ErrorCode::DatabaseError.status_code(),
409            StatusCode::INTERNAL_SERVER_ERROR
410        );
411    }
412
413    #[test]
414    fn api_error_serialization() {
415        let err = ApiError::license_not_found();
416        let json = serde_json::to_string(&err).unwrap();
417        assert!(json.contains("LICENSE_NOT_FOUND"));
418        assert!(json.contains("message"));
419    }
420
421    #[test]
422    fn api_error_with_details() {
423        let err = ApiError::invalid_field("email", "must be a valid email address");
424        let json = serde_json::to_string(&err).unwrap();
425        assert!(json.contains("INVALID_FIELD"));
426        assert!(json.contains("email"));
427    }
428
429    #[test]
430    fn license_error_conversion() {
431        let license_err = LicenseError::InvalidLicense("bad key".to_string());
432        let api_err: ApiError = license_err.into();
433        assert_eq!(api_err.error.code, ErrorCode::InvalidRequest);
434    }
435}