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    #[tokio::test]
251    async fn test_handle_save_config_with_api_keys_and_rpc() {
252        use crate::chains::DefaultClientFactory;
253        use crate::config::Config;
254        use crate::web::AppState;
255        use axum::extract::State;
256        use axum::response::IntoResponse;
257
258        let config = Config::default();
259        let factory = DefaultClientFactory {
260            chains_config: config.chains.clone(),
261        };
262        let state = std::sync::Arc::new(AppState { config, factory });
263
264        let mut api_keys = std::collections::HashMap::new();
265        api_keys.insert("etherscan".to_string(), "test_key_abc".to_string());
266        api_keys.insert("polygonscan".to_string(), "test_key_def".to_string());
267        api_keys.insert("empty_key".to_string(), "".to_string()); // empty value - should be skipped
268
269        let mut rpc_endpoints = std::collections::HashMap::new();
270        rpc_endpoints.insert(
271            "ethereum_rpc".to_string(),
272            "https://eth.example.com".to_string(),
273        );
274        rpc_endpoints.insert("bsc_rpc".to_string(), "https://bsc.example.com".to_string());
275        rpc_endpoints.insert(
276            "solana_rpc".to_string(),
277            "https://sol.example.com".to_string(),
278        );
279        rpc_endpoints.insert(
280            "tron_api".to_string(),
281            "https://tron.example.com".to_string(),
282        );
283        rpc_endpoints.insert(
284            "unknown_key".to_string(),
285            "https://unknown.example.com".to_string(),
286        );
287        rpc_endpoints.insert("empty_rpc".to_string(), "".to_string()); // empty value
288
289        let req = SaveConfigRequest {
290            api_keys,
291            rpc_endpoints,
292        };
293
294        let response = handle_save(State(state), axum::Json(req))
295            .await
296            .into_response();
297        let status = response.status();
298        // May succeed or fail depending on filesystem permissions, but we cover the code paths
299        assert!(
300            status == axum::http::StatusCode::OK
301                || status == axum::http::StatusCode::INTERNAL_SERVER_ERROR
302        );
303    }
304}