1use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ApiMeta {
28 pub page: Option<u32>,
30 pub per_page: Option<u32>,
32 pub total: Option<u64>,
34}
35
36impl ApiMeta {
37 pub fn empty() -> Self {
39 Self {
40 page: None,
41 per_page: None,
42 total: None,
43 }
44 }
45
46 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#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ApiResponse<T> {
72 pub data: T,
74 pub meta: Option<ApiMeta>,
76}
77
78#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct ErrorEnvelope {
100 pub code: String,
102 pub message: String,
104 #[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}