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            )
127            .await
128            {
129                Ok(analytics) => Json(serde_json::json!({
130                    "target_info": target_type,
131                    "data": analytics,
132                }))
133                .into_response(),
134                Err(e) => (
135                    StatusCode::INTERNAL_SERVER_ERROR,
136                    Json(serde_json::json!({ "error": e.to_string() })),
137                )
138                    .into_response(),
139            }
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_deserialize_full() {
150        let json = serde_json::json!({
151            "target": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
152            "chain": "polygon",
153            "decode": true,
154            "trace": true
155        });
156        let req: InsightsRequest = serde_json::from_value(json).unwrap();
157        assert_eq!(req.target, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
158        assert_eq!(req.chain, Some("polygon".to_string()));
159        assert!(req.decode);
160        assert!(req.trace);
161    }
162
163    #[test]
164    fn test_deserialize_minimal() {
165        let json = serde_json::json!({
166            "target": "0x1234567890123456789012345678901234567890"
167        });
168        let req: InsightsRequest = serde_json::from_value(json).unwrap();
169        assert_eq!(req.target, "0x1234567890123456789012345678901234567890");
170        assert_eq!(req.chain, None);
171        assert!(!req.decode);
172        assert!(!req.trace);
173    }
174
175    #[test]
176    fn test_with_chain_override() {
177        let json = serde_json::json!({
178            "target": "USDC",
179            "chain": "ethereum"
180        });
181        let req: InsightsRequest = serde_json::from_value(json).unwrap();
182        assert_eq!(req.target, "USDC");
183        assert_eq!(req.chain, Some("ethereum".to_string()));
184        assert!(!req.decode);
185        assert!(!req.trace);
186    }
187
188    #[test]
189    fn test_flags() {
190        let json_decode = serde_json::json!({
191            "target": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
192            "decode": true,
193            "trace": false
194        });
195        let req_decode: InsightsRequest = serde_json::from_value(json_decode).unwrap();
196        assert!(req_decode.decode);
197        assert!(!req_decode.trace);
198
199        let json_trace = serde_json::json!({
200            "target": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
201            "decode": false,
202            "trace": true
203        });
204        let req_trace: InsightsRequest = serde_json::from_value(json_trace).unwrap();
205        assert!(!req_trace.decode);
206        assert!(req_trace.trace);
207
208        let json_both = serde_json::json!({
209            "target": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
210            "decode": true,
211            "trace": true
212        });
213        let req_both: InsightsRequest = serde_json::from_value(json_both).unwrap();
214        assert!(req_both.decode);
215        assert!(req_both.trace);
216    }
217
218    #[tokio::test]
219    async fn test_handle_insights_address() {
220        use crate::chains::DefaultClientFactory;
221        use crate::config::Config;
222        use crate::web::AppState;
223        use axum::extract::State;
224        use axum::response::IntoResponse;
225
226        let config = Config::default();
227        let factory = DefaultClientFactory {
228            chains_config: config.chains.clone(),
229        };
230        let state = std::sync::Arc::new(AppState { config, factory });
231        let req = InsightsRequest {
232            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
233            chain: None,
234            decode: false,
235            trace: false,
236        };
237        let response = handle(State(state), axum::Json(req)).await.into_response();
238        let status = response.status();
239        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
240    }
241
242    #[tokio::test]
243    async fn test_handle_insights_token() {
244        use crate::chains::DefaultClientFactory;
245        use crate::config::Config;
246        use crate::web::AppState;
247        use axum::extract::State;
248        use axum::response::IntoResponse;
249
250        let config = Config::default();
251        let factory = DefaultClientFactory {
252            chains_config: config.chains.clone(),
253        };
254        let state = std::sync::Arc::new(AppState { config, factory });
255        let req = InsightsRequest {
256            target: "USDC".to_string(),
257            chain: Some("ethereum".to_string()),
258            decode: false,
259            trace: false,
260        };
261        let response = handle(State(state), axum::Json(req)).await.into_response();
262        let status = response.status();
263        assert!(status.is_success() || status.is_client_error() || status.is_server_error());
264    }
265
266    #[tokio::test]
267    async fn test_handle_insights_tx() {
268        use crate::chains::DefaultClientFactory;
269        use crate::config::Config;
270        use crate::web::AppState;
271        use axum::extract::State;
272        use axum::response::IntoResponse;
273
274        let config = Config::default();
275        let factory = DefaultClientFactory {
276            chains_config: config.chains.clone(),
277        };
278        let state = std::sync::Arc::new(AppState { config, factory });
279        let req = InsightsRequest {
280            target: "0xabc123def456789012345678901234567890123456789012345678901234abcd"
281                .to_string(),
282            chain: None,
283            decode: true,
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}