1use 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#[derive(Debug, Deserialize)]
15pub struct CrawlRequest {
16 pub token: String,
18 #[serde(default = "super::default_chain")]
20 pub chain: String,
21 #[serde(default)]
23 pub period: Option<String>,
24 #[serde(default = "default_holders_limit")]
26 pub holders_limit: u32,
27}
28
29fn default_holders_limit() -> u32 {
30 10
31}
32
33pub async fn handle(
38 State(state): State<Arc<AppState>>,
39 Json(req): Json<CrawlRequest>,
40) -> impl IntoResponse {
41 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(),
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}