diff --git a/Cargo.lock b/Cargo.lock index d8ad69b..2e27aea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -257,6 +257,7 @@ dependencies = [ "async-trait", "axum", "bytemuck", + "bytes", "color-eyre", "criterion", "ctor", @@ -266,6 +267,8 @@ dependencies = [ "fnv", "futures", "http", + "http-body", + "http-body-util", "impl_serialize", "indexmap", "macro_rules_attribute", @@ -287,6 +290,7 @@ dependencies = [ "thiserror 2.0.17", "time", "tokio", + "tower-service", "tracing", "tracing-error", "tracing-forest", diff --git a/Cargo.toml b/Cargo.toml index 9245701..0b33ca0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ bytemuck = { version = "1.24.0", features = [ "derive", "extern_crate_std", ], optional = true } +bytes = { version = "1.10.1", optional = true } criterion = { version = "0.8.1", optional = true } derive_more = { workspace = true, features = [ "debug", @@ -51,6 +52,8 @@ netdev = { version = "0.40.0" } eyre = { workspace = true } futures = { workspace = true } http = { version = "1.4.0", optional = true } +http-body = { version = "1.0.1", optional = true } +http-body-util = { version = "0.1.3", optional = true } indexmap = { version = "2.12.1", features = ["serde"], optional = true } macro_rules_attribute = "0.2.2" mediatype = { version = "0.21.0", optional = true } @@ -72,6 +75,7 @@ socket2 = "0.6.1" thiserror = "2.0.17" time = { version = "0.3.44", features = ["macros", "parsing", "formatting"] } tokio = { workspace = true, features = ["net", "rt", "io-util"] } +tower-service = { version = "0.3.3", optional = true } tracing = { workspace = true } tracing-futures = { version = "0.2.5", features = [ "futures-03", @@ -145,9 +149,13 @@ server = [ "__anynetwork", "dep:bytemuck", "dep:axum", + "dep:bytes", "dep:http", + "dep:http-body", + "dep:http-body-util", "dep:indexmap", "dep:serde_plain", + "dep:tower-service", ] [package.metadata.docs.rs] diff --git a/src/lib.rs b/src/lib.rs index e6b6b44..399b38f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -382,7 +382,7 @@ pub use client::Client; #[cfg(feature = "server")] mod server; #[cfg(feature = "server")] -pub use server::{BoundServer, Server}; +pub use server::{AlpacaService, BoundServer, BoxError, Server}; pub mod discovery; diff --git a/src/server/mod.rs b/src/server/mod.rs index 2112770..8b9c1df 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -31,15 +31,22 @@ use crate::response::ValueResponse; use axum::extract::{FromRequest, Path, Request}; use axum::response::{Html, IntoResponse, Response}; use axum::{Router, routing}; +use bytes::Bytes; use fnv::FnvHashSet; use futures::future::{BoxFuture, FutureExt}; use http::StatusCode; +use http_body::Body as HttpBody; +use http_body_util::BodyExt; +use http_body_util::combinators::UnsyncBoxBody; use serde::Deserialize; use socket2::{Domain, Protocol, Socket, Type}; use std::collections::BTreeMap; +use std::convert::Infallible; use std::net::{IpAddr, Ipv6Addr, SocketAddr}; use std::sync::{Arc, RwLock}; +use std::task::{Context, Poll}; use tokio::net::TcpListener; +use tower_service::Service; use tracing::Instrument; /// The Alpaca server. @@ -81,6 +88,65 @@ impl Server { } } +/// Boxed error type used by [`AlpacaService`]'s response body. +pub type BoxError = Box; + +/// Tower [`Service`] for the Alpaca HTTP protocol. +/// +/// Obtained via [`Server::into_service`]. Accepts any request body +/// implementing [`http_body::Body`] with `Data = Bytes`; produces +/// responses with a type-erased [`UnsyncBoxBody`] so the caller doesn't +/// need to know the concrete server implementation's body type. +/// +/// Use this when you need custom socket binding, TLS termination, or +/// middleware composition. The caller is responsible for starting the +/// Alpaca discovery server separately if needed. +/// +/// # Example +/// +/// ```no_run +/// # use ascom_alpaca::{Server, api::CargoServerInfo}; +/// # async fn run() -> eyre::Result<()> { +/// let server = Server::new(CargoServerInfo!()); +/// let service = server.into_service(); +/// // Compose with your preferred TLS / middleware stack, e.g. +/// // `axum_server::bind_rustls` or `hyper_util::server::conn::auto`. +/// # Ok(()) } +/// ``` +#[derive(Clone, derive_more::Debug)] +pub struct AlpacaService { + #[debug(skip)] + router: Router, +} + +impl Service> for AlpacaService +where + B: HttpBody + Send + 'static, + B::Error: Into, +{ + type Response = http::Response>; + type Error = Infallible; + type Future = BoxFuture<'static, std::result::Result>; + + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: http::Request) -> Self::Future { + let fut = self.router.call(req); + async move { + let resp = fut.await?; + let (parts, body) = resp.into_parts(); + let boxed = body.map_err(Into::into).boxed_unsync(); + Ok(http::Response::from_parts(parts, boxed)) + } + .boxed() + } +} + struct ServerHandler { path: String, params: ActionParams, @@ -135,7 +201,7 @@ impl ServerHandler { pub struct BoundServer { // Axum types are a bit complicated, so just Box it for now. #[debug(skip)] - axum: BoxFuture<'static, eyre::Result>, + axum: BoxFuture<'static, eyre::Result>, axum_listen_addr: SocketAddr, discovery: Option, } @@ -159,7 +225,7 @@ impl BoundServer { /// /// Note: this function starts an infinite async loop and it's your responsibility to spawn it off /// via [`tokio::spawn`] if necessary. - pub async fn start(self) -> eyre::Result { + pub async fn start(self) -> eyre::Result { match self.discovery { Some(discovery) => { match tokio::select! { @@ -227,7 +293,7 @@ impl Server { axum: async move { axum::serve( listener, - self.into_router() + self.into_router_inner() // .layer(TraceLayer::new_for_http()) .into_make_service(), ) @@ -244,12 +310,24 @@ impl Server { /// Binds the Alpaca and discovery servers to local ports and starts them. /// /// This is a convenience method that is equivalent to calling [`Self::bind`] and [`BoundServer::start`]. - pub async fn start(self) -> eyre::Result { + pub async fn start(self) -> eyre::Result { self.bind().await?.start().await } + /// Consumes the server and returns the Alpaca HTTP [`Service`]. + /// + /// Use this when you need to handle socket binding, TLS termination, + /// or middleware composition yourself instead of calling [`Self::bind`]. + /// The caller is responsible for starting the Alpaca discovery server + /// separately if needed. + /// + /// See [`AlpacaService`] for the returned type. + pub fn into_service(self) -> AlpacaService { + AlpacaService { router: self.into_router_inner() } + } + #[expect(clippy::too_many_lines)] - fn into_router(self) -> Router { + fn into_router_inner(self) -> Router { let devices = Arc::new(self.devices); let server_info = Arc::new(self.info); let connecting_devices = Arc::new(RwLock::new(FnvHashSet::default()));