Skip to main content

scope/web/api/
address_book.rs

1//! Address book management API handlers.
2
3use crate::cli::address_book::{AddressBook, WatchedAddress};
4use crate::web::AppState;
5use axum::Json;
6use axum::extract::State;
7use axum::http::StatusCode;
8use axum::response::IntoResponse;
9use serde::Deserialize;
10use std::sync::Arc;
11
12/// GET /api/address-book/list — List address book entries.
13pub async fn handle_list(State(state): State<Arc<AppState>>) -> impl IntoResponse {
14    let data_dir = state.config.data_dir();
15    match AddressBook::load(&data_dir) {
16        Ok(address_book) => Json(serde_json::json!({
17            "addresses": address_book.addresses,
18        }))
19        .into_response(),
20        Err(e) => (
21            StatusCode::INTERNAL_SERVER_ERROR,
22            Json(serde_json::json!({ "error": e.to_string() })),
23        )
24            .into_response(),
25    }
26}
27
28/// Request body for adding an address book entry.
29#[derive(Debug, Deserialize)]
30pub struct AddAddressBookRequest {
31    /// Blockchain address.
32    pub address: String,
33    /// Chain (default: "ethereum").
34    #[serde(default = "default_chain")]
35    pub chain: String,
36    /// Optional label.
37    pub label: Option<String>,
38    /// Optional tags.
39    #[serde(default)]
40    pub tags: Vec<String>,
41}
42
43fn default_chain() -> String {
44    "ethereum".to_string()
45}
46
47/// POST /api/address-book/add — Add an address to the address book.
48pub async fn handle_add(
49    State(state): State<Arc<AppState>>,
50    Json(req): Json<AddAddressBookRequest>,
51) -> impl IntoResponse {
52    let data_dir = state.config.data_dir();
53    let mut address_book = match AddressBook::load(&data_dir) {
54        Ok(ab) => ab,
55        Err(e) => {
56            return (
57                StatusCode::INTERNAL_SERVER_ERROR,
58                Json(serde_json::json!({ "error": e.to_string() })),
59            )
60                .into_response();
61        }
62    };
63
64    let watched = WatchedAddress {
65        address: req.address.clone(),
66        label: req.label,
67        chain: req.chain,
68        tags: req.tags,
69        added_at: chrono::Utc::now().timestamp() as u64,
70    };
71
72    match address_book.add_address(watched) {
73        Ok(_) => {
74            let data_dir_buf = data_dir.to_path_buf();
75            if let Err(e) = address_book.save(&data_dir_buf) {
76                return (
77                    StatusCode::INTERNAL_SERVER_ERROR,
78                    Json(serde_json::json!({ "error": e.to_string() })),
79                )
80                    .into_response();
81            }
82            Json(serde_json::json!({
83                "status": "added",
84                "address": req.address,
85                "addresses": address_book.addresses,
86            }))
87            .into_response()
88        }
89        Err(e) => (
90            StatusCode::BAD_REQUEST,
91            Json(serde_json::json!({ "error": e.to_string() })),
92        )
93            .into_response(),
94    }
95}
96
97/// Request body for removing an address book entry.
98#[derive(Debug, Deserialize)]
99pub struct RemoveAddressBookRequest {
100    /// Blockchain address to remove.
101    pub address: String,
102}
103
104/// POST /api/address-book/remove — Remove an address from the address book.
105pub async fn handle_remove(
106    State(state): State<Arc<AppState>>,
107    Json(req): Json<RemoveAddressBookRequest>,
108) -> impl IntoResponse {
109    let data_dir = state.config.data_dir();
110    let mut address_book = match AddressBook::load(&data_dir) {
111        Ok(ab) => ab,
112        Err(e) => {
113            return (
114                StatusCode::INTERNAL_SERVER_ERROR,
115                Json(serde_json::json!({ "error": e.to_string() })),
116            )
117                .into_response();
118        }
119    };
120
121    match address_book.remove_address(&req.address) {
122        Ok(true) => {
123            let data_dir_buf = data_dir.to_path_buf();
124            if let Err(e) = address_book.save(&data_dir_buf) {
125                return (
126                    StatusCode::INTERNAL_SERVER_ERROR,
127                    Json(serde_json::json!({ "error": e.to_string() })),
128                )
129                    .into_response();
130            }
131            Json(serde_json::json!({
132                "status": "removed",
133                "address": req.address,
134                "addresses": address_book.addresses,
135            }))
136            .into_response()
137        }
138        Ok(false) => (
139            StatusCode::NOT_FOUND,
140            Json(serde_json::json!({ "error": format!("Address '{}' not found in address book", req.address) })),
141        )
142            .into_response(),
143        Err(e) => (
144            StatusCode::INTERNAL_SERVER_ERROR,
145            Json(serde_json::json!({ "error": e.to_string() })),
146        )
147            .into_response(),
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn test_deserialize_full() {
157        let json = serde_json::json!({
158            "address": "0x1234567890123456789012345678901234567890",
159            "chain": "polygon",
160            "label": "My Wallet",
161            "tags": ["defi", "nft"]
162        });
163        let req: AddAddressBookRequest = serde_json::from_value(json).unwrap();
164        assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
165        assert_eq!(req.chain, "polygon");
166        assert_eq!(req.label, Some("My Wallet".to_string()));
167        assert_eq!(req.tags.len(), 2);
168        assert_eq!(req.tags[0], "defi");
169        assert_eq!(req.tags[1], "nft");
170    }
171
172    #[test]
173    fn test_deserialize_minimal() {
174        let json = serde_json::json!({
175            "address": "0x1234567890123456789012345678901234567890"
176        });
177        let req: AddAddressBookRequest = serde_json::from_value(json).unwrap();
178        assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
179        assert_eq!(req.chain, "ethereum");
180        assert_eq!(req.label, None);
181        assert_eq!(req.tags.len(), 0);
182    }
183
184    #[test]
185    fn test_default_chain() {
186        assert_eq!(default_chain(), "ethereum");
187    }
188
189    #[test]
190    fn test_with_tags() {
191        let json = serde_json::json!({
192            "address": "0x1234567890123456789012345678901234567890",
193            "tags": ["tag1", "tag2", "tag3"]
194        });
195        let req: AddAddressBookRequest = serde_json::from_value(json).unwrap();
196        assert_eq!(req.tags.len(), 3);
197        assert_eq!(req.tags[0], "tag1");
198        assert_eq!(req.tags[1], "tag2");
199        assert_eq!(req.tags[2], "tag3");
200    }
201
202    #[test]
203    fn test_with_label() {
204        let json = serde_json::json!({
205            "address": "0x1234567890123456789012345678901234567890",
206            "label": "Test Label"
207        });
208        let req: AddAddressBookRequest = serde_json::from_value(json).unwrap();
209        assert_eq!(req.label, Some("Test Label".to_string()));
210    }
211
212    #[tokio::test]
213    async fn test_handle_address_book_list_direct() {
214        use crate::chains::DefaultClientFactory;
215        use crate::config::Config;
216        use crate::web::AppState;
217        use axum::extract::State;
218        use axum::response::IntoResponse;
219
220        let config = Config::default();
221        let factory = DefaultClientFactory {
222            chains_config: config.chains.clone(),
223        };
224        let state = std::sync::Arc::new(AppState { config, factory });
225        let response = handle_list(State(state)).await.into_response();
226        let status = response.status();
227        assert!(status.is_success() || status.is_server_error());
228    }
229
230    #[tokio::test]
231    async fn test_handle_address_book_add_direct() {
232        use crate::chains::DefaultClientFactory;
233        use crate::config::Config;
234        use crate::web::AppState;
235        use axum::extract::State;
236        use axum::response::IntoResponse;
237
238        let config = Config::default();
239        let factory = DefaultClientFactory {
240            chains_config: config.chains.clone(),
241        };
242        let state = std::sync::Arc::new(AppState { config, factory });
243        let req = AddAddressBookRequest {
244            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
245            chain: "ethereum".to_string(),
246            label: Some("Test".to_string()),
247            tags: vec!["test".to_string()],
248        };
249        let response = handle_add(State(state), axum::Json(req))
250            .await
251            .into_response();
252        let status = response.status();
253        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
254    }
255
256    #[test]
257    fn test_deserialize_remove_request() {
258        let json = serde_json::json!({
259            "address": "0x1234567890123456789012345678901234567890"
260        });
261        let req: RemoveAddressBookRequest = serde_json::from_value(json).unwrap();
262        assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
263    }
264
265    #[tokio::test]
266    async fn test_handle_address_book_remove_direct() {
267        use crate::chains::DefaultClientFactory;
268        use crate::config::Config;
269        use crate::web::AppState;
270        use axum::extract::State;
271        use axum::response::IntoResponse;
272
273        let config = Config::default();
274        let factory = DefaultClientFactory {
275            chains_config: config.chains.clone(),
276        };
277        let state = std::sync::Arc::new(AppState { config, factory });
278        let req = RemoveAddressBookRequest {
279            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
280        };
281        let response = handle_remove(State(state), axum::Json(req))
282            .await
283            .into_response();
284        let status = response.status();
285        // 200 (removed), 404 (not found), or 500 (load/save error)
286        assert!(
287            status.is_success()
288                || status == axum::http::StatusCode::NOT_FOUND
289                || status.is_server_error()
290        );
291    }
292
293    #[tokio::test]
294    async fn test_handle_address_book_remove_nonexistent() {
295        use crate::chains::DefaultClientFactory;
296        use crate::config::Config;
297        use crate::web::AppState;
298        use axum::extract::State;
299        use axum::response::IntoResponse;
300
301        let config = Config::default();
302        let factory = DefaultClientFactory {
303            chains_config: config.chains.clone(),
304        };
305        let state = std::sync::Arc::new(AppState { config, factory });
306        let req = RemoveAddressBookRequest {
307            address: "0x000000000000000000000000000000000000dead".to_string(),
308        };
309        let response = handle_remove(State(state), axum::Json(req))
310            .await
311            .into_response();
312        // Should return 404 (not found) or 500 (load error)
313        assert!(
314            response.status() == axum::http::StatusCode::NOT_FOUND
315                || response.status().is_server_error()
316                || response.status().is_success()
317        );
318    }
319
320    #[tokio::test]
321    async fn test_handle_address_book_list_json_structure() {
322        use crate::chains::DefaultClientFactory;
323        use crate::config::Config;
324        use axum::body;
325        use axum::extract::State;
326
327        let config = Config::default();
328        let factory = DefaultClientFactory {
329            chains_config: config.chains.clone(),
330        };
331        let state = std::sync::Arc::new(AppState { config, factory });
332        let response = handle_list(State(state)).await.into_response();
333        if response.status().is_success() {
334            let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
335                .await
336                .unwrap();
337            let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
338            assert!(json.get("addresses").is_some());
339        }
340    }
341
342    #[tokio::test]
343    async fn test_handle_address_book_add_duplicate_returns_bad_request() {
344        use crate::chains::DefaultClientFactory;
345        use crate::config::Config;
346        use crate::web::AppState;
347        use axum::extract::State;
348        use axum::http::StatusCode;
349        use axum::response::IntoResponse;
350
351        let tmp_dir = tempfile::tempdir().unwrap();
352        let data_dir = tmp_dir.path().to_path_buf();
353        let mut config = Config::default();
354        config.address_book.data_dir = Some(data_dir.clone());
355
356        let factory = DefaultClientFactory {
357            chains_config: config.chains.clone(),
358        };
359        let state = std::sync::Arc::new(AppState { config, factory });
360
361        let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string();
362        let req1 = AddAddressBookRequest {
363            address: addr.clone(),
364            chain: "ethereum".to_string(),
365            label: Some("First".to_string()),
366            tags: vec![],
367        };
368        let r1 = handle_add(State(state.clone()), axum::Json(req1))
369            .await
370            .into_response();
371        if !r1.status().is_success() {
372            return;
373        }
374
375        let req2 = AddAddressBookRequest {
376            address: addr,
377            chain: "ethereum".to_string(),
378            label: Some("Duplicate".to_string()),
379            tags: vec![],
380        };
381        let r2 = handle_add(State(state), axum::Json(req2))
382            .await
383            .into_response();
384        assert_eq!(r2.status(), StatusCode::BAD_REQUEST);
385        let body = axum::body::to_bytes(r2.into_body(), 1_000_000)
386            .await
387            .unwrap();
388        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
389        assert!(
390            json["error"]
391                .as_str()
392                .unwrap()
393                .to_lowercase()
394                .contains("already")
395        );
396    }
397
398    #[tokio::test]
399    async fn test_handle_address_book_list_corrupt_file_returns_500() {
400        use crate::chains::DefaultClientFactory;
401        use crate::config::Config;
402        use crate::web::AppState;
403        use axum::extract::State;
404        use axum::http::StatusCode;
405
406        let tmp_dir = tempfile::tempdir().unwrap();
407        let yaml_path = tmp_dir.path().join("address_book.yaml");
408        std::fs::write(&yaml_path, "{{{ invalid yaml").unwrap();
409
410        let mut config = Config::default();
411        config.address_book.data_dir = Some(tmp_dir.path().to_path_buf());
412        let factory = DefaultClientFactory {
413            chains_config: config.chains.clone(),
414        };
415        let state = std::sync::Arc::new(AppState { config, factory });
416        let response = handle_list(State(state)).await.into_response();
417        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
418    }
419}