Skip to main content

ironflow_types/
lib.rs

1//! Shared API envelope types for the Ironflow ecosystem.
2//!
3//! Defines the standard response envelope (`ApiResponse<T>` + `ApiMeta`)
4//! and error envelope (`ErrorEnvelope`) used by both the server
5//! ([`ironflow-api`]) and the client SDK ([`ironflow-sdk`]).
6//!
7//! # Features
8//!
9//! - **`openapi`** -- derive [`utoipa::ToSchema`] for OpenAPI spec generation.
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14/// Pagination metadata returned by list endpoints.
15///
16/// # Examples
17///
18/// ```
19/// use ironflow_types::ApiMeta;
20///
21/// let meta = ApiMeta::paginated(2, 50, 200);
22/// assert_eq!(meta.page, Some(2));
23/// assert_eq!(meta.total, Some(200));
24/// ```
25#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ApiMeta {
28    /// Current page number (1-based).
29    pub page: Option<u32>,
30    /// Items per page.
31    pub per_page: Option<u32>,
32    /// Total number of items matching the filter.
33    pub total: Option<u64>,
34}
35
36impl ApiMeta {
37    /// Create an empty metadata object (no pagination).
38    pub fn empty() -> Self {
39        Self {
40            page: None,
41            per_page: None,
42            total: None,
43        }
44    }
45
46    /// Create pagination metadata.
47    pub fn paginated(page: u32, per_page: u32, total: u64) -> Self {
48        Self {
49            page: Some(page),
50            per_page: Some(per_page),
51            total: Some(total),
52        }
53    }
54}
55
56/// Standard response envelope for all successful API responses.
57///
58/// Serialized as: `{ "data": ..., "meta": { "page": ..., "total": ... } }`
59///
60/// # Examples
61///
62/// ```
63/// use ironflow_types::ApiResponse;
64///
65/// let json = r#"{"data": [1, 2, 3], "meta": null}"#;
66/// let resp: ApiResponse<Vec<i32>> = serde_json::from_str(json).unwrap();
67/// assert_eq!(resp.data.len(), 3);
68/// ```
69#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ApiResponse<T> {
72    /// The response payload.
73    pub data: T,
74    /// Optional pagination metadata.
75    pub meta: Option<ApiMeta>,
76}
77
78/// Error response body.
79///
80/// The inner part of the API error envelope:
81/// `{ "error": { "code": "...", "message": "...", "details": { ... } } }`.
82///
83/// `details` carries error-specific structured context (for example the run
84/// holding a conflicting idempotency key). It is omitted from the JSON output
85/// when absent.
86///
87/// # Examples
88///
89/// ```
90/// use ironflow_types::ErrorEnvelope;
91///
92/// let json = r#"{"code": "RUN_NOT_FOUND", "message": "run not found"}"#;
93/// let err: ErrorEnvelope = serde_json::from_str(json).unwrap();
94/// assert_eq!(err.code, "RUN_NOT_FOUND");
95/// assert!(err.details.is_none());
96/// ```
97#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ErrorEnvelope {
100    /// Machine-readable error code (e.g., `RUN_NOT_FOUND`).
101    pub code: String,
102    /// Human-readable error message.
103    pub message: String,
104    /// Optional structured context attached to the error.
105    #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub details: Option<Value>,
108}
109
110#[cfg(test)]
111mod tests {
112    use serde_json::json;
113
114    use super::*;
115
116    #[test]
117    fn api_meta_empty() {
118        let meta = ApiMeta::empty();
119        assert!(meta.page.is_none());
120        assert!(meta.per_page.is_none());
121        assert!(meta.total.is_none());
122    }
123
124    #[test]
125    fn api_meta_paginated() {
126        let meta = ApiMeta::paginated(2, 50, 200);
127        assert_eq!(meta.page, Some(2));
128        assert_eq!(meta.per_page, Some(50));
129        assert_eq!(meta.total, Some(200));
130    }
131
132    #[test]
133    fn api_response_roundtrip() {
134        let response = ApiResponse {
135            data: vec![1, 2, 3],
136            meta: Some(ApiMeta::paginated(1, 10, 50)),
137        };
138        let json = serde_json::to_string(&response).unwrap();
139        let deserialized: ApiResponse<Vec<i32>> = serde_json::from_str(&json).unwrap();
140        assert_eq!(deserialized.data, vec![1, 2, 3]);
141        assert_eq!(deserialized.meta.unwrap().total, Some(50));
142    }
143
144    #[test]
145    fn error_envelope_roundtrip() {
146        let envelope = ErrorEnvelope {
147            code: "BAD_REQUEST".to_string(),
148            message: "invalid input".to_string(),
149            details: None,
150        };
151        let json = serde_json::to_string(&envelope).unwrap();
152        let deserialized: ErrorEnvelope = serde_json::from_str(&json).unwrap();
153        assert_eq!(deserialized.code, "BAD_REQUEST");
154        assert_eq!(deserialized.message, "invalid input");
155        assert!(deserialized.details.is_none());
156    }
157
158    #[test]
159    fn error_envelope_omits_absent_details() {
160        let envelope = ErrorEnvelope {
161            code: "BAD_REQUEST".to_string(),
162            message: "invalid input".to_string(),
163            details: None,
164        };
165        let json = serde_json::to_string(&envelope).unwrap();
166        assert_eq!(json, r#"{"code":"BAD_REQUEST","message":"invalid input"}"#);
167    }
168
169    #[test]
170    fn error_envelope_roundtrip_with_details() {
171        let envelope = ErrorEnvelope {
172            code: "IDEMPOTENCY_KEY_CONFLICT".to_string(),
173            message: "conflict".to_string(),
174            details: Some(json!({ "run_id": "0199-abc" })),
175        };
176        let json = serde_json::to_string(&envelope).unwrap();
177        let deserialized: ErrorEnvelope = serde_json::from_str(&json).unwrap();
178        assert_eq!(
179            deserialized.details.expect("details present")["run_id"],
180            "0199-abc"
181        );
182    }
183
184    #[test]
185    fn error_envelope_deserializes_without_details_field() {
186        let json = r#"{"code": "RUN_NOT_FOUND", "message": "run not found"}"#;
187        let envelope: ErrorEnvelope = serde_json::from_str(json).unwrap();
188        assert!(envelope.details.is_none());
189    }
190}