Skip to main content

tiny_proxy/api/
endpoints.rs

1//! API endpoints for proxy management
2
3use anyhow::Result;
4use arc_swap::ArcSwap;
5use bytes::Bytes;
6use http_body::Body;
7use http_body_util::{BodyExt, Full};
8use hyper::{Request, Response};
9use std::sync::Arc;
10use tracing::{error, info};
11
12use crate::config::Config;
13
14/// Handle GET /config
15pub async fn handle_get_config<B>(
16    _req: Request<B>,
17    config: Arc<ArcSwap<Config>>,
18) -> Result<Response<Full<Bytes>>>
19where
20    B: Body,
21{
22    let config = config.load_full();
23
24    let json = serde_json::to_string_pretty(&*config)
25        .unwrap_or_else(|_| r#"{"error": "Failed to serialize config"}"#.to_string());
26
27    info!("GET /config - Returning configuration");
28
29    let response = Response::builder()
30        .status(200)
31        .header("Content-Type", "application/json")
32        .body(Full::new(Bytes::from(json)))
33        .expect("static response build");
34
35    Ok(response)
36}
37
38/// Handle POST /config
39///
40/// Accepts a JSON body representing the new configuration and atomically
41/// replaces the current configuration. The new config takes effect
42/// immediately for all new incoming proxy connections.
43///
44/// # Request Body
45///
46/// JSON representation of the full `Config` struct, e.g.:
47/// ```json
48/// {
49///   "sites": {
50///     "localhost:8080": {
51///       "address": "localhost:8080",
52///       "directives": [
53///         { "ReverseProxy": { "to": "localhost:9001" } }
54///       ]
55///     }
56///   }
57/// }
58/// ```
59pub async fn handle_post_config<B>(
60    req: Request<B>,
61    config: Arc<ArcSwap<Config>>,
62) -> Result<Response<Full<Bytes>>>
63where
64    B: Body,
65    B::Error: std::fmt::Display,
66{
67    let body_bytes = match BodyExt::collect(req.into_body()).await {
68        Ok(collected) => collected.to_bytes(),
69        Err(e) => {
70            let error_json = serde_json::json!({
71                "status": "error",
72                "message": format!("Failed to read request body: {}", e)
73            });
74            let response = Response::builder()
75                .status(400)
76                .header("Content-Type", "application/json")
77                .body(Full::new(Bytes::from(
78                    serde_json::to_string(&error_json).expect("json!() is always valid"),
79                )))
80                .expect("static response build");
81            return Ok(response);
82        }
83    };
84
85    let body_str = match std::str::from_utf8(&body_bytes) {
86        Ok(s) => s,
87        Err(_) => {
88            let error_json = serde_json::json!({
89                "status": "error",
90                "message": "Invalid UTF-8 in request body"
91            });
92            let response = Response::builder()
93                .status(400)
94                .header("Content-Type", "application/json")
95                .body(Full::new(Bytes::from(
96                    serde_json::to_string(&error_json).expect("json!() is always valid"),
97                )))
98                .expect("static response build");
99            return Ok(response);
100        }
101    };
102
103    // Parse JSON body into Config
104    let new_config: Config = match serde_json::from_str(body_str) {
105        Ok(config) => config,
106        Err(e) => {
107            error!("Failed to parse config JSON: {}", e);
108            let error_json = serde_json::json!({
109                "status": "error",
110                "message": format!("Invalid configuration JSON: {}", e)
111            });
112            let response = Response::builder()
113                .status(400)
114                .header("Content-Type", "application/json")
115                .body(Full::new(Bytes::from(
116                    serde_json::to_string(&error_json).expect("json!() is always valid"),
117                )))
118                .expect("static response build");
119            return Ok(response);
120        }
121    };
122
123    // Atomically replace the configuration
124    {
125        let sites_count = new_config.sites.len();
126        config.store(Arc::new(new_config));
127        info!(
128            "POST /config - Configuration updated successfully ({} sites)",
129            sites_count
130        );
131    }
132
133    let response = Response::builder()
134        .status(200)
135        .header("Content-Type", "application/json")
136        .body(Full::new(Bytes::from(
137            r#"{"status": "success", "message": "Configuration updated"}"#.to_string(),
138        )))
139        .expect("static response build");
140
141    Ok(response)
142}
143
144/// Handle GET /health
145pub async fn handle_health_check<B>(_req: Request<B>) -> Result<Response<Full<Bytes>>>
146where
147    B: Body,
148{
149    info!("GET /health - Health check");
150
151    let health = serde_json::json!({
152        "status": "healthy",
153        "service": "tiny-proxy",
154        "version": env!("CARGO_PKG_VERSION")
155    });
156
157    let response = Response::builder()
158        .status(200)
159        .header("Content-Type", "application/json")
160        .body(Full::new(Bytes::from(
161            serde_json::to_string(&health).expect("json!() is always valid"),
162        )))
163        .expect("static response build");
164
165    Ok(response)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use http_body_util::Empty;
172    use std::collections::HashMap;
173    use std::sync::Arc;
174
175    #[tokio::test]
176    async fn test_handle_health_check() {
177        let req: Request<Empty<Bytes>> = Request::builder().body(Empty::new()).unwrap();
178
179        let response = handle_health_check(req).await.unwrap();
180        assert_eq!(response.status(), 200);
181    }
182
183    #[tokio::test]
184    async fn test_handle_get_config() {
185        let config = Arc::new(ArcSwap::from_pointee(Config {
186            sites: HashMap::new(),
187        }));
188
189        let req: Request<Empty<Bytes>> = Request::builder().body(Empty::new()).unwrap();
190
191        let response = handle_get_config(req, config).await.unwrap();
192        assert_eq!(response.status(), 200);
193    }
194
195    #[tokio::test]
196    async fn test_handle_post_config_valid_json() {
197        let config = Arc::new(ArcSwap::from_pointee(Config {
198            sites: HashMap::new(),
199        }));
200
201        let new_config_json = r#"{
202            "sites": {
203                "localhost:8080": {
204                    "address": "localhost:8080",
205                    "directives": [
206                        {"ReverseProxy": {"to": "localhost:9001"}}
207                    ]
208                }
209            }
210        }"#;
211
212        let req = Request::builder()
213            .method("POST")
214            .uri("/config")
215            .body(Full::new(Bytes::from(new_config_json.to_string())))
216            .unwrap();
217
218        let response = handle_post_config(req, config.clone()).await.unwrap();
219        assert_eq!(response.status(), 200);
220
221        // Verify config was actually updated
222        let guard = config.load_full();
223        assert_eq!(guard.sites.len(), 1);
224        assert!(guard.sites.contains_key("localhost:8080"));
225    }
226
227    #[tokio::test]
228    async fn test_handle_post_config_invalid_json() {
229        let config = Arc::new(ArcSwap::from_pointee(Config {
230            sites: HashMap::new(),
231        }));
232
233        let req = Request::builder()
234            .method("POST")
235            .uri("/config")
236            .body(Full::new(Bytes::from("not valid json")))
237            .unwrap();
238
239        let response = handle_post_config(req, config.clone()).await.unwrap();
240        assert_eq!(response.status(), 400);
241
242        // Verify config was NOT updated
243        let guard = config.load_full();
244        assert_eq!(guard.sites.len(), 0);
245    }
246
247    #[tokio::test]
248    async fn test_handle_post_config_empty_body() {
249        let config = Arc::new(ArcSwap::from_pointee(Config {
250            sites: HashMap::new(),
251        }));
252
253        let req: Request<Empty<Bytes>> = Request::builder()
254            .method("POST")
255            .uri("/config")
256            .body(Empty::new())
257            .unwrap();
258
259        let response = handle_post_config(req, config).await.unwrap();
260        assert_eq!(response.status(), 400);
261    }
262}