Skip to main content

bear_cli/cloudkit/
models.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value as JsonValue;
5
6#[derive(Debug, Serialize, Deserialize, Clone)]
7#[serde(rename_all = "camelCase")]
8pub struct ZoneId {
9    pub zone_name: String,
10}
11
12impl Default for ZoneId {
13    fn default() -> Self {
14        Self::notes()
15    }
16}
17
18impl ZoneId {
19    pub fn notes() -> Self {
20        Self {
21            zone_name: "Notes".into(),
22        }
23    }
24
25    pub fn default_zone() -> Self {
26        Self {
27            zone_name: "_defaultZone".into(),
28        }
29    }
30}
31
32/// A CloudKit field value with type tag and optional encryption flag.
33#[derive(Debug, Serialize, Deserialize, Clone)]
34#[serde(rename_all = "camelCase")]
35pub struct CkField {
36    #[serde(rename = "type")]
37    pub kind: String,
38    pub value: JsonValue,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub is_encrypted: Option<bool>,
41}
42
43impl CkField {
44    pub fn string(v: impl Into<String>) -> Self {
45        Self {
46            kind: "STRING".into(),
47            value: JsonValue::String(v.into()),
48            is_encrypted: None,
49        }
50    }
51
52    pub fn string_encrypted(v: impl Into<String>) -> Self {
53        Self {
54            kind: "STRING".into(),
55            value: JsonValue::String(v.into()),
56            is_encrypted: Some(true),
57        }
58    }
59
60    pub fn string_null() -> Self {
61        Self {
62            kind: "STRING".into(),
63            value: JsonValue::Null,
64            is_encrypted: None,
65        }
66    }
67
68    pub fn string_list(v: Vec<String>) -> Self {
69        Self {
70            kind: "STRING_LIST".into(),
71            value: serde_json::to_value(v).unwrap(),
72            is_encrypted: None,
73        }
74    }
75
76    pub fn string_list_null() -> Self {
77        Self {
78            kind: "STRING_LIST".into(),
79            value: JsonValue::Null,
80            is_encrypted: None,
81        }
82    }
83
84    pub fn int64(v: i64) -> Self {
85        Self {
86            kind: "INT64".into(),
87            value: JsonValue::Number(v.into()),
88            is_encrypted: None,
89        }
90    }
91
92    pub fn timestamp(ms: i64) -> Self {
93        Self {
94            kind: "TIMESTAMP".into(),
95            value: JsonValue::Number(ms.into()),
96            is_encrypted: None,
97        }
98    }
99
100    pub fn timestamp_null() -> Self {
101        Self {
102            kind: "TIMESTAMP".into(),
103            value: JsonValue::Null,
104            is_encrypted: None,
105        }
106    }
107
108    pub fn bytes(b64: impl Into<String>) -> Self {
109        Self {
110            kind: "BYTES".into(),
111            value: JsonValue::String(b64.into()),
112            is_encrypted: None,
113        }
114    }
115
116    pub fn asset_id(receipt: AssetReceipt) -> Self {
117        Self {
118            kind: "ASSETID".into(),
119            value: serde_json::to_value(receipt).unwrap(),
120            is_encrypted: None,
121        }
122    }
123}
124
125#[derive(Debug, Serialize, Deserialize, Clone)]
126#[serde(rename_all = "camelCase")]
127pub struct AssetToken {
128    pub record_type: String,
129    pub record_name: String,
130    pub field_name: String,
131}
132
133#[derive(Debug, Serialize, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct AssetUploadRequest {
136    #[serde(rename = "zoneID")]
137    pub zone_id: ZoneId,
138    pub tokens: Vec<AssetToken>,
139}
140
141#[derive(Debug, Serialize, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct AssetUploadResponse {
144    pub tokens: Vec<AssetUploadToken>,
145}
146
147#[derive(Debug, Serialize, Deserialize)]
148#[serde(rename_all = "camelCase")]
149pub struct AssetUploadToken {
150    pub record_name: String,
151    pub field_name: String,
152    pub url: String,
153}
154
155#[derive(Debug, Serialize, Deserialize, Clone)]
156#[serde(rename_all = "camelCase")]
157pub struct AssetReceipt {
158    pub file_checksum: String,
159    pub receipt: String,
160    pub size: i64,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub wrapping_key: Option<String>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub reference_checksum: Option<String>,
165}
166
167#[derive(Debug, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct AssetUploadResult {
170    pub single_file: AssetReceipt,
171}
172
173pub type Fields = HashMap<String, CkField>;
174
175#[derive(Debug, Serialize, Deserialize, Clone)]
176#[serde(rename_all = "camelCase")]
177pub struct CkRecord {
178    pub record_name: String,
179    #[serde(default)]
180    pub record_type: String,
181    #[serde(rename = "zoneID", skip_serializing_if = "Option::is_none")]
182    pub zone_id: Option<ZoneId>,
183    #[serde(default)]
184    pub fields: Fields,
185    #[serde(default)]
186    pub plugin_fields: HashMap<String, JsonValue>,
187    pub record_change_tag: Option<String>,
188    pub created: Option<JsonValue>,
189    pub modified: Option<JsonValue>,
190    #[serde(default)]
191    pub deleted: bool,
192    // Present on error responses:
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub server_error_code: Option<String>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub reason: Option<String>,
197}
198
199#[derive(Debug, Serialize)]
200#[serde(rename_all = "camelCase")]
201pub struct ModifyOperation {
202    pub operation_type: String, // "create" | "update" | "delete"
203    pub record: CkRecord,
204    pub record_type: String,
205}
206
207#[derive(Debug, Serialize)]
208#[serde(rename_all = "camelCase")]
209pub struct ModifyRequest {
210    pub operations: Vec<ModifyOperation>,
211    #[serde(rename = "zoneID")]
212    pub zone_id: ZoneId,
213}
214
215#[derive(Debug, Deserialize)]
216#[serde(rename_all = "camelCase")]
217pub struct ModifyResponse {
218    pub records: Vec<CkRecord>,
219}
220
221#[derive(Debug, Serialize, Clone)]
222#[serde(rename_all = "camelCase")]
223pub struct CkQuery {
224    pub record_type: String,
225    #[serde(skip_serializing_if = "Vec::is_empty")]
226    pub filter_by: Vec<CkFilter>,
227    #[serde(skip_serializing_if = "Vec::is_empty")]
228    pub sort_by: Vec<CkSort>,
229}
230
231#[derive(Debug, Serialize, Clone)]
232#[serde(rename_all = "camelCase")]
233pub struct CkFilter {
234    pub field_name: String,
235    pub comparator: String,
236    pub field_value: CkFilterValue,
237}
238
239#[derive(Debug, Serialize, Clone)]
240pub struct CkFilterValue {
241    pub value: JsonValue,
242    #[serde(rename = "type")]
243    pub kind: String,
244}
245
246#[derive(Debug, Serialize, Clone)]
247#[serde(rename_all = "camelCase")]
248pub struct CkSort {
249    pub field_name: String,
250    pub ascending: bool,
251}
252
253#[derive(Debug, Serialize)]
254#[serde(rename_all = "camelCase")]
255pub struct QueryRequest {
256    #[serde(rename = "zoneID")]
257    pub zone_id: ZoneId,
258    pub query: CkQuery,
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub results_limit: Option<usize>,
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub desired_keys: Option<Vec<String>>,
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub continuation_marker: Option<String>,
265}
266
267#[derive(Debug, Deserialize)]
268#[serde(rename_all = "camelCase")]
269pub struct QueryResponse {
270    pub records: Vec<CkRecord>,
271    #[serde(default)]
272    pub continuation_marker: Option<String>,
273}
274
275#[derive(Debug, Serialize)]
276#[serde(rename_all = "camelCase")]
277pub struct LookupRequest {
278    pub records: Vec<LookupRecord>,
279    #[serde(rename = "zoneID")]
280    pub zone_id: ZoneId,
281}
282
283#[derive(Debug, Serialize)]
284#[serde(rename_all = "camelCase")]
285pub struct LookupRecord {
286    pub record_name: String,
287}
288
289#[derive(Debug, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct LookupResponse {
292    pub records: Vec<CkRecord>,
293}
294
295impl CkRecord {
296    pub fn str_field(&self, name: &str) -> Option<&str> {
297        self.fields.get(name)?.value.as_str()
298    }
299
300    pub fn i64_field(&self, name: &str) -> Option<i64> {
301        self.fields.get(name)?.value.as_i64()
302    }
303
304    pub fn bool_field(&self, name: &str) -> Option<bool> {
305        self.i64_field(name).map(|v| v != 0)
306    }
307
308    pub fn string_list_field(&self, name: &str) -> Vec<String> {
309        self.fields
310            .get(name)
311            .and_then(|f| f.value.as_array())
312            .map(|arr| {
313                arr.iter()
314                    .filter_map(|v| v.as_str().map(str::to_string))
315                    .collect()
316            })
317            .unwrap_or_default()
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn modify_request_serializes_zone_id_exactly() {
327        let json = serde_json::to_value(ModifyRequest {
328            operations: vec![],
329            zone_id: ZoneId::notes(),
330        })
331        .unwrap();
332
333        assert!(json.get("zoneID").is_some());
334        assert!(json.get("zoneId").is_none());
335    }
336
337    #[test]
338    fn query_request_serializes_zone_id_exactly() {
339        let json = serde_json::to_value(QueryRequest {
340            zone_id: ZoneId::notes(),
341            query: CkQuery {
342                record_type: "SFNote".into(),
343                filter_by: vec![],
344                sort_by: vec![],
345            },
346            results_limit: Some(1),
347            desired_keys: None,
348            continuation_marker: None,
349        })
350        .unwrap();
351
352        assert!(json.get("zoneID").is_some());
353        assert!(json.get("zoneId").is_none());
354    }
355}