use crate::AppState;
use axum::Json;
use axum::extract::State;
use axum::response::IntoResponse;
use scope_cli::cli::crawl::{self, Period};
use serde::Deserialize;
use std::sync::Arc;
use super::{error_response, status_for};
#[derive(Debug, Deserialize)]
pub struct CrawlRequest {
pub token: String,
#[serde(default = "super::default_chain")]
pub chain: String,
#[serde(default)]
pub period: Option<String>,
#[serde(default = "default_holders_limit")]
pub holders_limit: u32,
}
fn default_holders_limit() -> u32 {
10
}
pub async fn handle(
State(state): State<Arc<AppState>>,
Json(req): Json<CrawlRequest>,
) -> impl IntoResponse {
let resolved = match super::resolve_address_book(&req.token, &state.config) {
Ok(r) => r,
Err(e) => return error_response(status_for(&e), e.to_string()),
};
let token = resolved.value;
let chain = resolved.chain.unwrap_or(req.chain);
let period = match req.period.as_deref() {
Some("1h") => Period::Hour1,
Some("7d") => Period::Day7,
Some("30d") => Period::Day30,
_ => Period::Hour24,
};
match crawl::fetch_analytics_for_input(
&token,
&chain,
period,
req.holders_limit,
state.factory.as_ref(),
None,
&scope_cli::cli::output::Output::stdio(),
)
.await
{
Ok(analytics) => Json(serde_json::json!(analytics)).into_response(),
Err(e) => error_response(status_for(&e), e.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_full() {
let json = serde_json::json!({
"token": "USDC",
"chain": "polygon",
"period": "7d",
"holders_limit": 20
});
let req: CrawlRequest = serde_json::from_value(json).unwrap();
assert_eq!(req.token, "USDC");
assert_eq!(req.chain, "polygon");
assert_eq!(req.period, Some("7d".to_string()));
assert_eq!(req.holders_limit, 20);
}
#[test]
fn test_deserialize_minimal() {
let json = serde_json::json!({
"token": "ETH"
});
let req: CrawlRequest = serde_json::from_value(json).unwrap();
assert_eq!(req.token, "ETH");
assert_eq!(req.chain, "ethereum");
assert_eq!(req.period, None);
assert_eq!(req.holders_limit, 10);
}
#[test]
fn test_defaults() {
assert_eq!(super::super::default_chain(), "ethereum");
assert_eq!(default_holders_limit(), 10);
}
#[test]
fn test_period_variations() {
let json_1h = serde_json::json!({
"token": "USDT",
"period": "1h"
});
let req_1h: CrawlRequest = serde_json::from_value(json_1h).unwrap();
assert_eq!(req_1h.period, Some("1h".to_string()));
let json_24h = serde_json::json!({
"token": "USDT",
"period": "24h"
});
let req_24h: CrawlRequest = serde_json::from_value(json_24h).unwrap();
assert_eq!(req_24h.period, Some("24h".to_string()));
let json_30d = serde_json::json!({
"token": "USDT",
"period": "30d"
});
let req_30d: CrawlRequest = serde_json::from_value(json_30d).unwrap();
assert_eq!(req_30d.period, Some("30d".to_string()));
let json_no_period = serde_json::json!({
"token": "USDT"
});
let req_no_period: CrawlRequest = serde_json::from_value(json_no_period).unwrap();
assert_eq!(req_no_period.period, None);
}
#[tokio::test]
async fn test_handle_crawl_direct() {
use crate::AppState;
use axum::extract::State;
use axum::response::IntoResponse;
use scope::chains::DefaultClientFactory;
use scope::config::Config;
let config = Config::default();
let http: std::sync::Arc<dyn scope::http::HttpClient> =
std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
let factory = DefaultClientFactory {
chains_config: config.chains.clone(),
http,
};
let state = std::sync::Arc::new(AppState::new(config, factory));
let req = CrawlRequest {
token: "USDC".to_string(),
chain: "ethereum".to_string(),
period: Some("24h".to_string()),
holders_limit: 5,
};
let response = handle(State(state), axum::Json(req)).await.into_response();
let status = response.status();
assert!(status.is_success() || status.is_client_error() || status.is_server_error());
}
#[tokio::test]
async fn test_handle_crawl_label_not_found() {
use crate::AppState;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use scope::chains::DefaultClientFactory;
use scope::config::Config;
let tmp = tempfile::tempdir().unwrap();
let config = Config {
address_book: scope::config::AddressBookConfig {
data_dir: Some(tmp.path().to_path_buf()),
},
..Default::default()
};
let http: std::sync::Arc<dyn scope::http::HttpClient> =
std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
let factory = DefaultClientFactory {
chains_config: config.chains.clone(),
http,
};
let state = std::sync::Arc::new(AppState::new(config, factory));
let req = CrawlRequest {
token: "@no-such-token".to_string(),
chain: "ethereum".to_string(),
period: None,
holders_limit: 10,
};
let response = handle(State(state), axum::Json(req)).await.into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(response.into_body(), 1_000_000)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["error"].as_str().unwrap().contains("@no-such-token"));
}
}