Skip to main content

scope_web/api/
discover.rs

1//! Token discovery API handler.
2
3use crate::AppState;
4use axum::Json;
5use axum::extract::{Query, State};
6use axum::response::IntoResponse;
7use scope::chains::DexClient;
8use serde::Deserialize;
9use std::sync::Arc;
10
11use super::{error_response, status_for};
12
13/// Query parameters for token discovery.
14#[derive(Debug, Deserialize)]
15pub struct DiscoverQuery {
16    /// Source: "profiles", "boosts", "top-boosts".
17    #[serde(default = "default_source")]
18    pub source: String,
19    /// Filter by chain (optional).
20    pub chain: Option<String>,
21    /// Max results (default: 15).
22    #[serde(default = "default_limit")]
23    pub limit: u32,
24}
25
26fn default_source() -> String {
27    "profiles".to_string()
28}
29
30fn default_limit() -> u32 {
31    15
32}
33
34/// GET /api/discover — Browse trending/boosted tokens.
35pub async fn handle(
36    State(_state): State<Arc<AppState>>,
37    Query(params): Query<DiscoverQuery>,
38) -> impl IntoResponse {
39    let client = DexClient::new();
40
41    let tokens = match params.source.as_str() {
42        "boosts" => client.get_token_boosts().await,
43        "top-boosts" => client.get_token_boosts_top().await,
44        _ => client.get_token_profiles().await,
45    };
46
47    match tokens {
48        Ok(tokens) => {
49            let filtered: Vec<_> = if let Some(ref chain) = params.chain {
50                let c = chain.to_lowercase();
51                tokens
52                    .into_iter()
53                    .filter(|t| t.chain_id.to_lowercase() == c)
54                    .take(params.limit as usize)
55                    .collect()
56            } else {
57                tokens.into_iter().take(params.limit as usize).collect()
58            };
59            Json(serde_json::json!(filtered)).into_response()
60        }
61        Err(e) => error_response(status_for(&e), e.to_string()),
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    #[test]
70    fn test_deserialize_full() {
71        let json = serde_json::json!({
72            "source": "boosts",
73            "chain": "ethereum",
74            "limit": 25
75        });
76        let req: DiscoverQuery = serde_json::from_value(json).unwrap();
77        assert_eq!(req.source, "boosts");
78        assert_eq!(req.chain, Some("ethereum".to_string()));
79        assert_eq!(req.limit, 25);
80    }
81
82    #[test]
83    fn test_deserialize_minimal() {
84        let json = serde_json::json!({});
85        let req: DiscoverQuery = serde_json::from_value(json).unwrap();
86        assert_eq!(req.source, "profiles");
87        assert_eq!(req.chain, None);
88        assert_eq!(req.limit, 15);
89    }
90
91    #[test]
92    fn test_defaults() {
93        assert_eq!(default_source(), "profiles");
94        assert_eq!(default_limit(), 15);
95    }
96
97    #[test]
98    fn test_with_chain_filter() {
99        let json = serde_json::json!({
100            "chain": "polygon",
101            "limit": 10
102        });
103        let req: DiscoverQuery = serde_json::from_value(json).unwrap();
104        assert_eq!(req.source, "profiles");
105        assert_eq!(req.chain, Some("polygon".to_string()));
106        assert_eq!(req.limit, 10);
107    }
108
109    #[tokio::test]
110    async fn test_handle_discover_profiles() {
111        use crate::AppState;
112        use axum::extract::{Query, State};
113        use axum::response::IntoResponse;
114        use scope::chains::DefaultClientFactory;
115        use scope::config::Config;
116
117        let config = Config::default();
118        let http: std::sync::Arc<dyn scope::http::HttpClient> =
119            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
120        let factory = DefaultClientFactory {
121            chains_config: config.chains.clone(),
122            http,
123        };
124        let state = std::sync::Arc::new(AppState::new(config, factory));
125
126        let params = DiscoverQuery {
127            source: "profiles".to_string(),
128            chain: None,
129            limit: 5,
130        };
131        let response = handle(State(state), Query(params)).await.into_response();
132        let status = response.status();
133        assert!(status.is_success() || status.is_server_error());
134    }
135
136    #[tokio::test]
137    async fn test_handle_discover_boosts() {
138        use crate::AppState;
139        use axum::extract::{Query, State};
140        use axum::response::IntoResponse;
141        use scope::chains::DefaultClientFactory;
142        use scope::config::Config;
143
144        let config = Config::default();
145        let http: std::sync::Arc<dyn scope::http::HttpClient> =
146            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
147        let factory = DefaultClientFactory {
148            chains_config: config.chains.clone(),
149            http,
150        };
151        let state = std::sync::Arc::new(AppState::new(config, factory));
152
153        let params = DiscoverQuery {
154            source: "boosts".to_string(),
155            chain: None,
156            limit: 5,
157        };
158        let response = handle(State(state), Query(params)).await.into_response();
159        let status = response.status();
160        assert!(status.is_success() || status.is_server_error());
161    }
162
163    #[tokio::test]
164    async fn test_handle_discover_top_boosts() {
165        use crate::AppState;
166        use axum::extract::{Query, State};
167        use axum::response::IntoResponse;
168        use scope::chains::DefaultClientFactory;
169        use scope::config::Config;
170
171        let config = Config::default();
172        let http: std::sync::Arc<dyn scope::http::HttpClient> =
173            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
174        let factory = DefaultClientFactory {
175            chains_config: config.chains.clone(),
176            http,
177        };
178        let state = std::sync::Arc::new(AppState::new(config, factory));
179
180        let params = DiscoverQuery {
181            source: "top-boosts".to_string(),
182            chain: None,
183            limit: 5,
184        };
185        let response = handle(State(state), Query(params)).await.into_response();
186        let status = response.status();
187        assert!(status.is_success() || status.is_server_error());
188    }
189
190    #[tokio::test]
191    async fn test_handle_discover_with_chain_filter() {
192        use crate::AppState;
193        use axum::extract::{Query, State};
194        use axum::response::IntoResponse;
195        use scope::chains::DefaultClientFactory;
196        use scope::config::Config;
197
198        let config = Config::default();
199        let http: std::sync::Arc<dyn scope::http::HttpClient> =
200            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
201        let factory = DefaultClientFactory {
202            chains_config: config.chains.clone(),
203            http,
204        };
205        let state = std::sync::Arc::new(AppState::new(config, factory));
206
207        let params = DiscoverQuery {
208            source: "profiles".to_string(),
209            chain: Some("ethereum".to_string()),
210            limit: 10,
211        };
212        let response = handle(State(state), Query(params)).await.into_response();
213        let status = response.status();
214        assert!(status.is_success() || status.is_server_error());
215    }
216
217    #[tokio::test]
218    async fn test_handle_discover_error_path() {
219        use crate::AppState;
220        use axum::extract::{Query, State};
221        use axum::http::StatusCode;
222        use axum::response::IntoResponse;
223        use scope::chains::DefaultClientFactory;
224        use scope::config::Config;
225
226        let config = Config::default();
227        let http: std::sync::Arc<dyn scope::http::HttpClient> =
228            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
229        let factory = DefaultClientFactory {
230            chains_config: config.chains.clone(),
231            http,
232        };
233        let state = std::sync::Arc::new(AppState::new(config, factory));
234
235        let params = DiscoverQuery {
236            source: "unknown-source".to_string(),
237            chain: None,
238            limit: 5,
239        };
240        let response = handle(State(state), Query(params)).await.into_response();
241        let status = response.status();
242        if status == StatusCode::INTERNAL_SERVER_ERROR {
243            let body = axum::body::to_bytes(response.into_body(), 1_000_000)
244                .await
245                .unwrap();
246            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
247            assert!(json.get("error").is_some());
248        }
249    }
250}