1use serde::de::DeserializeOwned;
5use serde::{Deserialize, Serialize};
6
7use crate::error::{ApiError, Error, ErrorCode};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct Meta {
12 pub page: u32,
14 pub per_page: u32,
16 pub total: u64,
18 pub total_pages: u32,
20}
21
22#[derive(Debug, Clone)]
24pub struct Page<T> {
25 pub items: Vec<T>,
27 pub meta: Option<Meta>,
29}
30
31#[derive(Debug, Clone)]
33pub struct CursorPage<T> {
34 pub items: Vec<T>,
36 pub next_cursor: Option<String>,
38}
39
40#[derive(Deserialize)]
41struct Envelope<T> {
42 success: bool,
43 data: Option<T>,
44 error: Option<WireError>,
45 meta: Option<serde_json::Value>,
47}
48
49#[derive(Deserialize)]
50struct WireError {
51 code: String,
52 message: String,
53 details: Option<serde_json::Value>,
54}
55
56const SNIPPET_LEN: usize = 200;
57
58pub(crate) fn decode<T: DeserializeOwned>(
60 status: u16,
61 body: &[u8],
62) -> Result<(T, Option<Meta>), Error> {
63 let envelope = parse::<T>(status, body)?;
64 let meta = envelope
65 .meta
66 .and_then(|meta| serde_json::from_value::<Meta>(meta).ok());
67 match envelope.data {
68 Some(data) => Ok((data, meta)),
69 None => Err(decode_error(status, body)),
70 }
71}
72
73pub(crate) fn decode_cursor<T: DeserializeOwned>(
76 status: u16,
77 body: &[u8],
78) -> Result<(T, Option<String>), Error> {
79 let envelope = parse::<T>(status, body)?;
80 let next_cursor = envelope
81 .meta
82 .as_ref()
83 .and_then(|meta| meta.get("next_cursor"))
84 .and_then(|cursor| cursor.as_str())
85 .filter(|cursor| !cursor.is_empty())
86 .map(str::to_owned);
87 match envelope.data {
88 Some(data) => Ok((data, next_cursor)),
89 None => Err(decode_error(status, body)),
90 }
91}
92
93pub(crate) fn decode_empty(status: u16, body: &[u8]) -> Result<(), Error> {
95 parse::<serde_json::Value>(status, body).map(|_| ())
96}
97
98pub(crate) fn decode_file(status: u16, body: Vec<u8>) -> Result<String, Error> {
102 if !(200..300).contains(&status) {
103 parse::<serde_json::Value>(status, &body)?;
104 return Err(decode_error(status, &body));
105 }
106 String::from_utf8(body).map_err(|e| decode_error(status, e.as_bytes()))
107}
108
109fn parse<T: DeserializeOwned>(status: u16, body: &[u8]) -> Result<Envelope<T>, Error> {
110 let envelope: Envelope<T> =
111 serde_json::from_slice(body).map_err(|_| decode_error(status, body))?;
112 if envelope.success {
113 return Ok(envelope);
114 }
115 match envelope.error {
116 Some(err) => Err(Error::Api(ApiError {
117 status,
118 code: ErrorCode::from_wire(&err.code),
119 message: err.message,
120 details: err.details,
121 })),
122 None => Err(decode_error(status, body)),
123 }
124}
125
126pub(crate) fn decode_error(status: u16, body: &[u8]) -> Error {
127 let text = String::from_utf8_lossy(body);
128 let snippet: String = text.chars().take(SNIPPET_LEN).collect();
129 Error::Decode { status, snippet }
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn data_and_meta() {
138 let body = br#"{"success":true,"data":[1,2],"meta":{"page":1,"per_page":20,"total":2,"total_pages":1}}"#;
139 let (data, meta): (Vec<u32>, _) = decode(200, body).unwrap();
140 assert_eq!(data, vec![1, 2]);
141 assert_eq!(meta.unwrap().total, 2);
142 }
143
144 #[test]
145 fn cursor_meta() {
146 let body = br#"{"success":true,"data":[1],"meta":{"limit":1,"next_cursor":"abc"}}"#;
147 let (data, next): (Vec<u32>, _) = decode_cursor(200, body).unwrap();
148 assert_eq!((data, next.as_deref()), (vec![1], Some("abc")));
149 let last = br#"{"success":true,"data":[],"meta":{"limit":1,"next_cursor":null}}"#;
150 let (_, next): (Vec<u32>, _) = decode_cursor(200, last).unwrap();
151 assert_eq!(next, None);
152 let (_, meta): (Vec<u32>, _) = decode(200, body).unwrap();
154 assert!(meta.is_none());
155 }
156
157 #[test]
158 fn empty_success() {
159 decode_empty(200, br#"{"success":true}"#).unwrap();
160 }
161
162 #[test]
163 fn missing_data_is_decode_error() {
164 let err = decode::<u32>(200, br#"{"success":true}"#).unwrap_err();
165 assert!(matches!(err, Error::Decode { status: 200, .. }));
166 }
167
168 #[test]
169 fn api_error_keeps_code_and_details() {
170 let body = br#"{"success":false,"error":{"code":"CONFLICT","message":"rule violated","details":["a"]}}"#;
171 let Error::Api(err) = decode::<u32>(409, body).unwrap_err() else {
172 panic!("expected Api error")
173 };
174 assert_eq!(err.status, 409);
175 assert_eq!(err.code, ErrorCode::Conflict);
176 assert_eq!(err.details, Some(serde_json::json!(["a"])));
177 }
178
179 #[test]
180 fn managed_record_details() {
181 let body = br#"{"success":false,"error":{"code":"MANAGED_RECORD","message":"managed","details":{"managed_by":"Redirect","name":"go.example.com"}}}"#;
182 let Error::Api(err) = decode_empty(409, body).unwrap_err() else {
183 panic!("expected Api error")
184 };
185 assert_eq!(err.code, ErrorCode::ManagedRecord);
186 assert_eq!(err.managed(), Some(("Redirect", "go.example.com")));
187 }
188
189 #[test]
190 fn file_is_raw_and_errors_are_envelopes() {
191 let doc = br#"{"format":"dinio-zone","version":1,"records":[]}"#.to_vec();
192 assert_eq!(
193 decode_file(200, doc).unwrap(),
194 r#"{"format":"dinio-zone","version":1,"records":[]}"#
195 );
196 let body = br#"{"success":false,"error":{"code":"NOT_FOUND","message":"Zone not found"}}"#;
197 let Error::Api(err) = decode_file(404, body.to_vec()).unwrap_err() else {
198 panic!("expected Api error")
199 };
200 assert_eq!(err.code, ErrorCode::NotFound);
201 assert!(matches!(
202 decode_file(502, b"<html>bad gateway</html>".to_vec()),
203 Err(Error::Decode { status: 502, .. })
204 ));
205 }
206
207 #[test]
208 fn unknown_code_is_kept() {
209 let body = br#"{"success":false,"error":{"code":"RATE_LIMITED","message":"slow down"}}"#;
210 let Error::Api(err) = decode_empty(429, body).unwrap_err() else {
211 panic!("expected Api error")
212 };
213 assert_eq!(err.code, ErrorCode::Other("RATE_LIMITED".into()));
214 }
215
216 #[test]
217 fn non_json_is_decode_error() {
218 let err = decode_empty(502, b"<html>Bad Gateway</html>").unwrap_err();
219 assert!(matches!(err, Error::Decode { status: 502, .. }));
220 }
221}