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!({ "status": "added", "address": req.address })).into_response()
83        }
84        Err(e) => (
85            StatusCode::BAD_REQUEST,
86            Json(serde_json::json!({ "error": e.to_string() })),
87        )
88            .into_response(),
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn test_deserialize_full() {
98        let json = serde_json::json!({
99            "address": "0x1234567890123456789012345678901234567890",
100            "chain": "polygon",
101            "label": "My Wallet",
102            "tags": ["defi", "nft"]
103        });
104        let req: AddAddressBookRequest = serde_json::from_value(json).unwrap();
105        assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
106        assert_eq!(req.chain, "polygon");
107        assert_eq!(req.label, Some("My Wallet".to_string()));
108        assert_eq!(req.tags.len(), 2);
109        assert_eq!(req.tags[0], "defi");
110        assert_eq!(req.tags[1], "nft");
111    }
112
113    #[test]
114    fn test_deserialize_minimal() {
115        let json = serde_json::json!({
116            "address": "0x1234567890123456789012345678901234567890"
117        });
118        let req: AddAddressBookRequest = serde_json::from_value(json).unwrap();
119        assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
120        assert_eq!(req.chain, "ethereum");
121        assert_eq!(req.label, None);
122        assert_eq!(req.tags.len(), 0);
123    }
124
125    #[test]
126    fn test_default_chain() {
127        assert_eq!(default_chain(), "ethereum");
128    }
129
130    #[test]
131    fn test_with_tags() {
132        let json = serde_json::json!({
133            "address": "0x1234567890123456789012345678901234567890",
134            "tags": ["tag1", "tag2", "tag3"]
135        });
136        let req: AddAddressBookRequest = serde_json::from_value(json).unwrap();
137        assert_eq!(req.tags.len(), 3);
138        assert_eq!(req.tags[0], "tag1");
139        assert_eq!(req.tags[1], "tag2");
140        assert_eq!(req.tags[2], "tag3");
141    }
142
143    #[test]
144    fn test_with_label() {
145        let json = serde_json::json!({
146            "address": "0x1234567890123456789012345678901234567890",
147            "label": "Test Label"
148        });
149        let req: AddAddressBookRequest = serde_json::from_value(json).unwrap();
150        assert_eq!(req.label, Some("Test Label".to_string()));
151    }
152
153    #[tokio::test]
154    async fn test_handle_address_book_list_direct() {
155        use crate::chains::DefaultClientFactory;
156        use crate::config::Config;
157        use crate::web::AppState;
158        use axum::extract::State;
159        use axum::response::IntoResponse;
160
161        let config = Config::default();
162        let factory = DefaultClientFactory {
163            chains_config: config.chains.clone(),
164        };
165        let state = std::sync::Arc::new(AppState { config, factory });
166        let response = handle_list(State(state)).await.into_response();
167        let status = response.status();
168        assert!(status.is_success() || status.is_server_error());
169    }
170
171    #[tokio::test]
172    async fn test_handle_address_book_add_direct() {
173        use crate::chains::DefaultClientFactory;
174        use crate::config::Config;
175        use crate::web::AppState;
176        use axum::extract::State;
177        use axum::response::IntoResponse;
178
179        let config = Config::default();
180        let factory = DefaultClientFactory {
181            chains_config: config.chains.clone(),
182        };
183        let state = std::sync::Arc::new(AppState { config, factory });
184        let req = AddAddressBookRequest {
185            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
186            chain: "ethereum".to_string(),
187            label: Some("Test".to_string()),
188            tags: vec!["test".to_string()],
189        };
190        let response = handle_add(State(state), axum::Json(req))
191            .await
192            .into_response();
193        let status = response.status();
194        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
195    }
196}