Skip to main content

scope/web/api/
config_status.rs

1//! Config status API handler.
2
3use crate::config::Config;
4use crate::web::AppState;
5use axum::Json;
6use axum::extract::State;
7use axum::http::StatusCode;
8use axum::response::IntoResponse;
9use serde::Deserialize;
10use std::sync::Arc;
11
12/// GET /api/config/status — Returns config status (which keys are set).
13pub async fn handle(State(state): State<Arc<AppState>>) -> impl IntoResponse {
14    let config = &state.config;
15    let config_path = Config::config_path()
16        .map(|p| p.display().to_string())
17        .unwrap_or_else(|| "unknown".to_string());
18
19    let config_exists = Config::config_path().map(|p| p.exists()).unwrap_or(false);
20
21    // Report which API keys are configured (without exposing values)
22    let api_keys_status: serde_json::Value = serde_json::json!({
23        "etherscan": config.chains.api_keys.contains_key("etherscan") ||
24            std::env::var("ETHERSCAN_API_KEY").is_ok(),
25        "polygonscan": config.chains.api_keys.contains_key("polygonscan"),
26        "bscscan": config.chains.api_keys.contains_key("bscscan"),
27        "solscan": config.chains.api_keys.contains_key("solscan"),
28        "tronscan": config.chains.api_keys.contains_key("tronscan"),
29    });
30
31    let rpc_status = serde_json::json!({
32        "ethereum_rpc": config.chains.ethereum_rpc.is_some(),
33        "bsc_rpc": config.chains.bsc_rpc.is_some(),
34        "solana_rpc": config.chains.solana_rpc.is_some(),
35        "tron_api": config.chains.tron_api.is_some(),
36    });
37
38    Json(serde_json::json!({
39        "config_path": config_path,
40        "config_exists": config_exists,
41        "output_format": format!("{:?}", config.output.format),
42        "color_enabled": config.output.color,
43        "api_keys": api_keys_status,
44        "rpc_endpoints": rpc_status,
45        "version": crate::VERSION,
46    }))
47    .into_response()
48}
49
50/// Request body for saving configuration.
51#[derive(Debug, Deserialize)]
52pub struct SaveConfigRequest {
53    /// API keys to set (key name -> value).
54    #[serde(default)]
55    pub api_keys: std::collections::HashMap<String, String>,
56    /// RPC endpoints to set.
57    #[serde(default)]
58    pub rpc_endpoints: std::collections::HashMap<String, String>,
59}
60
61/// POST /api/config — Save API keys and RPC endpoints.
62pub async fn handle_save(
63    State(_state): State<Arc<AppState>>,
64    Json(req): Json<SaveConfigRequest>,
65) -> impl IntoResponse {
66    // Load existing config or create new
67    let mut config = Config::load(None).unwrap_or_default();
68
69    // Update API keys
70    for (key, value) in &req.api_keys {
71        if !value.is_empty() {
72            config.chains.api_keys.insert(key.clone(), value.clone());
73        }
74    }
75
76    // Update RPC endpoints
77    for (key, value) in &req.rpc_endpoints {
78        if !value.is_empty() {
79            match key.as_str() {
80                "ethereum_rpc" => config.chains.ethereum_rpc = Some(value.clone()),
81                "bsc_rpc" => config.chains.bsc_rpc = Some(value.clone()),
82                "solana_rpc" => config.chains.solana_rpc = Some(value.clone()),
83                "tron_api" => config.chains.tron_api = Some(value.clone()),
84                _ => {}
85            }
86        }
87    }
88
89    // Save to disk
90    let config_path = match Config::config_path() {
91        Some(p) => p,
92        None => {
93            return (
94                StatusCode::INTERNAL_SERVER_ERROR,
95                Json(serde_json::json!({ "error": "Cannot determine config path" })),
96            )
97                .into_response();
98        }
99    };
100
101    // Ensure parent directory exists
102    if let Some(parent) = config_path.parent()
103        && let Err(e) = std::fs::create_dir_all(parent)
104    {
105        return (
106            StatusCode::INTERNAL_SERVER_ERROR,
107            Json(serde_json::json!({ "error": format!("Failed to create config dir: {}", e) })),
108        )
109            .into_response();
110    }
111
112    match serde_yaml::to_string(&config) {
113        Ok(yaml) => {
114            if let Err(e) = std::fs::write(&config_path, yaml) {
115                return (
116                    StatusCode::INTERNAL_SERVER_ERROR,
117                    Json(serde_json::json!({ "error": format!("Failed to write config: {}", e) })),
118                )
119                    .into_response();
120            }
121            Json(serde_json::json!({
122                "status": "saved",
123                "path": config_path.display().to_string(),
124            }))
125            .into_response()
126        }
127        Err(e) => (
128            StatusCode::INTERNAL_SERVER_ERROR,
129            Json(serde_json::json!({ "error": format!("Failed to serialize config: {}", e) })),
130        )
131            .into_response(),
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_deserialize_save_config_full() {
141        let json = serde_json::json!({
142            "api_keys": {
143                "etherscan": "test_key_123",
144                "polygonscan": "test_key_456"
145            },
146            "rpc_endpoints": {
147                "ethereum_rpc": "https://eth.example.com",
148                "bsc_rpc": "https://bsc.example.com"
149            }
150        });
151        let req: SaveConfigRequest = serde_json::from_value(json).unwrap();
152        assert_eq!(req.api_keys.len(), 2);
153        assert_eq!(
154            req.api_keys.get("etherscan"),
155            Some(&"test_key_123".to_string())
156        );
157        assert_eq!(
158            req.api_keys.get("polygonscan"),
159            Some(&"test_key_456".to_string())
160        );
161        assert_eq!(req.rpc_endpoints.len(), 2);
162        assert_eq!(
163            req.rpc_endpoints.get("ethereum_rpc"),
164            Some(&"https://eth.example.com".to_string())
165        );
166        assert_eq!(
167            req.rpc_endpoints.get("bsc_rpc"),
168            Some(&"https://bsc.example.com".to_string())
169        );
170    }
171
172    #[test]
173    fn test_deserialize_save_config_empty() {
174        let json = serde_json::json!({});
175        let req: SaveConfigRequest = serde_json::from_value(json).unwrap();
176        assert_eq!(req.api_keys.len(), 0);
177        assert_eq!(req.rpc_endpoints.len(), 0);
178    }
179
180    #[test]
181    fn test_deserialize_save_config_partial() {
182        let json = serde_json::json!({
183            "api_keys": {
184                "etherscan": "test_key_123"
185            }
186        });
187        let req: SaveConfigRequest = serde_json::from_value(json).unwrap();
188        assert_eq!(req.api_keys.len(), 1);
189        assert_eq!(
190            req.api_keys.get("etherscan"),
191            Some(&"test_key_123".to_string())
192        );
193        assert_eq!(req.rpc_endpoints.len(), 0);
194    }
195
196    #[tokio::test]
197    async fn test_handle_config_status() {
198        use crate::chains::DefaultClientFactory;
199        use crate::config::Config;
200        use crate::web::AppState;
201        use axum::extract::State;
202        use axum::response::IntoResponse;
203
204        let config = Config::default();
205        let http: std::sync::Arc<dyn crate::http::HttpClient> =
206            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
207        let factory = DefaultClientFactory {
208            chains_config: config.chains.clone(),
209            http,
210        };
211        let state = std::sync::Arc::new(AppState { config, factory });
212        let response = handle(State(state)).await.into_response();
213        assert_eq!(response.status(), axum::http::StatusCode::OK);
214
215        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
216            .await
217            .unwrap();
218        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
219        assert!(json.get("config_path").is_some());
220        assert!(json.get("api_keys").is_some());
221        assert!(json.get("rpc_endpoints").is_some());
222        assert!(json.get("version").is_some());
223    }
224
225    #[tokio::test]
226    async fn test_handle_save_config() {
227        use crate::chains::DefaultClientFactory;
228        use crate::config::Config;
229        use crate::web::AppState;
230        use axum::extract::State;
231        use axum::response::IntoResponse;
232
233        let config = Config::default();
234        let http: std::sync::Arc<dyn crate::http::HttpClient> =
235            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
236        let factory = DefaultClientFactory {
237            chains_config: config.chains.clone(),
238            http,
239        };
240        let state = std::sync::Arc::new(AppState { config, factory });
241        let req = SaveConfigRequest {
242            api_keys: std::collections::HashMap::new(),
243            rpc_endpoints: std::collections::HashMap::new(),
244        };
245        let response = handle_save(State(state), axum::Json(req))
246            .await
247            .into_response();
248        // May succeed or fail depending on filesystem
249        let status = response.status();
250        assert!(
251            status == axum::http::StatusCode::OK
252                || status == axum::http::StatusCode::INTERNAL_SERVER_ERROR
253        );
254    }
255
256    #[test]
257    fn test_save_config_request_debug() {
258        let req = SaveConfigRequest {
259            api_keys: std::collections::HashMap::new(),
260            rpc_endpoints: std::collections::HashMap::new(),
261        };
262        let debug = format!("{:?}", req);
263        assert!(debug.contains("SaveConfigRequest"));
264    }
265
266    #[tokio::test]
267    async fn test_handle_save_config_path_none() {
268        use crate::chains::DefaultClientFactory;
269        use crate::config::Config;
270        use crate::web::AppState;
271        use axum::extract::State;
272        use axum::http::StatusCode;
273        use axum::response::IntoResponse;
274
275        let config = Config::default();
276        let http: std::sync::Arc<dyn crate::http::HttpClient> =
277            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
278        let factory = DefaultClientFactory {
279            chains_config: config.chains.clone(),
280            http,
281        };
282        let state = std::sync::Arc::new(AppState { config, factory });
283
284        let req = SaveConfigRequest {
285            api_keys: std::collections::HashMap::new(),
286            rpc_endpoints: std::collections::HashMap::new(),
287        };
288
289        let old_home = std::env::var_os("HOME");
290        let old_userprofile = std::env::var_os("USERPROFILE");
291        unsafe {
292            std::env::remove_var("HOME");
293            std::env::remove_var("USERPROFILE");
294        }
295
296        let response = handle_save(State(state), axum::Json(req))
297            .await
298            .into_response();
299
300        if let Some(h) = old_home {
301            unsafe { std::env::set_var("HOME", h) };
302        }
303        if let Some(u) = old_userprofile {
304            unsafe { std::env::set_var("USERPROFILE", u) };
305        }
306
307        if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
308            let body = axum::body::to_bytes(response.into_body(), 1_000_000)
309                .await
310                .unwrap();
311            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
312            assert!(
313                json["error"]
314                    .as_str()
315                    .unwrap()
316                    .contains("Cannot determine config path")
317                    || json["error"]
318                        .as_str()
319                        .unwrap()
320                        .contains("Failed to create config dir")
321                    || json["error"]
322                        .as_str()
323                        .unwrap()
324                        .contains("Failed to write config")
325            );
326        }
327    }
328
329    #[tokio::test]
330    async fn test_handle_save_config_create_dir_fails() {
331        use crate::chains::DefaultClientFactory;
332        use crate::config::Config;
333        use crate::web::AppState;
334        use axum::extract::State;
335        use axum::http::StatusCode;
336        use axum::response::IntoResponse;
337
338        let tmp = tempfile::tempdir().unwrap();
339        let fake_home = tmp.path().join("fake_home");
340        std::fs::create_dir_all(&fake_home).unwrap();
341        let config_as_file = fake_home.join(".config");
342        std::fs::File::create(&config_as_file).unwrap();
343
344        let config = Config::default();
345        let http: std::sync::Arc<dyn crate::http::HttpClient> =
346            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
347        let factory = DefaultClientFactory {
348            chains_config: config.chains.clone(),
349            http,
350        };
351        let state = std::sync::Arc::new(AppState { config, factory });
352
353        let req = SaveConfigRequest {
354            api_keys: std::collections::HashMap::new(),
355            rpc_endpoints: std::collections::HashMap::new(),
356        };
357
358        let old_home = std::env::var_os("HOME");
359        unsafe { std::env::set_var("HOME", &fake_home) };
360
361        let response = handle_save(State(state), axum::Json(req))
362            .await
363            .into_response();
364
365        if let Some(h) = old_home {
366            unsafe { std::env::set_var("HOME", h) };
367        } else {
368            unsafe { std::env::remove_var("HOME") };
369        }
370
371        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
372        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
373            .await
374            .unwrap();
375        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
376        assert!(
377            json["error"]
378                .as_str()
379                .unwrap()
380                .contains("Failed to create config dir")
381        );
382    }
383
384    #[tokio::test]
385    async fn test_handle_save_config_with_api_keys_and_rpc() {
386        use crate::chains::DefaultClientFactory;
387        use crate::config::Config;
388        use crate::web::AppState;
389        use axum::extract::State;
390        use axum::response::IntoResponse;
391
392        let config = Config::default();
393        let http: std::sync::Arc<dyn crate::http::HttpClient> =
394            std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
395        let factory = DefaultClientFactory {
396            chains_config: config.chains.clone(),
397            http,
398        };
399        let state = std::sync::Arc::new(AppState { config, factory });
400
401        let mut api_keys = std::collections::HashMap::new();
402        api_keys.insert("etherscan".to_string(), "test_key_abc".to_string());
403        api_keys.insert("polygonscan".to_string(), "test_key_def".to_string());
404        api_keys.insert("empty_key".to_string(), "".to_string()); // empty value - should be skipped
405
406        let mut rpc_endpoints = std::collections::HashMap::new();
407        rpc_endpoints.insert(
408            "ethereum_rpc".to_string(),
409            "https://eth.example.com".to_string(),
410        );
411        rpc_endpoints.insert("bsc_rpc".to_string(), "https://bsc.example.com".to_string());
412        rpc_endpoints.insert(
413            "solana_rpc".to_string(),
414            "https://sol.example.com".to_string(),
415        );
416        rpc_endpoints.insert(
417            "tron_api".to_string(),
418            "https://tron.example.com".to_string(),
419        );
420        rpc_endpoints.insert(
421            "unknown_key".to_string(),
422            "https://unknown.example.com".to_string(),
423        );
424        rpc_endpoints.insert("empty_rpc".to_string(), "".to_string()); // empty value
425
426        let req = SaveConfigRequest {
427            api_keys,
428            rpc_endpoints,
429        };
430
431        let response = handle_save(State(state), axum::Json(req))
432            .await
433            .into_response();
434        let status = response.status();
435        // May succeed or fail depending on filesystem permissions, but we cover the code paths
436        assert!(
437            status == axum::http::StatusCode::OK
438                || status == axum::http::StatusCode::INTERNAL_SERVER_ERROR
439        );
440    }
441}