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