feat: add HTTP API and integration coverage

Expose the daemon request surface over /v1 with Axum, reuse shared key
parsing between CLI and HTTP, and add an isolated end-to-end HTTP test
that boots a real daemon process with temp XDG paths.
This commit is contained in:
44r0n7
2026-04-15 15:40:50 -04:00
parent 45620b1ab5
commit b8a0a0ff16
9 changed files with 865 additions and 68 deletions
+38 -4
View File
@@ -6,6 +6,7 @@ pub mod registry;
pub mod state;
use std::{
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
@@ -25,6 +26,7 @@ use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::{UnixListener, UnixStream},
sync::Mutex,
task::JoinHandle,
time::{self, MissedTickBehavior, sleep},
};
use tracing::warn;
@@ -33,6 +35,9 @@ use tracing::warn;
use std::os::unix::fs::PermissionsExt;
use crate::adapters::{Device, TvKey};
use crate::api;
pub type SharedDaemon = Arc<Mutex<Daemon>>;
/// The long-lived tvctld process.
#[derive(Debug)]
@@ -85,7 +90,7 @@ impl Daemon {
/// Run the long-lived daemon loop over a Unix socket.
pub async fn serve() -> anyhow::Result<()> {
let daemon = Arc::new(Mutex::new(Daemon::load().await?));
let daemon: SharedDaemon = Arc::new(Mutex::new(Daemon::load().await?));
{
let mut guard = daemon.lock().await;
@@ -101,6 +106,7 @@ pub async fn serve() -> anyhow::Result<()> {
guard.config.discovery.interval_secs,
)
};
let http_server = start_http_server_if_enabled(daemon.clone()).await?;
if let Some(parent) = socket_path.parent() {
fs::create_dir_all(parent).await?;
@@ -163,6 +169,9 @@ pub async fn serve() -> anyhow::Result<()> {
let guard = daemon.lock().await;
let _ = fs::remove_file(&guard.paths.active_socket_file).await;
}
if let Some(task) = http_server {
task.abort();
}
Ok(())
}
@@ -174,6 +183,31 @@ async fn set_socket_permissions(path: &Path) -> anyhow::Result<()> {
Ok(())
}
async fn start_http_server_if_enabled(daemon: SharedDaemon) -> anyhow::Result<Option<JoinHandle<()>>> {
let (enabled, host, port) = {
let guard = daemon.lock().await;
(
guard.config.daemon.http_enabled,
guard.config.daemon.http_host.clone(),
guard.config.daemon.http_port,
)
};
if !enabled {
return Ok(None);
}
let address: SocketAddr = format!("{host}:{port}").parse()?;
let listener = tokio::net::TcpListener::bind(address).await?;
let app = api::router(daemon);
let task = tokio::spawn(async move {
if let Err(error) = axum::serve(listener, app).await {
warn!("HTTP API server stopped: {error}");
}
});
Ok(Some(task))
}
async fn handle_connection(
mut stream: UnixStream,
daemon: Arc<Mutex<Daemon>>,
@@ -194,7 +228,7 @@ async fn handle_connection(
}
};
let (response, should_stop) = handle_request(request, daemon).await;
let (response, should_stop) = execute_request(request, daemon).await;
write_response(&mut stream, &response).await?;
Ok(should_stop)
}
@@ -206,9 +240,9 @@ async fn write_response(stream: &mut UnixStream, response: &DaemonResponse) -> a
Ok(())
}
async fn handle_request(
pub(crate) async fn execute_request(
request: DaemonRequest,
daemon: Arc<Mutex<Daemon>>,
daemon: SharedDaemon,
) -> (DaemonResponse, bool) {
match request {
DaemonRequest::Ping => {