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/// 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    let client: Box<dyn crate::chains::ChainClient> =
46        match state.factory.create_chain_client(&req.chain) {
47            Ok(c) => c,
48            Err(e) => {
49                return (
50                    StatusCode::BAD_REQUEST,
51                    Json(serde_json::json!({ "error": e.to_string() })),
52                )
53                    .into_response();
54            }
55        };
56
57    // Fetch balance
58    let mut balance = match client.get_balance(&req.address).await {
59        Ok(b) => b,
60        Err(e) => {
61            return (
62                StatusCode::INTERNAL_SERVER_ERROR,
63                Json(serde_json::json!({ "error": e.to_string() })),
64            )
65                .into_response();
66        }
67    };
68    client.enrich_balance_usd(&mut balance).await;
69
70    // Fetch transactions
71    let txs = client.get_transactions(&req.address, 100).await.ok();
72
73    // Fetch token balances
74    let tokens = client.get_token_balances(&req.address).await.ok();
75
76    Json(serde_json::json!({
77        "address": req.address,
78        "chain": req.chain,
79        "format": req.format,
80        "balance": {
81            "raw": balance.raw,
82            "formatted": balance.formatted,
83            "usd_value": balance.usd_value,
84        },
85        "transactions": txs,
86        "tokens": tokens,
87    }))
88    .into_response()
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn test_deserialize_full() {
97        let json = serde_json::json!({
98            "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
99            "chain": "polygon",
100            "format": "csv",
101            "start_date": "2024-01-01T00:00:00Z",
102            "end_date": "2024-12-31T23:59:59Z"
103        });
104        let req: ExportRequest = serde_json::from_value(json).unwrap();
105        assert_eq!(req.address, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
106        assert_eq!(req.chain, "polygon");
107        assert_eq!(req.format, "csv");
108        assert_eq!(req.start_date, Some("2024-01-01T00:00:00Z".to_string()));
109        assert_eq!(req.end_date, Some("2024-12-31T23:59:59Z".to_string()));
110    }
111
112    #[test]
113    fn test_deserialize_minimal() {
114        let json = serde_json::json!({
115            "address": "0x1234567890123456789012345678901234567890"
116        });
117        let req: ExportRequest = serde_json::from_value(json).unwrap();
118        assert_eq!(req.address, "0x1234567890123456789012345678901234567890");
119        assert_eq!(req.chain, "ethereum");
120        assert_eq!(req.format, "json");
121        assert_eq!(req.start_date, None);
122        assert_eq!(req.end_date, None);
123    }
124
125    #[test]
126    fn test_defaults() {
127        assert_eq!(default_chain(), "ethereum");
128        assert_eq!(default_format(), "json");
129    }
130
131    #[test]
132    fn test_with_date_filters() {
133        let json = serde_json::json!({
134            "address": "0xabcdef1234567890abcdef1234567890abcdef1234",
135            "start_date": "2024-06-01T00:00:00Z",
136            "end_date": "2024-06-30T23:59:59Z"
137        });
138        let req: ExportRequest = serde_json::from_value(json).unwrap();
139        assert_eq!(req.address, "0xabcdef1234567890abcdef1234567890abcdef1234");
140        assert_eq!(req.chain, "ethereum");
141        assert_eq!(req.format, "json");
142        assert_eq!(req.start_date, Some("2024-06-01T00:00:00Z".to_string()));
143        assert_eq!(req.end_date, Some("2024-06-30T23:59:59Z".to_string()));
144    }
145
146    #[tokio::test]
147    async fn test_handle_export_direct() {
148        use crate::chains::DefaultClientFactory;
149        use crate::config::Config;
150        use crate::web::AppState;
151        use axum::extract::State;
152        use axum::response::IntoResponse;
153
154        let config = Config::default();
155        let factory = DefaultClientFactory {
156            chains_config: config.chains.clone(),
157        };
158        let state = std::sync::Arc::new(AppState { config, factory });
159        let req = ExportRequest {
160            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
161            chain: "ethereum".to_string(),
162            format: "json".to_string(),
163            start_date: None,
164            end_date: None,
165        };
166        let response = handle(State(state), axum::Json(req)).await.into_response();
167        let status = response.status();
168        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
169    }
170}