Skip to main content

rch_common/api/
response.rs

1//! Unified API Response Envelope
2//!
3//! Provides [`ApiResponse<T>`] which wraps all API responses in a consistent
4//! envelope format.
5
6use super::ApiError;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11/// Current API version.
12///
13/// This is used for API compatibility detection. Clients can check this
14/// value to determine if they're compatible with the response format.
15pub const API_VERSION: &str = "1.0";
16
17/// Unified API response envelope.
18///
19/// All CLI commands and daemon endpoints should return this structure
20/// for consistent agent parsing.
21///
22/// # Example
23///
24/// ```rust
25/// use rch_common::api::{ApiResponse, ApiError};
26/// use rch_common::ErrorCode;
27///
28/// // Success response
29/// #[derive(serde::Serialize)]
30/// struct WorkerList {
31///     workers: Vec<String>,
32///     count: usize,
33/// }
34///
35/// let data = WorkerList {
36///     workers: vec!["worker-1".to_string(), "worker-2".to_string()],
37///     count: 2,
38/// };
39/// let response = ApiResponse::ok("workers list", data);
40///
41/// // Error response
42/// let error = ApiError::from_code(ErrorCode::ConfigNotFound);
43/// let response: ApiResponse<()> = ApiResponse::err("config show", error);
44/// ```
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46pub struct ApiResponse<T: Serialize> {
47    /// API version for compatibility detection.
48    pub api_version: &'static str,
49
50    /// Unix timestamp when response was generated.
51    pub timestamp: u64,
52
53    /// Optional request ID for correlation/tracing.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub request_id: Option<String>,
56
57    /// Command that produced this response (e.g., "workers list").
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub command: Option<String>,
60
61    /// Whether the operation succeeded.
62    pub success: bool,
63
64    /// Response data on success.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub data: Option<T>,
67
68    /// Error details on failure.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub error: Option<ApiError>,
71}
72
73/// JSON "any" value for schema generation.
74///
75/// This exists so we can generate a stable schema for `ApiResponse<T>` where
76/// `data` is intentionally unconstrained (any JSON value).
77#[allow(dead_code)]
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(transparent)]
80pub struct AnyJson(pub serde_json::Value);
81
82impl JsonSchema for AnyJson {
83    fn schema_name() -> String {
84        "AnyJson".to_string()
85    }
86
87    fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
88        // Accept any JSON value.
89        schemars::schema::Schema::Bool(true)
90    }
91}
92
93impl<T: Serialize> ApiResponse<T> {
94    /// Create a successful response.
95    ///
96    /// # Arguments
97    ///
98    /// * `command` - Command name that produced this response
99    /// * `data` - Response payload
100    #[must_use]
101    pub fn ok(command: impl Into<String>, data: T) -> Self {
102        Self {
103            api_version: API_VERSION,
104            timestamp: current_timestamp(),
105            request_id: None,
106            command: Some(command.into()),
107            success: true,
108            data: Some(data),
109            error: None,
110        }
111    }
112
113    /// Create a successful response without command name.
114    ///
115    /// Useful for daemon endpoints where command context isn't applicable.
116    #[must_use]
117    pub fn ok_data(data: T) -> Self {
118        Self {
119            api_version: API_VERSION,
120            timestamp: current_timestamp(),
121            request_id: None,
122            command: None,
123            success: true,
124            data: Some(data),
125            error: None,
126        }
127    }
128
129    /// Add a request ID for correlation.
130    #[must_use]
131    pub fn with_request_id(mut self, id: impl Into<String>) -> Self {
132        self.request_id = Some(id.into());
133        self
134    }
135
136    /// Add or override command name.
137    #[must_use]
138    pub fn with_command(mut self, command: impl Into<String>) -> Self {
139        self.command = Some(command.into());
140        self
141    }
142}
143
144impl<T: Serialize> ApiResponse<T> {
145    /// Create an error response.
146    ///
147    /// # Arguments
148    ///
149    /// * `command` - Command name that failed
150    /// * `error` - Error details
151    #[must_use]
152    pub fn err(command: impl Into<String>, error: ApiError) -> Self {
153        Self {
154            api_version: API_VERSION,
155            timestamp: current_timestamp(),
156            request_id: None,
157            command: Some(command.into()),
158            success: false,
159            data: None,
160            error: Some(error),
161        }
162    }
163
164    /// Create an error response without command name.
165    ///
166    /// Useful for daemon endpoints.
167    #[must_use]
168    pub fn err_only(error: ApiError) -> Self {
169        Self {
170            api_version: API_VERSION,
171            timestamp: current_timestamp(),
172            request_id: None,
173            command: None,
174            success: false,
175            data: None,
176            error: Some(error),
177        }
178    }
179}
180
181impl ApiResponse<()> {
182    /// Create a simple success response with no data.
183    #[must_use]
184    pub fn ok_empty(command: impl Into<String>) -> Self {
185        Self {
186            api_version: API_VERSION,
187            timestamp: current_timestamp(),
188            request_id: None,
189            command: Some(command.into()),
190            success: true,
191            data: None,
192            error: None,
193        }
194    }
195}
196
197/// Get current Unix timestamp.
198fn current_timestamp() -> u64 {
199    SystemTime::now()
200        .duration_since(UNIX_EPOCH)
201        .map(|d| d.as_secs())
202        .unwrap_or(0)
203}
204
205// =============================================================================
206// Conversion Traits
207// =============================================================================
208
209impl<T: Serialize, E: Into<ApiError>> From<Result<T, E>> for ApiResponse<T> {
210    fn from(result: Result<T, E>) -> Self {
211        match result {
212            Ok(data) => Self::ok_data(data),
213            Err(e) => Self::err_only(e.into()),
214        }
215    }
216}
217
218// =============================================================================
219// Builder Pattern for Complex Responses
220// =============================================================================
221
222/// Builder for constructing [`ApiResponse`] with full control.
223#[allow(dead_code)]
224pub struct ApiResponseBuilder<T: Serialize> {
225    command: Option<String>,
226    request_id: Option<String>,
227    data: Option<T>,
228    error: Option<ApiError>,
229}
230
231#[allow(dead_code)]
232impl<T: Serialize> ApiResponseBuilder<T> {
233    /// Create a new builder.
234    #[must_use]
235    pub fn new() -> Self {
236        Self {
237            command: None,
238            request_id: None,
239            data: None,
240            error: None,
241        }
242    }
243
244    /// Set the command name.
245    #[must_use]
246    pub fn command(mut self, cmd: impl Into<String>) -> Self {
247        self.command = Some(cmd.into());
248        self
249    }
250
251    /// Set the request ID.
252    #[must_use]
253    pub fn request_id(mut self, id: impl Into<String>) -> Self {
254        self.request_id = Some(id.into());
255        self
256    }
257
258    /// Set success data.
259    #[must_use]
260    pub fn data(mut self, data: T) -> Self {
261        self.data = Some(data);
262        self.error = None;
263        self
264    }
265
266    /// Set error.
267    #[must_use]
268    pub fn error(mut self, error: ApiError) -> Self {
269        self.error = Some(error);
270        self.data = None;
271        self
272    }
273
274    /// Build the response.
275    #[must_use]
276    pub fn build(self) -> ApiResponse<T> {
277        let success = self.data.is_some();
278        ApiResponse {
279            api_version: API_VERSION,
280            timestamp: current_timestamp(),
281            request_id: self.request_id,
282            command: self.command,
283            success,
284            data: self.data,
285            error: self.error,
286        }
287    }
288}
289
290impl<T: Serialize> Default for ApiResponseBuilder<T> {
291    fn default() -> Self {
292        Self::new()
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::ErrorCode;
300
301    #[test]
302    fn test_ok_response() {
303        let response = ApiResponse::ok("test", "hello");
304        assert!(response.success);
305        assert_eq!(response.data, Some("hello"));
306        assert!(response.error.is_none());
307        assert_eq!(response.api_version, "1.0");
308        assert!(response.timestamp > 0);
309    }
310
311    #[test]
312    fn test_err_response() {
313        let error = ApiError::from_code(ErrorCode::ConfigNotFound);
314        let response: ApiResponse<()> = ApiResponse::err("config show", error);
315        assert!(!response.success);
316        assert!(response.data.is_none());
317        assert!(response.error.is_some());
318        assert_eq!(response.error.as_ref().unwrap().code, "RCH-E001");
319    }
320
321    #[test]
322    fn test_ok_empty() {
323        let response = ApiResponse::ok_empty("shutdown");
324        assert!(response.success);
325        assert!(response.data.is_none());
326        assert!(response.error.is_none());
327    }
328
329    #[test]
330    fn test_with_request_id() {
331        let response = ApiResponse::ok("test", "data").with_request_id("req-123");
332        assert_eq!(response.request_id, Some("req-123".to_string()));
333    }
334
335    #[test]
336    fn test_serialization_success() {
337        #[derive(Serialize, Deserialize, PartialEq, Debug)]
338        struct Data {
339            count: u32,
340        }
341
342        let response = ApiResponse::ok("count", Data { count: 42 });
343        let json = serde_json::to_string(&response).unwrap();
344
345        assert!(json.contains("\"api_version\":\"1.0\""));
346        assert!(json.contains("\"success\":true"));
347        assert!(json.contains("\"count\":42"));
348        assert!(!json.contains("\"error\""));
349    }
350
351    #[test]
352    fn test_serialization_error() {
353        let error = ApiError::from_code(ErrorCode::SshConnectionFailed)
354            .with_context("worker", "test-worker");
355        let response: ApiResponse<()> = ApiResponse::err("probe", error);
356        let json = serde_json::to_string(&response).unwrap();
357
358        assert!(json.contains("\"success\":false"));
359        assert!(json.contains("\"code\":\"RCH-E100\""));
360        assert!(json.contains("\"worker\":\"test-worker\""));
361        assert!(!json.contains("\"data\""));
362    }
363
364    #[test]
365    fn test_builder() {
366        let response: ApiResponse<String> = ApiResponseBuilder::new()
367            .command("workers list")
368            .request_id("req-456")
369            .data("test data".to_string())
370            .build();
371
372        assert!(response.success);
373        assert_eq!(response.command, Some("workers list".to_string()));
374        assert_eq!(response.request_id, Some("req-456".to_string()));
375        assert_eq!(response.data, Some("test data".to_string()));
376    }
377
378    #[test]
379    fn test_from_result_ok() {
380        let result: Result<String, ApiError> = Ok("success".to_string());
381        let response: ApiResponse<String> = result.into();
382        assert!(response.success);
383        assert_eq!(response.data, Some("success".to_string()));
384    }
385
386    #[test]
387    fn test_from_result_err() {
388        let result: Result<String, ApiError> = Err(ApiError::from_code(ErrorCode::ConfigNotFound));
389        let response: ApiResponse<String> = result.into();
390        assert!(!response.success);
391        assert!(response.error.is_some());
392    }
393}