Skip to main content

postrust_proxy/admin/
api.rs

1//! REST API endpoints for proxy management.
2
3use crate::config::{Backend, HealthCheckConfig, LoadBalanceStrategy, Route, RouteMatch, Upstream};
4use crate::ProxyState;
5use axum::{
6    extract::{Path, State},
7    http::StatusCode,
8    response::IntoResponse,
9    routing::{delete, get, post, put},
10    Json, Router,
11};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::Arc;
15use uuid::Uuid;
16
17/// API response wrapper.
18#[derive(Serialize)]
19pub struct ApiResponse<T> {
20    pub success: bool,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub data: Option<T>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub error: Option<String>,
25}
26
27impl<T: Serialize> ApiResponse<T> {
28    pub fn success(data: T) -> Self {
29        Self {
30            success: true,
31            data: Some(data),
32            error: None,
33        }
34    }
35
36    pub fn error(message: impl Into<String>) -> ApiResponse<()> {
37        ApiResponse {
38            success: false,
39            data: None,
40            error: Some(message.into()),
41        }
42    }
43}
44
45/// Create the admin API router.
46pub fn admin_router() -> Router<Arc<ProxyState>> {
47    Router::new()
48        // Routes
49        .route("/routes", get(list_routes))
50        .route("/routes", post(create_route))
51        .route("/routes/:id", get(get_route))
52        .route("/routes/:id", put(update_route))
53        .route("/routes/:id", delete(delete_route))
54        // Upstreams
55        .route("/upstreams", get(list_upstreams))
56        .route("/upstreams", post(create_upstream))
57        .route("/upstreams/:id", get(get_upstream))
58        .route("/upstreams/:id", put(update_upstream))
59        .route("/upstreams/:id", delete(delete_upstream))
60        // Backends
61        .route("/upstreams/:id/backends", get(list_backends))
62        .route("/upstreams/:id/backends", post(add_backend))
63        .route(
64            "/upstreams/:id/backends/:backend_id",
65            delete(remove_backend),
66        )
67        // Health
68        .route("/health", get(health_status))
69        .route("/health/:backend_id", get(backend_health))
70        // Config
71        .route("/config/reload", post(reload_config))
72        // Stats
73        .route("/stats", get(get_stats))
74}
75
76// Route handlers
77
78async fn list_routes(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
79    let config = state.config.read().await;
80    let routes = config.routes.clone();
81    Json(ApiResponse::success(routes))
82}
83
84async fn get_route(
85    State(state): State<Arc<ProxyState>>,
86    Path(id): Path<Uuid>,
87) -> impl IntoResponse {
88    let config = state.config.read().await;
89    match config.routes.iter().find(|r| r.id == Some(id)) {
90        Some(route) => Json(ApiResponse::success(route)).into_response(),
91        None => (
92            StatusCode::NOT_FOUND,
93            Json(ApiResponse::<()>::error("Route not found")),
94        )
95            .into_response(),
96    }
97}
98
99#[derive(Deserialize)]
100pub struct CreateRouteRequest {
101    pub name: String,
102    pub host: Option<String>,
103    pub path: Option<String>,
104    pub upstream: String,
105    pub strip_path: Option<bool>,
106    pub priority: Option<i32>,
107    pub add_headers: Option<HashMap<String, String>>,
108    pub remove_headers: Option<Vec<String>>,
109}
110
111async fn create_route(
112    State(state): State<Arc<ProxyState>>,
113    Json(req): Json<CreateRouteRequest>,
114) -> impl IntoResponse {
115    let route = Route {
116        id: Some(Uuid::new_v4()),
117        name: req.name,
118        description: None,
119        match_: RouteMatch {
120            host: req.host,
121            path: req.path,
122            path_type: Default::default(),
123            headers: HashMap::new(),
124            methods: None,
125        },
126        priority: req.priority.unwrap_or(100),
127        upstream: req.upstream,
128        strip_path: req.strip_path.unwrap_or(false),
129        add_headers: req.add_headers.unwrap_or_default(),
130        remove_headers: req.remove_headers.unwrap_or_default(),
131        rate_limit: None,
132        timeout_secs: 30,
133        retry_count: 0,
134        enabled: true,
135    };
136
137    // TODO: Persist to database
138    let mut config = state.config.write().await;
139    config.routes.push(route.clone());
140
141    (StatusCode::CREATED, Json(ApiResponse::success(route)))
142}
143
144async fn update_route(
145    State(state): State<Arc<ProxyState>>,
146    Path(id): Path<Uuid>,
147    Json(req): Json<CreateRouteRequest>,
148) -> impl IntoResponse {
149    let mut config = state.config.write().await;
150
151    if let Some(route) = config.routes.iter_mut().find(|r| r.id == Some(id)) {
152        route.name = req.name;
153        route.match_.host = req.host;
154        route.match_.path = req.path;
155        route.upstream = req.upstream;
156        route.strip_path = req.strip_path.unwrap_or(false);
157        route.priority = req.priority.unwrap_or(100);
158        if let Some(add_headers) = req.add_headers {
159            route.add_headers = add_headers;
160        }
161        if let Some(remove_headers) = req.remove_headers {
162            route.remove_headers = remove_headers;
163        }
164
165        // TODO: Persist to database
166        Json(ApiResponse::success(route.clone())).into_response()
167    } else {
168        (
169            StatusCode::NOT_FOUND,
170            Json(ApiResponse::<()>::error("Route not found")),
171        )
172            .into_response()
173    }
174}
175
176async fn delete_route(
177    State(state): State<Arc<ProxyState>>,
178    Path(id): Path<Uuid>,
179) -> impl IntoResponse {
180    let mut config = state.config.write().await;
181    let len_before = config.routes.len();
182    config.routes.retain(|r| r.id != Some(id));
183
184    if config.routes.len() < len_before {
185        // TODO: Delete from database
186        Json(ApiResponse::success(())).into_response()
187    } else {
188        (
189            StatusCode::NOT_FOUND,
190            Json(ApiResponse::<()>::error("Route not found")),
191        )
192            .into_response()
193    }
194}
195
196// Upstream handlers
197
198async fn list_upstreams(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
199    let config = state.config.read().await;
200    let upstreams = config.upstreams.clone();
201    Json(ApiResponse::success(upstreams))
202}
203
204async fn get_upstream(
205    State(state): State<Arc<ProxyState>>,
206    Path(id): Path<Uuid>,
207) -> impl IntoResponse {
208    let config = state.config.read().await;
209    match config.upstreams.iter().find(|u| u.id == Some(id)) {
210        Some(upstream) => Json(ApiResponse::success(upstream)).into_response(),
211        None => (
212            StatusCode::NOT_FOUND,
213            Json(ApiResponse::<()>::error("Upstream not found")),
214        )
215            .into_response(),
216    }
217}
218
219#[derive(Deserialize)]
220pub struct CreateUpstreamRequest {
221    pub name: String,
222    pub lb_strategy: Option<LoadBalanceStrategy>,
223    pub backends: Vec<BackendRequest>,
224}
225
226#[derive(Deserialize)]
227pub struct BackendRequest {
228    pub address: String,
229    pub weight: Option<u32>,
230    pub scheme: Option<String>,
231}
232
233async fn create_upstream(
234    State(state): State<Arc<ProxyState>>,
235    Json(req): Json<CreateUpstreamRequest>,
236) -> impl IntoResponse {
237    let backends: Vec<Backend> = req
238        .backends
239        .into_iter()
240        .map(|b| Backend {
241            id: Some(Uuid::new_v4()),
242            address: b.address,
243            weight: b.weight.unwrap_or(100),
244            scheme: b.scheme.unwrap_or_else(|| "http".to_string()),
245            enabled: true,
246            http_version: Default::default(),
247        })
248        .collect();
249
250    let upstream = Upstream {
251        id: Some(Uuid::new_v4()),
252        name: req.name,
253        description: None,
254        lb_strategy: req.lb_strategy.unwrap_or_default(),
255        backends,
256        health_check: HealthCheckConfig::default(),
257        enabled: true,
258    };
259
260    // TODO: Persist to database
261    let mut config = state.config.write().await;
262    config.upstreams.push(upstream.clone());
263
264    (StatusCode::CREATED, Json(ApiResponse::success(upstream)))
265}
266
267async fn update_upstream(
268    State(state): State<Arc<ProxyState>>,
269    Path(id): Path<Uuid>,
270    Json(req): Json<CreateUpstreamRequest>,
271) -> impl IntoResponse {
272    let mut config = state.config.write().await;
273
274    if let Some(upstream) = config.upstreams.iter_mut().find(|u| u.id == Some(id)) {
275        upstream.name = req.name;
276        upstream.lb_strategy = req.lb_strategy.unwrap_or_default();
277        // Note: backends update would need more sophisticated handling
278
279        // TODO: Persist to database
280        Json(ApiResponse::success(upstream.clone())).into_response()
281    } else {
282        (
283            StatusCode::NOT_FOUND,
284            Json(ApiResponse::<()>::error("Upstream not found")),
285        )
286            .into_response()
287    }
288}
289
290async fn delete_upstream(
291    State(state): State<Arc<ProxyState>>,
292    Path(id): Path<Uuid>,
293) -> impl IntoResponse {
294    let mut config = state.config.write().await;
295    let len_before = config.upstreams.len();
296    config.upstreams.retain(|u| u.id != Some(id));
297
298    if config.upstreams.len() < len_before {
299        // TODO: Delete from database
300        Json(ApiResponse::success(())).into_response()
301    } else {
302        (
303            StatusCode::NOT_FOUND,
304            Json(ApiResponse::<()>::error("Upstream not found")),
305        )
306            .into_response()
307    }
308}
309
310// Backend handlers
311
312async fn list_backends(
313    State(state): State<Arc<ProxyState>>,
314    Path(upstream_id): Path<Uuid>,
315) -> impl IntoResponse {
316    let config = state.config.read().await;
317    match config.upstreams.iter().find(|u| u.id == Some(upstream_id)) {
318        Some(upstream) => {
319            let backends = upstream.backends.clone();
320            Json(ApiResponse::success(backends)).into_response()
321        }
322        None => (
323            StatusCode::NOT_FOUND,
324            Json(ApiResponse::<()>::error("Upstream not found")),
325        )
326            .into_response(),
327    }
328}
329
330async fn add_backend(
331    State(state): State<Arc<ProxyState>>,
332    Path(upstream_id): Path<Uuid>,
333    Json(req): Json<BackendRequest>,
334) -> impl IntoResponse {
335    let mut config = state.config.write().await;
336
337    if let Some(upstream) = config
338        .upstreams
339        .iter_mut()
340        .find(|u| u.id == Some(upstream_id))
341    {
342        let backend = Backend {
343            id: Some(Uuid::new_v4()),
344            address: req.address,
345            weight: req.weight.unwrap_or(100),
346            scheme: req.scheme.unwrap_or_else(|| "http".to_string()),
347            enabled: true,
348            http_version: Default::default(),
349        };
350
351        upstream.backends.push(backend.clone());
352
353        // TODO: Persist to database
354        (StatusCode::CREATED, Json(ApiResponse::success(backend))).into_response()
355    } else {
356        (
357            StatusCode::NOT_FOUND,
358            Json(ApiResponse::<()>::error("Upstream not found")),
359        )
360            .into_response()
361    }
362}
363
364async fn remove_backend(
365    State(state): State<Arc<ProxyState>>,
366    Path((upstream_id, backend_id)): Path<(Uuid, Uuid)>,
367) -> impl IntoResponse {
368    let mut config = state.config.write().await;
369
370    if let Some(upstream) = config
371        .upstreams
372        .iter_mut()
373        .find(|u| u.id == Some(upstream_id))
374    {
375        let len_before = upstream.backends.len();
376        upstream.backends.retain(|b| b.id != Some(backend_id));
377
378        if upstream.backends.len() < len_before {
379            // TODO: Delete from database
380            Json(ApiResponse::success(())).into_response()
381        } else {
382            (
383                StatusCode::NOT_FOUND,
384                Json(ApiResponse::<()>::error("Backend not found")),
385            )
386                .into_response()
387        }
388    } else {
389        (
390            StatusCode::NOT_FOUND,
391            Json(ApiResponse::<()>::error("Upstream not found")),
392        )
393            .into_response()
394    }
395}
396
397// Health handlers
398
399#[derive(Serialize)]
400pub struct HealthSummary {
401    pub total_backends: usize,
402    pub healthy_backends: usize,
403    pub unhealthy_backends: usize,
404}
405
406async fn health_status(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
407    let config = state.config.read().await;
408
409    let mut total = 0;
410    let mut healthy = 0;
411
412    for upstream in &config.upstreams {
413        for backend in &upstream.backends {
414            total += 1;
415            if let Some(id) = backend.id {
416                if state.health_checker.is_healthy(id) {
417                    healthy += 1;
418                }
419            }
420        }
421    }
422
423    Json(ApiResponse::success(HealthSummary {
424        total_backends: total,
425        healthy_backends: healthy,
426        unhealthy_backends: total - healthy,
427    }))
428}
429
430async fn backend_health(
431    State(state): State<Arc<ProxyState>>,
432    Path(backend_id): Path<Uuid>,
433) -> impl IntoResponse {
434    match state.health_checker.get_health(backend_id) {
435        Some(health) => Json(ApiResponse::success(health)).into_response(),
436        None => (
437            StatusCode::NOT_FOUND,
438            Json(ApiResponse::<()>::error("Backend not found")),
439        )
440            .into_response(),
441    }
442}
443
444// Config handlers
445
446async fn reload_config(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
447    state.config_reloader.request_reload().await;
448    Json(ApiResponse::success("Configuration reload requested"))
449}
450
451// Stats handlers
452
453#[derive(Serialize)]
454pub struct ProxyStats {
455    pub routes_count: usize,
456    pub upstreams_count: usize,
457    pub backends_count: usize,
458}
459
460async fn get_stats(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
461    let config = state.config.read().await;
462
463    let backends_count: usize = config.upstreams.iter().map(|u| u.backends.len()).sum();
464
465    Json(ApiResponse::success(ProxyStats {
466        routes_count: config.routes.len(),
467        upstreams_count: config.upstreams.len(),
468        backends_count,
469    }))
470}