Skip to main content

scope/web/api/
export.rs

1//! Export API handler.
2
3use crate::chains::ChainClientFactory;
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/// Request body for data export.
13#[derive(Debug, Deserialize)]
14pub struct ExportRequest {
15    /// Address to export data for.
16    pub address: String,
17    /// Chain (default: "ethereum").
18    #[serde(default = "default_chain")]
19    pub chain: String,
20    /// Format: "json" or "csv".
21    #[serde(default = "default_format")]
22    pub format: String,
23    /// Optional start date filter (ISO 8601).
24    pub start_date: Option<String>,
25    /// Optional end date filter (ISO 8601).
26    pub end_date: Option<String>,
27}
28
29fn default_chain() -> String {
30    "ethereum".to_string()
31}
32fn default_format() -> String {
33    "json".to_string()
34}
35
36/// POST /api/export — Export address data.
37///
38/// Supports address book shortcuts: pass `@label` as the address to
39/// resolve it from the address book.
40///
41/// Returns the export data directly as JSON (regardless of requested format)
42/// since the web API always returns JSON. The `format` field is preserved
43/// in the response metadata for client-side handling.
44pub async fn handle(
45    State(state): State<Arc<AppState>>,
46    Json(req): Json<ExportRequest>,
47) -> impl IntoResponse {
48    // Resolve address book shortcuts (@label or direct address match)
49    let resolved = match super::resolve_address_book(&req.address, &state.config) {
50        Ok(r) => r,
51        Err(e) => {
52            return (
53                StatusCode::BAD_REQUEST,
54                Json(serde_json::json!({ "error": e })),
55            )
56                .into_response();
57        }
58    };
59    let address = resolved.value;
60    let chain = resolved.chain.unwrap_or(req.chain);
61
62    let client: Box<dyn crate::chains::ChainClient> =
63        match state.factory.create_chain_client(&chain) {
64            Ok(c) => c,
65            Err(e) => {
66                return (
67                    StatusCode::BAD_REQUEST,
68                    Json(serde_json::json!({ "error": e.to_string() })),
69                )
70                    .into_response();
71            }
72        };
73
74    // Fetch balance
75    let mut balance = match client.get_balance(&address).await {
76        Ok(b) => b,
77        Err(e) => {
78            return (
79                StatusCode::INTERNAL_SERVER_ERROR,
80                Json(serde_json::json!({ "error": e.to_string() })),
81            )
82                .into_response();
83        }
84    };
85    client.enrich_balance_usd(&mut balance).await;
86
87    // Fetch transactions
88    let txs = client.get_transactions(&address, 100).await.ok();
89
90    // Fetch token balances
91    let tokens = client.get_token_balances(&address).await.ok();
92
93    Json(serde_json::json!({
94        "address": address,
95        "chain": chain,
96        "format": req.format,
97        "balance": {
98            "raw": balance.raw,
99            "formatted": balance.formatted,
100            "usd_value": balance.usd_value,
101        },
102        "transactions": txs,
103        "tokens": tokens,
104    }))
105    .into_response()
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn test_deserialize_full() {
114        let json = serde_json::json!({
115            "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
116            "chain": "polygon",
117            "format": "csv",
118            "start_date": "2024-01-01T00:00:00Z",
119            "end_date": "2024-12-31T23:59:59Z"
120        });
121        let req: ExportRequest = serde_json::from_value(json).unwrap();
122        assert_eq!(req.address, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
123        assert_eq!(req.chain, "polygon");
124        assert_eq!(req.format, "csv");
125        assert_eq!(req.start_date, Some("2024-01-01T00:00:00Z".to_string()));
126        assert_eq!(req.end_date, Some("2024-12-31T23:59:59Z".to_string()));
127    }
128
129    #[test]
130    fn test_deserialize_minimal() {
131        let json = serde_json::json!({
132            "address": "0x1234567890123456789012345678901234567890"
133        });
134        let req: ExportRequest = serde_json::from_value(json).unwrap();
135        assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
136        assert_eq!(req.chain, "ethereum");
137        assert_eq!(req.format, "json");
138        assert_eq!(req.start_date, None);
139        assert_eq!(req.end_date, None);
140    }
141
142    #[test]
143    fn test_defaults() {
144        assert_eq!(default_chain(), "ethereum");
145        assert_eq!(default_format(), "json");
146    }
147
148    #[test]
149    fn test_with_date_filters() {
150        let json = serde_json::json!({
151            "address": "0xabcdef1234567890abcdef1234567890abcdef1234",
152            "start_date": "2024-06-01T00:00:00Z",
153            "end_date": "2024-06-30T23:59:59Z"
154        });
155        let req: ExportRequest = serde_json::from_value(json).unwrap();
156        assert_eq!(req.address, "0xabcdef1234567890abcdef1234567890abcdef1234");
157        assert_eq!(req.chain, "ethereum");
158        assert_eq!(req.format, "json");
159        assert_eq!(req.start_date, Some("2024-06-01T00:00:00Z".to_string()));
160        assert_eq!(req.end_date, Some("2024-06-30T23:59:59Z".to_string()));
161    }
162
163    #[tokio::test]
164    async fn test_handle_export_direct() {
165        use crate::chains::DefaultClientFactory;
166        use crate::config::Config;
167        use crate::web::AppState;
168        use axum::extract::State;
169        use axum::response::IntoResponse;
170
171        let config = Config::default();
172        let http: std::sync::Arc<dyn crate::http::HttpClient> =
173            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
174        let factory = DefaultClientFactory {
175            chains_config: config.chains.clone(),
176            http,
177        };
178        let state = std::sync::Arc::new(AppState { config, factory });
179        let req = ExportRequest {
180            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
181            chain: "ethereum".to_string(),
182            format: "json".to_string(),
183            start_date: None,
184            end_date: None,
185        };
186        let response = handle(State(state), axum::Json(req)).await.into_response();
187        let status = response.status();
188        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
189    }
190
191    #[tokio::test]
192    async fn test_handle_export_success_json_structure() {
193        use crate::chains::DefaultClientFactory;
194        use crate::config::Config;
195        use crate::web::AppState;
196        use axum::body;
197        use axum::extract::State;
198        use axum::response::IntoResponse;
199
200        let config = Config::default();
201        let http: std::sync::Arc<dyn crate::http::HttpClient> =
202            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
203        let factory = DefaultClientFactory {
204            chains_config: config.chains.clone(),
205            http,
206        };
207        let state = std::sync::Arc::new(AppState { config, factory });
208        let req = ExportRequest {
209            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
210            chain: "ethereum".to_string(),
211            format: "json".to_string(),
212            start_date: None,
213            end_date: None,
214        };
215        let response = handle(State(state), axum::Json(req)).await.into_response();
216        if response.status().is_success() {
217            let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
218                .await
219                .unwrap();
220            let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
221            assert!(json.get("address").is_some());
222            assert!(json.get("chain").is_some());
223            assert!(json.get("format").is_some());
224            assert!(json.get("balance").is_some());
225        }
226    }
227
228    #[test]
229    fn test_export_request_debug() {
230        let req = ExportRequest {
231            address: "0xabc".to_string(),
232            chain: "ethereum".to_string(),
233            format: "json".to_string(),
234            start_date: None,
235            end_date: None,
236        };
237        let debug = format!("{:?}", req);
238        assert!(debug.contains("ExportRequest"));
239    }
240
241    #[test]
242    fn test_deserialize_export_csv_format() {
243        let json = serde_json::json!({
244            "address": "0x1234567890123456789012345678901234567890",
245            "format": "csv"
246        });
247        let req: ExportRequest = serde_json::from_value(json).unwrap();
248        assert_eq!(req.format, "csv");
249    }
250
251    #[tokio::test]
252    async fn test_handle_export_unsupported_chain_bad_request() {
253        use crate::chains::DefaultClientFactory;
254        use crate::config::Config;
255        use crate::web::AppState;
256        use axum::extract::State;
257        use axum::http::StatusCode;
258        use axum::response::IntoResponse;
259
260        // Use a temp data dir to avoid local address book interfering
261        let tmp = tempfile::tempdir().unwrap();
262        let config = Config {
263            address_book: crate::config::AddressBookConfig {
264                data_dir: Some(tmp.path().to_path_buf()),
265            },
266            ..Default::default()
267        };
268        let http: std::sync::Arc<dyn crate::http::HttpClient> =
269            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
270        let factory = DefaultClientFactory {
271            chains_config: config.chains.clone(),
272            http,
273        };
274        let state = std::sync::Arc::new(AppState { config, factory });
275        let req = ExportRequest {
276            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
277            chain: "bitcoin".to_string(), // Unsupported chain
278            format: "json".to_string(),
279            start_date: None,
280            end_date: None,
281        };
282        let response = handle(State(state), axum::Json(req)).await.into_response();
283        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
284        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
285            .await
286            .unwrap();
287        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
288        assert!(
289            json["error"]
290                .as_str()
291                .unwrap()
292                .contains("Unsupported chain")
293        );
294    }
295
296    #[tokio::test]
297    async fn test_handle_export_label_not_found() {
298        use crate::chains::DefaultClientFactory;
299        use crate::config::Config;
300        use crate::web::AppState;
301        use axum::extract::State;
302        use axum::http::StatusCode;
303        use axum::response::IntoResponse;
304
305        let tmp = tempfile::tempdir().unwrap();
306        let config = Config {
307            address_book: crate::config::AddressBookConfig {
308                data_dir: Some(tmp.path().to_path_buf()),
309            },
310            ..Default::default()
311        };
312        let http: std::sync::Arc<dyn crate::http::HttpClient> =
313            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
314        let factory = DefaultClientFactory {
315            chains_config: config.chains.clone(),
316            http,
317        };
318        let state = std::sync::Arc::new(AppState { config, factory });
319        let req = ExportRequest {
320            address: "@ghost-wallet".to_string(),
321            chain: "ethereum".to_string(),
322            format: "json".to_string(),
323            start_date: None,
324            end_date: None,
325        };
326        let response = handle(State(state), axum::Json(req)).await.into_response();
327        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
328        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
329            .await
330            .unwrap();
331        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
332        assert!(json["error"].as_str().unwrap().contains("@ghost-wallet"));
333    }
334}