Skip to main content

scope/web/api/
crawl.rs

1//! Token crawl API handler.
2
3use crate::cli::crawl::{self, Period};
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 token crawl.
13#[derive(Debug, Deserialize)]
14pub struct CrawlRequest {
15    /// Token address or symbol.
16    pub token: String,
17    /// Target chain (default: "ethereum").
18    #[serde(default = "default_chain")]
19    pub chain: String,
20    /// Time period: "1h", "24h", "7d", "30d".
21    #[serde(default)]
22    pub period: Option<String>,
23    /// Max holders to include.
24    #[serde(default = "default_holders_limit")]
25    pub holders_limit: u32,
26}
27
28fn default_chain() -> String {
29    "ethereum".to_string()
30}
31
32fn default_holders_limit() -> u32 {
33    10
34}
35
36/// POST /api/crawl — Token analytics.
37///
38/// Supports address book shortcuts: pass `@label` as the token to
39/// resolve it from the address book.
40pub async fn handle(
41    State(state): State<Arc<AppState>>,
42    Json(req): Json<CrawlRequest>,
43) -> impl IntoResponse {
44    // Resolve address book shortcuts (@label or direct address match)
45    let resolved = match super::resolve_address_book(&req.token, &state.config) {
46        Ok(r) => r,
47        Err(e) => {
48            return (
49                StatusCode::BAD_REQUEST,
50                Json(serde_json::json!({ "error": e })),
51            )
52                .into_response();
53        }
54    };
55    let token = resolved.value;
56    let chain = resolved.chain.unwrap_or(req.chain);
57
58    let period = match req.period.as_deref() {
59        Some("1h") => Period::Hour1,
60        Some("7d") => Period::Day7,
61        Some("30d") => Period::Day30,
62        _ => Period::Hour24,
63    };
64
65    match crawl::fetch_analytics_for_input(
66        &token,
67        &chain,
68        period,
69        req.holders_limit,
70        &state.factory,
71        None,
72    )
73    .await
74    {
75        Ok(analytics) => Json(serde_json::json!(analytics)).into_response(),
76        Err(e) => (
77            StatusCode::INTERNAL_SERVER_ERROR,
78            Json(serde_json::json!({ "error": e.to_string() })),
79        )
80            .into_response(),
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn test_deserialize_full() {
90        let json = serde_json::json!({
91            "token": "USDC",
92            "chain": "polygon",
93            "period": "7d",
94            "holders_limit": 20
95        });
96        let req: CrawlRequest = serde_json::from_value(json).unwrap();
97        assert_eq!(req.token, "USDC");
98        assert_eq!(req.chain, "polygon");
99        assert_eq!(req.period, Some("7d".to_string()));
100        assert_eq!(req.holders_limit, 20);
101    }
102
103    #[test]
104    fn test_deserialize_minimal() {
105        let json = serde_json::json!({
106            "token": "ETH"
107        });
108        let req: CrawlRequest = serde_json::from_value(json).unwrap();
109        assert_eq!(req.token, "ETH");
110        assert_eq!(req.chain, "ethereum");
111        assert_eq!(req.period, None);
112        assert_eq!(req.holders_limit, 10);
113    }
114
115    #[test]
116    fn test_defaults() {
117        assert_eq!(default_chain(), "ethereum");
118        assert_eq!(default_holders_limit(), 10);
119    }
120
121    #[test]
122    fn test_period_variations() {
123        let json_1h = serde_json::json!({
124            "token": "USDT",
125            "period": "1h"
126        });
127        let req_1h: CrawlRequest = serde_json::from_value(json_1h).unwrap();
128        assert_eq!(req_1h.period, Some("1h".to_string()));
129
130        let json_24h = serde_json::json!({
131            "token": "USDT",
132            "period": "24h"
133        });
134        let req_24h: CrawlRequest = serde_json::from_value(json_24h).unwrap();
135        assert_eq!(req_24h.period, Some("24h".to_string()));
136
137        let json_30d = serde_json::json!({
138            "token": "USDT",
139            "period": "30d"
140        });
141        let req_30d: CrawlRequest = serde_json::from_value(json_30d).unwrap();
142        assert_eq!(req_30d.period, Some("30d".to_string()));
143
144        let json_no_period = serde_json::json!({
145            "token": "USDT"
146        });
147        let req_no_period: CrawlRequest = serde_json::from_value(json_no_period).unwrap();
148        assert_eq!(req_no_period.period, None);
149    }
150
151    #[tokio::test]
152    async fn test_handle_crawl_direct() {
153        use crate::chains::DefaultClientFactory;
154        use crate::config::Config;
155        use crate::web::AppState;
156        use axum::extract::State;
157        use axum::response::IntoResponse;
158
159        let config = Config::default();
160        let factory = DefaultClientFactory {
161            chains_config: config.chains.clone(),
162        };
163        let state = std::sync::Arc::new(AppState { config, factory });
164        let req = CrawlRequest {
165            token: "USDC".to_string(),
166            chain: "ethereum".to_string(),
167            period: Some("24h".to_string()),
168            holders_limit: 5,
169        };
170        let response = handle(State(state), axum::Json(req)).await.into_response();
171        let status = response.status();
172        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
173    }
174
175    #[tokio::test]
176    async fn test_handle_crawl_label_not_found() {
177        use crate::chains::DefaultClientFactory;
178        use crate::config::Config;
179        use crate::web::AppState;
180        use axum::extract::State;
181        use axum::http::StatusCode;
182        use axum::response::IntoResponse;
183
184        let tmp = tempfile::tempdir().unwrap();
185        let config = Config {
186            address_book: crate::config::AddressBookConfig {
187                data_dir: Some(tmp.path().to_path_buf()),
188            },
189            ..Default::default()
190        };
191        let factory = DefaultClientFactory {
192            chains_config: config.chains.clone(),
193        };
194        let state = std::sync::Arc::new(AppState { config, factory });
195        let req = CrawlRequest {
196            token: "@no-such-token".to_string(),
197            chain: "ethereum".to_string(),
198            period: None,
199            holders_limit: 10,
200        };
201        let response = handle(State(state), axum::Json(req)).await.into_response();
202        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
203        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
204            .await
205            .unwrap();
206        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
207        assert!(json["error"].as_str().unwrap().contains("@no-such-token"));
208    }
209}