Skip to main content

lightshuttle_control/
error.rs

1//! JSON-shaped HTTP errors returned by every REST endpoint.
2//!
3//! Every REST handler maps [`lightshuttle_runtime::LifecycleHandleError`]
4//! to one of three HTTP status codes via the [`From`] impl on [`ApiError`].
5//! Axum serialises the response through the [`axum::response::IntoResponse`]
6//! impl, which pairs the status with a JSON [`ApiErrorBody`].
7
8use axum::Json;
9use axum::http::StatusCode;
10use axum::response::{IntoResponse, Response};
11use lightshuttle_runtime::LifecycleHandleError;
12use serde::Serialize;
13
14/// Wire representation of an API error body, serialised as JSON.
15///
16/// All REST endpoints that can fail return this structure as their error
17/// payload. The `resource` field is omitted from the JSON output when it
18/// is not applicable (`null` would be misleading for non-resource errors).
19#[derive(Debug, Serialize)]
20pub struct ApiErrorBody {
21    /// Short, machine-friendly slug describing the error category.
22    pub error: String,
23    /// Resource name when the error is scoped to a single resource.
24    ///
25    /// Absent from the serialised output when `None`
26    /// (controlled by `#[serde(skip_serializing_if)]`).
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub resource: Option<String>,
29}
30
31/// HTTP error type returned by every control-plane REST handler.
32///
33/// Wraps an HTTP status code and a JSON body ([`ApiErrorBody`]). Handlers
34/// return `Result<_, ApiError>` and rely on the [`axum::response::IntoResponse`]
35/// impl to render the response automatically.
36///
37/// `ApiError` also implements [`From`]`<`[`lightshuttle_runtime::LifecycleHandleError`]`>`,
38/// so the `?` operator converts runtime errors into the right HTTP status without
39/// boilerplate in each handler.
40///
41/// Three constructors cover all current failure modes:
42///
43/// | Constructor | HTTP status | When to use |
44/// |---|---|---|
45/// | [`ApiError::unknown_resource`] | 404 | Named resource does not exist |
46/// | [`ApiError::not_supported`] | 501 | Operation not implemented yet |
47/// | [`ApiError::runtime`] | 500 | Unexpected runtime failure |
48#[derive(Debug)]
49pub struct ApiError {
50    status: StatusCode,
51    body: ApiErrorBody,
52}
53
54impl ApiError {
55    /// Build a 404 response for a resource that does not exist.
56    ///
57    /// The serialised body is `{"error":"unknown resource","resource":"<name>"}`.
58    ///
59    /// Prefer this over constructing [`ApiError`] directly so the HTTP
60    /// status and the error slug remain consistent across all handlers.
61    #[must_use]
62    pub fn unknown_resource(name: impl Into<String>) -> Self {
63        Self {
64            status: StatusCode::NOT_FOUND,
65            body: ApiErrorBody {
66                error: "unknown resource".to_owned(),
67                resource: Some(name.into()),
68            },
69        }
70    }
71
72    /// Build a 501 response for an operation that is not yet implemented.
73    ///
74    /// The serialised body is ``{"error":"operation `op` is not supported yet"}``.
75    #[must_use]
76    pub fn not_supported(op: &'static str) -> Self {
77        Self {
78            status: StatusCode::NOT_IMPLEMENTED,
79            body: ApiErrorBody {
80                error: format!("operation `{op}` is not supported yet"),
81                resource: None,
82            },
83        }
84    }
85
86    /// Build a 500 response for an unexpected runtime failure.
87    ///
88    /// The serialised body is `{"error":"<message>"}`.
89    #[must_use]
90    pub fn runtime(message: impl Into<String>) -> Self {
91        Self {
92            status: StatusCode::INTERNAL_SERVER_ERROR,
93            body: ApiErrorBody {
94                error: message.into(),
95                resource: None,
96            },
97        }
98    }
99}
100
101impl From<LifecycleHandleError> for ApiError {
102    fn from(err: LifecycleHandleError) -> Self {
103        match err {
104            LifecycleHandleError::UnknownResource(name) => Self::unknown_resource(name),
105            LifecycleHandleError::NotSupported(op) => Self::not_supported(op),
106            LifecycleHandleError::Runtime(e) => Self::runtime(e.to_string()),
107        }
108    }
109}
110
111impl IntoResponse for ApiError {
112    fn into_response(self) -> Response {
113        (self.status, Json(self.body)).into_response()
114    }
115}