Give every API operation a unique operationId and surface scanner errors

Scanner status handlers all derived operationId "status" from their fn
name, so Swagger UI's "Try it out" executed the first one (ARP) — the
DHCP doc hit /api/arp_scanner/status. Same collision for read/list/
register/unregister. Each path now sets an explicit unique operation_id.

main's tokio::join!(...).0 kept only the ARP result and silently dropped
the other tasks' errors, so a DHCP scanner that failed to bind port 67
just showed "off" with no log. Each task is now wrapped to log its error.

Fixes #5
This commit is contained in:
rzuasti
2026-06-22 11:12:47 -04:00
parent 3621973201
commit c7ea2a3a98
16 changed files with 97 additions and 15 deletions
+5 -1
View File
@@ -124,7 +124,11 @@ mod tests {
let after_update = list().unwrap();
let rows: Vec<_> = after_update.iter().filter(|t| t.token == token).collect();
assert_eq!(rows.len(), 1, "Re-registering must not create a second row");
assert_eq!(rows[0].platform, PushPlatform::Ios, "Platform should update");
assert_eq!(
rows[0].platform,
PushPlatform::Ios,
"Platform should update"
);
assert_eq!(
rows[0].created_on, created_on,
"created_on must be preserved across an upsert"
+27 -10
View File
@@ -1,6 +1,7 @@
use crate::settings::get_settings;
use clap::Parser;
use log::{LevelFilter, info};
use log::{LevelFilter, error, info};
use std::future::Future;
mod data;
mod db;
@@ -66,15 +67,31 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Start the device scanners, web server, retention cleaner, and notification delivery loop in
// parallel. Notification delivery runs on its own task so a slow Pushover never stalls a scan.
// Each task is wrapped so that if it exits with an error (e.g. the DHCP scanner failing to bind
// its socket because the port is already in use) the failure is logged rather than silently
// swallowed — otherwise a scanner just appears "off" with no explanation.
tokio::join!(
scanners::arp::scanner::scan(),
scanners::mdns::scanner::listen(),
scanners::ssdp::scanner::listen(),
scanners::dhcp::scanner::listen(),
scanners::snmp::scanner::scan(),
web_server::serve(),
log_task_errors("ARP scanner", scanners::arp::scanner::scan()),
log_task_errors("mDNS scanner", scanners::mdns::scanner::listen()),
log_task_errors("SSDP scanner", scanners::ssdp::scanner::listen()),
log_task_errors("DHCP scanner", scanners::dhcp::scanner::listen()),
log_task_errors("SNMP scanner", scanners::snmp::scanner::scan()),
log_task_errors("web server", web_server::serve()),
retention::run(),
notifications::run_delivery()
)
.0
notifications::run_delivery(),
);
Ok(())
}
/// Await a long-running task and log its error if it exits with one. Without this the errors of
/// every task but the first were dropped by `tokio::join!`, so a scanner that failed to start
/// (for example the DHCP scanner being unable to bind port 67) reported no diagnostic at all.
async fn log_task_errors<F>(name: &str, task: F)
where
F: Future<Output = Result<(), Box<dyn std::error::Error>>>,
{
if let Err(err) = task.await {
error!("{name} exited with error: {err}");
}
}
+4 -1
View File
@@ -107,7 +107,10 @@ mod tests {
#[test]
fn platform_parse_is_case_insensitive() {
assert_eq!("ANDROID".parse::<PushPlatform>().unwrap(), PushPlatform::Android);
assert_eq!(
"ANDROID".parse::<PushPlatform>().unwrap(),
PushPlatform::Android
);
assert_eq!("iOS".parse::<PushPlatform>().unwrap(), PushPlatform::Ios);
}
+5 -1
View File
@@ -62,7 +62,11 @@ async fn deliver(request: DeliveryRequest) {
// The relay URL defaults to the project-operated relay, so the [notifications.push]
// section is optional. The send call is async (reqwest), so unlike Pushover it is
// awaited directly rather than dispatched to the blocking pool.
let config = get_settings().notifications.push.clone().unwrap_or_default();
let config = get_settings()
.notifications
.push
.clone()
.unwrap_or_default();
if let Err(err) = push::send(&config, request.title, request.body).await {
error!("Failed to deliver notification via the push relay: {err}");
}
+8 -2
View File
@@ -77,7 +77,10 @@ pub async fn send(config: &Push, title: String, body: String) -> Result<usize, D
let parsed: RelayResponse = response.json().await?;
let dead = dead_tokens(&parsed.results);
if !dead.is_empty() {
debug!("Pruning {} dead push token(s) reported by the relay", dead.len());
debug!(
"Pruning {} dead push token(s) reported by the relay",
dead.len()
);
db::run_blocking(move || db::push_tokens::delete_many(&dead)).await?;
}
@@ -150,7 +153,10 @@ mod tests {
relay_url: format!("http://{addr}/v1/push"),
};
let delivered = send(&config, "title".into(), "body".into()).await.unwrap();
assert_eq!(delivered, 1, "Only the live token should count as delivered");
assert_eq!(
delivered, 1,
"Only the live token should count as delivered"
);
let all = db::push_tokens::list().unwrap();
assert!(
+33
View File
@@ -335,4 +335,37 @@ mod tests {
let openapi = <ApiDoc as OpenApi>::openapi();
assert_eq!(openapi.info.version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn openapi_operation_ids_are_unique() {
// Every operation defaults its operationId to its handler function name, so handlers that
// share a name (e.g. each scanner's `status`, or `read`/`list` across resources) collide.
// Duplicate operationIds are invalid OpenAPI and make Swagger UI's "Try it out" execute the
// first operation with that id (so the DHCP doc hit `/api/arp_scanner/status`). Assert they
// are all unique so that regression cannot return.
let openapi = <ApiDoc as OpenApi>::openapi();
let mut seen = std::collections::HashSet::new();
for (path, item) in &openapi.paths.paths {
let operations = [
&item.get,
&item.put,
&item.post,
&item.delete,
&item.options,
&item.head,
&item.patch,
&item.trace,
];
for operation in operations.into_iter().flatten() {
let id = operation
.operation_id
.as_ref()
.unwrap_or_else(|| panic!("{path} has an operation with no operationId"));
assert!(
seen.insert(id.clone()),
"duplicate operationId {id:?} (at {path})"
);
}
}
}
}
+1
View File
@@ -5,6 +5,7 @@ use crate::web_server::scanner_status::{ActiveScannerStatusResponse, active_resp
#[utoipa::path(
get,
path = "/api/arp_scanner/status",
operation_id = "arp_scanner_status",
tag = "arp_scanner",
responses(
(status = 200, description = "ARP scanner status", body = ActiveScannerStatusResponse),
+1
View File
@@ -6,6 +6,7 @@ use crate::settings::get_settings;
#[utoipa::path(
get,
path = "/api/config",
operation_id = "config_read",
tag = "config",
responses(
(status = 200, description = "Front-end configuration", body = Config),
+1
View File
@@ -12,6 +12,7 @@ use crate::{db, model::device_events::DeviceEvent, web_server::utils};
#[utoipa::path(
get,
path = "/api/devices/{mac_address}/events",
operation_id = "device_events_list",
tag = "device_events",
params(
("mac_address" = String, Path, description = "MAC address of the device"),
+4
View File
@@ -18,6 +18,7 @@ use crate::web_server::utils;
#[utoipa::path(
get,
path = "/api/devices",
operation_id = "devices_list",
tag = "devices",
params(
("is_registered" = Option<bool>, Query, description = "Filter by registration status"),
@@ -95,6 +96,7 @@ pub async fn list(
#[utoipa::path(
get,
path = "/api/devices/{mac_address}",
operation_id = "devices_read",
tag = "devices",
params(
("mac_address" = String, Path, description = "MAC address of the device"),
@@ -116,6 +118,7 @@ pub async fn read(Path(mac_address): Path<String>) -> Result<Json<Device>, Statu
#[utoipa::path(
put,
path = "/api/devices",
operation_id = "devices_register",
tag = "devices",
request_body = RegisterDevicePayload,
responses(
@@ -235,6 +238,7 @@ pub async fn update(
#[utoipa::path(
delete,
path = "/api/devices/{mac_address}",
operation_id = "devices_unregister",
tag = "devices",
params(
("mac_address" = String, Path, description = "MAC address of the device"),
+1
View File
@@ -5,6 +5,7 @@ use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_re
#[utoipa::path(
get,
path = "/api/dhcp_scanner/status",
operation_id = "dhcp_scanner_status",
tag = "dhcp_scanner",
responses(
(status = 200, description = "DHCP scanner status", body = PassiveScannerStatusResponse),
+1
View File
@@ -5,6 +5,7 @@ use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_re
#[utoipa::path(
get,
path = "/api/mdns_scanner/status",
operation_id = "mdns_scanner_status",
tag = "mdns_scanner",
responses(
(status = 200, description = "mDNS scanner status", body = PassiveScannerStatusResponse),
+2
View File
@@ -26,6 +26,7 @@ pub struct TestNotificationResponse {
#[utoipa::path(
get,
path = "/api/notifications/{id}",
operation_id = "notifications_read",
tag = "notifications",
params(
("id" = i64, Path, description = "Notification ID"),
@@ -150,6 +151,7 @@ pub async fn send_test() -> Result<Json<TestNotificationResponse>, StatusCode> {
#[utoipa::path(
get,
path = "/api/notifications",
operation_id = "notifications_list",
tag = "notifications",
params(
("is_new" = Option<bool>, Query, description = "Filter by new/read status"),
+2
View File
@@ -11,6 +11,7 @@ use crate::model::push_tokens::PushPlatform;
#[utoipa::path(
put,
path = "/api/push_tokens",
operation_id = "push_tokens_register",
tag = "push_tokens",
request_body = RegisterPushTokenPayload,
responses(
@@ -38,6 +39,7 @@ pub async fn register(Json(payload): Json<RegisterPushTokenPayload>) -> impl Int
#[utoipa::path(
delete,
path = "/api/push_tokens/{token}",
operation_id = "push_tokens_unregister",
tag = "push_tokens",
params(
("token" = String, Path, description = "The push token to unregister"),
+1
View File
@@ -5,6 +5,7 @@ use crate::web_server::scanner_status::{ActiveScannerStatusResponse, active_resp
#[utoipa::path(
get,
path = "/api/snmp_scanner/status",
operation_id = "snmp_scanner_status",
tag = "snmp_scanner",
responses(
(status = 200, description = "SNMP scanner status", body = ActiveScannerStatusResponse),
+1
View File
@@ -5,6 +5,7 @@ use crate::web_server::scanner_status::{PassiveScannerStatusResponse, passive_re
#[utoipa::path(
get,
path = "/api/ssdp_scanner/status",
operation_id = "ssdp_scanner_status",
tag = "ssdp_scanner",
responses(
(status = 200, description = "SSDP scanner status", body = PassiveScannerStatusResponse),