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 http: std::sync::Arc<dyn crate::http::HttpClient> =
222            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
223        let factory = DefaultClientFactory {
224            chains_config: config.chains.clone(),
225            http,
226        };
227        let state = std::sync::Arc::new(AppState { config, factory });
228        let response = handle_list(State(state)).await.into_response();
229        let status = response.status();
230        assert!(status.is_success() || status.is_server_error());
231    }
232
233    #[tokio::test]
234    async fn test_handle_address_book_add_direct() {
235        use crate::chains::DefaultClientFactory;
236        use crate::config::Config;
237        use crate::web::AppState;
238        use axum::extract::State;
239        use axum::response::IntoResponse;
240
241        let config = Config::default();
242        let http: std::sync::Arc<dyn crate::http::HttpClient> =
243            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
244        let factory = DefaultClientFactory {
245            chains_config: config.chains.clone(),
246            http,
247        };
248        let state = std::sync::Arc::new(AppState { config, factory });
249        let req = AddAddressBookRequest {
250            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
251            chain: "ethereum".to_string(),
252            label: Some("Test".to_string()),
253            tags: vec!["test".to_string()],
254        };
255        let response = handle_add(State(state), axum::Json(req))
256            .await
257            .into_response();
258        let status = response.status();
259        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
260    }
261
262    #[test]
263    fn test_deserialize_remove_request() {
264        let json = serde_json::json!({
265            "address": "0x1234567890123456789012345678901234567890"
266        });
267        let req: RemoveAddressBookRequest = serde_json::from_value(json).unwrap();
268        assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
269    }
270
271    #[tokio::test]
272    async fn test_handle_address_book_remove_direct() {
273        use crate::chains::DefaultClientFactory;
274        use crate::config::Config;
275        use crate::web::AppState;
276        use axum::extract::State;
277        use axum::response::IntoResponse;
278
279        let config = Config::default();
280        let http: std::sync::Arc<dyn crate::http::HttpClient> =
281            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
282        let factory = DefaultClientFactory {
283            chains_config: config.chains.clone(),
284            http,
285        };
286        let state = std::sync::Arc::new(AppState { config, factory });
287        let req = RemoveAddressBookRequest {
288            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
289        };
290        let response = handle_remove(State(state), axum::Json(req))
291            .await
292            .into_response();
293        let status = response.status();
294        // 200 (removed), 404 (not found), or 500 (load/save error)
295        assert!(
296            status.is_success()
297                || status == axum::http::StatusCode::NOT_FOUND
298                || status.is_server_error()
299        );
300    }
301
302    #[tokio::test]
303    async fn test_handle_address_book_remove_nonexistent() {
304        use crate::chains::DefaultClientFactory;
305        use crate::config::Config;
306        use crate::web::AppState;
307        use axum::extract::State;
308        use axum::response::IntoResponse;
309
310        let config = Config::default();
311        let http: std::sync::Arc<dyn crate::http::HttpClient> =
312            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
313        let factory = DefaultClientFactory {
314            chains_config: config.chains.clone(),
315            http,
316        };
317        let state = std::sync::Arc::new(AppState { config, factory });
318        let req = RemoveAddressBookRequest {
319            address: "0x000000000000000000000000000000000000dead".to_string(),
320        };
321        let response = handle_remove(State(state), axum::Json(req))
322            .await
323            .into_response();
324        // Should return 404 (not found) or 500 (load error)
325        assert!(
326            response.status() == axum::http::StatusCode::NOT_FOUND
327                || response.status().is_server_error()
328                || response.status().is_success()
329        );
330    }
331
332    #[tokio::test]
333    async fn test_handle_address_book_list_json_structure() {
334        use crate::chains::DefaultClientFactory;
335        use crate::config::Config;
336        use axum::body;
337        use axum::extract::State;
338
339        let config = Config::default();
340        let http: std::sync::Arc<dyn crate::http::HttpClient> =
341            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
342        let factory = DefaultClientFactory {
343            chains_config: config.chains.clone(),
344            http,
345        };
346        let state = std::sync::Arc::new(AppState { config, factory });
347        let response = handle_list(State(state)).await.into_response();
348        if response.status().is_success() {
349            let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
350                .await
351                .unwrap();
352            let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
353            assert!(json.get("addresses").is_some());
354        }
355    }
356
357    #[tokio::test]
358    async fn test_handle_address_book_add_duplicate_returns_bad_request() {
359        use crate::chains::DefaultClientFactory;
360        use crate::config::Config;
361        use crate::web::AppState;
362        use axum::extract::State;
363        use axum::http::StatusCode;
364        use axum::response::IntoResponse;
365
366        let tmp_dir = tempfile::tempdir().unwrap();
367        let data_dir = tmp_dir.path().to_path_buf();
368        let mut config = Config::default();
369        config.address_book.data_dir = Some(data_dir.clone());
370
371        let http: std::sync::Arc<dyn crate::http::HttpClient> =
372            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
373        let factory = DefaultClientFactory {
374            chains_config: config.chains.clone(),
375            http,
376        };
377        let state = std::sync::Arc::new(AppState { config, factory });
378
379        let addr = "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string();
380        let req1 = AddAddressBookRequest {
381            address: addr.clone(),
382            chain: "ethereum".to_string(),
383            label: Some("First".to_string()),
384            tags: vec![],
385        };
386        let r1 = handle_add(State(state.clone()), axum::Json(req1))
387            .await
388            .into_response();
389        if !r1.status().is_success() {
390            return;
391        }
392
393        let req2 = AddAddressBookRequest {
394            address: addr,
395            chain: "ethereum".to_string(),
396            label: Some("Duplicate".to_string()),
397            tags: vec![],
398        };
399        let r2 = handle_add(State(state), axum::Json(req2))
400            .await
401            .into_response();
402        assert_eq!(r2.status(), StatusCode::BAD_REQUEST);
403        let body = axum::body::to_bytes(r2.into_body(), 1_000_000)
404            .await
405            .unwrap();
406        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
407        assert!(
408            json["error"]
409                .as_str()
410                .unwrap()
411                .to_lowercase()
412                .contains("already")
413        );
414    }
415
416    #[tokio::test]
417    async fn test_handle_address_book_list_corrupt_file_returns_500() {
418        use crate::chains::DefaultClientFactory;
419        use crate::config::Config;
420        use crate::web::AppState;
421        use axum::extract::State;
422        use axum::http::StatusCode;
423
424        let tmp_dir = tempfile::tempdir().unwrap();
425        let yaml_path = tmp_dir.path().join("address_book.yaml");
426        std::fs::write(&yaml_path, "{{{ invalid yaml").unwrap();
427
428        let mut config = Config::default();
429        config.address_book.data_dir = Some(tmp_dir.path().to_path_buf());
430        let http: std::sync::Arc<dyn crate::http::HttpClient> =
431            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
432        let factory = DefaultClientFactory {
433            chains_config: config.chains.clone(),
434            http,
435        };
436        let state = std::sync::Arc::new(AppState { config, factory });
437        let response = handle_list(State(state)).await.into_response();
438        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
439    }
440}