Skip to main content

hackmd/types/
folder.rs

1//! Folder types.
2//!
3//! Mirrors `_ref/api-client/nodejs/src/type.ts:124-176`.
4//!
5//! ## `Option<Option<T>>` on update bodies
6//!
7//! The folder PATCH endpoints need to distinguish three caller intents per
8//! field:
9//!
10//! * absent in the request body → "don't change this field"
11//! * explicit `null` in the request body → "clear this field"
12//! * a concrete value → "set to this"
13//!
14//! Rust's `Option<T>` collapses the first two. The standard fix is the
15//! `Option<Option<T>>` pattern: outer `None` ⇒ skip serializing; outer
16//! `Some(None)` ⇒ serialize as `null`; outer `Some(Some(v))` ⇒ serialize as
17//! the value. Serde needs a helper to deserialize the `null` case correctly,
18//! so we use [`serde_with::rust::double_option`].
19
20use std::collections::HashMap;
21
22use serde::{Deserialize, Serialize};
23
24/// Breadcrumb segment returned on notes' `folderPaths` field (OpenAPI
25/// `FolderPath`).
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct FolderPath {
29    pub id: String,
30    pub name: String,
31    pub icon: Option<String>,
32    pub color: Option<String>,
33    pub parent_id: Option<String>,
34    pub client_id: String,
35}
36
37/// A user or team folder entity.
38///
39/// Note: `created_at` / `updated_at` are millisecond unix epochs as `i64`,
40/// not strings — this is one of the few wire shapes where HackMD does serve
41/// numbers instead of ISO strings.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct ApiFolder {
45    pub id: String,
46    pub name: String,
47    pub description: Option<String>,
48    pub icon: Option<String>,
49    pub color: Option<String>,
50    pub parent_folder_id: Option<String>,
51    pub created_at: i64,
52    pub updated_at: i64,
53}
54
55/// Folder ordering map. Each key is either a parent folder id or the literal
56/// `"root"` (for the root-level ordering); the value is the ordered list of
57/// child folder ids under that parent.
58pub type ApiFolderOrder = HashMap<String, Vec<String>>;
59
60/// POST `/folders` request body — every field optional, absent fields are
61/// omitted from the wire.
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct CreateUserFolderBody {
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub name: Option<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub description: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub icon: Option<String>,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub color: Option<String>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub parent_folder_id: Option<String>,
75}
76
77/// PATCH `/folders/{id}` request body. Uses [`Option<Option<T>>`] on the
78/// fields that may be cleared, so callers can distinguish "don't change"
79/// (outer `None`) from "set to null" (outer `Some(None)`).
80#[derive(Debug, Clone, Default, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct UpdateUserFolderBody {
83    /// Folder name. Upstream type is `string | undefined` (no `null`), so a
84    /// plain `Option<String>` is sufficient here.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub name: Option<String>,
87    #[serde(
88        default,
89        with = "serde_with::rust::double_option",
90        skip_serializing_if = "Option::is_none"
91    )]
92    pub description: Option<Option<String>>,
93    #[serde(
94        default,
95        with = "serde_with::rust::double_option",
96        skip_serializing_if = "Option::is_none"
97    )]
98    pub icon: Option<Option<String>>,
99    #[serde(
100        default,
101        with = "serde_with::rust::double_option",
102        skip_serializing_if = "Option::is_none"
103    )]
104    pub color: Option<Option<String>>,
105    #[serde(
106        default,
107        with = "serde_with::rust::double_option",
108        skip_serializing_if = "Option::is_none"
109    )]
110    pub parent_folder_id: Option<Option<String>>,
111}
112
113/// Team-scope folder create body. Upstream type alias: identical shape to
114/// [`CreateUserFolderBody`]; we mirror that with a type alias to keep the
115/// surface small.
116pub type CreateTeamFolderBody = CreateUserFolderBody;
117
118/// Team-scope folder update body. Upstream type alias: identical shape to
119/// [`UpdateUserFolderBody`]; we mirror that with a type alias to keep the
120/// surface small.
121pub type UpdateTeamFolderBody = UpdateUserFolderBody;
122
123/// PUT `/folders/folder-order` request body — wraps an [`ApiFolderOrder`].
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct UpdateFolderOrderBody {
126    pub order: ApiFolderOrder,
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn folder_path_round_trip() {
135        let raw = r##"{
136            "id": "f1",
137            "name": "Research",
138            "icon": null,
139            "color": null,
140            "parentId": null,
141            "clientId": "cid-1"
142        }"##;
143        let fp: FolderPath = serde_json::from_str(raw).expect("parse FolderPath");
144        assert_eq!(fp.id, "f1");
145        assert_eq!(fp.name, "Research");
146        assert!(fp.parent_id.is_none());
147        assert_eq!(fp.client_id, "cid-1");
148
149        let json = serde_json::to_value(&fp).expect("to_value");
150        assert_eq!(json["parentId"], serde_json::Value::Null);
151        assert_eq!(json["clientId"], "cid-1");
152    }
153
154    #[test]
155    fn api_folder_round_trip() {
156        // Mirrors the fixture shape used in upstream tests/api.spec.ts:117-139.
157        let raw = r##"{
158            "id": "folder-1",
159            "name": "Research",
160            "description": "notes about things",
161            "icon": "book",
162            "color": "#ff8800",
163            "parentFolderId": null,
164            "createdAt": 1700000000000,
165            "updatedAt": 1700000001000
166        }"##;
167        let f: ApiFolder = serde_json::from_str(raw).expect("parse ApiFolder");
168        assert_eq!(f.id, "folder-1");
169        assert_eq!(f.created_at, 1700000000000);
170        assert_eq!(f.updated_at, 1700000001000);
171        assert_eq!(f.description.as_deref(), Some("notes about things"));
172        assert_eq!(f.icon.as_deref(), Some("book"));
173        assert_eq!(f.color.as_deref(), Some("#ff8800"));
174        assert!(f.parent_folder_id.is_none());
175
176        let json = serde_json::to_value(&f).expect("to_value");
177        assert_eq!(json["createdAt"], 1700000000000_i64);
178        assert_eq!(json["updatedAt"], 1700000001000_i64);
179        assert_eq!(json["parentFolderId"], serde_json::Value::Null);
180    }
181
182    #[test]
183    fn api_folder_round_trip_nulls() {
184        // Verifies null description/icon/color (the common "freshly created" shape)
185        // also deserializes cleanly.
186        let raw = r##"{
187            "id": "folder-2",
188            "name": "Empty",
189            "description": null,
190            "icon": null,
191            "color": null,
192            "parentFolderId": null,
193            "createdAt": 1700000000000,
194            "updatedAt": 1700000000000
195        }"##;
196        let f: ApiFolder = serde_json::from_str(raw).expect("parse ApiFolder");
197        assert!(f.description.is_none());
198        assert!(f.icon.is_none());
199        assert!(f.color.is_none());
200        assert!(f.parent_folder_id.is_none());
201    }
202
203    #[test]
204    fn create_user_folder_body_skips_absent_fields() {
205        let body = CreateUserFolderBody {
206            name: Some("New".into()),
207            ..Default::default()
208        };
209        let json = serde_json::to_value(&body).expect("to_value");
210        assert_eq!(json, serde_json::json!({ "name": "New" }));
211    }
212
213    #[test]
214    fn update_user_folder_body_only_name() {
215        let body = UpdateUserFolderBody {
216            name: Some("Renamed".into()),
217            ..Default::default()
218        };
219        let json = serde_json::to_value(&body).expect("to_value");
220        // All other fields are absent — verify the wire payload has only `name`.
221        assert_eq!(json, serde_json::json!({ "name": "Renamed" }));
222    }
223
224    #[test]
225    fn update_user_folder_body_explicit_null_clears_field() {
226        let body = UpdateUserFolderBody {
227            description: Some(None),
228            ..Default::default()
229        };
230        let json = serde_json::to_value(&body).expect("to_value");
231        // Critical: `description: null` MUST appear on the wire to instruct the
232        // server to clear the field.
233        assert_eq!(json, serde_json::json!({ "description": null }));
234    }
235
236    #[test]
237    fn update_user_folder_body_value_round_trip() {
238        let body = UpdateUserFolderBody {
239            color: Some(Some("#000000".into())),
240            ..Default::default()
241        };
242        let json = serde_json::to_value(&body).expect("to_value");
243        assert_eq!(json, serde_json::json!({ "color": "#000000" }));
244
245        // And the reverse — a JSON value with `color: "#000000"` parses back to
246        // `Some(Some("#000000"))`.
247        let parsed: UpdateUserFolderBody = serde_json::from_value(json).expect("parse");
248        assert_eq!(parsed.color, Some(Some("#000000".into())));
249        assert!(parsed.name.is_none());
250        assert!(parsed.description.is_none());
251    }
252
253    #[test]
254    fn update_user_folder_body_all_absent_is_empty_object() {
255        let body = UpdateUserFolderBody::default();
256        let json = serde_json::to_value(&body).expect("to_value");
257        assert_eq!(json, serde_json::json!({}));
258    }
259
260    #[test]
261    fn update_user_folder_body_deserializes_null_as_some_none() {
262        // Verify the double_option deserializer treats an explicit `null` as
263        // `Some(None)` (i.e. preserves the distinction).
264        let raw = r##"{ "description": null }"##;
265        let body: UpdateUserFolderBody = serde_json::from_str(raw).expect("parse");
266        assert_eq!(body.description, Some(None));
267        // And an absent field stays absent (outer None).
268        assert!(body.color.is_none());
269    }
270
271    #[test]
272    fn api_folder_order_round_trip() {
273        let mut order: ApiFolderOrder = HashMap::new();
274        order.insert("root".into(), vec!["folder-1".into(), "folder-2".into()]);
275        order.insert("folder-1".into(), vec!["folder-3".into()]);
276        let json = serde_json::to_value(&order).expect("to_value");
277        assert_eq!(json["root"], serde_json::json!(["folder-1", "folder-2"]));
278        assert_eq!(json["folder-1"], serde_json::json!(["folder-3"]));
279
280        let parsed: ApiFolderOrder = serde_json::from_value(json).expect("parse");
281        assert_eq!(parsed.get("root").map(Vec::len), Some(2));
282        assert_eq!(parsed.get("folder-1").map(Vec::len), Some(1));
283    }
284
285    #[test]
286    fn update_folder_order_body_round_trip() {
287        let mut order: ApiFolderOrder = HashMap::new();
288        order.insert("root".into(), vec!["a".into(), "b".into()]);
289        let body = UpdateFolderOrderBody { order };
290        let json = serde_json::to_value(&body).expect("to_value");
291        assert_eq!(json, serde_json::json!({ "order": { "root": ["a", "b"] } }));
292
293        let parsed: UpdateFolderOrderBody = serde_json::from_value(json).expect("parse");
294        assert_eq!(
295            parsed.order.get("root"),
296            Some(&vec!["a".into(), "b".into()])
297        );
298    }
299}