Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 }
Expand All @@ -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",
Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
88 changes: 83 additions & 5 deletions src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -81,6 +88,65 @@ impl Server {
}
}

/// Boxed error type used by [`AlpacaService`]'s response body.
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;

/// 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<B> Service<http::Request<B>> for AlpacaService
where
B: HttpBody<Data = Bytes> + Send + 'static,
B::Error: Into<BoxError>,
{
type Response = http::Response<UnsyncBoxBody<Bytes, BoxError>>;
type Error = Infallible;
type Future = BoxFuture<'static, std::result::Result<Self::Response, Infallible>>;

fn poll_ready(
&mut self,
_cx: &mut Context<'_>,
) -> Poll<std::result::Result<(), Infallible>> {
Poll::Ready(Ok(()))
}

fn call(&mut self, req: http::Request<B>) -> 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,
Expand Down Expand Up @@ -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<std::convert::Infallible>>,
axum: BoxFuture<'static, eyre::Result<Infallible>>,
axum_listen_addr: SocketAddr,
discovery: Option<BoundDiscoveryServer>,
}
Expand All @@ -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<std::convert::Infallible> {
pub async fn start(self) -> eyre::Result<Infallible> {
match self.discovery {
Some(discovery) => {
match tokio::select! {
Expand Down Expand Up @@ -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(),
)
Expand All @@ -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<std::convert::Infallible> {
pub async fn start(self) -> eyre::Result<Infallible> {
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()));
Expand Down
Loading