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 factory = DefaultClientFactory {
247            chains_config: config.chains.clone(),
248        };
249        let state = std::sync::Arc::new(AppState { config, factory });
250        let req = InsightsRequest {
251            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
252            chain: None,
253            decode: false,
254            trace: false,
255        };
256        let response = handle(State(state), axum::Json(req)).await.into_response();
257        let status = response.status();
258        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
259    }
260
261    #[tokio::test]
262    async fn test_handle_insights_token() {
263        use crate::chains::DefaultClientFactory;
264        use crate::config::Config;
265        use crate::web::AppState;
266        use axum::extract::State;
267        use axum::response::IntoResponse;
268
269        let config = Config::default();
270        let factory = DefaultClientFactory {
271            chains_config: config.chains.clone(),
272        };
273        let state = std::sync::Arc::new(AppState { config, factory });
274        let req = InsightsRequest {
275            target: "USDC".to_string(),
276            chain: Some("ethereum".to_string()),
277            decode: false,
278            trace: false,
279        };
280        let response = handle(State(state), axum::Json(req)).await.into_response();
281        let status = response.status();
282        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
283    }
284
285    #[tokio::test]
286    async fn test_handle_insights_tx() {
287        use crate::chains::DefaultClientFactory;
288        use crate::config::Config;
289        use crate::web::AppState;
290        use axum::extract::State;
291        use axum::response::IntoResponse;
292
293        let config = Config::default();
294        let factory = DefaultClientFactory {
295            chains_config: config.chains.clone(),
296        };
297        let state = std::sync::Arc::new(AppState { config, factory });
298        let req = InsightsRequest {
299            target: "0xabc123def456789012345678901234567890123456789012345678901234abcd"
300                .to_string(),
301            chain: None,
302            decode: true,
303            trace: false,
304        };
305        let response = handle(State(state), axum::Json(req)).await.into_response();
306        let status = response.status();
307        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
308    }
309
310    #[tokio::test]
311    async fn test_handle_insights_address_analyze_error() {
312        use crate::chains::DefaultClientFactory;
313        use crate::config::Config;
314        use crate::web::AppState;
315        use axum::extract::State;
316        use axum::http::StatusCode;
317        use axum::response::IntoResponse;
318
319        let config = Config::default();
320        let factory = DefaultClientFactory {
321            chains_config: config.chains.clone(),
322        };
323        let state = std::sync::Arc::new(AppState { config, factory });
324        let req = InsightsRequest {
325            target: "0x0000000000000000000000000000000000000000".to_string(),
326            chain: Some("ethereum".to_string()),
327            decode: false,
328            trace: false,
329        };
330        let response = handle(State(state), axum::Json(req)).await.into_response();
331        if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
332            let body = axum::body::to_bytes(response.into_body(), 1_000_000)
333                .await
334                .unwrap();
335            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
336            assert!(json.get("error").is_some());
337        }
338    }
339
340    #[tokio::test]
341    async fn test_handle_insights_tx_error() {
342        use crate::chains::DefaultClientFactory;
343        use crate::config::Config;
344        use crate::web::AppState;
345        use axum::extract::State;
346        use axum::http::StatusCode;
347        use axum::response::IntoResponse;
348
349        let config = Config::default();
350        let factory = DefaultClientFactory {
351            chains_config: config.chains.clone(),
352        };
353        let state = std::sync::Arc::new(AppState { config, factory });
354        let req = InsightsRequest {
355            target: "0x0000000000000000000000000000000000000000000000000000000000000000"
356                .to_string(),
357            chain: Some("ethereum".to_string()),
358            decode: false,
359            trace: false,
360        };
361        let response = handle(State(state), axum::Json(req)).await.into_response();
362        if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
363            let body = axum::body::to_bytes(response.into_body(), 1_000_000)
364                .await
365                .unwrap();
366            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
367            assert!(json.get("error").is_some());
368        }
369    }
370
371    #[tokio::test]
372    async fn test_handle_insights_token_error() {
373        use crate::chains::DefaultClientFactory;
374        use crate::config::Config;
375        use crate::web::AppState;
376        use axum::extract::State;
377        use axum::http::StatusCode;
378        use axum::response::IntoResponse;
379
380        let config = Config::default();
381        let factory = DefaultClientFactory {
382            chains_config: config.chains.clone(),
383        };
384        let state = std::sync::Arc::new(AppState { config, factory });
385        let req = InsightsRequest {
386            target: "NONEXISTENT_TOKEN_XYZ_123".to_string(),
387            chain: Some("ethereum".to_string()),
388            decode: false,
389            trace: false,
390        };
391        let response = handle(State(state), axum::Json(req)).await.into_response();
392        if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
393            let body = axum::body::to_bytes(response.into_body(), 1_000_000)
394                .await
395                .unwrap();
396            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
397            assert!(json.get("error").is_some());
398        }
399    }
400
401    #[tokio::test]
402    async fn test_handle_insights_unsupported_chain_bad_request() {
403        use crate::chains::DefaultClientFactory;
404        use crate::config::Config;
405        use crate::web::AppState;
406        use axum::extract::State;
407        use axum::http::StatusCode;
408        use axum::response::IntoResponse;
409
410        // Use a temp data dir to avoid local address book interfering
411        let tmp = tempfile::tempdir().unwrap();
412        let config = Config {
413            address_book: crate::config::AddressBookConfig {
414                data_dir: Some(tmp.path().to_path_buf()),
415            },
416            ..Default::default()
417        };
418        let factory = DefaultClientFactory {
419            chains_config: config.chains.clone(),
420        };
421        let state = std::sync::Arc::new(AppState { config, factory });
422        let req = InsightsRequest {
423            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
424            chain: Some("bitcoin".to_string()), // Unsupported chain
425            decode: false,
426            trace: false,
427        };
428        let response = handle(State(state), axum::Json(req)).await.into_response();
429        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
430        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
431            .await
432            .unwrap();
433        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
434        assert!(
435            json["error"]
436                .as_str()
437                .unwrap()
438                .contains("Unsupported chain")
439        );
440    }
441
442    #[tokio::test]
443    async fn test_handle_insights_label_not_found() {
444        use crate::chains::DefaultClientFactory;
445        use crate::config::Config;
446        use crate::web::AppState;
447        use axum::extract::State;
448        use axum::http::StatusCode;
449        use axum::response::IntoResponse;
450
451        let tmp = tempfile::tempdir().unwrap();
452        let config = Config {
453            address_book: crate::config::AddressBookConfig {
454                data_dir: Some(tmp.path().to_path_buf()),
455            },
456            ..Default::default()
457        };
458        let factory = DefaultClientFactory {
459            chains_config: config.chains.clone(),
460        };
461        let state = std::sync::Arc::new(AppState { config, factory });
462        let req = InsightsRequest {
463            target: "@no-such-label".to_string(),
464            chain: None,
465            decode: false,
466            trace: false,
467        };
468        let response = handle(State(state), axum::Json(req)).await.into_response();
469        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
470        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
471            .await
472            .unwrap();
473        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
474        assert!(json["error"].as_str().unwrap().contains("@no-such-label"));
475    }
476}