//! CRUD over the `routes` table. //! //! The orchestrator's `AppRouteTables` is repopulated from this repo //! after every write — see the route_admin module for the binding. use async_trait::async_trait; use picloud_shared::{AppId, DispatchMode, HostKind, PathKind, Route, ScriptId}; use sqlx::PgPool; use uuid::Uuid; use crate::repo::ScriptRepositoryError; #[derive(Debug, Clone)] pub struct NewRoute { pub app_id: AppId, pub script_id: ScriptId, pub host_kind: HostKind, pub host: String, pub host_param_name: Option, pub path_kind: PathKind, pub path: String, pub method: Option, pub dispatch_mode: DispatchMode, } #[async_trait] pub trait RouteRepository: Send + Sync { async fn list_all(&self) -> Result, ScriptRepositoryError>; /// Single-row lookup. Used by `DELETE /api/v1/admin/routes/{id}` so /// the capability check binds to the route's actual `app_id` /// (not a path param). async fn get(&self, route_id: Uuid) -> Result, ScriptRepositoryError>; async fn list_for_app(&self, app_id: AppId) -> Result, ScriptRepositoryError>; async fn list_for_script( &self, script_id: ScriptId, ) -> Result, ScriptRepositoryError>; async fn create(&self, input: NewRoute) -> Result; async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>; /// Count routes whose host_kind/host pair matches a pattern in /// `app_id`. Used by the domain-claim delete guard. async fn count_for_app_host( &self, app_id: AppId, host_kind: HostKind, host: &str, ) -> Result; } pub struct PostgresRouteRepository { pool: PgPool, } impl PostgresRouteRepository { #[must_use] pub fn new(pool: PgPool) -> Self { Self { pool } } } #[async_trait] impl RouteRepository for PostgresRouteRepository { async fn list_all(&self) -> Result, ScriptRepositoryError> { let rows = sqlx::query_as::<_, RouteRow>( "SELECT id, app_id, script_id, host_kind, host, host_param_name, \ path_kind, path, method, dispatch_mode, created_at \ FROM routes ORDER BY created_at", ) .fetch_all(&self.pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } async fn get(&self, route_id: Uuid) -> Result, ScriptRepositoryError> { let row = sqlx::query_as::<_, RouteRow>( "SELECT id, app_id, script_id, host_kind, host, host_param_name, \ path_kind, path, method, dispatch_mode, created_at \ FROM routes WHERE id = $1", ) .bind(route_id) .fetch_optional(&self.pool) .await?; Ok(row.map(Into::into)) } async fn list_for_app(&self, app_id: AppId) -> Result, ScriptRepositoryError> { let rows = sqlx::query_as::<_, RouteRow>( "SELECT id, app_id, script_id, host_kind, host, host_param_name, \ path_kind, path, method, dispatch_mode, created_at \ FROM routes WHERE app_id = $1 ORDER BY created_at", ) .bind(app_id.into_inner()) .fetch_all(&self.pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } async fn list_for_script( &self, script_id: ScriptId, ) -> Result, ScriptRepositoryError> { let rows = sqlx::query_as::<_, RouteRow>( "SELECT id, app_id, script_id, host_kind, host, host_param_name, \ path_kind, path, method, dispatch_mode, created_at \ FROM routes WHERE script_id = $1 ORDER BY created_at", ) .bind(script_id.into_inner()) .fetch_all(&self.pool) .await?; Ok(rows.into_iter().map(Into::into).collect()) } async fn create(&self, input: NewRoute) -> Result { let res = sqlx::query_as::<_, RouteRow>( "INSERT INTO routes ( \ app_id, script_id, host_kind, host, host_param_name, \ path_kind, path, method, dispatch_mode \ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \ RETURNING id, app_id, script_id, host_kind, host, host_param_name, \ path_kind, path, method, dispatch_mode, created_at", ) .bind(input.app_id.into_inner()) .bind(input.script_id.into_inner()) .bind(host_kind_str(input.host_kind)) .bind(&input.host) .bind(input.host_param_name.as_deref()) .bind(path_kind_str(input.path_kind)) .bind(&input.path) .bind(input.method.as_deref()) .bind(input.dispatch_mode.as_str()) .fetch_one(&self.pool) .await; match res { Ok(row) => Ok(row.into()), Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err( ScriptRepositoryError::Conflict("a route with this binding already exists".into()), ), Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => { Err(ScriptRepositoryError::NotFound(input.script_id)) } Err(e) => Err(e.into()), } } async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError> { let res = sqlx::query("DELETE FROM routes WHERE id = $1") .bind(route_id) .execute(&self.pool) .await?; if res.rows_affected() == 0 { return Err(ScriptRepositoryError::NotFound(ScriptId::from(route_id))); } Ok(()) } async fn count_for_app_host( &self, app_id: AppId, host_kind: HostKind, host: &str, ) -> Result { let count: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM routes \ WHERE app_id = $1 AND host_kind = $2 AND host = $3", ) .bind(app_id.into_inner()) .bind(host_kind_str(host_kind)) .bind(host) .fetch_one(&self.pool) .await?; Ok(count.0) } } const fn host_kind_str(k: HostKind) -> &'static str { match k { HostKind::Any => "any", HostKind::Strict => "strict", HostKind::Wildcard => "wildcard", } } const fn path_kind_str(k: PathKind) -> &'static str { match k { PathKind::Exact => "exact", PathKind::Prefix => "prefix", PathKind::Param => "param", } } #[derive(sqlx::FromRow)] struct RouteRow { id: Uuid, app_id: Uuid, script_id: Uuid, host_kind: String, host: String, host_param_name: Option, path_kind: String, path: String, method: Option, dispatch_mode: String, created_at: chrono::DateTime, } impl From for Route { fn from(r: RouteRow) -> Self { Self { id: r.id, app_id: r.app_id.into(), script_id: r.script_id.into(), host_kind: match r.host_kind.as_str() { "strict" => HostKind::Strict, "wildcard" => HostKind::Wildcard, _ => HostKind::Any, }, host: r.host, host_param_name: r.host_param_name, path_kind: match r.path_kind.as_str() { "prefix" => PathKind::Prefix, "param" => PathKind::Param, _ => PathKind::Exact, }, path: r.path, method: r.method, dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync), created_at: r.created_at, } } }