1use std::fmt;
11
12use thiserror::Error;
13
14#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct ApiErrorCause {
17 reason: String,
18 field: Option<String>,
19 message: String,
20}
21
22impl ApiErrorCause {
23 pub fn new(reason: impl Into<String>, message: impl Into<String>) -> Self {
25 Self {
26 reason: reason.into(),
27 field: None,
28 message: message.into(),
29 }
30 }
31
32 pub fn with_field(mut self, field: impl Into<String>) -> Self {
34 self.field = Some(field.into());
35 self
36 }
37
38 pub fn reason(&self) -> &str {
40 &self.reason
41 }
42
43 pub fn field(&self) -> Option<&str> {
45 self.field.as_deref()
46 }
47
48 pub fn message(&self) -> &str {
50 &self.message
51 }
52}
53
54#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct ApiConflict {
59 name: String,
60 causes: Vec<ApiErrorCause>,
61}
62
63impl ApiConflict {
64 pub fn new(name: impl Into<String>, causes: Vec<ApiErrorCause>) -> Self {
66 Self {
67 name: name.into(),
68 causes,
69 }
70 }
71
72 pub fn name(&self) -> &str {
74 &self.name
75 }
76
77 pub fn causes(&self) -> &[ApiErrorCause] {
79 &self.causes
80 }
81}
82
83impl fmt::Display for ApiConflict {
84 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85 write!(
86 formatter,
87 "write precondition failed for Task `{}`",
88 self.name
89 )?;
90 for (index, cause) in self.causes.iter().enumerate() {
91 if index == 0 {
92 formatter.write_str(": ")?;
93 } else {
94 formatter.write_str("; ")?;
95 }
96 formatter.write_str(cause.message())?;
97 }
98 Ok(())
99 }
100}
101
102impl std::error::Error for ApiConflict {}
103
104#[derive(Debug, Error)]
123#[non_exhaustive]
124pub enum ApiError {
125 #[error("invalid request: {0}")]
127 InvalidRequest(String),
128
129 #[error("unauthenticated: {0}")]
131 Unauthenticated(String),
132
133 #[error("task already exists: {0}")]
135 AlreadyExists(String),
136
137 #[error(transparent)]
139 Conflict(ApiConflict),
140
141 #[error("task not found: {0}")]
143 TaskNotFound(String),
144
145 #[error("not found: {0}")]
147 NotFound(String),
148
149 #[error("method not allowed: {0}")]
151 MethodNotAllowed(String),
152
153 #[error("unsupported media type: {0}")]
155 UnsupportedMediaType(String),
156
157 #[error("payload too large: {0}")]
159 PayloadTooLarge(String),
160
161 #[error("resource version expired: {0}")]
163 ResourceVersionExpired(String),
164
165 #[error("service unavailable: {0}")]
167 Unavailable(String),
168
169 #[error("internal error: {0}")]
171 Internal(String),
172}
173
174impl ApiError {
175 pub fn as_label(&self) -> &'static str {
177 match self {
178 ApiError::PayloadTooLarge(_) => "PayloadTooLarge",
179 ApiError::InvalidRequest(_) => "InvalidRequest",
180 ApiError::Unauthenticated(_) => "Unauthenticated",
181 ApiError::AlreadyExists(_) => "AlreadyExists",
182 ApiError::Conflict(_) => "Conflict",
183 ApiError::TaskNotFound(_) => "TaskNotFound",
184 ApiError::NotFound(_) => "NotFound",
185 ApiError::MethodNotAllowed(_) => "MethodNotAllowed",
186 ApiError::UnsupportedMediaType(_) => "UnsupportedMediaType",
187 ApiError::ResourceVersionExpired(_) => "ResourceVersionExpired",
188 ApiError::Unavailable(_) => "Unavailable",
189 ApiError::Internal(_) => "Internal",
190 }
191 }
192
193 #[cfg(feature = "http")]
194 fn http_reason(&self) -> &'static str {
195 match self {
196 ApiError::InvalidRequest(_) => "BadRequest",
197 ApiError::Unauthenticated(_) => "Unauthorized",
198 ApiError::AlreadyExists(_) => "AlreadyExists",
199 ApiError::Conflict(_) => "Conflict",
200 ApiError::TaskNotFound(_) => "NotFound",
201 ApiError::NotFound(_) => "NotFound",
202 ApiError::MethodNotAllowed(_) => "MethodNotAllowed",
203 ApiError::UnsupportedMediaType(_) => "UnsupportedMediaType",
204 ApiError::PayloadTooLarge(_) => "RequestEntityTooLarge",
205 ApiError::ResourceVersionExpired(_) => "Expired",
206 ApiError::Unavailable(_) => "ServiceUnavailable",
207 ApiError::Internal(_) => "InternalError",
208 }
209 }
210
211 #[cfg(feature = "http")]
212 pub(crate) fn into_http_status(self) -> (axum::http::StatusCode, HttpStatusResource) {
213 use axum::http::StatusCode;
214
215 let reason = self.http_reason();
216 let (status, message, details) = match self {
217 ApiError::InvalidRequest(msg) => (StatusCode::BAD_REQUEST, msg, None),
218 ApiError::Unauthenticated(msg) => (StatusCode::UNAUTHORIZED, msg, None),
219 ApiError::AlreadyExists(msg) => (StatusCode::CONFLICT, msg, None),
220 ApiError::Conflict(conflict) => {
221 let details = HttpStatusDetails {
222 name: conflict.name().to_owned(),
223 group: "solti.io",
224 kind: "Task",
225 causes: conflict
226 .causes()
227 .iter()
228 .map(|cause| HttpStatusCause {
229 reason: cause.reason().to_owned(),
230 field: cause.field().map(http_field_path),
231 message: cause.message().to_owned(),
232 })
233 .collect(),
234 };
235 (StatusCode::CONFLICT, conflict.to_string(), Some(details))
236 }
237 ApiError::TaskNotFound(msg) => (StatusCode::NOT_FOUND, msg, None),
238 ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg, None),
239 ApiError::MethodNotAllowed(msg) => (StatusCode::METHOD_NOT_ALLOWED, msg, None),
240 ApiError::UnsupportedMediaType(msg) => (StatusCode::UNSUPPORTED_MEDIA_TYPE, msg, None),
241 ApiError::PayloadTooLarge(msg) => (StatusCode::PAYLOAD_TOO_LARGE, msg, None),
242 ApiError::ResourceVersionExpired(msg) => (StatusCode::GONE, msg, None),
243 ApiError::Unavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg, None),
244 ApiError::Internal(msg) => {
245 tracing::error!(error = %msg, "API request failed internally");
246 (
247 StatusCode::INTERNAL_SERVER_ERROR,
248 "internal server error".to_owned(),
249 None,
250 )
251 }
252 };
253
254 let body = HttpStatusResource {
255 api_version: "v1",
256 kind: "Status",
257 metadata: HttpStatusMeta {},
258 status: "Failure",
259 message,
260 reason,
261 details,
262 code: status.as_u16(),
263 };
264 (status, body)
265 }
266}
267
268#[cfg(feature = "http")]
269#[derive(schemars::JsonSchema, serde::Serialize)]
270#[schemars(deny_unknown_fields)]
271#[serde(rename_all = "camelCase")]
272pub(crate) struct HttpStatusResource {
273 #[schemars(schema_with = "http_status_api_version")]
274 api_version: &'static str,
275 #[schemars(schema_with = "http_status_kind")]
276 kind: &'static str,
277 metadata: HttpStatusMeta,
278 #[schemars(schema_with = "http_status_value")]
279 status: &'static str,
280 message: String,
281 #[schemars(schema_with = "http_status_reason")]
282 reason: &'static str,
283 #[serde(skip_serializing_if = "Option::is_none")]
284 details: Option<HttpStatusDetails>,
285 #[schemars(range(min = 400, max = 599))]
286 code: u16,
287}
288
289#[cfg(feature = "http")]
290#[derive(schemars::JsonSchema, serde::Serialize)]
291#[schemars(deny_unknown_fields)]
292struct HttpStatusMeta {}
293
294#[cfg(feature = "http")]
295#[derive(schemars::JsonSchema, serde::Serialize)]
296#[schemars(deny_unknown_fields)]
297struct HttpStatusDetails {
298 name: String,
299 #[schemars(schema_with = "http_status_group")]
300 group: &'static str,
301 #[schemars(schema_with = "http_status_task_kind")]
302 kind: &'static str,
303 causes: Vec<HttpStatusCause>,
304}
305
306#[cfg(feature = "http")]
307#[derive(schemars::JsonSchema, serde::Serialize)]
308#[schemars(deny_unknown_fields)]
309struct HttpStatusCause {
310 reason: String,
311 #[serde(skip_serializing_if = "Option::is_none")]
312 field: Option<String>,
313 message: String,
314}
315
316#[cfg(feature = "http")]
317fn http_status_api_version(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
318 schemars::json_schema!({
319 "type": "string",
320 "const": "v1"
321 })
322}
323
324#[cfg(feature = "http")]
325fn http_status_kind(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
326 schemars::json_schema!({
327 "type": "string",
328 "const": "Status"
329 })
330}
331
332#[cfg(feature = "http")]
333fn http_status_value(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
334 schemars::json_schema!({
335 "type": "string",
336 "const": "Failure"
337 })
338}
339
340#[cfg(feature = "http")]
341fn http_status_reason(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
342 schemars::json_schema!({
343 "type": "string",
344 "enum": [
345 "AlreadyExists",
346 "BadRequest",
347 "Conflict",
348 "Expired",
349 "InternalError",
350 "MethodNotAllowed",
351 "NotFound",
352 "RequestEntityTooLarge",
353 "ServiceUnavailable",
354 "Unauthorized",
355 "UnsupportedMediaType"
356 ]
357 })
358}
359
360#[cfg(feature = "http")]
361fn http_status_group(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
362 schemars::json_schema!({
363 "type": "string",
364 "const": "solti.io"
365 })
366}
367
368#[cfg(feature = "http")]
369fn http_status_task_kind(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
370 schemars::json_schema!({
371 "type": "string",
372 "const": "Task"
373 })
374}
375
376#[cfg(feature = "grpc")]
377impl From<ApiError> for tonic::Status {
378 fn from(err: ApiError) -> Self {
379 match err {
380 ApiError::PayloadTooLarge(msg) => tonic::Status::resource_exhausted(msg),
381 ApiError::InvalidRequest(msg) => tonic::Status::invalid_argument(msg),
382 ApiError::Unauthenticated(msg) => tonic::Status::unauthenticated(msg),
383 ApiError::AlreadyExists(msg) => tonic::Status::already_exists(msg),
384 ApiError::Conflict(conflict) => {
385 use prost::Message as _;
386
387 let details = crate::proto_api::WriteConflictDetails {
388 name: conflict.name().to_owned(),
389 causes: conflict
390 .causes()
391 .iter()
392 .map(|cause| crate::proto_api::StatusCause {
393 reason: cause.reason().to_owned(),
394 field: cause.field().map(str::to_owned),
395 message: cause.message().to_owned(),
396 })
397 .collect(),
398 };
399 tonic::Status::with_details(
400 tonic::Code::Aborted,
401 conflict.to_string(),
402 details.encode_to_vec().into(),
403 )
404 }
405 ApiError::TaskNotFound(msg) => tonic::Status::not_found(msg),
406 ApiError::NotFound(msg) => tonic::Status::not_found(msg),
407 ApiError::MethodNotAllowed(msg) => tonic::Status::unimplemented(msg),
408 ApiError::UnsupportedMediaType(msg) => tonic::Status::invalid_argument(msg),
409 ApiError::ResourceVersionExpired(msg) => tonic::Status::out_of_range(msg),
410 ApiError::Unavailable(msg) => tonic::Status::unavailable(msg),
411 ApiError::Internal(msg) => {
412 tracing::error!(error = %msg, "API request failed internally");
413 tonic::Status::internal("internal server error")
414 }
415 }
416 }
417}
418
419#[cfg(feature = "http")]
420impl axum::response::IntoResponse for ApiError {
421 fn into_response(self) -> axum::response::Response {
422 let (status, body) = self.into_http_status();
423 (status, axum::Json(body)).into_response()
424 }
425}
426
427#[cfg(feature = "http")]
428fn http_field_path(field: &str) -> String {
429 match field {
430 "preconditions.uid" => "uid".to_owned(),
431 "preconditions.resourceVersion" => "resourceVersion".to_owned(),
432 other => other.to_owned(),
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 fn conflict() -> ApiConflict {
441 ApiConflict::new(
442 "task-1",
443 vec![
444 ApiErrorCause::new("ResourceVersionMismatch", "expected `1`, current `2`")
445 .with_field("preconditions.resourceVersion"),
446 ],
447 )
448 }
449
450 #[test]
451 fn as_label_covers_all_direct_variants() {
452 let cases = [
453 (ApiError::InvalidRequest("x".into()), "InvalidRequest"),
454 (ApiError::Unauthenticated("x".into()), "Unauthenticated"),
455 (ApiError::AlreadyExists("x".into()), "AlreadyExists"),
456 (ApiError::Conflict(conflict()), "Conflict"),
457 (ApiError::TaskNotFound("x".into()), "TaskNotFound"),
458 (ApiError::NotFound("x".into()), "NotFound"),
459 (ApiError::MethodNotAllowed("x".into()), "MethodNotAllowed"),
460 (
461 ApiError::UnsupportedMediaType("x".into()),
462 "UnsupportedMediaType",
463 ),
464 (ApiError::PayloadTooLarge("x".into()), "PayloadTooLarge"),
465 (
466 ApiError::ResourceVersionExpired("x".into()),
467 "ResourceVersionExpired",
468 ),
469 (ApiError::Unavailable("x".into()), "Unavailable"),
470 (ApiError::Internal("x".into()), "Internal"),
471 ];
472
473 for (error, expected) in cases {
474 assert_eq!(error.as_label(), expected);
475 }
476 }
477
478 #[cfg(feature = "http")]
479 #[test]
480 fn direct_errors_map_to_http_status_codes() {
481 use axum::http::StatusCode;
482 use axum::response::IntoResponse;
483
484 for (error, expected) in [
485 (ApiError::AlreadyExists("x".into()), StatusCode::CONFLICT),
486 (
487 ApiError::ResourceVersionExpired("old revision".into()),
488 StatusCode::GONE,
489 ),
490 (ApiError::Conflict(conflict()), StatusCode::CONFLICT),
491 (
492 ApiError::Unavailable("x".into()),
493 StatusCode::SERVICE_UNAVAILABLE,
494 ),
495 ] {
496 assert_eq!(error.into_response().status(), expected);
497 }
498 }
499
500 #[cfg(feature = "grpc")]
501 #[test]
502 fn direct_errors_map_to_grpc_status_codes() {
503 use tonic::Code;
504
505 for (error, expected) in [
506 (ApiError::AlreadyExists("x".into()), Code::AlreadyExists),
507 (
508 ApiError::ResourceVersionExpired("old revision".into()),
509 Code::OutOfRange,
510 ),
511 (ApiError::Unavailable("x".into()), Code::Unavailable),
512 ] {
513 assert_eq!(tonic::Status::from(error).code(), expected);
514 }
515 }
516
517 #[cfg(feature = "grpc")]
518 #[test]
519 fn conflict_maps_to_grpc_aborted() {
520 use prost::Message as _;
521 use tonic::Code;
522
523 let status = tonic::Status::from(ApiError::Conflict(conflict()));
524 assert_eq!(status.code(), Code::Aborted);
525 let details = crate::proto_api::WriteConflictDetails::decode(status.details()).unwrap();
526 assert_eq!(details.name, "task-1");
527 assert_eq!(details.causes.len(), 1);
528 assert_eq!(details.causes[0].reason, "ResourceVersionMismatch");
529 assert_eq!(
530 details.causes[0].field.as_deref(),
531 Some("preconditions.resourceVersion")
532 );
533 }
534
535 #[cfg(feature = "http")]
536 #[tokio::test]
537 async fn conflict_http_status_contains_structured_causes() {
538 use axum::response::IntoResponse;
539 use http_body_util::BodyExt;
540
541 let response = ApiError::Conflict(conflict()).into_response();
542 let body = response.into_body().collect().await.unwrap().to_bytes();
543 let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
544
545 assert_eq!(value["reason"], "Conflict");
546 assert_eq!(value["details"]["name"], "task-1");
547 assert_eq!(value["details"]["group"], "solti.io");
548 assert_eq!(value["details"]["kind"], "Task");
549 assert_eq!(
550 value["details"]["causes"][0]["reason"],
551 "ResourceVersionMismatch"
552 );
553 assert_eq!(value["details"]["causes"][0]["field"], "resourceVersion");
554 }
555
556 #[cfg(feature = "grpc")]
557 #[test]
558 fn grpc_internal_error_hides_diagnostic_message() {
559 let status = tonic::Status::from(ApiError::Internal("secret diagnostic".into()));
560 assert_eq!(status.code(), tonic::Code::Internal);
561 assert_eq!(status.message(), "internal server error");
562 }
563
564 #[cfg(feature = "http")]
565 #[tokio::test]
566 async fn http_internal_error_hides_diagnostic_message() {
567 use axum::response::IntoResponse;
568 use http_body_util::BodyExt;
569
570 let response = ApiError::Internal("secret diagnostic".into()).into_response();
571 let body = response.into_body().collect().await.unwrap().to_bytes();
572 let value: serde_json::Value = serde_json::from_slice(&body).unwrap();
573
574 assert_eq!(value["message"], "internal server error");
575 assert!(!String::from_utf8_lossy(&body).contains("secret diagnostic"));
576 }
577}