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