Skip to main content

scope/web/api/
insights.rs

1//! Unified insights API handler.
2
3use crate::chains::ChainClientFactory;
4use crate::cli::insights::{self, InsightsArgs};
5use crate::web::AppState;
6use axum::Json;
7use axum::extract::State;
8use axum::http::StatusCode;
9use axum::response::IntoResponse;
10use serde::Deserialize;
11use std::sync::Arc;
12
13/// Request body for insights analysis.
14#[derive(Debug, Deserialize)]
15pub struct InsightsRequest {
16    /// Target: address, tx hash, or token symbol/name.
17    pub target: String,
18    /// Override detected chain.
19    pub chain: Option<String>,
20    /// Decode tx input (for tx targets).
21    #[serde(default)]
22    pub decode: bool,
23    /// Include internal trace (for tx targets).
24    #[serde(default)]
25    pub trace: bool,
26}
27
28/// POST /api/insights — Unified insights for any target.
29///
30/// Supports address book shortcuts: pass `@label` as the target to
31/// resolve it from the address book. The chain will also be set from
32/// the book entry unless explicitly overridden.
33///
34/// Returns the insights markdown as JSON `{ "markdown": "..." }` along
35/// with structured metadata about the detected target type.
36pub async fn handle(
37    State(state): State<Arc<AppState>>,
38    Json(req): Json<InsightsRequest>,
39) -> impl IntoResponse {
40    // Resolve address book shortcuts (@label or direct address match)
41    let resolved = match super::resolve_address_book(&req.target, &state.config) {
42        Ok(r) => r,
43        Err(e) => {
44            return (
45                StatusCode::BAD_REQUEST,
46                Json(serde_json::json!({ "error": e })),
47            )
48                .into_response();
49        }
50    };
51    let target_str = resolved.value;
52    let chain_override = req.chain.or(resolved.chain);
53
54    let target = insights::infer_target(&target_str, chain_override.as_deref());
55
56    let target_type = match &target {
57        insights::InferredTarget::Address { chain } => {
58            serde_json::json!({ "type": "address", "chain": chain })
59        }
60        insights::InferredTarget::Transaction { chain } => {
61            serde_json::json!({ "type": "transaction", "chain": chain })
62        }
63        insights::InferredTarget::Token { chain } => {
64            serde_json::json!({ "type": "token", "chain": chain })
65        }
66    };
67
68    // Run the insights command which builds markdown output
69    // We capture it by running the underlying functions directly
70    let args = InsightsArgs {
71        target: target_str.clone(),
72        chain: chain_override,
73        decode: req.decode,
74        trace: req.trace,
75    };
76
77    // Run insights - it prints to stdout so we need to capture
78    // For the web API, we reconstruct the data using the inferred target
79    match &target {
80        insights::InferredTarget::Address { chain } => {
81            let addr_args = crate::cli::address::AddressArgs {
82                address: target_str.clone(),
83                chain: chain.clone(),
84                format: None,
85                include_txs: false,
86                include_tokens: true,
87                limit: 10,
88                report: None,
89                dossier: false,
90            };
91            let client: Box<dyn crate::chains::ChainClient> =
92                match state.factory.create_chain_client(chain) {
93                    Ok(c) => c,
94                    Err(e) => {
95                        return (
96                            StatusCode::BAD_REQUEST,
97                            Json(serde_json::json!({ "error": e.to_string() })),
98                        )
99                            .into_response();
100                    }
101                };
102            match crate::cli::address::analyze_address(&addr_args, client.as_ref()).await {
103                Ok(report) => Json(serde_json::json!({
104                    "target_info": target_type,
105                    "data": report,
106                }))
107                .into_response(),
108                Err(e) => (
109                    StatusCode::INTERNAL_SERVER_ERROR,
110                    Json(serde_json::json!({ "error": e.to_string() })),
111                )
112                    .into_response(),
113            }
114        }
115        insights::InferredTarget::Transaction { chain } => {
116            match crate::cli::tx::fetch_transaction_report(
117                &target_str,
118                chain,
119                args.decode,
120                args.trace,
121                &state.factory,
122            )
123            .await
124            {
125                Ok(report) => Json(serde_json::json!({
126                    "target_info": target_type,
127                    "data": report,
128                }))
129                .into_response(),
130                Err(e) => (
131                    StatusCode::INTERNAL_SERVER_ERROR,
132                    Json(serde_json::json!({ "error": e.to_string() })),
133                )
134                    .into_response(),
135            }
136        }
137        insights::InferredTarget::Token { chain } => {
138            match crate::cli::crawl::fetch_analytics_for_input(
139                &target_str,
140                chain,
141                crate::cli::crawl::Period::Hour24,
142                10,
143                &state.factory,
144                None,
145            )
146            .await
147            {
148                Ok(analytics) => Json(serde_json::json!({
149                    "target_info": target_type,
150                    "data": analytics,
151                }))
152                .into_response(),
153                Err(e) => (
154                    StatusCode::INTERNAL_SERVER_ERROR,
155                    Json(serde_json::json!({ "error": e.to_string() })),
156                )
157                    .into_response(),
158            }
159        }
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn test_deserialize_full() {
169        let json = serde_json::json!({
170            "target": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
171            "chain": "polygon",
172            "decode": true,
173            "trace": true
174        });
175        let req: InsightsRequest = serde_json::from_value(json).unwrap();
176        assert_eq!(req.target, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
177        assert_eq!(req.chain, Some("polygon".to_string()));
178        assert!(req.decode);
179        assert!(req.trace);
180    }
181
182    #[test]
183    fn test_deserialize_minimal() {
184        let json = serde_json::json!({
185            "target": "0x1234567890123456789012345678901234567890"
186        });
187        let req: InsightsRequest = serde_json::from_value(json).unwrap();
188        assert_eq!(req.target, "0x1234567890123456789012345678901234567890");
189        assert_eq!(req.chain, None);
190        assert!(!req.decode);
191        assert!(!req.trace);
192    }
193
194    #[test]
195    fn test_with_chain_override() {
196        let json = serde_json::json!({
197            "target": "USDC",
198            "chain": "ethereum"
199        });
200        let req: InsightsRequest = serde_json::from_value(json).unwrap();
201        assert_eq!(req.target, "USDC");
202        assert_eq!(req.chain, Some("ethereum".to_string()));
203        assert!(!req.decode);
204        assert!(!req.trace);
205    }
206
207    #[test]
208    fn test_flags() {
209        let json_decode = serde_json::json!({
210            "target": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
211            "decode": true,
212            "trace": false
213        });
214        let req_decode: InsightsRequest = serde_json::from_value(json_decode).unwrap();
215        assert!(req_decode.decode);
216        assert!(!req_decode.trace);
217
218        let json_trace = serde_json::json!({
219            "target": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
220            "decode": false,
221            "trace": true
222        });
223        let req_trace: InsightsRequest = serde_json::from_value(json_trace).unwrap();
224        assert!(!req_trace.decode);
225        assert!(req_trace.trace);
226
227        let json_both = serde_json::json!({
228            "target": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
229            "decode": true,
230            "trace": true
231        });
232        let req_both: InsightsRequest = serde_json::from_value(json_both).unwrap();
233        assert!(req_both.decode);
234        assert!(req_both.trace);
235    }
236
237    #[tokio::test]
238    async fn test_handle_insights_address() {
239        use crate::chains::DefaultClientFactory;
240        use crate::config::Config;
241        use crate::web::AppState;
242        use axum::extract::State;
243        use axum::response::IntoResponse;
244
245        let config = Config::default();
246        let http: std::sync::Arc<dyn crate::http::HttpClient> =
247            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
248        let factory = DefaultClientFactory {
249            chains_config: config.chains.clone(),
250            http,
251        };
252        let state = std::sync::Arc::new(AppState { config, factory });
253        let req = InsightsRequest {
254            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
255            chain: None,
256            decode: false,
257            trace: false,
258        };
259        let response = handle(State(state), axum::Json(req)).await.into_response();
260        let status = response.status();
261        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
262    }
263
264    #[tokio::test]
265    async fn test_handle_insights_token() {
266        use crate::chains::DefaultClientFactory;
267        use crate::config::Config;
268        use crate::web::AppState;
269        use axum::extract::State;
270        use axum::response::IntoResponse;
271
272        let config = Config::default();
273        let http: std::sync::Arc<dyn crate::http::HttpClient> =
274            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
275        let factory = DefaultClientFactory {
276            chains_config: config.chains.clone(),
277            http,
278        };
279        let state = std::sync::Arc::new(AppState { config, factory });
280        let req = InsightsRequest {
281            target: "USDC".to_string(),
282            chain: Some("ethereum".to_string()),
283            decode: false,
284            trace: false,
285        };
286        let response = handle(State(state), axum::Json(req)).await.into_response();
287        let status = response.status();
288        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
289    }
290
291    #[tokio::test]
292    async fn test_handle_insights_tx() {
293        use crate::chains::DefaultClientFactory;
294        use crate::config::Config;
295        use crate::web::AppState;
296        use axum::extract::State;
297        use axum::response::IntoResponse;
298
299        let config = Config::default();
300        let http: std::sync::Arc<dyn crate::http::HttpClient> =
301            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
302        let factory = DefaultClientFactory {
303            chains_config: config.chains.clone(),
304            http,
305        };
306        let state = std::sync::Arc::new(AppState { config, factory });
307        let req = InsightsRequest {
308            target: "0xabc123def456789012345678901234567890123456789012345678901234abcd"
309                .to_string(),
310            chain: None,
311            decode: true,
312            trace: false,
313        };
314        let response = handle(State(state), axum::Json(req)).await.into_response();
315        let status = response.status();
316        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
317    }
318
319    #[tokio::test]
320    async fn test_handle_insights_address_analyze_error() {
321        use crate::chains::DefaultClientFactory;
322        use crate::config::Config;
323        use crate::web::AppState;
324        use axum::extract::State;
325        use axum::http::StatusCode;
326        use axum::response::IntoResponse;
327
328        let config = Config::default();
329        let http: std::sync::Arc<dyn crate::http::HttpClient> =
330            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
331        let factory = DefaultClientFactory {
332            chains_config: config.chains.clone(),
333            http,
334        };
335        let state = std::sync::Arc::new(AppState { config, factory });
336        let req = InsightsRequest {
337            target: "0x0000000000000000000000000000000000000000".to_string(),
338            chain: Some("ethereum".to_string()),
339            decode: false,
340            trace: false,
341        };
342        let response = handle(State(state), axum::Json(req)).await.into_response();
343        if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
344            let body = axum::body::to_bytes(response.into_body(), 1_000_000)
345                .await
346                .unwrap();
347            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
348            assert!(json.get("error").is_some());
349        }
350    }
351
352    #[tokio::test]
353    async fn test_handle_insights_tx_error() {
354        use crate::chains::DefaultClientFactory;
355        use crate::config::Config;
356        use crate::web::AppState;
357        use axum::extract::State;
358        use axum::http::StatusCode;
359        use axum::response::IntoResponse;
360
361        let config = Config::default();
362        let http: std::sync::Arc<dyn crate::http::HttpClient> =
363            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
364        let factory = DefaultClientFactory {
365            chains_config: config.chains.clone(),
366            http,
367        };
368        let state = std::sync::Arc::new(AppState { config, factory });
369        let req = InsightsRequest {
370            target: "0x0000000000000000000000000000000000000000000000000000000000000000"
371                .to_string(),
372            chain: Some("ethereum".to_string()),
373            decode: false,
374            trace: false,
375        };
376        let response = handle(State(state), axum::Json(req)).await.into_response();
377        if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
378            let body = axum::body::to_bytes(response.into_body(), 1_000_000)
379                .await
380                .unwrap();
381            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
382            assert!(json.get("error").is_some());
383        }
384    }
385
386    #[tokio::test]
387    async fn test_handle_insights_token_error() {
388        use crate::chains::DefaultClientFactory;
389        use crate::config::Config;
390        use crate::web::AppState;
391        use axum::extract::State;
392        use axum::http::StatusCode;
393        use axum::response::IntoResponse;
394
395        let config = Config::default();
396        let http: std::sync::Arc<dyn crate::http::HttpClient> =
397            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
398        let factory = DefaultClientFactory {
399            chains_config: config.chains.clone(),
400            http,
401        };
402        let state = std::sync::Arc::new(AppState { config, factory });
403        let req = InsightsRequest {
404            target: "NONEXISTENT_TOKEN_XYZ_123".to_string(),
405            chain: Some("ethereum".to_string()),
406            decode: false,
407            trace: false,
408        };
409        let response = handle(State(state), axum::Json(req)).await.into_response();
410        if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
411            let body = axum::body::to_bytes(response.into_body(), 1_000_000)
412                .await
413                .unwrap();
414            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
415            assert!(json.get("error").is_some());
416        }
417    }
418
419    #[tokio::test]
420    async fn test_handle_insights_unsupported_chain_bad_request() {
421        use crate::chains::DefaultClientFactory;
422        use crate::config::Config;
423        use crate::web::AppState;
424        use axum::extract::State;
425        use axum::http::StatusCode;
426        use axum::response::IntoResponse;
427
428        // Use a temp data dir to avoid local address book interfering
429        let tmp = tempfile::tempdir().unwrap();
430        let config = Config {
431            address_book: crate::config::AddressBookConfig {
432                data_dir: Some(tmp.path().to_path_buf()),
433            },
434            ..Default::default()
435        };
436        let http: std::sync::Arc<dyn crate::http::HttpClient> =
437            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
438        let factory = DefaultClientFactory {
439            chains_config: config.chains.clone(),
440            http,
441        };
442        let state = std::sync::Arc::new(AppState { config, factory });
443        let req = InsightsRequest {
444            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
445            chain: Some("bitcoin".to_string()), // Unsupported chain
446            decode: false,
447            trace: false,
448        };
449        let response = handle(State(state), axum::Json(req)).await.into_response();
450        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
451        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
452            .await
453            .unwrap();
454        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
455        assert!(
456            json["error"]
457                .as_str()
458                .unwrap()
459                .contains("Unsupported chain")
460        );
461    }
462
463    #[tokio::test]
464    async fn test_handle_insights_label_not_found() {
465        use crate::chains::DefaultClientFactory;
466        use crate::config::Config;
467        use crate::web::AppState;
468        use axum::extract::State;
469        use axum::http::StatusCode;
470        use axum::response::IntoResponse;
471
472        let tmp = tempfile::tempdir().unwrap();
473        let config = Config {
474            address_book: crate::config::AddressBookConfig {
475                data_dir: Some(tmp.path().to_path_buf()),
476            },
477            ..Default::default()
478        };
479        let http: std::sync::Arc<dyn crate::http::HttpClient> =
480            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
481        let factory = DefaultClientFactory {
482            chains_config: config.chains.clone(),
483            http,
484        };
485        let state = std::sync::Arc::new(AppState { config, factory });
486        let req = InsightsRequest {
487            target: "@no-such-label".to_string(),
488            chain: None,
489            decode: false,
490            trace: false,
491        };
492        let response = handle(State(state), axum::Json(req)).await.into_response();
493        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
494        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
495            .await
496            .unwrap();
497        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
498        assert!(json["error"].as_str().unwrap().contains("@no-such-label"));
499    }
500}