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 factory = DefaultClientFactory {
206            chains_config: config.chains.clone(),
207        };
208        let state = std::sync::Arc::new(AppState { config, factory });
209        let response = handle(State(state)).await.into_response();
210        assert_eq!(response.status(), axum::http::StatusCode::OK);
211
212        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
213            .await
214            .unwrap();
215        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
216        assert!(json.get("config_path").is_some());
217        assert!(json.get("api_keys").is_some());
218        assert!(json.get("rpc_endpoints").is_some());
219        assert!(json.get("version").is_some());
220    }
221
222    #[tokio::test]
223    async fn test_handle_save_config() {
224        use crate::chains::DefaultClientFactory;
225        use crate::config::Config;
226        use crate::web::AppState;
227        use axum::extract::State;
228        use axum::response::IntoResponse;
229
230        let config = Config::default();
231        let factory = DefaultClientFactory {
232            chains_config: config.chains.clone(),
233        };
234        let state = std::sync::Arc::new(AppState { config, factory });
235        let req = SaveConfigRequest {
236            api_keys: std::collections::HashMap::new(),
237            rpc_endpoints: std::collections::HashMap::new(),
238        };
239        let response = handle_save(State(state), axum::Json(req))
240            .await
241            .into_response();
242        // May succeed or fail depending on filesystem
243        let status = response.status();
244        assert!(
245            status == axum::http::StatusCode::OK
246                || status == axum::http::StatusCode::INTERNAL_SERVER_ERROR
247        );
248    }
249
250    #[test]
251    fn test_save_config_request_debug() {
252        let req = SaveConfigRequest {
253            api_keys: std::collections::HashMap::new(),
254            rpc_endpoints: std::collections::HashMap::new(),
255        };
256        let debug = format!("{:?}", req);
257        assert!(debug.contains("SaveConfigRequest"));
258    }
259
260    #[tokio::test]
261    async fn test_handle_save_config_path_none() {
262        use crate::chains::DefaultClientFactory;
263        use crate::config::Config;
264        use crate::web::AppState;
265        use axum::extract::State;
266        use axum::http::StatusCode;
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
275        let req = SaveConfigRequest {
276            api_keys: std::collections::HashMap::new(),
277            rpc_endpoints: std::collections::HashMap::new(),
278        };
279
280        let old_home = std::env::var_os("HOME");
281        let old_userprofile = std::env::var_os("USERPROFILE");
282        unsafe {
283            std::env::remove_var("HOME");
284            std::env::remove_var("USERPROFILE");
285        }
286
287        let response = handle_save(State(state), axum::Json(req))
288            .await
289            .into_response();
290
291        if let Some(h) = old_home {
292            unsafe { std::env::set_var("HOME", h) };
293        }
294        if let Some(u) = old_userprofile {
295            unsafe { std::env::set_var("USERPROFILE", u) };
296        }
297
298        if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
299            let body = axum::body::to_bytes(response.into_body(), 1_000_000)
300                .await
301                .unwrap();
302            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
303            assert!(
304                json["error"]
305                    .as_str()
306                    .unwrap()
307                    .contains("Cannot determine config path")
308                    || json["error"]
309                        .as_str()
310                        .unwrap()
311                        .contains("Failed to create config dir")
312                    || json["error"]
313                        .as_str()
314                        .unwrap()
315                        .contains("Failed to write config")
316            );
317        }
318    }
319
320    #[tokio::test]
321    async fn test_handle_save_config_create_dir_fails() {
322        use crate::chains::DefaultClientFactory;
323        use crate::config::Config;
324        use crate::web::AppState;
325        use axum::extract::State;
326        use axum::http::StatusCode;
327        use axum::response::IntoResponse;
328
329        let tmp = tempfile::tempdir().unwrap();
330        let fake_home = tmp.path().join("fake_home");
331        std::fs::create_dir_all(&fake_home).unwrap();
332        let config_as_file = fake_home.join(".config");
333        std::fs::File::create(&config_as_file).unwrap();
334
335        let config = Config::default();
336        let factory = DefaultClientFactory {
337            chains_config: config.chains.clone(),
338        };
339        let state = std::sync::Arc::new(AppState { config, factory });
340
341        let req = SaveConfigRequest {
342            api_keys: std::collections::HashMap::new(),
343            rpc_endpoints: std::collections::HashMap::new(),
344        };
345
346        let old_home = std::env::var_os("HOME");
347        unsafe { std::env::set_var("HOME", &fake_home) };
348
349        let response = handle_save(State(state), axum::Json(req))
350            .await
351            .into_response();
352
353        if let Some(h) = old_home {
354            unsafe { std::env::set_var("HOME", h) };
355        } else {
356            unsafe { std::env::remove_var("HOME") };
357        }
358
359        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
360        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
361            .await
362            .unwrap();
363        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
364        assert!(
365            json["error"]
366                .as_str()
367                .unwrap()
368                .contains("Failed to create config dir")
369        );
370    }
371
372    #[tokio::test]
373    async fn test_handle_save_config_with_api_keys_and_rpc() {
374        use crate::chains::DefaultClientFactory;
375        use crate::config::Config;
376        use crate::web::AppState;
377        use axum::extract::State;
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
386        let mut api_keys = std::collections::HashMap::new();
387        api_keys.insert("etherscan".to_string(), "test_key_abc".to_string());
388        api_keys.insert("polygonscan".to_string(), "test_key_def".to_string());
389        api_keys.insert("empty_key".to_string(), "".to_string()); // empty value - should be skipped
390
391        let mut rpc_endpoints = std::collections::HashMap::new();
392        rpc_endpoints.insert(
393            "ethereum_rpc".to_string(),
394            "https://eth.example.com".to_string(),
395        );
396        rpc_endpoints.insert("bsc_rpc".to_string(), "https://bsc.example.com".to_string());
397        rpc_endpoints.insert(
398            "solana_rpc".to_string(),
399            "https://sol.example.com".to_string(),
400        );
401        rpc_endpoints.insert(
402            "tron_api".to_string(),
403            "https://tron.example.com".to_string(),
404        );
405        rpc_endpoints.insert(
406            "unknown_key".to_string(),
407            "https://unknown.example.com".to_string(),
408        );
409        rpc_endpoints.insert("empty_rpc".to_string(), "".to_string()); // empty value
410
411        let req = SaveConfigRequest {
412            api_keys,
413            rpc_endpoints,
414        };
415
416        let response = handle_save(State(state), axum::Json(req))
417            .await
418            .into_response();
419        let status = response.status();
420        // May succeed or fail depending on filesystem permissions, but we cover the code paths
421        assert!(
422            status == axum::http::StatusCode::OK
423                || status == axum::http::StatusCode::INTERNAL_SERVER_ERROR
424        );
425    }
426}