scope/web/api/
address_book.rs1use 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
12pub 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#[derive(Debug, Deserialize)]
30pub struct AddAddressBookRequest {
31 pub address: String,
33 #[serde(default = "default_chain")]
35 pub chain: String,
36 pub label: Option<String>,
38 #[serde(default)]
40 pub tags: Vec<String>,
41}
42
43fn default_chain() -> String {
44 "ethereum".to_string()
45}
46
47pub 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}