use actix_web::{
HttpRequest, Responder, delete, get, patch, post,
web::{self, Data, Json, Path},
};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::AppState;
use crate::api::auth::authorize_static_admin_key;
use crate::api::response::{api_created, api_success, bad_request, not_found};
use crate::data::service_routes::{
PatchPublicServiceRouteParams, SavePublicServiceRouteParams, create_public_service_route,
get_public_service_route_by_key, list_public_service_routes, patch_public_service_route,
soft_delete_public_service_route,
};
use super::{
client_catalog_pool, database_error_response, normalize_public_route_target_url,
normalize_route_key,
};
const SERVICE_ROUTE_LOCAL_PATH: &str = "local_path";
const SERVICE_ROUTE_HTTP_UPSTREAM: &str = "http_upstream";
#[derive(Debug, Deserialize)]
struct SavePublicServiceRouteRequest {
route_key: String,
service_key: String,
target_kind: String,
#[serde(default)]
local_path_prefix: Option<String>,
#[serde(default)]
target_url: Option<String>,
#[serde(default = "default_true")]
is_active: bool,
#[serde(default)]
metadata: Option<Value>,
}
#[derive(Debug, Deserialize)]
struct PatchPublicServiceRouteRequest {
#[serde(default)]
target_kind: Option<String>,
#[serde(default)]
local_path_prefix: Option<String>,
#[serde(default)]
target_url: Option<String>,
#[serde(default)]
is_active: Option<bool>,
#[serde(default)]
metadata: Option<Value>,
}
fn default_true() -> bool {
true
}
fn normalize_service_key(service_key: &str) -> Result<String, actix_web::HttpResponse> {
let normalized = service_key.trim().to_ascii_lowercase();
let canonical = match normalized.as_str() {
"auth" => "auth",
"storage" => "storage",
"db" => "db",
"realtime" | "real-time" => "realtime",
_ => {
return Err(bad_request(
"Invalid service_key",
"service_key must be one of: auth, storage, db, realtime.",
));
}
};
Ok(canonical.to_string())
}
fn normalize_target_kind(target_kind: &str) -> Result<String, actix_web::HttpResponse> {
let normalized = target_kind.trim().to_ascii_lowercase();
match normalized.as_str() {
SERVICE_ROUTE_LOCAL_PATH | SERVICE_ROUTE_HTTP_UPSTREAM => Ok(normalized),
_ => Err(bad_request(
"Invalid target_kind",
"target_kind must be one of: local_path, http_upstream.",
)),
}
}
fn normalize_local_path_prefix(
value: Option<&str>,
) -> Result<Option<String>, actix_web::HttpResponse> {
let Some(raw) = value else {
return Ok(None);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(bad_request(
"Invalid local_path_prefix",
"local_path_prefix must not be empty when provided.",
));
}
if !trimmed.starts_with('/') {
return Err(bad_request(
"Invalid local_path_prefix",
"local_path_prefix must start with '/'.",
));
}
if trimmed.contains('?') || trimmed.contains('#') {
return Err(bad_request(
"Invalid local_path_prefix",
"local_path_prefix must be a path prefix without query parameters or fragments.",
));
}
if trimmed == "/" {
return Ok(Some("/".to_string()));
}
Ok(Some(trimmed.trim_end_matches('/').to_string()))
}
fn validate_target_fields(
target_kind: &str,
local_path_prefix: Option<&str>,
target_url: Option<&str>,
) -> Result<(), actix_web::HttpResponse> {
match target_kind {
SERVICE_ROUTE_LOCAL_PATH if local_path_prefix.is_none() => Err(bad_request(
"Missing local_path_prefix",
"local_path_prefix is required when target_kind is local_path.",
)),
SERVICE_ROUTE_HTTP_UPSTREAM if target_url.is_none() => Err(bad_request(
"Missing target_url",
"target_url is required when target_kind is http_upstream.",
)),
_ => Ok(()),
}
}
#[get("/admin/service-routes")]
async fn admin_list_public_service_routes(
req: HttpRequest,
app_state: Data<AppState>,
) -> impl Responder {
if let Err(resp) = authorize_static_admin_key(&req) {
return resp;
}
let pool = match client_catalog_pool(app_state.get_ref()) {
Ok(pool) => pool,
Err(resp) => return resp,
};
match list_public_service_routes(&pool).await {
Ok(routes) => api_success("Listed service routes", json!({ "routes": routes })),
Err(err) => database_error_response("Failed to list service routes", err),
}
}
#[post("/admin/service-routes")]
async fn admin_create_public_service_route(
req: HttpRequest,
app_state: Data<AppState>,
body: Json<SavePublicServiceRouteRequest>,
) -> impl Responder {
if let Err(resp) = authorize_static_admin_key(&req) {
return resp;
}
let pool = match client_catalog_pool(app_state.get_ref()) {
Ok(pool) => pool,
Err(resp) => return resp,
};
let route_key = match normalize_route_key(&body.route_key) {
Ok(value) => value,
Err(resp) => return resp,
};
let service_key = match normalize_service_key(&body.service_key) {
Ok(value) => value,
Err(resp) => return resp,
};
let target_kind = match normalize_target_kind(&body.target_kind) {
Ok(value) => value,
Err(resp) => return resp,
};
let local_path_prefix = match normalize_local_path_prefix(body.local_path_prefix.as_deref()) {
Ok(value) => value,
Err(resp) => return resp,
};
let target_url = match normalize_public_route_target_url(body.target_url.as_deref()) {
Ok(value) => value,
Err(resp) => return resp,
};
if let Err(resp) = validate_target_fields(
&target_kind,
local_path_prefix.as_deref(),
target_url.as_deref(),
) {
return resp;
}
let params = SavePublicServiceRouteParams {
route_key,
service_key,
target_kind,
local_path_prefix,
target_url,
is_active: body.is_active,
metadata: body.metadata.clone().unwrap_or_else(|| json!({})),
};
match create_public_service_route(&pool, params).await {
Ok(route) => api_created("Created service route", json!({ "route": route })),
Err(err) => database_error_response("Failed to create service route", err),
}
}
#[patch("/admin/service-routes/{route_key}/{service_key}")]
async fn admin_patch_public_service_route(
req: HttpRequest,
app_state: Data<AppState>,
path: Path<(String, String)>,
body: Json<PatchPublicServiceRouteRequest>,
) -> impl Responder {
if let Err(resp) = authorize_static_admin_key(&req) {
return resp;
}
let pool = match client_catalog_pool(app_state.get_ref()) {
Ok(pool) => pool,
Err(resp) => return resp,
};
let (raw_route_key, raw_service_key) = path.into_inner();
let route_key = match normalize_route_key(&raw_route_key) {
Ok(value) => value,
Err(resp) => return resp,
};
let service_key = match normalize_service_key(&raw_service_key) {
Ok(value) => value,
Err(resp) => return resp,
};
let Some(existing) =
(match get_public_service_route_by_key(&pool, &route_key, &service_key).await {
Ok(route) => route,
Err(err) => return database_error_response("Failed to load service route", err),
})
else {
return not_found(
"Service route not found",
format!(
"No service route exists for route_key '{}' and service_key '{}'.",
route_key, service_key
),
);
};
let normalized_target_kind = match body.target_kind.as_deref() {
Some(value) => match normalize_target_kind(value) {
Ok(kind) => Some(kind),
Err(resp) => return resp,
},
None => None,
};
let normalized_local_path_prefix =
match normalize_local_path_prefix(body.local_path_prefix.as_deref()) {
Ok(value) => value,
Err(resp) => return resp,
};
let normalized_target_url = match normalize_public_route_target_url(body.target_url.as_deref())
{
Ok(value) => value,
Err(resp) => return resp,
};
let next_target_kind = normalized_target_kind
.clone()
.unwrap_or_else(|| existing.target_kind.clone());
let next_local_path_prefix = normalized_local_path_prefix
.clone()
.or_else(|| existing.local_path_prefix.clone());
let next_target_url = normalized_target_url
.clone()
.or_else(|| existing.target_url.clone());
if let Err(resp) = validate_target_fields(
&next_target_kind,
next_local_path_prefix.as_deref(),
next_target_url.as_deref(),
) {
return resp;
}
let patch = PatchPublicServiceRouteParams {
target_kind: normalized_target_kind,
local_path_prefix: normalized_local_path_prefix,
target_url: normalized_target_url,
is_active: body.is_active,
metadata: body.metadata.clone(),
};
match patch_public_service_route(&pool, &route_key, &service_key, patch).await {
Ok(Some(route)) => api_success("Updated service route", json!({ "route": route })),
Ok(None) => not_found(
"Service route not found",
format!(
"No service route exists for route_key '{}' and service_key '{}'.",
route_key, service_key
),
),
Err(err) => database_error_response("Failed to update service route", err),
}
}
#[delete("/admin/service-routes/{route_key}/{service_key}")]
async fn admin_delete_public_service_route(
req: HttpRequest,
app_state: Data<AppState>,
path: Path<(String, String)>,
) -> impl Responder {
if let Err(resp) = authorize_static_admin_key(&req) {
return resp;
}
let pool = match client_catalog_pool(app_state.get_ref()) {
Ok(pool) => pool,
Err(resp) => return resp,
};
let (raw_route_key, raw_service_key) = path.into_inner();
let route_key = match normalize_route_key(&raw_route_key) {
Ok(value) => value,
Err(resp) => return resp,
};
let service_key = match normalize_service_key(&raw_service_key) {
Ok(value) => value,
Err(resp) => return resp,
};
match soft_delete_public_service_route(&pool, &route_key, &service_key).await {
Ok(Some(route)) => api_success("Deleted service route", json!({ "route": route })),
Ok(None) => not_found(
"Service route not found",
format!(
"No service route exists for route_key '{}' and service_key '{}'.",
route_key, service_key
),
),
Err(err) => database_error_response("Failed to delete service route", err),
}
}
pub(super) fn services(cfg: &mut web::ServiceConfig) {
cfg.service(admin_list_public_service_routes)
.service(admin_create_public_service_route)
.service(admin_patch_public_service_route)
.service(admin_delete_public_service_route);
}