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 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}