Skip to main content

scope_web/api/
mod.rs

1//! # Web API Handlers
2//!
3//! REST API endpoints mirroring CLI commands. Each handler accepts JSON
4//! request bodies matching CLI argument structures and returns JSON responses.
5
6pub mod address;
7pub mod address_book;
8pub mod compliance;
9pub mod config_status;
10pub mod contract;
11pub mod crawl;
12pub mod discover;
13pub mod exchange;
14pub mod export;
15pub mod insights;
16pub mod market;
17pub mod token_health;
18pub mod tx;
19pub mod venues;
20
21use crate::AppState;
22use axum::Router;
23use std::sync::Arc;
24
25/// The one map from a core error kind to an HTTP status: a missing resource
26/// is 404, a request fault (unknown venue, chain mismatch, bad address) is
27/// 400, and anything else is 500.
28pub(crate) fn status_for(err: &scope::error::ScopeError) -> axum::http::StatusCode {
29    use axum::http::StatusCode;
30    use scope::error::ScopeError;
31    match err {
32        ScopeError::NotFound(_) => StatusCode::NOT_FOUND,
33        ScopeError::InvalidInput(_)
34        | ScopeError::InvalidAddress(_)
35        | ScopeError::InvalidHash(_) => StatusCode::BAD_REQUEST,
36        _ => StatusCode::INTERNAL_SERVER_ERROR,
37    }
38}
39
40/// A JSON error body, `{ "error": message }`, with `status`.
41pub(crate) fn error_response(
42    status: axum::http::StatusCode,
43    message: impl Into<String>,
44) -> axum::response::Response {
45    use axum::response::IntoResponse;
46    let body = serde_json::json!({ "error": message.into() });
47    (status, axum::Json(body)).into_response()
48}
49
50/// Serde default for a request's chain field.
51pub(crate) fn default_chain() -> String {
52    scope::chains::DEFAULT_CHAIN.to_string()
53}
54
55/// Serde default for a request's market venue field.
56pub(crate) fn default_venue() -> String {
57    scope::market::DEFAULT_VENUE.to_string()
58}
59
60/// Serde default for a market request's pair field.
61pub(crate) fn default_pair() -> String {
62    scope::market::DEFAULT_PAIR.to_string()
63}
64
65/// Result of resolving a user input through the address book.
66///
67/// When the input matches an address book entry (either via `@label`
68/// prefix or direct address match), both the resolved address and chain
69/// are returned. Handlers should use the resolved chain when the user
70/// hasn't explicitly overridden it.
71#[derive(Debug)]
72pub struct ResolvedInput {
73    /// The resolved address/token string.
74    pub value: String,
75    /// The chain from the address book entry (if resolved).
76    pub chain: Option<String>,
77}
78
79/// Resolves a user-supplied input string against the address book.
80///
81/// Supports `@label` shortcuts (e.g. `@main-wallet`) and direct address
82/// matching. Returns the original input unchanged if no match is found.
83///
84/// # Errors
85///
86/// [`scope::error::ScopeError::InvalidInput`] when `@label` names no address
87/// book entry — a request fault, so handlers answer
88/// `error_response(status_for(&e), ...)` with 400.
89pub fn resolve_address_book(
90    input: &str,
91    config: &scope::config::Config,
92) -> scope::error::Result<ResolvedInput> {
93    match scope_cli::cli::address_book::resolve_address_book_input(input, config) {
94        Ok(Some((address, chain))) => Ok(ResolvedInput {
95            value: address,
96            chain: Some(chain),
97        }),
98        Ok(None) => Ok(ResolvedInput {
99            value: input.to_string(),
100            chain: None,
101        }),
102        // A misspelled @label is a bad request, not a missing resource.
103        Err(scope::error::ScopeError::NotFound(msg)) => {
104            Err(scope::error::ScopeError::InvalidInput(msg))
105        }
106        Err(e) => Err(scope::error::ScopeError::InvalidInput(e.to_string())),
107    }
108}
109
110/// Registers all API routes under the `/api` prefix.
111pub fn routes(state: Arc<AppState>) -> Router<Arc<AppState>> {
112    Router::new()
113        .route("/address", axum::routing::post(address::handle))
114        .route("/tx", axum::routing::post(tx::handle))
115        .route("/insights", axum::routing::post(insights::handle))
116        .route("/crawl", axum::routing::post(crawl::handle))
117        .route("/discover", axum::routing::get(discover::handle))
118        .route("/token-health", axum::routing::post(token_health::handle))
119        .route("/market/summary", axum::routing::post(market::handle))
120        .route(
121            "/address-book/list",
122            axum::routing::get(address_book::handle_list),
123        )
124        .route(
125            "/address-book/add",
126            axum::routing::post(address_book::handle_add),
127        )
128        .route(
129            "/address-book/remove",
130            axum::routing::post(address_book::handle_remove),
131        )
132        .route("/contract", axum::routing::post(contract::handle))
133        .route("/export", axum::routing::post(export::handle))
134        .route(
135            "/compliance/risk",
136            axum::routing::post(compliance::handle_risk),
137        )
138        .route("/config/status", axum::routing::get(config_status::handle))
139        .route("/config", axum::routing::post(config_status::handle_save))
140        .route("/venues", axum::routing::get(venues::handle))
141        .route("/exchange/snapshot", axum::routing::post(exchange::handle))
142        .route(
143            "/exchange/trades",
144            axum::routing::post(exchange::handle_trades),
145        )
146        .route("/exchange/ohlc", axum::routing::post(exchange::handle_ohlc))
147        .with_state(state)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use scope::chains::DefaultClientFactory;
154    use scope::config::Config;
155    use scope_cli::cli::address_book::{AddressBook, WatchedAddress};
156
157    #[test]
158    fn test_routes_construction() {
159        let config = Config::default();
160        let http: std::sync::Arc<dyn scope::http::HttpClient> =
161            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
162        let factory = DefaultClientFactory {
163            chains_config: config.chains.clone(),
164            http,
165        };
166        let state = Arc::new(AppState::new(config, factory));
167        let _router = routes(state);
168        // If this doesn't panic, routes are properly constructed
169    }
170
171    #[test]
172    fn test_resolve_address_book_label_found() {
173        let tmp = tempfile::tempdir().unwrap();
174        let mut ab = AddressBook::default();
175        ab.add_address(WatchedAddress {
176            address: "0xABCDEF1234567890abcdef1234567890ABCDEF12".to_string(),
177            label: Some("hot-wallet".to_string()),
178            chain: "polygon".to_string(),
179            tags: vec![],
180            added_at: 0,
181        })
182        .unwrap();
183        ab.save(&tmp.path().to_path_buf()).unwrap();
184
185        let config = Config {
186            address_book: scope::config::AddressBookConfig {
187                data_dir: Some(tmp.path().to_path_buf()),
188            },
189            ..Default::default()
190        };
191        let result = resolve_address_book("@hot-wallet", &config);
192        assert!(result.is_ok());
193        let r = result.unwrap();
194        assert_eq!(r.value, "0xABCDEF1234567890abcdef1234567890ABCDEF12");
195        assert_eq!(r.chain, Some("polygon".to_string()));
196    }
197
198    #[test]
199    fn test_resolve_address_book_label_not_found() {
200        let tmp = tempfile::tempdir().unwrap();
201        let config = Config {
202            address_book: scope::config::AddressBookConfig {
203                data_dir: Some(tmp.path().to_path_buf()),
204            },
205            ..Default::default()
206        };
207        let result = resolve_address_book("@nonexistent", &config);
208        assert!(result.is_err());
209        let err = result.unwrap_err();
210        assert!(matches!(err, scope::error::ScopeError::InvalidInput(_)));
211        assert!(err.to_string().contains("@nonexistent"));
212    }
213
214    #[test]
215    fn test_resolve_address_book_raw_address_passthrough() {
216        let tmp = tempfile::tempdir().unwrap();
217        let config = Config {
218            address_book: scope::config::AddressBookConfig {
219                data_dir: Some(tmp.path().to_path_buf()),
220            },
221            ..Default::default()
222        };
223        let result = resolve_address_book("0xSomeRawAddress", &config);
224        assert!(result.is_ok());
225        let r = result.unwrap();
226        assert_eq!(r.value, "0xSomeRawAddress");
227        assert_eq!(r.chain, None);
228    }
229
230    #[test]
231    fn test_resolve_address_book_address_match_returns_chain() {
232        let tmp = tempfile::tempdir().unwrap();
233        let mut ab = AddressBook::default();
234        ab.add_address(WatchedAddress {
235            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
236            label: Some("main".to_string()),
237            chain: "arbitrum".to_string(),
238            tags: vec![],
239            added_at: 0,
240        })
241        .unwrap();
242        ab.save(&tmp.path().to_path_buf()).unwrap();
243
244        let config = Config {
245            address_book: scope::config::AddressBookConfig {
246                data_dir: Some(tmp.path().to_path_buf()),
247            },
248            ..Default::default()
249        };
250        // Pass raw address (no @ prefix) — should still match and return chain
251        let result = resolve_address_book("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", &config);
252        assert!(result.is_ok());
253        let r = result.unwrap();
254        assert_eq!(r.value, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
255        assert_eq!(r.chain, Some("arbitrum".to_string()));
256    }
257
258    #[test]
259    fn test_resolve_address_book_token_symbol_passthrough() {
260        let tmp = tempfile::tempdir().unwrap();
261        let config = Config {
262            address_book: scope::config::AddressBookConfig {
263                data_dir: Some(tmp.path().to_path_buf()),
264            },
265            ..Default::default()
266        };
267        // Token symbols like "USDC" should pass through unchanged
268        let result = resolve_address_book("USDC", &config);
269        assert!(result.is_ok());
270        let r = result.unwrap();
271        assert_eq!(r.value, "USDC");
272        assert_eq!(r.chain, None);
273    }
274}
275
276#[cfg(test)]
277mod status_tests {
278    use super::*;
279    use axum::http::StatusCode;
280    use scope::error::ScopeError;
281
282    /// One map from core error kinds to HTTP status: a missing resource
283    /// (e.g. no DEX pairs for the token) is 404, bad input is 400, and
284    /// anything else is 500.
285    #[test]
286    fn core_error_kinds_map_to_one_status_each() {
287        let no_pairs = ScopeError::NotFound("No DEX pairs found for USDC on solana".into());
288        assert_eq!(status_for(&no_pairs), StatusCode::NOT_FOUND);
289        assert_eq!(
290            status_for(&ScopeError::InvalidAddress("0x1".into())),
291            StatusCode::BAD_REQUEST
292        );
293        assert_eq!(
294            status_for(&ScopeError::Network("timeout".into())),
295            StatusCode::INTERNAL_SERVER_ERROR
296        );
297    }
298}