Skip to main content

scope_web/api/
export.rs

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