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.
37pub async fn handle(
38    State(state): State<Arc<AppState>>,
39    Json(req): Json<CrawlRequest>,
40) -> impl IntoResponse {
41    let period = match req.period.as_deref() {
42        Some("1h") => Period::Hour1,
43        Some("7d") => Period::Day7,
44        Some("30d") => Period::Day30,
45        _ => Period::Hour24,
46    };
47
48    match crawl::fetch_analytics_for_input(
49        &req.token,
50        &req.chain,
51        period,
52        req.holders_limit,
53        &state.factory,
54        None,
55    )
56    .await
57    {
58        Ok(analytics) => Json(serde_json::json!(analytics)).into_response(),
59        Err(e) => (
60            StatusCode::INTERNAL_SERVER_ERROR,
61            Json(serde_json::json!({ "error": e.to_string() })),
62        )
63            .into_response(),
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    #[test]
72    fn test_deserialize_full() {
73        let json = serde_json::json!({
74            "token": "USDC",
75            "chain": "polygon",
76            "period": "7d",
77            "holders_limit": 20
78        });
79        let req: CrawlRequest = serde_json::from_value(json).unwrap();
80        assert_eq!(req.token, "USDC");
81        assert_eq!(req.chain, "polygon");
82        assert_eq!(req.period, Some("7d".to_string()));
83        assert_eq!(req.holders_limit, 20);
84    }
85
86    #[test]
87    fn test_deserialize_minimal() {
88        let json = serde_json::json!({
89            "token": "ETH"
90        });
91        let req: CrawlRequest = serde_json::from_value(json).unwrap();
92        assert_eq!(req.token, "ETH");
93        assert_eq!(req.chain, "ethereum");
94        assert_eq!(req.period, None);
95        assert_eq!(req.holders_limit, 10);
96    }
97
98    #[test]
99    fn test_defaults() {
100        assert_eq!(default_chain(), "ethereum");
101        assert_eq!(default_holders_limit(), 10);
102    }
103
104    #[test]
105    fn test_period_variations() {
106        let json_1h = serde_json::json!({
107            "token": "USDT",
108            "period": "1h"
109        });
110        let req_1h: CrawlRequest = serde_json::from_value(json_1h).unwrap();
111        assert_eq!(req_1h.period, Some("1h".to_string()));
112
113        let json_24h = serde_json::json!({
114            "token": "USDT",
115            "period": "24h"
116        });
117        let req_24h: CrawlRequest = serde_json::from_value(json_24h).unwrap();
118        assert_eq!(req_24h.period, Some("24h".to_string()));
119
120        let json_30d = serde_json::json!({
121            "token": "USDT",
122            "period": "30d"
123        });
124        let req_30d: CrawlRequest = serde_json::from_value(json_30d).unwrap();
125        assert_eq!(req_30d.period, Some("30d".to_string()));
126
127        let json_no_period = serde_json::json!({
128            "token": "USDT"
129        });
130        let req_no_period: CrawlRequest = serde_json::from_value(json_no_period).unwrap();
131        assert_eq!(req_no_period.period, None);
132    }
133
134    #[tokio::test]
135    async fn test_handle_crawl_direct() {
136        use crate::chains::DefaultClientFactory;
137        use crate::config::Config;
138        use crate::web::AppState;
139        use axum::extract::State;
140        use axum::response::IntoResponse;
141
142        let config = Config::default();
143        let factory = DefaultClientFactory {
144            chains_config: config.chains.clone(),
145        };
146        let state = std::sync::Arc::new(AppState { config, factory });
147        let req = CrawlRequest {
148            token: "USDC".to_string(),
149            chain: "ethereum".to_string(),
150            period: Some("24h".to_string()),
151            holders_limit: 5,
152        };
153        let response = handle(State(state), axum::Json(req)).await.into_response();
154        let status = response.status();
155        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
156    }
157}