From a352a8edfdfd89819107419a74f6094630c67ee3 Mon Sep 17 00:00:00 2001 From: mathieu Date: Fri, 15 May 2026 21:52:32 +0200 Subject: [PATCH] feat(repository): add CRUD layer for all domain entities - Add server/repository/ module with networks, hosts, ports, applications - Use sqlx::query() + manual row mapping (no compile-time DB required) - Handle unique-constraint conflicts with is_unique_violation() for cross-database compatibility (SQLite + PostgreSQL via AnyPool) - add_port_to_host auto-registers the port in the catalog (prevents FK errors) - application_ports has no FK to ports (intentional: loose association) - Add DbError::NotFound variant for missing-record cases Co-Authored-By: Claude Sonnet 4.6 --- src/server/db.rs | 3 + src/server/mod.rs | 3 + src/server/repository/applications.rs | 134 ++++++++++++++++++++++ src/server/repository/hosts.rs | 86 ++++++++++++++ src/server/repository/mod.rs | 19 ++++ src/server/repository/networks.rs | 79 +++++++++++++ src/server/repository/ports.rs | 156 ++++++++++++++++++++++++++ 7 files changed, 480 insertions(+) create mode 100644 src/server/repository/applications.rs create mode 100644 src/server/repository/hosts.rs create mode 100644 src/server/repository/mod.rs create mode 100644 src/server/repository/networks.rs create mode 100644 src/server/repository/ports.rs diff --git a/src/server/db.rs b/src/server/db.rs index e124b7e..5f677ea 100644 --- a/src/server/db.rs +++ b/src/server/db.rs @@ -21,6 +21,9 @@ pub enum DbError { #[error("Migration failed: {0}")] Migration(#[from] sqlx::migrate::MigrateError), + + #[error("Record not found: {0}")] + NotFound(String), } // ─── Pool creation ──────────────────────────────────────────────────────────── diff --git a/src/server/mod.rs b/src/server/mod.rs index 55d1055..3d4617f 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -14,6 +14,9 @@ pub mod config; #[cfg(feature = "ssr")] pub mod db; +#[cfg(feature = "ssr")] +pub mod repository; + #[cfg(feature = "ssr")] pub mod state; diff --git a/src/server/repository/applications.rs b/src/server/repository/applications.rs new file mode 100644 index 0000000..bc72f22 --- /dev/null +++ b/src/server/repository/applications.rs @@ -0,0 +1,134 @@ +// repository/applications.rs — CRUD for applications and their port associations + +use sqlx::{AnyPool, Row}; + +use crate::models::Application; +use crate::server::db::DbError; + +// ─── Read ───────────────────────────────────────────────────────────────────── + +/// Returns every application ordered by name. +pub async fn list_applications(pool: &AnyPool) -> Result, DbError> { + let rows = sqlx::query("SELECT id, name FROM applications ORDER BY name") + .fetch_all(pool) + .await?; + + Ok(rows.iter().map(row_to_application).collect()) +} + +/// Returns a single application by id, or `None` if it does not exist. +pub async fn find_application( + pool: &AnyPool, + id: i64, +) -> Result, DbError> { + let row = sqlx::query("SELECT id, name FROM applications WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?; + + Ok(row.as_ref().map(row_to_application)) +} + +// ─── Write ──────────────────────────────────────────────────────────────────── + +/// Inserts a new application and returns the created record. +pub async fn create_application(pool: &AnyPool, name: &str) -> Result { + let row = + sqlx::query("INSERT INTO applications (name) VALUES ($1) RETURNING id, name") + .bind(name) + .fetch_one(pool) + .await?; + + Ok(row_to_application(&row)) +} + +/// Deletes an application and its port associations (via `ON DELETE CASCADE`). +/// +/// Returns `true` if a row was deleted, `false` if the id did not exist. +pub async fn delete_application(pool: &AnyPool, id: i64) -> Result { + let result = sqlx::query("DELETE FROM applications WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +// ─── Application-port associations ─────────────────────────────────────────── + +/// Returns all port numbers associated with an application, sorted numerically. +/// +/// Note: the `application_ports` table does NOT have a FK to `ports`, +/// so an application can reference a port number that is not in the catalog. +/// Returns plain `u16` numbers, not full `Port` structs. +pub async fn list_ports_for_application( + pool: &AnyPool, + application_id: i64, +) -> Result, DbError> { + let rows = sqlx::query( + "SELECT port_number FROM application_ports + WHERE application_id = $1 + ORDER BY port_number", + ) + .bind(application_id) + .fetch_all(pool) + .await?; + + Ok(rows + .iter() + .map(|r| r.get::("port_number") as u16) + .collect()) +} + +/// Associates a port number with an application. +/// +/// If the association already exists, the call is a no-op (not an error). +/// The port does not need to be in the `ports` catalog. +pub async fn add_port_to_application( + pool: &AnyPool, + application_id: i64, + port_number: u16, +) -> Result<(), DbError> { + let result = sqlx::query( + "INSERT INTO application_ports (application_id, port_number) VALUES ($1, $2)", + ) + .bind(application_id) + .bind(port_number as i64) + .execute(pool) + .await; + + match result { + Ok(_) => Ok(()), + Err(sqlx::Error::Database(ref e)) if e.is_unique_violation() => Ok(()), + Err(e) => Err(DbError::Connection(e)), + } +} + +/// Removes a port association from an application. +/// +/// If the association did not exist, this is a no-op (not an error). +pub async fn remove_port_from_application( + pool: &AnyPool, + application_id: i64, + port_number: u16, +) -> Result<(), DbError> { + sqlx::query( + "DELETE FROM application_ports + WHERE application_id = $1 AND port_number = $2", + ) + .bind(application_id) + .bind(port_number as i64) + .execute(pool) + .await?; + + Ok(()) +} + +// ─── Row mapping ────────────────────────────────────────────────────────────── + +fn row_to_application(row: &sqlx::any::AnyRow) -> Application { + Application { + id: row.get("id"), + name: row.get("name"), + } +} diff --git a/src/server/repository/hosts.rs b/src/server/repository/hosts.rs new file mode 100644 index 0000000..e6ccf6e --- /dev/null +++ b/src/server/repository/hosts.rs @@ -0,0 +1,86 @@ +// repository/hosts.rs — CRUD for the `hosts` table + +use sqlx::{AnyPool, Row}; + +use crate::models::Host; +use crate::server::db::DbError; + +// ─── Read ───────────────────────────────────────────────────────────────────── + +/// Returns every host belonging to a network, sorted by name. +pub async fn list_hosts_by_network( + pool: &AnyPool, + network_id: i64, +) -> Result, DbError> { + let rows = sqlx::query( + "SELECT id, name, ip, network_id FROM hosts + WHERE network_id = $1 + ORDER BY name", + ) + .bind(network_id) + .fetch_all(pool) + .await?; + + Ok(rows.iter().map(row_to_host).collect()) +} + +/// Returns a single host by id, or `None` if it does not exist. +pub async fn find_host(pool: &AnyPool, id: i64) -> Result, DbError> { + let row = sqlx::query( + "SELECT id, name, ip, network_id FROM hosts WHERE id = $1", + ) + .bind(id) + .fetch_optional(pool) + .await?; + + Ok(row.as_ref().map(row_to_host)) +} + +// ─── Write ──────────────────────────────────────────────────────────────────── + +/// Inserts a new host into the given network and returns the created record. +/// +/// The caller must validate that `ip` falls within the CIDR range of `network_id` +/// before calling this function — use `server::validation::validate_ip_in_network`. +pub async fn create_host( + pool: &AnyPool, + name: &str, + ip: &str, + network_id: i64, +) -> Result { + let row = sqlx::query( + "INSERT INTO hosts (name, ip, network_id) + VALUES ($1, $2, $3) + RETURNING id, name, ip, network_id", + ) + .bind(name) + .bind(ip) + .bind(network_id) + .fetch_one(pool) + .await?; + + Ok(row_to_host(&row)) +} + +/// Deletes a host and all its port associations (via `ON DELETE CASCADE`). +/// +/// Returns `true` if a row was deleted, `false` if the id did not exist. +pub async fn delete_host(pool: &AnyPool, id: i64) -> Result { + let result = sqlx::query("DELETE FROM hosts WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +// ─── Row mapping ────────────────────────────────────────────────────────────── + +fn row_to_host(row: &sqlx::any::AnyRow) -> Host { + Host { + id: row.get("id"), + name: row.get("name"), + ip: row.get("ip"), + network_id: row.get("network_id"), + } +} diff --git a/src/server/repository/mod.rs b/src/server/repository/mod.rs new file mode 100644 index 0000000..3dc30d2 --- /dev/null +++ b/src/server/repository/mod.rs @@ -0,0 +1,19 @@ +// server/repository — Database access layer (CRUD) +// +// Each sub-module owns the queries for one domain entity. +// All functions accept `&AnyPool` and return `Result<_, DbError>`, +// so the caller never has to think about raw SQL or connection management. +// +// Design choices: +// - Free functions (not struct methods) keep the API simple. +// - `sqlx::query()` is used instead of `sqlx::query!()` because the +// compile-time macro requires a live database at build time. +// - All integer columns are read as `i64` (the AnyPool normalizes +// INTEGER/BIGINT to 64-bit integers internally). +// - Port numbers are stored as i64 in the DB and cast to u16 in Rust +// (safe because valid port numbers 0–65535 fit in u16). + +pub mod applications; +pub mod hosts; +pub mod networks; +pub mod ports; diff --git a/src/server/repository/networks.rs b/src/server/repository/networks.rs new file mode 100644 index 0000000..f7fe08d --- /dev/null +++ b/src/server/repository/networks.rs @@ -0,0 +1,79 @@ +// repository/networks.rs — CRUD for the `networks` table + +use sqlx::{AnyPool, Row}; + +use crate::models::Network; +use crate::server::db::DbError; + +// ─── Read ───────────────────────────────────────────────────────────────────── + +/// Returns every network ordered by id. +pub async fn list_networks(pool: &AnyPool) -> Result, DbError> { + // `fetch_all` runs the query and collects every row into a Vec. + // It returns an error if the query fails; an empty table returns Ok(vec![]). + let rows = sqlx::query("SELECT id, cidr FROM networks ORDER BY id") + .fetch_all(pool) + .await?; + + // `.iter().map(...).collect()` transforms each raw DB row into a Network struct. + Ok(rows.iter().map(row_to_network).collect()) +} + +/// Returns a single network by id, or `None` if it does not exist. +pub async fn find_network(pool: &AnyPool, id: i64) -> Result, DbError> { + // `fetch_optional` returns `Ok(None)` when no row matches — unlike + // `fetch_one`, which returns an error when nothing is found. + let row = sqlx::query("SELECT id, cidr FROM networks WHERE id = $1") + .bind(id) // `$1` is replaced with the value of `id` at runtime + .fetch_optional(pool) + .await?; + + // `Option::map` applies the conversion only if the row is Some. + Ok(row.as_ref().map(row_to_network)) +} + +// ─── Write ──────────────────────────────────────────────────────────────────── + +/// Inserts a new network and returns the created record (with its auto-generated id). +/// +/// Fails with `DbError::Connection` if the CIDR is already registered +/// (the `cidr` column has a UNIQUE constraint). +/// +/// `RETURNING id, cidr` reads back the inserted row in a single round-trip, +/// avoiding a separate SELECT after the INSERT. +/// Requires SQLite ≥ 3.35 (2021) and any PostgreSQL version. +pub async fn create_network(pool: &AnyPool, cidr: &str) -> Result { + let row = sqlx::query("INSERT INTO networks (cidr) VALUES ($1) RETURNING id, cidr") + .bind(cidr) + .fetch_one(pool) // exactly one row is returned by RETURNING + .await?; + + Ok(row_to_network(&row)) +} + +/// Deletes a network by id and all its hosts (via `ON DELETE CASCADE`). +/// +/// Returns `true` if a row was deleted, `false` if the id did not exist. +pub async fn delete_network(pool: &AnyPool, id: i64) -> Result { + // `execute` runs the query without fetching rows. + // `rows_affected()` tells us how many rows were actually deleted. + let result = sqlx::query("DELETE FROM networks WHERE id = $1") + .bind(id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + +// ─── Row mapping ────────────────────────────────────────────────────────────── + +/// Converts a raw database row into a `Network` struct. +/// +/// `row.get("col")` extracts a typed value by column name. +/// The type must implement `sqlx::Decode` for the `Any` backend. +fn row_to_network(row: &sqlx::any::AnyRow) -> Network { + Network { + id: row.get("id"), + cidr: row.get("cidr"), + } +} diff --git a/src/server/repository/ports.rs b/src/server/repository/ports.rs new file mode 100644 index 0000000..b27bdeb --- /dev/null +++ b/src/server/repository/ports.rs @@ -0,0 +1,156 @@ +// repository/ports.rs — Port catalog and host-port associations +// +// The `ports` table is a global catalog: a port number (e.g. 22) is defined +// once with an optional description, then linked to hosts via `host_ports`. +// +// Port numbers in Rust are `u16` (0–65535). +// In the database they are stored as INTEGER (i64 in SQLx/AnyPool). +// `as u16` casts are safe because values above 65535 cannot be inserted +// (the schema and application code enforce this). + +use sqlx::{AnyPool, Row}; + +use crate::models::Port; +use crate::server::db::DbError; + +// ─── Port catalog ───────────────────────────────────────────────────────────── + +/// Inserts or updates a port in the global catalog. +/// +/// If the port number already exists, its description is updated. +/// If it does not exist, a new row is inserted. +/// +/// We use a try-INSERT then UPDATE pattern instead of database-specific +/// upsert syntax (`INSERT OR REPLACE` for SQLite, `ON CONFLICT` for PostgreSQL) +/// so the same code works with `AnyPool` on both backends. +pub async fn upsert_port( + pool: &AnyPool, + number: u16, + description: Option<&str>, +) -> Result { + let insert_result = sqlx::query( + "INSERT INTO ports (number, description) VALUES ($1, $2)", + ) + .bind(number as i64) + .bind(description) + .execute(pool) + .await; + + match insert_result { + Ok(_) => {} + Err(sqlx::Error::Database(ref db_err)) if db_err.is_unique_violation() => { + // Port already exists: update the description instead. + // `is_unique_violation()` works for both SQLite and PostgreSQL. + sqlx::query("UPDATE ports SET description = $1 WHERE number = $2") + .bind(description) + .bind(number as i64) + .execute(pool) + .await?; + } + Err(e) => return Err(DbError::Connection(e)), + } + + // Return the current state of the port row. + find_port(pool, number) + .await? + .ok_or_else(|| DbError::NotFound(format!("port {number}"))) +} + +/// Returns a port from the catalog, or `None` if the number is not registered. +pub async fn find_port(pool: &AnyPool, number: u16) -> Result, DbError> { + let row = sqlx::query( + "SELECT number, description FROM ports WHERE number = $1", + ) + .bind(number as i64) + .fetch_optional(pool) + .await?; + + Ok(row.as_ref().map(row_to_port)) +} + +// ─── Host-port associations ─────────────────────────────────────────────────── + +/// Returns all ports currently associated with a host, sorted by port number. +/// +/// Uses a JOIN so callers get full `Port` structs (with descriptions), not just numbers. +pub async fn list_ports_for_host( + pool: &AnyPool, + host_id: i64, +) -> Result, DbError> { + let rows = sqlx::query( + "SELECT p.number, p.description + FROM ports p + JOIN host_ports hp ON hp.port_number = p.number + WHERE hp.host_id = $1 + ORDER BY p.number", + ) + .bind(host_id) + .fetch_all(pool) + .await?; + + Ok(rows.iter().map(row_to_port).collect()) +} + +/// Adds a port to a host. +/// +/// If the port is not yet in the global catalog, it is automatically registered +/// with a description from `Port::known_protocol` (e.g. port 22 → "SSH"). +/// If the host already has this port, the call is a no-op (not an error). +pub async fn add_port_to_host( + pool: &AnyPool, + host_id: i64, + port_number: u16, +) -> Result<(), DbError> { + // Auto-register the port in the catalog if it is not already there. + // The `host_ports` table has a FK to `ports`, so the port must exist first. + let description = crate::models::Port::known_protocol(port_number); + upsert_port(pool, port_number, description).await?; + + // Insert the host-port association. + // If it already exists, treat it as a no-op (unique violation is expected). + let result = sqlx::query( + "INSERT INTO host_ports (host_id, port_number) VALUES ($1, $2)", + ) + .bind(host_id) + .bind(port_number as i64) + .execute(pool) + .await; + + match result { + Ok(_) => Ok(()), + Err(sqlx::Error::Database(ref e)) if e.is_unique_violation() => Ok(()), + Err(e) => Err(DbError::Connection(e)), + } +} + +/// Removes a port from a host. +/// +/// If the association did not exist, this is a no-op (not an error). +/// The port entry in the global catalog is NOT deleted. +pub async fn remove_port_from_host( + pool: &AnyPool, + host_id: i64, + port_number: u16, +) -> Result<(), DbError> { + sqlx::query( + "DELETE FROM host_ports WHERE host_id = $1 AND port_number = $2", + ) + .bind(host_id) + .bind(port_number as i64) + .execute(pool) + .await?; + + Ok(()) +} + +// ─── Row mapping ────────────────────────────────────────────────────────────── + +fn row_to_port(row: &sqlx::any::AnyRow) -> Port { + Port { + // Port numbers are stored as INTEGER in the DB. + // SQLx/AnyPool decodes all integers to i64; `as u16` is safe + // because values above 65535 cannot reach the DB. + number: row.get::("number") as u16, + description: row.get("description"), + } +}