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::<AnyPool>() - 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 <noreply@anthropic.com>
This commit is contained in:
107
src/api/applications.rs
Normal file
107
src/api/applications.rs
Normal file
@@ -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<Vec<Application>, ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::repository::applications as repo;
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.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<Vec<u16>, ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::repository::applications as repo;
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.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<Application, ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::repository::applications as repo;
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.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<bool, ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::repository::applications as repo;
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.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::<AnyPool>()
|
||||||
|
.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::<AnyPool>()
|
||||||
|
.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()))
|
||||||
|
}
|
||||||
77
src/api/hosts.rs
Normal file
77
src/api/hosts.rs
Normal file
@@ -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<Vec<Host>, ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::repository::hosts as repo;
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.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<Host, ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::{
|
||||||
|
repository::{hosts, networks},
|
||||||
|
validation::validate_ip_in_network,
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.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<bool, ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::repository::hosts as repo;
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.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()))
|
||||||
|
}
|
||||||
19
src/api/mod.rs
Normal file
19
src/api/mod.rs
Normal file
@@ -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/<fn-name>
|
||||||
|
//
|
||||||
|
// 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::<AnyPool>()` — it is injected
|
||||||
|
// into the Leptos context by `main.rs` for every request.
|
||||||
|
|
||||||
|
pub mod applications;
|
||||||
|
pub mod hosts;
|
||||||
|
pub mod networks;
|
||||||
67
src/api/networks.rs
Normal file
67
src/api/networks.rs
Normal file
@@ -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<Vec<Network>, 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::<AnyPool>()
|
||||||
|
.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<Network, ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::{repository::networks as repo, validation::validate_cidr};
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.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<bool, ServerFnError> {
|
||||||
|
use sqlx::AnyPool;
|
||||||
|
use crate::server::repository::networks as repo;
|
||||||
|
|
||||||
|
let pool = use_context::<AnyPool>()
|
||||||
|
.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()))
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
// Declare the sub-modules of this library.
|
// Declare the sub-modules of this library.
|
||||||
// `pub` makes them accessible from main.rs and other crates.
|
// `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 app; // Root App() component and router configuration
|
||||||
pub mod client; // UI pages and Leptos components
|
pub mod client; // UI pages and Leptos components
|
||||||
pub mod models; // Shared data structs: Network, Host, Port, Application
|
pub mod models; // Shared data structs: Network, Host, Port, Application
|
||||||
|
|||||||
49
src/main.rs
49
src/main.rs
@@ -17,8 +17,11 @@
|
|||||||
async fn main() {
|
async fn main() {
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use leptos::config::get_configuration;
|
use leptos::config::get_configuration;
|
||||||
|
use leptos::prelude::provide_context;
|
||||||
use leptos::view;
|
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::{
|
use rust_ipam::{
|
||||||
app::{App, Shell},
|
app::{App, Shell},
|
||||||
server::{
|
server::{
|
||||||
@@ -77,6 +80,12 @@ async fn main() {
|
|||||||
// that Leptos SSR must handle.
|
// that Leptos SSR must handle.
|
||||||
let routes = generate_route_list(App);
|
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).
|
// Build the Axum router using the builder pattern (method chaining).
|
||||||
//
|
//
|
||||||
// `Router::<AppState>::new()` explicitly tells Rust the state type is `AppState`.
|
// `Router::<AppState>::new()` explicitly tells Rust the state type is `AppState`.
|
||||||
@@ -86,16 +95,40 @@ async fn main() {
|
|||||||
// Serve static files compiled by trunk (WASM, JS...).
|
// Serve static files compiled by trunk (WASM, JS...).
|
||||||
// Trunk places them in target/site/pkg/ as configured in [package.metadata.leptos].
|
// Trunk places them in target/site/pkg/ as configured in [package.metadata.leptos].
|
||||||
.nest_service("/pkg", ServeDir::new("target/site/pkg"))
|
.nest_service("/pkg", ServeDir::new("target/site/pkg"))
|
||||||
|
// Handle server function HTTP calls from the WASM client.
|
||||||
|
//
|
||||||
|
// `#[server]` functions register themselves at "/api/<fn-name>".
|
||||||
|
// `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::<AnyPool>()` 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.
|
// Mount all Leptos routes into Axum.
|
||||||
// For each URL, Axum renders Shell() to HTML and sends it to the browser.
|
// `leptos_routes_with_context` injects the pool into the Leptos context
|
||||||
// `leptos_routes` receives `&state` (the full AppState).
|
// for every SSR render — needed for server functions called during SSR
|
||||||
// It extracts `LeptosOptions` via `FromRef<AppState>` implemented in state.rs.
|
// (e.g. when a `Resource` pre-fetches data on the server).
|
||||||
.leptos_routes(&state, routes, {
|
.leptos_routes_with_context(
|
||||||
// Clone options before moving into the closure.
|
&state,
|
||||||
// `move` transfers ownership of `leptos_options` into the closure.
|
routes,
|
||||||
|
{
|
||||||
|
move || provide_context(pool_for_routes.clone())
|
||||||
|
},
|
||||||
|
{
|
||||||
let leptos_options = state.leptos_options.clone();
|
let leptos_options = state.leptos_options.clone();
|
||||||
move || view! { <Shell options=leptos_options.clone()/> }
|
move || view! { <Shell options=leptos_options.clone()/> }
|
||||||
})
|
},
|
||||||
|
)
|
||||||
.fallback(not_found_handler)
|
.fallback(not_found_handler)
|
||||||
// Share AppState (Leptos options + DB pool) with all handlers.
|
// Share AppState (Leptos options + DB pool) with all handlers.
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|||||||
Reference in New Issue
Block a user