Skip to main content

systemprompt_models/api/errors/
mod.rs

1//! Public HTTP error envelope ([`ApiError`], [`ErrorCode`],
2//! [`ValidationError`], [`ErrorResponse`]) plus the internal
3//! `thiserror`-derived [`InternalApiError`] used by the application
4//! tier.
5
6mod internal;
7
8pub use internal::InternalApiError;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14#[cfg(feature = "web")]
15use axum::Json;
16#[cfg(feature = "web")]
17use axum::http::{StatusCode, header};
18#[cfg(feature = "web")]
19use axum::response::IntoResponse;
20
21#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ErrorCode {
24    NotFound,
25    BadRequest,
26    Unauthorized,
27    Forbidden,
28    InternalError,
29    ValidationError,
30    ConflictError,
31    RateLimited,
32    ServiceUnavailable,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ValidationError {
37    pub field: String,
38    pub message: String,
39    pub code: String,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub context: Option<Value>,
42}
43
44#[derive(Debug, Serialize, Deserialize)]
45pub struct ApiError {
46    pub code: ErrorCode,
47    pub message: String,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub details: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub error_key: Option<String>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub path: Option<String>,
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub validation_errors: Vec<ValidationError>,
56    pub timestamp: DateTime<Utc>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub trace_id: Option<String>,
59}
60
61impl ApiError {
62    pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
63        Self {
64            code,
65            message: message.into(),
66            details: None,
67            error_key: None,
68            path: None,
69            validation_errors: Vec::new(),
70            timestamp: Utc::now(),
71            trace_id: None,
72        }
73    }
74
75    #[must_use]
76    pub fn with_details(mut self, details: impl Into<String>) -> Self {
77        self.details = Some(details.into());
78        self
79    }
80
81    #[must_use]
82    pub fn with_error_key(mut self, key: impl Into<String>) -> Self {
83        self.error_key = Some(key.into());
84        self
85    }
86
87    #[must_use]
88    pub fn with_path(mut self, path: impl Into<String>) -> Self {
89        self.path = Some(path.into());
90        self
91    }
92
93    #[must_use]
94    pub fn with_validation_errors(mut self, errors: Vec<ValidationError>) -> Self {
95        self.validation_errors = errors;
96        self
97    }
98
99    #[must_use]
100    pub fn with_trace_id(mut self, id: impl Into<String>) -> Self {
101        self.trace_id = Some(id.into());
102        self
103    }
104
105    pub fn not_found(message: impl Into<String>) -> Self {
106        Self::new(ErrorCode::NotFound, message)
107    }
108
109    pub fn bad_request(message: impl Into<String>) -> Self {
110        Self::new(ErrorCode::BadRequest, message)
111    }
112
113    pub fn unauthorized(message: impl Into<String>) -> Self {
114        Self::new(ErrorCode::Unauthorized, message)
115    }
116
117    pub fn forbidden(message: impl Into<String>) -> Self {
118        Self::new(ErrorCode::Forbidden, message)
119    }
120
121    pub fn internal_error(message: impl Into<String>) -> Self {
122        Self::new(ErrorCode::InternalError, message)
123    }
124
125    pub fn validation_error(message: impl Into<String>, errors: Vec<ValidationError>) -> Self {
126        Self::new(ErrorCode::ValidationError, message).with_validation_errors(errors)
127    }
128
129    pub fn conflict(message: impl Into<String>) -> Self {
130        Self::new(ErrorCode::ConflictError, message)
131    }
132}
133
134#[derive(Debug, Serialize, Deserialize)]
135pub struct ErrorResponse {
136    pub error: ApiError,
137    pub api_version: String,
138}
139
140#[cfg(feature = "web")]
141impl ErrorCode {
142    #[must_use]
143    pub const fn status_code(&self) -> StatusCode {
144        match self {
145            Self::NotFound => StatusCode::NOT_FOUND,
146            Self::BadRequest => StatusCode::BAD_REQUEST,
147            Self::Unauthorized => StatusCode::UNAUTHORIZED,
148            Self::Forbidden => StatusCode::FORBIDDEN,
149            Self::ValidationError => StatusCode::UNPROCESSABLE_ENTITY,
150            Self::ConflictError => StatusCode::CONFLICT,
151            Self::RateLimited => StatusCode::TOO_MANY_REQUESTS,
152            Self::ServiceUnavailable => StatusCode::SERVICE_UNAVAILABLE,
153            Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
154        }
155    }
156}
157
158#[cfg(feature = "web")]
159impl IntoResponse for ApiError {
160    fn into_response(self) -> axum::response::Response {
161        let status = self.code.status_code();
162
163        if status.is_server_error() {
164            tracing::error!(
165                error_code = ?self.code,
166                message = %self.message,
167                path = ?self.path,
168                trace_id = ?self.trace_id,
169                "API server error response"
170            );
171        } else if status.is_client_error() {
172            tracing::warn!(
173                error_code = ?self.code,
174                message = %self.message,
175                path = ?self.path,
176                trace_id = ?self.trace_id,
177                "API client error response"
178            );
179        }
180
181        let mut response = (status, Json(self)).into_response();
182
183        if status == StatusCode::UNAUTHORIZED
184            && let Ok(header_value) =
185                "Bearer resource_metadata=\"/.well-known/oauth-protected-resource\"".parse()
186        {
187            response
188                .headers_mut()
189                .insert(header::WWW_AUTHENTICATE, header_value);
190        }
191
192        response
193    }
194}