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 factory = DefaultClientFactory {
173            chains_config: config.chains.clone(),
174        };
175        let state = std::sync::Arc::new(AppState { config, factory });
176        let req = ExportRequest {
177            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
178            chain: "ethereum".to_string(),
179            format: "json".to_string(),
180            start_date: None,
181            end_date: None,
182        };
183        let response = handle(State(state), axum::Json(req)).await.into_response();
184        let status = response.status();
185        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
186    }
187
188    #[tokio::test]
189    async fn test_handle_export_success_json_structure() {
190        use crate::chains::DefaultClientFactory;
191        use crate::config::Config;
192        use crate::web::AppState;
193        use axum::body;
194        use axum::extract::State;
195        use axum::response::IntoResponse;
196
197        let config = Config::default();
198        let factory = DefaultClientFactory {
199            chains_config: config.chains.clone(),
200        };
201        let state = std::sync::Arc::new(AppState { config, factory });
202        let req = ExportRequest {
203            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
204            chain: "ethereum".to_string(),
205            format: "json".to_string(),
206            start_date: None,
207            end_date: None,
208        };
209        let response = handle(State(state), axum::Json(req)).await.into_response();
210        if response.status().is_success() {
211            let body_bytes = body::to_bytes(response.into_body(), 1_000_000)
212                .await
213                .unwrap();
214            let json: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
215            assert!(json.get("address").is_some());
216            assert!(json.get("chain").is_some());
217            assert!(json.get("format").is_some());
218            assert!(json.get("balance").is_some());
219        }
220    }
221
222    #[test]
223    fn test_export_request_debug() {
224        let req = ExportRequest {
225            address: "0xabc".to_string(),
226            chain: "ethereum".to_string(),
227            format: "json".to_string(),
228            start_date: None,
229            end_date: None,
230        };
231        let debug = format!("{:?}", req);
232        assert!(debug.contains("ExportRequest"));
233    }
234
235    #[test]
236    fn test_deserialize_export_csv_format() {
237        let json = serde_json::json!({
238            "address": "0x1234567890123456789012345678901234567890",
239            "format": "csv"
240        });
241        let req: ExportRequest = serde_json::from_value(json).unwrap();
242        assert_eq!(req.format, "csv");
243    }
244
245    #[tokio::test]
246    async fn test_handle_export_unsupported_chain_bad_request() {
247        use crate::chains::DefaultClientFactory;
248        use crate::config::Config;
249        use crate::web::AppState;
250        use axum::extract::State;
251        use axum::http::StatusCode;
252        use axum::response::IntoResponse;
253
254        // Use a temp data dir to avoid local address book interfering
255        let tmp = tempfile::tempdir().unwrap();
256        let config = Config {
257            address_book: crate::config::AddressBookConfig {
258                data_dir: Some(tmp.path().to_path_buf()),
259            },
260            ..Default::default()
261        };
262        let factory = DefaultClientFactory {
263            chains_config: config.chains.clone(),
264        };
265        let state = std::sync::Arc::new(AppState { config, factory });
266        let req = ExportRequest {
267            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
268            chain: "bitcoin".to_string(), // Unsupported chain
269            format: "json".to_string(),
270            start_date: None,
271            end_date: None,
272        };
273        let response = handle(State(state), axum::Json(req)).await.into_response();
274        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
275        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
276            .await
277            .unwrap();
278        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
279        assert!(
280            json["error"]
281                .as_str()
282                .unwrap()
283                .contains("Unsupported chain")
284        );
285    }
286
287    #[tokio::test]
288    async fn test_handle_export_label_not_found() {
289        use crate::chains::DefaultClientFactory;
290        use crate::config::Config;
291        use crate::web::AppState;
292        use axum::extract::State;
293        use axum::http::StatusCode;
294        use axum::response::IntoResponse;
295
296        let tmp = tempfile::tempdir().unwrap();
297        let config = Config {
298            address_book: crate::config::AddressBookConfig {
299                data_dir: Some(tmp.path().to_path_buf()),
300            },
301            ..Default::default()
302        };
303        let factory = DefaultClientFactory {
304            chains_config: config.chains.clone(),
305        };
306        let state = std::sync::Arc::new(AppState { config, factory });
307        let req = ExportRequest {
308            address: "@ghost-wallet".to_string(),
309            chain: "ethereum".to_string(),
310            format: "json".to_string(),
311            start_date: None,
312            end_date: None,
313        };
314        let response = handle(State(state), axum::Json(req)).await.into_response();
315        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
316        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
317            .await
318            .unwrap();
319        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
320        assert!(json["error"].as_str().unwrap().contains("@ghost-wallet"));
321    }
322}