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/// Returns the insights markdown as JSON `{ "markdown": "..." }` along
31/// with structured metadata about the detected target type.
32pub async fn handle(
33    State(state): State<Arc<AppState>>,
34    Json(req): Json<InsightsRequest>,
35) -> impl IntoResponse {
36    let target = insights::infer_target(&req.target, req.chain.as_deref());
37
38    let target_type = match &target {
39        insights::InferredTarget::Address { chain } => {
40            serde_json::json!({ "type": "address", "chain": chain })
41        }
42        insights::InferredTarget::Transaction { chain } => {
43            serde_json::json!({ "type": "transaction", "chain": chain })
44        }
45        insights::InferredTarget::Token { chain } => {
46            serde_json::json!({ "type": "token", "chain": chain })
47        }
48    };
49
50    // Run the insights command which builds markdown output
51    // We capture it by running the underlying functions directly
52    let args = InsightsArgs {
53        target: req.target.clone(),
54        chain: req.chain,
55        decode: req.decode,
56        trace: req.trace,
57    };
58
59    // Run insights - it prints to stdout so we need to capture
60    // For the web API, we reconstruct the data using the inferred target
61    match &target {
62        insights::InferredTarget::Address { chain } => {
63            let addr_args = crate::cli::address::AddressArgs {
64                address: req.target,
65                chain: chain.clone(),
66                format: None,
67                include_txs: false,
68                include_tokens: true,
69                limit: 10,
70                report: None,
71                dossier: false,
72            };
73            let client: Box<dyn crate::chains::ChainClient> =
74                match state.factory.create_chain_client(chain) {
75                    Ok(c) => c,
76                    Err(e) => {
77                        return (
78                            StatusCode::BAD_REQUEST,
79                            Json(serde_json::json!({ "error": e.to_string() })),
80                        )
81                            .into_response();
82                    }
83                };
84            match crate::cli::address::analyze_address(&addr_args, client.as_ref()).await {
85                Ok(report) => Json(serde_json::json!({
86                    "target_info": target_type,
87                    "data": report,
88                }))
89                .into_response(),
90                Err(e) => (
91                    StatusCode::INTERNAL_SERVER_ERROR,
92                    Json(serde_json::json!({ "error": e.to_string() })),
93                )
94                    .into_response(),
95            }
96        }
97        insights::InferredTarget::Transaction { chain } => {
98            match crate::cli::tx::fetch_transaction_report(
99                &req.target,
100                chain,
101                args.decode,
102                args.trace,
103                &state.factory,
104            )
105            .await
106            {
107                Ok(report) => Json(serde_json::json!({
108                    "target_info": target_type,
109                    "data": report,
110                }))
111                .into_response(),
112                Err(e) => (
113                    StatusCode::INTERNAL_SERVER_ERROR,
114                    Json(serde_json::json!({ "error": e.to_string() })),
115                )
116                    .into_response(),
117            }
118        }
119        insights::InferredTarget::Token { chain } => {
120            match crate::cli::crawl::fetch_analytics_for_input(
121                &req.target,
122                chain,
123                crate::cli::crawl::Period::Hour24,
124                10,
125                &state.factory,
126                None,
127            )
128            .await
129            {
130                Ok(analytics) => Json(serde_json::json!({
131                    "target_info": target_type,
132                    "data": analytics,
133                }))
134                .into_response(),
135                Err(e) => (
136                    StatusCode::INTERNAL_SERVER_ERROR,
137                    Json(serde_json::json!({ "error": e.to_string() })),
138                )
139                    .into_response(),
140            }
141        }
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_deserialize_full() {
151        let json = serde_json::json!({
152            "target": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
153            "chain": "polygon",
154            "decode": true,
155            "trace": true
156        });
157        let req: InsightsRequest = serde_json::from_value(json).unwrap();
158        assert_eq!(req.target, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
159        assert_eq!(req.chain, Some("polygon".to_string()));
160        assert!(req.decode);
161        assert!(req.trace);
162    }
163
164    #[test]
165    fn test_deserialize_minimal() {
166        let json = serde_json::json!({
167            "target": "0x1234567890123456789012345678901234567890"
168        });
169        let req: InsightsRequest = serde_json::from_value(json).unwrap();
170        assert_eq!(req.target, "0x1234567890123456789012345678901234567890");
171        assert_eq!(req.chain, None);
172        assert!(!req.decode);
173        assert!(!req.trace);
174    }
175
176    #[test]
177    fn test_with_chain_override() {
178        let json = serde_json::json!({
179            "target": "USDC",
180            "chain": "ethereum"
181        });
182        let req: InsightsRequest = serde_json::from_value(json).unwrap();
183        assert_eq!(req.target, "USDC");
184        assert_eq!(req.chain, Some("ethereum".to_string()));
185        assert!(!req.decode);
186        assert!(!req.trace);
187    }
188
189    #[test]
190    fn test_flags() {
191        let json_decode = serde_json::json!({
192            "target": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
193            "decode": true,
194            "trace": false
195        });
196        let req_decode: InsightsRequest = serde_json::from_value(json_decode).unwrap();
197        assert!(req_decode.decode);
198        assert!(!req_decode.trace);
199
200        let json_trace = serde_json::json!({
201            "target": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
202            "decode": false,
203            "trace": true
204        });
205        let req_trace: InsightsRequest = serde_json::from_value(json_trace).unwrap();
206        assert!(!req_trace.decode);
207        assert!(req_trace.trace);
208
209        let json_both = serde_json::json!({
210            "target": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
211            "decode": true,
212            "trace": true
213        });
214        let req_both: InsightsRequest = serde_json::from_value(json_both).unwrap();
215        assert!(req_both.decode);
216        assert!(req_both.trace);
217    }
218
219    #[tokio::test]
220    async fn test_handle_insights_address() {
221        use crate::chains::DefaultClientFactory;
222        use crate::config::Config;
223        use crate::web::AppState;
224        use axum::extract::State;
225        use axum::response::IntoResponse;
226
227        let config = Config::default();
228        let factory = DefaultClientFactory {
229            chains_config: config.chains.clone(),
230        };
231        let state = std::sync::Arc::new(AppState { config, factory });
232        let req = InsightsRequest {
233            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
234            chain: None,
235            decode: false,
236            trace: false,
237        };
238        let response = handle(State(state), axum::Json(req)).await.into_response();
239        let status = response.status();
240        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
241    }
242
243    #[tokio::test]
244    async fn test_handle_insights_token() {
245        use crate::chains::DefaultClientFactory;
246        use crate::config::Config;
247        use crate::web::AppState;
248        use axum::extract::State;
249        use axum::response::IntoResponse;
250
251        let config = Config::default();
252        let factory = DefaultClientFactory {
253            chains_config: config.chains.clone(),
254        };
255        let state = std::sync::Arc::new(AppState { config, factory });
256        let req = InsightsRequest {
257            target: "USDC".to_string(),
258            chain: Some("ethereum".to_string()),
259            decode: false,
260            trace: false,
261        };
262        let response = handle(State(state), axum::Json(req)).await.into_response();
263        let status = response.status();
264        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
265    }
266
267    #[tokio::test]
268    async fn test_handle_insights_tx() {
269        use crate::chains::DefaultClientFactory;
270        use crate::config::Config;
271        use crate::web::AppState;
272        use axum::extract::State;
273        use axum::response::IntoResponse;
274
275        let config = Config::default();
276        let factory = DefaultClientFactory {
277            chains_config: config.chains.clone(),
278        };
279        let state = std::sync::Arc::new(AppState { config, factory });
280        let req = InsightsRequest {
281            target: "0xabc123def456789012345678901234567890123456789012345678901234abcd"
282                .to_string(),
283            chain: None,
284            decode: true,
285            trace: false,
286        };
287        let response = handle(State(state), axum::Json(req)).await.into_response();
288        let status = response.status();
289        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
290    }
291}