From 75c13b261b5aeece3335cecdb34f28de12a4568b Mon Sep 17 00:00:00 2001 From: mathieu Date: Fri, 15 May 2026 22:01:31 +0200 Subject: [PATCH] feat(api): add Leptos server functions bridging client and server - Add src/api/ module with server functions for networks, hosts, applications - Each server function retrieves the pool via use_context::() - Pool is injected via provide_context in two places in main.rs: * leptos_routes_with_context: for SSR renders and inline server fn calls * handle_server_fns_with_context on /api/*fn_name: for WASM client calls - create_host validates IP against network CIDR before inserting - create_network validates CIDR format before inserting Co-Authored-By: Claude Sonnet 4.6 --- src/api/applications.rs | 107 ++++++++++++++++++++++++++++++++++++++++ src/api/hosts.rs | 77 +++++++++++++++++++++++++++++ src/api/mod.rs | 19 +++++++ src/api/networks.rs | 67 +++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 53 ++++++++++++++++---- 6 files changed, 314 insertions(+), 10 deletions(-) create mode 100644 src/api/applications.rs create mode 100644 src/api/hosts.rs create mode 100644 src/api/mod.rs create mode 100644 src/api/networks.rs diff --git a/src/api/applications.rs b/src/api/applications.rs new file mode 100644 index 0000000..6a43e5f --- /dev/null +++ b/src/api/applications.rs @@ -0,0 +1,107 @@ +// api/applications.rs — Server functions for applications and their port associations + +use leptos::prelude::*; + +use crate::models::Application; + +// ─── Queries ────────────────────────────────────────────────────────────────── + +/// Returns all applications ordered by name. +#[server] +pub async fn get_applications() -> Result, ServerFnError> { + use sqlx::AnyPool; + use crate::server::repository::applications as repo; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + repo::list_applications(&pool) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + +/// Returns the port numbers associated with an application. +#[server] +pub async fn get_ports_for_application( + application_id: i64, +) -> Result, ServerFnError> { + use sqlx::AnyPool; + use crate::server::repository::applications as repo; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + repo::list_ports_for_application(&pool, application_id) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + +// ─── Mutations ──────────────────────────────────────────────────────────────── + +/// Creates a new application and returns the created record. +#[server] +pub async fn create_application(name: String) -> Result { + use sqlx::AnyPool; + use crate::server::repository::applications as repo; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + repo::create_application(&pool, &name) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + +/// Deletes an application and all its port associations. +/// +/// Returns `true` if the application existed and was deleted. +#[server] +pub async fn delete_application(id: i64) -> Result { + use sqlx::AnyPool; + use crate::server::repository::applications as repo; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + repo::delete_application(&pool, id) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + +/// Associates a port number with an application. +/// +/// If the association already exists, this is a no-op. +#[server] +pub async fn add_port_to_application( + application_id: i64, + port_number: u16, +) -> Result<(), ServerFnError> { + use sqlx::AnyPool; + use crate::server::repository::applications as repo; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + repo::add_port_to_application(&pool, application_id, port_number) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + +/// Removes a port association from an application. +/// +/// If the association does not exist, this is a no-op. +#[server] +pub async fn remove_port_from_application( + application_id: i64, + port_number: u16, +) -> Result<(), ServerFnError> { + use sqlx::AnyPool; + use crate::server::repository::applications as repo; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + repo::remove_port_from_application(&pool, application_id, port_number) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} diff --git a/src/api/hosts.rs b/src/api/hosts.rs new file mode 100644 index 0000000..8cdd1fd --- /dev/null +++ b/src/api/hosts.rs @@ -0,0 +1,77 @@ +// api/hosts.rs — Server functions for hosts + +use leptos::prelude::*; + +use crate::models::Host; + +// ─── Queries ────────────────────────────────────────────────────────────────── + +/// Returns all hosts belonging to a given network. +#[server] +pub async fn get_hosts_by_network(network_id: i64) -> Result, ServerFnError> { + use sqlx::AnyPool; + use crate::server::repository::hosts as repo; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + repo::list_hosts_by_network(&pool, network_id) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + +// ─── Mutations ──────────────────────────────────────────────────────────────── + +/// Creates a new host inside the specified network. +/// +/// Validates that `ip` falls within the CIDR of `network_id`. +/// Returns an error if the network does not exist or the IP is out of range. +#[server] +pub async fn create_host( + name: String, + ip: String, + network_id: i64, +) -> Result { + use sqlx::AnyPool; + use crate::server::{ + repository::{hosts, networks}, + validation::validate_ip_in_network, + }; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + // Look up the parent network to get its CIDR for IP validation. + let network = networks::find_network(&pool, network_id) + .await + .map_err(|e| ServerFnError::new(e.to_string()))? + .ok_or_else(|| { + ServerFnError::new(format!("Network {network_id} not found")) + })?; + + // Enforce the business rule: a host's IP must belong to its network's CIDR. + // This check runs on the server before any INSERT, so the database stays consistent. + validate_ip_in_network(&ip, &network.cidr) + .map_err(|e| ServerFnError::new(e.to_string()))?; + + hosts::create_host(&pool, &name, &ip, network_id) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + +/// Deletes a host by id. +/// +/// Also removes all its port associations (via `ON DELETE CASCADE`). +/// Returns `true` if the host existed and was deleted. +#[server] +pub async fn delete_host(id: i64) -> Result { + use sqlx::AnyPool; + use crate::server::repository::hosts as repo; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + repo::delete_host(&pool, id) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..0e5a030 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,19 @@ +// api/ — Leptos server functions (the bridge between client and server) +// +// Server functions are annotated with `#[server]`. +// Leptos compiles them differently depending on the active feature: +// +// ssr → the function body runs on the server (normal async Rust code) +// hydrate → the body is replaced by an HTTP POST call to /api/ +// +// This means: +// - Function signatures (arguments + return types) must compile for BOTH targets. +// All types used here must also be in `models.rs` (shared code). +// - Imports inside the function body are only compiled for `ssr`, +// so ssr-only modules (server::repository, sqlx) can be used freely there. +// - The pool is retrieved via `use_context::()` — it is injected +// into the Leptos context by `main.rs` for every request. + +pub mod applications; +pub mod hosts; +pub mod networks; diff --git a/src/api/networks.rs b/src/api/networks.rs new file mode 100644 index 0000000..1b4e53c --- /dev/null +++ b/src/api/networks.rs @@ -0,0 +1,67 @@ +// api/networks.rs — Server functions for networks + +use leptos::prelude::*; + +use crate::models::Network; + +// ─── Queries ────────────────────────────────────────────────────────────────── + +/// Returns all networks from the database. +/// +/// Called by the Networks page to populate the list. +#[server] +pub async fn get_networks() -> Result, ServerFnError> { + use sqlx::AnyPool; + use crate::server::repository::networks as repo; + + // `use_context` retrieves a value previously registered with `provide_context`. + // The pool was injected in main.rs before every request. + // `ok_or_else` converts `None` into an error (defensive: should never happen). + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + // Propagate any DB error as a ServerFnError so the client sees a clean message. + repo::list_networks(&pool) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + +// ─── Mutations ──────────────────────────────────────────────────────────────── + +/// Creates a new network with the given CIDR block. +/// +/// Returns the created record (with its auto-generated id). +/// Returns an error if the CIDR is malformed or already exists. +#[server] +pub async fn create_network(cidr: String) -> Result { + use sqlx::AnyPool; + use crate::server::{repository::networks as repo, validation::validate_cidr}; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + // Validate the CIDR before touching the database. + // Example of a valid CIDR: "192.168.1.0/24" + validate_cidr(&cidr).map_err(|e| ServerFnError::new(e.to_string()))?; + + repo::create_network(&pool, &cidr) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} + +/// Deletes a network by id. +/// +/// Also deletes all hosts in that network (via `ON DELETE CASCADE`). +/// Returns `true` if the network existed and was deleted. +#[server] +pub async fn delete_network(id: i64) -> Result { + use sqlx::AnyPool; + use crate::server::repository::networks as repo; + + let pool = use_context::() + .ok_or_else(|| ServerFnError::new("Database pool not found in context"))?; + + repo::delete_network(&pool, id) + .await + .map_err(|e| ServerFnError::new(e.to_string())) +} diff --git a/src/lib.rs b/src/lib.rs index 28a7de4..7db32c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ // Declare the sub-modules of this library. // `pub` makes them accessible from main.rs and other crates. +pub mod api; // Leptos server functions — the HTTP API between client and server pub mod app; // Root App() component and router configuration pub mod client; // UI pages and Leptos components pub mod models; // Shared data structs: Network, Host, Port, Application diff --git a/src/main.rs b/src/main.rs index 4938bb9..24d0881 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,8 +17,11 @@ async fn main() { use axum::Router; use leptos::config::get_configuration; + use leptos::prelude::provide_context; use leptos::view; - use leptos_axum::{generate_route_list, LeptosRoutes}; + use leptos_axum::{ + generate_route_list, handle_server_fns_with_context, LeptosRoutes, + }; use rust_ipam::{ app::{App, Shell}, server::{ @@ -77,6 +80,12 @@ async fn main() { // that Leptos SSR must handle. let routes = generate_route_list(App); + // Clone the pool so we can inject it into two different contexts: + // 1. `leptos_routes_with_context` — SSR rendering + server functions called during SSR + // 2. `handle_server_fns_with_context` — server functions called from the WASM client + let pool_for_routes = state.db.clone(); + let pool_for_fns = state.db.clone(); + // Build the Axum router using the builder pattern (method chaining). // // `Router::::new()` explicitly tells Rust the state type is `AppState`. @@ -86,16 +95,40 @@ async fn main() { // Serve static files compiled by trunk (WASM, JS...). // Trunk places them in target/site/pkg/ as configured in [package.metadata.leptos]. .nest_service("/pkg", ServeDir::new("target/site/pkg")) + // Handle server function HTTP calls from the WASM client. + // + // `#[server]` functions register themselves at "/api/". + // `handle_server_fns_with_context` runs the server function body and injects + // `additional_context` into the Leptos context before execution, + // so server functions can call `use_context::()` to get the pool. + .route( + "/api/*fn_name", + axum::routing::post({ + let pool = pool_for_fns; + move |req| { + let pool = pool.clone(); + handle_server_fns_with_context( + move || provide_context(pool.clone()), + req, + ) + } + }), + ) // Mount all Leptos routes into Axum. - // For each URL, Axum renders Shell() to HTML and sends it to the browser. - // `leptos_routes` receives `&state` (the full AppState). - // It extracts `LeptosOptions` via `FromRef` implemented in state.rs. - .leptos_routes(&state, routes, { - // Clone options before moving into the closure. - // `move` transfers ownership of `leptos_options` into the closure. - let leptos_options = state.leptos_options.clone(); - move || view! { } - }) + // `leptos_routes_with_context` injects the pool into the Leptos context + // for every SSR render — needed for server functions called during SSR + // (e.g. when a `Resource` pre-fetches data on the server). + .leptos_routes_with_context( + &state, + routes, + { + move || provide_context(pool_for_routes.clone()) + }, + { + let leptos_options = state.leptos_options.clone(); + move || view! { } + }, + ) .fallback(not_found_handler) // Share AppState (Leptos options + DB pool) with all handlers. .with_state(state);