1use std::collections::HashMap;
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ApiMeta {
30 pub page: Option<u32>,
32 pub per_page: Option<u32>,
34 pub total: Option<u64>,
36 #[serde(flatten)]
38 #[cfg_attr(feature = "openapi", schema(additional_properties))]
39 pub extra: HashMap<String, Value>,
40}
41
42impl ApiMeta {
43 pub fn empty() -> Self {
45 Self {
46 page: None,
47 per_page: None,
48 total: None,
49 extra: HashMap::new(),
50 }
51 }
52
53 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#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ApiResponse<T> {
80 pub data: T,
82 pub meta: Option<ApiMeta>,
84}
85
86#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct ErrorEnvelope {
108 pub code: String,
110 pub message: String,
112 #[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}