1use 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#[derive(Debug, Deserialize)]
14pub struct CrawlRequest {
15 pub token: String,
17 #[serde(default = "default_chain")]
19 pub chain: String,
20 #[serde(default)]
22 pub period: Option<String>,
23 #[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
36pub async fn handle(
41 State(state): State<Arc<AppState>>,
42 Json(req): Json<CrawlRequest>,
43) -> impl IntoResponse {
44 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 http: std::sync::Arc<dyn crate::http::HttpClient> =
161 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
162 let factory = DefaultClientFactory {
163 chains_config: config.chains.clone(),
164 http,
165 };
166 let state = std::sync::Arc::new(AppState { config, factory });
167 let req = CrawlRequest {
168 token: "USDC".to_string(),
169 chain: "ethereum".to_string(),
170 period: Some("24h".to_string()),
171 holders_limit: 5,
172 };
173 let response = handle(State(state), axum::Json(req)).await.into_response();
174 let status = response.status();
175 assert!(status.is_success() || status.is_client_error() || status.is_server_error());
176 }
177
178 #[tokio::test]
179 async fn test_handle_crawl_label_not_found() {
180 use crate::chains::DefaultClientFactory;
181 use crate::config::Config;
182 use crate::web::AppState;
183 use axum::extract::State;
184 use axum::http::StatusCode;
185 use axum::response::IntoResponse;
186
187 let tmp = tempfile::tempdir().unwrap();
188 let config = Config {
189 address_book: crate::config::AddressBookConfig {
190 data_dir: Some(tmp.path().to_path_buf()),
191 },
192 ..Default::default()
193 };
194 let http: std::sync::Arc<dyn crate::http::HttpClient> =
195 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
196 let factory = DefaultClientFactory {
197 chains_config: config.chains.clone(),
198 http,
199 };
200 let state = std::sync::Arc::new(AppState { config, factory });
201 let req = CrawlRequest {
202 token: "@no-such-token".to_string(),
203 chain: "ethereum".to_string(),
204 period: None,
205 holders_limit: 10,
206 };
207 let response = handle(State(state), axum::Json(req)).await.into_response();
208 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
209 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
210 .await
211 .unwrap();
212 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
213 assert!(json["error"].as_str().unwrap().contains("@no-such-token"));
214 }
215}