Skip to main content

scope_web/api/
crawl.rs

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