1use thiserror::Error;
4
5#[derive(Debug, Error)]
6#[non_exhaustive]
7pub enum ApiError {
8 #[error("invalid request: {0}")]
10 InvalidRequest(String),
11
12 #[error("unauthenticated: {0}")]
14 Unauthenticated(String),
15
16 #[error("task not found: {0}")]
18 TaskNotFound(String),
19
20 #[error("payload too large: {0}")]
22 PayloadTooLarge(String),
23
24 #[error("internal error: {0}")]
26 Internal(String),
27
28 #[error("core error: {0}")]
30 Core(#[from] solti_core::CoreError),
31}
32
33impl ApiError {
34 pub fn as_label(&self) -> &'static str {
39 match self {
40 ApiError::Core(solti_core::CoreError::InvalidSpec(_)) => "InvalidRequest",
41 ApiError::Core(solti_core::CoreError::AlreadyExists(_)) => "AlreadyExists",
42 ApiError::Core(solti_core::CoreError::NotFound(_)) => "TaskNotFound",
43 ApiError::PayloadTooLarge(_) => "PayloadTooLarge",
44 ApiError::InvalidRequest(_) => "InvalidRequest",
45 ApiError::Unauthenticated(_) => "Unauthenticated",
46 ApiError::TaskNotFound(_) => "TaskNotFound",
47 ApiError::Internal(_) => "Internal",
48 ApiError::Core(_) => "Internal",
49 }
50 }
51}
52
53#[cfg(feature = "grpc")]
54impl From<ApiError> for tonic::Status {
55 fn from(err: ApiError) -> Self {
56 match err {
57 ApiError::PayloadTooLarge(msg) => tonic::Status::resource_exhausted(msg),
58 ApiError::InvalidRequest(msg) => tonic::Status::invalid_argument(msg),
59 ApiError::Unauthenticated(msg) => tonic::Status::unauthenticated(msg),
60 ApiError::TaskNotFound(msg) => tonic::Status::not_found(msg),
61 ApiError::Internal(msg) => tonic::Status::internal(msg),
62 ApiError::Core(e) => core_to_status(e),
63 }
64 }
65}
66
67#[cfg(feature = "grpc")]
68fn core_to_status(e: solti_core::CoreError) -> tonic::Status {
69 use solti_core::CoreError;
70 match e {
71 CoreError::InvalidSpec(inner) => tonic::Status::invalid_argument(inner.to_string()),
72 CoreError::AlreadyExists(msg) => tonic::Status::already_exists(msg),
73 CoreError::NotFound(msg) => tonic::Status::not_found(msg),
74 CoreError::Supervisor(_) | CoreError::Mapping(_) | CoreError::Runner(_) => {
75 tonic::Status::internal(e.to_string())
76 }
77 _ => tonic::Status::internal(e.to_string()),
80 }
81}
82
83#[cfg(feature = "http")]
84impl axum::response::IntoResponse for ApiError {
85 fn into_response(self) -> axum::response::Response {
86 use axum::http::StatusCode;
87
88 let label = self.as_label();
89 let (status, message) = match self {
90 ApiError::InvalidRequest(msg) => (StatusCode::BAD_REQUEST, msg),
91 ApiError::Unauthenticated(msg) => (StatusCode::UNAUTHORIZED, msg),
92 ApiError::TaskNotFound(msg) => (StatusCode::NOT_FOUND, msg),
93 ApiError::PayloadTooLarge(msg) => (StatusCode::PAYLOAD_TOO_LARGE, msg),
94 ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
95 ApiError::Core(e) => core_to_http_status(e),
96 };
97
98 let body = serde_json::json!({ "error": label, "message": message });
99 (status, axum::Json(body)).into_response()
100 }
101}
102
103#[cfg(feature = "http")]
104fn core_to_http_status(e: solti_core::CoreError) -> (axum::http::StatusCode, String) {
105 use axum::http::StatusCode;
106 use solti_core::CoreError;
107 match e {
108 CoreError::InvalidSpec(inner) => (StatusCode::BAD_REQUEST, inner.to_string()),
109 CoreError::AlreadyExists(msg) => (StatusCode::CONFLICT, msg),
110 CoreError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
111 CoreError::Supervisor(_) | CoreError::Mapping(_) | CoreError::Runner(_) => {
112 (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())
113 }
114 _ => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn as_label_covers_all_direct_variants() {
126 assert_eq!(
127 ApiError::InvalidRequest("x".into()).as_label(),
128 "InvalidRequest"
129 );
130 assert_eq!(
131 ApiError::TaskNotFound("x".into()).as_label(),
132 "TaskNotFound"
133 );
134 assert_eq!(ApiError::Internal("x".into()).as_label(), "Internal");
135 }
136
137 #[test]
138 fn as_label_flattens_core_invalid_spec_to_invalid_request() {
139 let inner = solti_model::ModelError::Invalid("bad".into());
140 let e = ApiError::Core(solti_core::CoreError::InvalidSpec(inner));
141 assert_eq!(e.as_label(), "InvalidRequest");
142 }
143
144 #[test]
145 fn as_label_maps_core_already_exists_and_not_found() {
146 let dup = ApiError::Core(solti_core::CoreError::AlreadyExists("t".into()));
147 assert_eq!(dup.as_label(), "AlreadyExists");
148
149 let missing = ApiError::Core(solti_core::CoreError::NotFound("t".into()));
150 assert_eq!(missing.as_label(), "TaskNotFound");
151 }
152
153 #[cfg(feature = "http")]
154 #[test]
155 fn core_already_exists_is_conflict_and_not_found_is_404() {
156 use axum::http::StatusCode;
157 let (status, _) = core_to_http_status(solti_core::CoreError::AlreadyExists("t".into()));
158 assert_eq!(status, StatusCode::CONFLICT);
159 let (status, _) = core_to_http_status(solti_core::CoreError::NotFound("t".into()));
160 assert_eq!(status, StatusCode::NOT_FOUND);
161 }
162
163 #[cfg(feature = "http")]
167 #[test]
168 fn core_to_http_status_maps_every_known_variant() {
169 use axum::http::StatusCode;
170 use solti_core::CoreError;
171
172 let cases = [
173 (
174 CoreError::InvalidSpec(solti_model::ModelError::Invalid("x".into())),
175 StatusCode::BAD_REQUEST,
176 ),
177 (CoreError::AlreadyExists("x".into()), StatusCode::CONFLICT),
178 (CoreError::NotFound("x".into()), StatusCode::NOT_FOUND),
179 (
180 CoreError::Supervisor("x".into()),
181 StatusCode::INTERNAL_SERVER_ERROR,
182 ),
183 (
184 CoreError::Mapping("x".into()),
185 StatusCode::INTERNAL_SERVER_ERROR,
186 ),
187 ];
188 for (err, expected) in cases {
189 assert_eq!(core_to_http_status(err).0, expected);
190 }
191 }
192
193 #[cfg(feature = "grpc")]
194 #[test]
195 fn core_to_status_maps_every_known_variant() {
196 use solti_core::CoreError;
197 use tonic::Code;
198
199 let cases = [
200 (
201 CoreError::InvalidSpec(solti_model::ModelError::Invalid("x".into())),
202 Code::InvalidArgument,
203 ),
204 (CoreError::AlreadyExists("x".into()), Code::AlreadyExists),
205 (CoreError::NotFound("x".into()), Code::NotFound),
206 (CoreError::Supervisor("x".into()), Code::Internal),
207 (CoreError::Mapping("x".into()), Code::Internal),
208 ];
209 for (err, expected) in cases {
210 assert_eq!(core_to_status(err).code(), expected);
211 }
212 }
213}