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        })
247        .collect();
248
249    let upstream = Upstream {
250        id: Some(Uuid::new_v4()),
251        name: req.name,
252        description: None,
253        lb_strategy: req.lb_strategy.unwrap_or_default(),
254        backends,
255        health_check: HealthCheckConfig::default(),
256        enabled: true,
257    };
258
259    // TODO: Persist to database
260    let mut config = state.config.write().await;
261    config.upstreams.push(upstream.clone());
262
263    (StatusCode::CREATED, Json(ApiResponse::success(upstream)))
264}
265
266async fn update_upstream(
267    State(state): State<Arc<ProxyState>>,
268    Path(id): Path<Uuid>,
269    Json(req): Json<CreateUpstreamRequest>,
270) -> impl IntoResponse {
271    let mut config = state.config.write().await;
272
273    if let Some(upstream) = config.upstreams.iter_mut().find(|u| u.id == Some(id)) {
274        upstream.name = req.name;
275        upstream.lb_strategy = req.lb_strategy.unwrap_or_default();
276        // Note: backends update would need more sophisticated handling
277
278        // TODO: Persist to database
279        Json(ApiResponse::success(upstream.clone())).into_response()
280    } else {
281        (
282            StatusCode::NOT_FOUND,
283            Json(ApiResponse::<()>::error("Upstream not found")),
284        )
285            .into_response()
286    }
287}
288
289async fn delete_upstream(
290    State(state): State<Arc<ProxyState>>,
291    Path(id): Path<Uuid>,
292) -> impl IntoResponse {
293    let mut config = state.config.write().await;
294    let len_before = config.upstreams.len();
295    config.upstreams.retain(|u| u.id != Some(id));
296
297    if config.upstreams.len() < len_before {
298        // TODO: Delete from database
299        Json(ApiResponse::success(())).into_response()
300    } else {
301        (
302            StatusCode::NOT_FOUND,
303            Json(ApiResponse::<()>::error("Upstream not found")),
304        )
305            .into_response()
306    }
307}
308
309// Backend handlers
310
311async fn list_backends(
312    State(state): State<Arc<ProxyState>>,
313    Path(upstream_id): Path<Uuid>,
314) -> impl IntoResponse {
315    let config = state.config.read().await;
316    match config.upstreams.iter().find(|u| u.id == Some(upstream_id)) {
317        Some(upstream) => {
318            let backends = upstream.backends.clone();
319            Json(ApiResponse::success(backends)).into_response()
320        }
321        None => (
322            StatusCode::NOT_FOUND,
323            Json(ApiResponse::<()>::error("Upstream not found")),
324        )
325            .into_response(),
326    }
327}
328
329async fn add_backend(
330    State(state): State<Arc<ProxyState>>,
331    Path(upstream_id): Path<Uuid>,
332    Json(req): Json<BackendRequest>,
333) -> impl IntoResponse {
334    let mut config = state.config.write().await;
335
336    if let Some(upstream) = config
337        .upstreams
338        .iter_mut()
339        .find(|u| u.id == Some(upstream_id))
340    {
341        let backend = Backend {
342            id: Some(Uuid::new_v4()),
343            address: req.address,
344            weight: req.weight.unwrap_or(100),
345            scheme: req.scheme.unwrap_or_else(|| "http".to_string()),
346            enabled: true,
347        };
348
349        upstream.backends.push(backend.clone());
350
351        // TODO: Persist to database
352        (StatusCode::CREATED, Json(ApiResponse::success(backend))).into_response()
353    } else {
354        (
355            StatusCode::NOT_FOUND,
356            Json(ApiResponse::<()>::error("Upstream not found")),
357        )
358            .into_response()
359    }
360}
361
362async fn remove_backend(
363    State(state): State<Arc<ProxyState>>,
364    Path((upstream_id, backend_id)): Path<(Uuid, Uuid)>,
365) -> impl IntoResponse {
366    let mut config = state.config.write().await;
367
368    if let Some(upstream) = config
369        .upstreams
370        .iter_mut()
371        .find(|u| u.id == Some(upstream_id))
372    {
373        let len_before = upstream.backends.len();
374        upstream.backends.retain(|b| b.id != Some(backend_id));
375
376        if upstream.backends.len() < len_before {
377            // TODO: Delete from database
378            Json(ApiResponse::success(())).into_response()
379        } else {
380            (
381                StatusCode::NOT_FOUND,
382                Json(ApiResponse::<()>::error("Backend not found")),
383            )
384                .into_response()
385        }
386    } else {
387        (
388            StatusCode::NOT_FOUND,
389            Json(ApiResponse::<()>::error("Upstream not found")),
390        )
391            .into_response()
392    }
393}
394
395// Health handlers
396
397#[derive(Serialize)]
398pub struct HealthSummary {
399    pub total_backends: usize,
400    pub healthy_backends: usize,
401    pub unhealthy_backends: usize,
402}
403
404async fn health_status(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
405    let config = state.config.read().await;
406
407    let mut total = 0;
408    let mut healthy = 0;
409
410    for upstream in &config.upstreams {
411        for backend in &upstream.backends {
412            total += 1;
413            if let Some(id) = backend.id {
414                if state.health_checker.is_healthy(id) {
415                    healthy += 1;
416                }
417            }
418        }
419    }
420
421    Json(ApiResponse::success(HealthSummary {
422        total_backends: total,
423        healthy_backends: healthy,
424        unhealthy_backends: total - healthy,
425    }))
426}
427
428async fn backend_health(
429    State(state): State<Arc<ProxyState>>,
430    Path(backend_id): Path<Uuid>,
431) -> impl IntoResponse {
432    match state.health_checker.get_health(backend_id) {
433        Some(health) => Json(ApiResponse::success(health)).into_response(),
434        None => (
435            StatusCode::NOT_FOUND,
436            Json(ApiResponse::<()>::error("Backend not found")),
437        )
438            .into_response(),
439    }
440}
441
442// Config handlers
443
444async fn reload_config(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
445    state.config_reloader.request_reload().await;
446    Json(ApiResponse::success("Configuration reload requested"))
447}
448
449// Stats handlers
450
451#[derive(Serialize)]
452pub struct ProxyStats {
453    pub routes_count: usize,
454    pub upstreams_count: usize,
455    pub backends_count: usize,
456}
457
458async fn get_stats(State(state): State<Arc<ProxyState>>) -> impl IntoResponse {
459    let config = state.config.read().await;
460
461    let backends_count: usize = config.upstreams.iter().map(|u| u.backends.len()).sum();
462
463    Json(ApiResponse::success(ProxyStats {
464        routes_count: config.routes.len(),
465        upstreams_count: config.upstreams.len(),
466        backends_count,
467    }))
468}