Skip to main content

ai_model_vault/api/
error.rs

1//! API error types and JSON error responses.
2
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use serde::Serialize;
6
7use crate::error::VaultError;
8
9/// JSON error body returned to clients.
10#[derive(Debug, Serialize)]
11pub struct ApiErrorBody {
12    pub error: String,
13    pub code: u16,
14}
15
16/// API error that converts to an HTTP response.
17#[derive(Debug)]
18pub struct ApiError {
19    pub status: StatusCode,
20    pub message: String,
21}
22
23impl ApiError {
24    pub fn bad_request(msg: impl Into<String>) -> Self {
25        Self {
26            status: StatusCode::BAD_REQUEST,
27            message: msg.into(),
28        }
29    }
30
31    pub fn not_found(msg: impl Into<String>) -> Self {
32        Self {
33            status: StatusCode::NOT_FOUND,
34            message: msg.into(),
35        }
36    }
37
38    pub fn unauthorized(msg: impl Into<String>) -> Self {
39        Self {
40            status: StatusCode::UNAUTHORIZED,
41            message: msg.into(),
42        }
43    }
44
45    pub fn internal(msg: impl Into<String>) -> Self {
46        Self {
47            status: StatusCode::INTERNAL_SERVER_ERROR,
48            message: msg.into(),
49        }
50    }
51
52    pub fn conflict(msg: impl Into<String>) -> Self {
53        Self {
54            status: StatusCode::CONFLICT,
55            message: msg.into(),
56        }
57    }
58
59    pub fn rate_limited(msg: impl Into<String>) -> Self {
60        Self {
61            status: StatusCode::TOO_MANY_REQUESTS,
62            message: msg.into(),
63        }
64    }
65}
66
67impl IntoResponse for ApiError {
68    fn into_response(self) -> Response {
69        let body = ApiErrorBody {
70            error: self.message,
71            code: self.status.as_u16(),
72        };
73        (self.status, axum::Json(body)).into_response()
74    }
75}
76
77impl From<VaultError> for ApiError {
78    fn from(err: VaultError) -> Self {
79        match &err {
80            VaultError::ModelNotFound(_)
81            | VaultError::VersionNotFound(_, _)
82            | VaultError::NotFound(_) => ApiError::not_found(err.to_string()),
83            VaultError::AuthenticationFailed => ApiError::unauthorized(err.to_string()),
84            VaultError::SecurityViolation(_) => ApiError::unauthorized("Access denied"),
85            VaultError::InvalidInput(_) | VaultError::UnsupportedFormat(_) => {
86                ApiError::bad_request(err.to_string())
87            }
88            // Don't leak internal error details to clients
89            _ => {
90                tracing::error!("Internal error: {err}");
91                ApiError::internal("An internal error occurred")
92            }
93        }
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100
101    #[test]
102    fn test_bad_request() {
103        let err = ApiError::bad_request("oops");
104        assert_eq!(err.status, StatusCode::BAD_REQUEST);
105        assert_eq!(err.message, "oops");
106    }
107
108    #[test]
109    fn test_not_found() {
110        let err = ApiError::not_found("missing");
111        assert_eq!(err.status, StatusCode::NOT_FOUND);
112        assert_eq!(err.message, "missing");
113    }
114
115    #[test]
116    fn test_unauthorized() {
117        let err = ApiError::unauthorized("denied");
118        assert_eq!(err.status, StatusCode::UNAUTHORIZED);
119        assert_eq!(err.message, "denied");
120    }
121
122    #[test]
123    fn test_internal() {
124        let err = ApiError::internal("boom");
125        assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR);
126        assert_eq!(err.message, "boom");
127    }
128
129    #[test]
130    fn test_conflict() {
131        let err = ApiError::conflict("exists");
132        assert_eq!(err.status, StatusCode::CONFLICT);
133        assert_eq!(err.message, "exists");
134    }
135
136    #[test]
137    fn test_rate_limited() {
138        let err = ApiError::rate_limited("slow down");
139        assert_eq!(err.status, StatusCode::TOO_MANY_REQUESTS);
140        assert_eq!(err.message, "slow down");
141    }
142
143    #[test]
144    fn test_into_response() {
145        let err = ApiError::bad_request("test error");
146        let response = err.into_response();
147        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
148    }
149
150    #[test]
151    fn test_from_vault_error_model_not_found() {
152        let err: ApiError = VaultError::ModelNotFound("m1".into()).into();
153        assert_eq!(err.status, StatusCode::NOT_FOUND);
154    }
155
156    #[test]
157    fn test_from_vault_error_version_not_found() {
158        let err: ApiError = VaultError::VersionNotFound(1, "m1".into()).into();
159        assert_eq!(err.status, StatusCode::NOT_FOUND);
160    }
161
162    #[test]
163    fn test_from_vault_error_auth_failed() {
164        let err: ApiError = VaultError::AuthenticationFailed.into();
165        assert_eq!(err.status, StatusCode::UNAUTHORIZED);
166    }
167
168    /// Every not-found category must reach 404. The wildcard arm below maps
169    /// anything unlisted to 500, so a new variant added without touching that
170    /// match would silently report a missing resource as a server fault.
171    #[test]
172    fn test_all_not_found_variants_map_to_404() {
173        for err in [
174            VaultError::ModelNotFound("m".into()),
175            VaultError::VersionNotFound(3, "m".into()),
176            VaultError::NotFound("profile 'p'".into()),
177        ] {
178            let text = err.to_string();
179            let api: ApiError = err.into();
180            assert_eq!(api.status, StatusCode::NOT_FOUND, "{text} must be 404");
181        }
182    }
183
184    #[test]
185    fn test_from_vault_error_security_violation() {
186        let err: ApiError = VaultError::SecurityViolation("bad".into()).into();
187        assert_eq!(err.status, StatusCode::UNAUTHORIZED);
188    }
189
190    #[test]
191    fn test_from_vault_error_invalid_input() {
192        let err: ApiError = VaultError::InvalidInput("bad input".into()).into();
193        assert_eq!(err.status, StatusCode::BAD_REQUEST);
194    }
195
196    #[test]
197    fn test_from_vault_error_unsupported_format() {
198        let err: ApiError = VaultError::UnsupportedFormat("xyz".into()).into();
199        assert_eq!(err.status, StatusCode::BAD_REQUEST);
200    }
201
202    #[test]
203    fn test_from_vault_error_internal() {
204        let err: ApiError = VaultError::CryptoError("crypto fail".into()).into();
205        assert_eq!(err.status, StatusCode::INTERNAL_SERVER_ERROR);
206        assert_eq!(err.message, "An internal error occurred");
207    }
208
209    #[test]
210    fn test_api_error_body_serialization() {
211        let body = ApiErrorBody {
212            error: "not found".into(),
213            code: 404,
214        };
215        let json = serde_json::to_string(&body).unwrap();
216        assert!(json.contains("not found"));
217        assert!(json.contains("404"));
218    }
219}