Skip to main content

worldinterface_daemon/
error.rs

1//! Error types for the daemon.
2
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use axum::Json;
6use serde::Serialize;
7use worldinterface_host::HostError;
8use worldinterface_http_trigger::WebhookError;
9
10/// Top-level daemon errors (startup/shutdown).
11#[derive(Debug, thiserror::Error)]
12pub enum DaemonError {
13    /// Host startup or shutdown error.
14    #[error("host error: {0}")]
15    Host(#[from] HostError),
16
17    /// Configuration error.
18    #[error("configuration error: {0}")]
19    Config(String),
20
21    /// TCP bind failed.
22    #[error("failed to bind: {0}")]
23    Bind(#[source] std::io::Error),
24
25    /// HTTP server error.
26    #[error("server error: {0}")]
27    Serve(#[source] std::io::Error),
28
29    /// I/O error (config file reading, etc.)
30    #[error("I/O error: {0}")]
31    Io(#[from] std::io::Error),
32
33    /// TOML parsing error.
34    #[error("config parse error: {0}")]
35    ConfigParse(#[from] toml::de::Error),
36}
37
38/// API-level errors that map to HTTP status codes.
39#[derive(Debug, thiserror::Error)]
40pub enum ApiError {
41    /// Resource not found (404).
42    #[error("{0}")]
43    NotFound(String),
44
45    /// Bad request — validation, compilation, or parse errors (400).
46    #[error("{0}")]
47    BadRequest(String),
48
49    /// Conflict — duplicate resource (409).
50    #[error("{0}")]
51    Conflict(String),
52
53    /// Internal server error (500).
54    #[error("{0}")]
55    Internal(String),
56}
57
58#[derive(Serialize)]
59struct ErrorResponse {
60    error: String,
61}
62
63impl IntoResponse for ApiError {
64    fn into_response(self) -> Response {
65        let (status, message) = match &self {
66            ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
67            ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
68            ApiError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
69            ApiError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
70        };
71        (status, Json(ErrorResponse { error: message })).into_response()
72    }
73}
74
75impl From<HostError> for ApiError {
76    fn from(err: HostError) -> Self {
77        match err {
78            HostError::FlowRunNotFound(_) => ApiError::NotFound(err.to_string()),
79            HostError::ConnectorNotFound(_) => ApiError::NotFound(err.to_string()),
80            HostError::InvalidConfig(_) => ApiError::BadRequest(err.to_string()),
81            HostError::Compilation(_) => ApiError::BadRequest(err.to_string()),
82            HostError::FlowFailed { .. } => ApiError::Internal(err.to_string()),
83            HostError::FlowCanceled(_) => ApiError::Internal(err.to_string()),
84            _ => ApiError::Internal(err.to_string()),
85        }
86    }
87}
88
89impl From<WebhookError> for ApiError {
90    fn from(err: WebhookError) -> Self {
91        match &err {
92            WebhookError::PathAlreadyRegistered(_) => ApiError::Conflict(err.to_string()),
93            WebhookError::WebhookNotFound(_) => ApiError::NotFound(err.to_string()),
94            WebhookError::PathNotFound(_) => ApiError::NotFound(err.to_string()),
95            WebhookError::InvalidPath(_) => ApiError::BadRequest(err.to_string()),
96            _ => ApiError::Internal(err.to_string()),
97        }
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use axum::response::IntoResponse;
104    use worldinterface_core::id::FlowRunId;
105
106    use super::*;
107
108    #[test]
109    fn host_not_found_maps_to_404() {
110        let err: ApiError = HostError::FlowRunNotFound(FlowRunId::new()).into();
111        assert!(matches!(err, ApiError::NotFound(_)));
112    }
113
114    #[test]
115    fn host_connector_not_found_maps_to_404() {
116        let err: ApiError = HostError::ConnectorNotFound("foo".to_string()).into();
117        assert!(matches!(err, ApiError::NotFound(_)));
118    }
119
120    #[test]
121    fn host_invalid_config_maps_to_400() {
122        let err: ApiError = HostError::InvalidConfig("bad".to_string()).into();
123        assert!(matches!(err, ApiError::BadRequest(_)));
124    }
125
126    #[test]
127    fn host_internal_error_maps_to_500() {
128        let err: ApiError = HostError::InternalError("bug".to_string()).into();
129        assert!(matches!(err, ApiError::Internal(_)));
130    }
131
132    #[test]
133    fn api_error_serializes_to_json() {
134        let err = ApiError::NotFound("gone".to_string());
135        let response = err.into_response();
136        assert_eq!(response.status(), StatusCode::NOT_FOUND);
137    }
138
139    #[test]
140    fn api_error_bad_request_status() {
141        let err = ApiError::BadRequest("invalid".to_string());
142        let response = err.into_response();
143        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
144    }
145
146    #[test]
147    fn api_error_internal_status() {
148        let err = ApiError::Internal("crash".to_string());
149        let response = err.into_response();
150        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
151    }
152
153    #[test]
154    fn api_error_conflict_status() {
155        let err = ApiError::Conflict("duplicate".to_string());
156        let response = err.into_response();
157        assert_eq!(response.status(), StatusCode::CONFLICT);
158    }
159
160    // T-13: WebhookError -> ApiError mapping
161
162    #[test]
163    fn webhook_path_registered_maps_to_409() {
164        let err: ApiError = WebhookError::PathAlreadyRegistered("github/push".to_string()).into();
165        assert!(matches!(err, ApiError::Conflict(_)));
166        let response = err.into_response();
167        assert_eq!(response.status(), StatusCode::CONFLICT);
168    }
169
170    #[test]
171    fn webhook_not_found_maps_to_404() {
172        let err: ApiError = WebhookError::WebhookNotFound(worldinterface_http_trigger::WebhookId::new()).into();
173        assert!(matches!(err, ApiError::NotFound(_)));
174    }
175
176    #[test]
177    fn webhook_path_not_found_maps_to_404() {
178        let err: ApiError = WebhookError::PathNotFound("unknown".to_string()).into();
179        assert!(matches!(err, ApiError::NotFound(_)));
180    }
181
182    #[test]
183    fn webhook_invalid_path_maps_to_400() {
184        let err: ApiError = WebhookError::InvalidPath("bad".to_string()).into();
185        assert!(matches!(err, ApiError::BadRequest(_)));
186    }
187}