Skip to main content

dinio_sdk/
envelope.rs

1//! Core's response envelope `{ success, data?, error?, meta? }`. This is the only place it
2//! is unwrapped.
3
4use serde::de::DeserializeOwned;
5use serde::{Deserialize, Serialize};
6
7use crate::error::{ApiError, Error, ErrorCode};
8
9/// Pagination info; Core sends it only on page-numbered lists (`/zones`, `/records`).
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct Meta {
12    /// This page's number, from 1.
13    pub page: u32,
14    /// Items per page.
15    pub per_page: u32,
16    /// Items across all pages.
17    pub total: u64,
18    /// Number of pages.
19    pub total_pages: u32,
20}
21
22/// One page of a paginated list.
23#[derive(Debug, Clone)]
24pub struct Page<T> {
25    /// The items on this page.
26    pub items: Vec<T>,
27    /// Pagination info; `None` when Core returned the whole list at once.
28    pub meta: Option<Meta>,
29}
30
31/// One page of a cursor-paginated list (`/audit-logs`).
32#[derive(Debug, Clone)]
33pub struct CursorPage<T> {
34    /// The items on this page.
35    pub items: Vec<T>,
36    /// Pass it as `cursor` to get the next page; `None` on the last page.
37    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    /// Page numbers ([`Meta`]) or a cursor (`{limit, next_cursor}`), so read loosely.
46    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
58/// Decodes a response that must carry `data`.
59pub(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
73/// Decodes a cursor-paginated list: `data` and `meta.next_cursor` (`None` on the last
74/// page).
75pub(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
93/// Decodes a response without `data` (e.g. DELETE → `{ "success": true }`).
94pub(crate) fn decode_empty(status: u16, body: &[u8]) -> Result<(), Error> {
95    parse::<serde_json::Value>(status, body).map(|_| ())
96}
97
98/// Decodes a response whose success is a file (zone export): any 2xx body is the file,
99/// whatever its content type (the `json` export is a plain document, not an envelope);
100/// anything else is the error envelope.
101pub(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        // A cursor meta is not page meta, and does not break a plain decode.
153        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}