Skip to main content

scope/web/api/
discover.rs

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