General Upgrade, NEW: WebServers, Better Docs
Some checks failed
CI / checks (push) Failing after 2m23s
Some checks failed
CI / checks (push) Failing after 2m23s
This commit is contained in:
parent
5f11d476b6
commit
3afc75b45d
120 changed files with 10032 additions and 4886 deletions
29
mtp-webserver/Cargo.toml
Normal file
29
mtp-webserver/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "mtp-webserver"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
mtp-common = { version = "0.2.0", path = "../common" }
|
||||
mtp-codec = { version = "0.2.0", path = "../codec", features = ["registry"] }
|
||||
mtp-host = { version = "0.2.0", path = "../host" }
|
||||
mtp-transport = { version = "0.2.0", path = "../transport" }
|
||||
bytes = "1"
|
||||
http = "1"
|
||||
tokio = { version = "1", features = ["io-util", "macros", "net", "rt", "sync", "time"] }
|
||||
h3 = "0.0.8"
|
||||
h3-quinn = { version = "0.0.10", features = ["datagram"] }
|
||||
h3-webtransport = "0.1.2"
|
||||
quinn = "0.11"
|
||||
rustls = "0.23"
|
||||
tracing = "0.1"
|
||||
thiserror = "2"
|
||||
async-trait = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
rcgen = "0.14"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
crypto = ["mtp-host/crypto"]
|
||||
pipes = ["mtp-host/pipes", "mtp-transport/pipes"]
|
||||
37
mtp-webserver/src/error.rs
Normal file
37
mtp-webserver/src/error.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
use mtp_common::CommunicationError;
|
||||
use std::fmt;
|
||||
|
||||
/// Unified error type for the webserver transport adapter.
|
||||
#[derive(Debug)]
|
||||
pub enum WebServerError {
|
||||
Transport(CommunicationError),
|
||||
WebTransport(String),
|
||||
Http(String),
|
||||
NotFound(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for WebServerError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Transport(e) => write!(f, "transport error: {e}"),
|
||||
Self::WebTransport(msg) => write!(f, "webtransport error: {msg}"),
|
||||
Self::Http(msg) => write!(f, "HTTP error: {msg}"),
|
||||
Self::NotFound(route) => write!(f, "route not found: {route}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WebServerError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Transport(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CommunicationError> for WebServerError {
|
||||
fn from(e: CommunicationError) -> Self {
|
||||
Self::Transport(e)
|
||||
}
|
||||
}
|
||||
20
mtp-webserver/src/lib.rs
Normal file
20
mtp-webserver/src/lib.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//! HTTP/3 routing primitives and the combined MTP web-server API.
|
||||
//!
|
||||
//! The public routing API is transport-independent. The HTTP/3 driver is
|
||||
//! intentionally kept behind the crate's implementation boundary so callers
|
||||
//! do not need to depend on a particular QUIC implementation.
|
||||
|
||||
mod error;
|
||||
mod router;
|
||||
mod server;
|
||||
mod stream;
|
||||
mod transport;
|
||||
|
||||
pub use error::WebServerError;
|
||||
pub use router::{HttpHandler, Router, RouterError};
|
||||
pub use server::{MTPWebServer, WebServerConfig, WebServerMetrics};
|
||||
pub use stream::{Http3Request, Http3Response};
|
||||
pub use transport::{
|
||||
H3TransportConnection, H3TransportReceiver, H3TransportSender, WebMTPConnection,
|
||||
WebMtpReceiver, WebMtpSender,
|
||||
};
|
||||
129
mtp-webserver/src/router.rs
Normal file
129
mtp-webserver/src/router.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
use crate::{Http3Request, Http3Response};
|
||||
use http::Method;
|
||||
use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc};
|
||||
|
||||
/// An asynchronous HTTP/3 route handler.
|
||||
pub type HttpHandler = Arc<
|
||||
dyn Fn(Http3Request, Http3Response) -> Pin<Box<dyn Future<Output = Http3Response> + Send>>
|
||||
+ Send
|
||||
+ Sync,
|
||||
>;
|
||||
|
||||
/// Errors returned by [`Router`] route registration.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RouterError {
|
||||
#[error("duplicate route registration for {0}")]
|
||||
DuplicateRoute(String),
|
||||
|
||||
#[error("a router fallback is already registered")]
|
||||
DuplicateFallback,
|
||||
}
|
||||
|
||||
/// Exact-path HTTP route table used by [`MTPWebServer`](crate::MTPWebServer).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Router {
|
||||
routes: HashMap<(Option<Method>, String), HttpHandler>,
|
||||
fallback: Option<HttpHandler>,
|
||||
}
|
||||
|
||||
impl Router {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn route<F, Fut>(self, path: impl Into<String>, handler: F) -> Result<Self, RouterError>
|
||||
where
|
||||
F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = Http3Response> + Send + 'static,
|
||||
{
|
||||
self.route_inner(
|
||||
None,
|
||||
path.into(),
|
||||
Arc::new(move |request, response| Box::pin(handler(request, response))),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn route_method<F, Fut>(
|
||||
self,
|
||||
method: Method,
|
||||
path: impl Into<String>,
|
||||
handler: F,
|
||||
) -> Result<Self, RouterError>
|
||||
where
|
||||
F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = Http3Response> + Send + 'static,
|
||||
{
|
||||
self.route_inner(
|
||||
Some(method),
|
||||
path.into(),
|
||||
Arc::new(move |request, response| Box::pin(handler(request, response))),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn fallback<F, Fut>(mut self, handler: F) -> Result<Self, RouterError>
|
||||
where
|
||||
F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = Http3Response> + Send + 'static,
|
||||
{
|
||||
if self.fallback.is_some() {
|
||||
return Err(RouterError::DuplicateFallback);
|
||||
}
|
||||
self.fallback = Some(Arc::new(move |request, response| {
|
||||
Box::pin(handler(request, response))
|
||||
}));
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
fn route_inner(
|
||||
mut self,
|
||||
method: Option<Method>,
|
||||
path: String,
|
||||
handler: HttpHandler,
|
||||
) -> Result<Self, RouterError> {
|
||||
if self
|
||||
.routes
|
||||
.insert((method, path.clone()), handler)
|
||||
.is_some()
|
||||
{
|
||||
return Err(RouterError::DuplicateRoute(path));
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub(crate) fn handler(&self, method: &Method, path: &str) -> Option<HttpHandler> {
|
||||
self.routes
|
||||
.get(&(Some(method.clone()), path.to_string()))
|
||||
.or_else(|| self.routes.get(&(None, path.to_string())))
|
||||
.cloned()
|
||||
}
|
||||
pub(crate) fn fallback_handler(&self) -> Option<HttpHandler> {
|
||||
self.fallback.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bytes::Bytes;
|
||||
use http::{Method, StatusCode, Uri};
|
||||
|
||||
#[tokio::test]
|
||||
async fn route_dispatches_an_exact_path() {
|
||||
let router = Router::new()
|
||||
.route("/health", |_, response| async move {
|
||||
response.status(StatusCode::NO_CONTENT)
|
||||
})
|
||||
.unwrap();
|
||||
let request = Http3Request {
|
||||
method: Method::GET,
|
||||
uri: Uri::from_static("/health"),
|
||||
headers: Default::default(),
|
||||
body: Some(Bytes::new()),
|
||||
};
|
||||
let response =
|
||||
router.handler(&Method::GET, "/health").unwrap()(request, Http3Response::default())
|
||||
.await;
|
||||
assert_eq!(response.status, StatusCode::NO_CONTENT);
|
||||
assert!(router.handler(&Method::GET, "/missing").is_none());
|
||||
}
|
||||
}
|
||||
577
mtp-webserver/src/server.rs
Normal file
577
mtp-webserver/src/server.rs
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
use crate::{
|
||||
Http3Request, Http3Response, Router, RouterError, WebMTPConnection, WebServerError,
|
||||
transport::accept_web_connection,
|
||||
};
|
||||
use bytes::{Buf, Bytes};
|
||||
use http::{Method, Request, Response, StatusCode};
|
||||
use mtp_common::CommunicationError;
|
||||
use mtp_host::HostConfig;
|
||||
use rustls::pki_types::{PrivateKeyDer, pem::PemObject};
|
||||
use std::{net::SocketAddr, sync::Arc, time::Duration};
|
||||
use tokio::sync::{Semaphore, watch};
|
||||
|
||||
/// Observability hooks for the web server.
|
||||
///
|
||||
/// Implement this trait to receive metrics about connections, requests, and
|
||||
/// errors. All methods have default no-op implementations so callers only
|
||||
/// need to override the hooks they care about.
|
||||
pub trait WebServerMetrics: Send + Sync {
|
||||
fn connection_accepted(&self) {}
|
||||
fn connection_closed(&self, _duration: Duration, _reason: &str) {}
|
||||
fn request_started(&self, _path: &str) {}
|
||||
fn request_completed(&self, _path: &str, _status: u16, _duration: Duration) {}
|
||||
fn error_occurred(&self, _error: &WebServerError) {}
|
||||
}
|
||||
|
||||
/// Configuration for the HTTP/3 server and MTP routing.
|
||||
///
|
||||
/// Use the builder methods to customise behaviour. All fields have sensible
|
||||
/// defaults so `WebServerConfig::new()` gives a usable production-ready
|
||||
/// configuration.
|
||||
#[derive(Clone)]
|
||||
pub struct WebServerConfig {
|
||||
pub(crate) router: Router,
|
||||
pub(crate) mtp_path: String,
|
||||
pub max_request_body: usize,
|
||||
pub max_connections: usize,
|
||||
pub request_timeout: Duration,
|
||||
pub drain_timeout: Duration,
|
||||
pub(crate) metrics: Option<Arc<dyn WebServerMetrics>>,
|
||||
}
|
||||
|
||||
impl Default for WebServerConfig {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl WebServerConfig {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
router: Router::new(),
|
||||
mtp_path: "/".to_string(),
|
||||
max_request_body: 4 * 1024 * 1024,
|
||||
max_connections: 256,
|
||||
request_timeout: Duration::from_secs(30),
|
||||
drain_timeout: Duration::from_secs(10),
|
||||
metrics: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route<F, Fut>(mut self, path: impl Into<String>, handler: F) -> Result<Self, RouterError>
|
||||
where
|
||||
F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static,
|
||||
Fut: std::future::Future<Output = Http3Response> + Send + 'static,
|
||||
{
|
||||
self.router = self.router.route(path, handler)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn route_method<F, Fut>(
|
||||
mut self,
|
||||
method: Method,
|
||||
path: impl Into<String>,
|
||||
handler: F,
|
||||
) -> Result<Self, RouterError>
|
||||
where
|
||||
F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static,
|
||||
Fut: std::future::Future<Output = Http3Response> + Send + 'static,
|
||||
{
|
||||
self.router = self.router.route_method(method, path, handler)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn fallback<F, Fut>(mut self, handler: F) -> Result<Self, RouterError>
|
||||
where
|
||||
F: Fn(Http3Request, Http3Response) -> Fut + Send + Sync + 'static,
|
||||
Fut: std::future::Future<Output = Http3Response> + Send + 'static,
|
||||
{
|
||||
self.router = self.router.fallback(handler)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn mtp_path(mut self, path: impl Into<String>) -> Self {
|
||||
self.mtp_path = path.into();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn max_request_body(mut self, bytes: usize) -> Self {
|
||||
self.max_request_body = bytes;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn max_connections(mut self, max: usize) -> Self {
|
||||
self.max_connections = max;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn request_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.request_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn drain_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.drain_timeout = timeout;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_metrics(mut self, metrics: Arc<dyn WebServerMetrics>) -> Self {
|
||||
self.metrics = Some(metrics);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTP/3 server bound to MTP's configured address and certificate.
|
||||
///
|
||||
/// One task owns the Quinn endpoint and dispatches all HTTP/3 requests. This
|
||||
/// is the required ownership model for adding WebTransport MTP sessions on the
|
||||
/// same UDP socket without competing endpoint accept loops.
|
||||
pub struct MTPWebServer {
|
||||
endpoint: quinn::Endpoint,
|
||||
mtp_incoming: tokio::sync::mpsc::Receiver<Result<WebMTPConnection, mtp_host::AcceptError>>,
|
||||
shutdown_tx: watch::Sender<()>,
|
||||
_driver: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl MTPWebServer {
|
||||
pub async fn new(
|
||||
host_config: HostConfig,
|
||||
web_config: WebServerConfig,
|
||||
) -> Result<Self, CommunicationError> {
|
||||
#[cfg(feature = "crypto")]
|
||||
if !matches!(
|
||||
host_config.authentication_policy,
|
||||
mtp_host::AuthenticationPolicy::Unauthenticated
|
||||
) {
|
||||
return Err(CommunicationError::Other(
|
||||
"web authentication is not supported yet; use Unauthenticated".into(),
|
||||
));
|
||||
}
|
||||
let endpoint = build_endpoint(&host_config)?;
|
||||
let driver_endpoint = endpoint.clone();
|
||||
let (mtp_tx, mtp_incoming) = tokio::sync::mpsc::channel(16);
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(());
|
||||
let connection_semaphore = Arc::new(Semaphore::new(web_config.max_connections));
|
||||
let driver_config = DriverConfig {
|
||||
router: web_config.router,
|
||||
mtp_path: web_config.mtp_path,
|
||||
max_request_body: web_config.max_request_body,
|
||||
request_timeout: web_config.request_timeout,
|
||||
drain_timeout: web_config.drain_timeout,
|
||||
send_pongs: host_config.send_pongs,
|
||||
policy: host_config.policy,
|
||||
metrics: web_config.metrics,
|
||||
};
|
||||
let driver = tokio::spawn(run_driver(
|
||||
driver_endpoint,
|
||||
driver_config,
|
||||
mtp_tx,
|
||||
connection_semaphore,
|
||||
shutdown_rx,
|
||||
));
|
||||
Ok(Self {
|
||||
endpoint,
|
||||
mtp_incoming,
|
||||
shutdown_tx,
|
||||
_driver: Some(driver),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn local_addr(&self) -> SocketAddr {
|
||||
self.endpoint
|
||||
.local_addr()
|
||||
.expect("endpoint has a local address")
|
||||
}
|
||||
|
||||
pub async fn accept(&mut self) -> Result<Option<WebMTPConnection>, mtp_host::AcceptError> {
|
||||
match self.mtp_incoming.recv().await {
|
||||
Some(result) => result.map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal the server to shut down gracefully.
|
||||
///
|
||||
/// Stops accepting new QUIC connections, waits for the driver task to
|
||||
/// finish its accept loop, then allows a configurable drain period for
|
||||
/// in-flight requests to complete before closing the endpoint.
|
||||
pub async fn shutdown(mut self) {
|
||||
let _ = self.shutdown_tx.send(());
|
||||
if let Some(driver) = self._driver.take() {
|
||||
let _ = driver.await;
|
||||
}
|
||||
self.endpoint
|
||||
.close(quinn::VarInt::from_u32(0), b"mtp-webserver shutdown");
|
||||
}
|
||||
|
||||
/// Stop accepting new QUIC connections and wait briefly for the driver to stop.
|
||||
pub async fn close(mut self) {
|
||||
self.endpoint
|
||||
.close(quinn::VarInt::from_u32(0), b"mtp-webserver shutdown");
|
||||
if let Some(driver) = self._driver.take() {
|
||||
driver.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MTPWebServer {
|
||||
fn drop(&mut self) {
|
||||
if let Some(driver) = self._driver.take() {
|
||||
driver.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DriverConfig {
|
||||
router: Router,
|
||||
mtp_path: String,
|
||||
max_request_body: usize,
|
||||
request_timeout: Duration,
|
||||
drain_timeout: Duration,
|
||||
send_pongs: bool,
|
||||
policy: mtp_transport::Policy,
|
||||
metrics: Option<Arc<dyn WebServerMetrics>>,
|
||||
}
|
||||
|
||||
fn build_endpoint(config: &HostConfig) -> Result<quinn::Endpoint, CommunicationError> {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let certificates = rustls::pki_types::CertificateDer::pem_slice_iter(&config.tls_fullchain)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|_| CommunicationError::CertificateLoadFailed)?;
|
||||
let key = PrivateKeyDer::from_pem_slice(&config.tls_key)
|
||||
.map_err(|_| CommunicationError::CertificateParseFailed)?;
|
||||
|
||||
let mut tls = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certificates, key)
|
||||
.map_err(|_| CommunicationError::CertificateLoadFailed)?;
|
||||
tls.alpn_protocols = vec![b"h3".to_vec()];
|
||||
|
||||
let server = quinn::ServerConfig::with_crypto(Arc::new(
|
||||
quinn::crypto::rustls::QuicServerConfig::try_from(tls)
|
||||
.map_err(|error| CommunicationError::Other(error.to_string()))?,
|
||||
));
|
||||
quinn::Endpoint::server(server, SocketAddr::new(config.ip, config.port))
|
||||
.map_err(|error| CommunicationError::Other(error.to_string()))
|
||||
}
|
||||
|
||||
async fn run_driver(
|
||||
endpoint: quinn::Endpoint,
|
||||
config: DriverConfig,
|
||||
mtp_tx: tokio::sync::mpsc::Sender<Result<WebMTPConnection, mtp_host::AcceptError>>,
|
||||
connection_semaphore: Arc<Semaphore>,
|
||||
mut shutdown_rx: watch::Receiver<()>,
|
||||
) {
|
||||
let DriverConfig {
|
||||
router,
|
||||
mtp_path,
|
||||
max_request_body,
|
||||
request_timeout,
|
||||
drain_timeout,
|
||||
send_pongs,
|
||||
policy,
|
||||
metrics,
|
||||
} = config;
|
||||
let mut connection_tasks = tokio::task::JoinSet::new();
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = shutdown_rx.changed() => {
|
||||
break;
|
||||
}
|
||||
incoming = endpoint.accept() => {
|
||||
let Some(incoming) = incoming else {
|
||||
break;
|
||||
};
|
||||
let permit = match connection_semaphore.clone().acquire_owned().await {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => break,
|
||||
};
|
||||
let router = router.clone();
|
||||
let mtp_path = mtp_path.clone();
|
||||
let mtp_tx = mtp_tx.clone();
|
||||
let metrics = metrics.clone();
|
||||
connection_tasks.spawn(async move {
|
||||
let _permit = permit;
|
||||
let connect_start = std::time::Instant::now();
|
||||
let connection = match incoming.await {
|
||||
Ok(connection) => connection,
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "QUIC connection failed during handshake");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut builder = h3::server::builder();
|
||||
builder.enable_extended_connect(true);
|
||||
builder.enable_webtransport(true);
|
||||
builder.enable_datagram(true);
|
||||
builder.max_webtransport_sessions(16);
|
||||
let mut h3 = match builder
|
||||
.build(h3_quinn::Connection::new(connection.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(connection) => connection,
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "HTTP/3 connection setup failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref m) = metrics {
|
||||
m.connection_accepted();
|
||||
}
|
||||
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
loop {
|
||||
let resolver = match h3.accept().await {
|
||||
Ok(Some(resolver)) => resolver,
|
||||
Ok(None) => break,
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "HTTP/3 request accept failed");
|
||||
break;
|
||||
}
|
||||
};
|
||||
let (request, mut stream) = match resolver.resolve_request().await {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "HTTP/3 request parse failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if request.method() == http::Method::CONNECT && request.uri().path() == mtp_path {
|
||||
if request.extensions().get::<h3::ext::Protocol>()
|
||||
!= Some(&h3::ext::Protocol::WEB_TRANSPORT)
|
||||
{
|
||||
let _ = stream
|
||||
.send_response(
|
||||
Response::builder()
|
||||
.status(StatusCode::METHOD_NOT_ALLOWED)
|
||||
.body(())
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let _ = stream.finish().await;
|
||||
continue;
|
||||
}
|
||||
let session = match h3_webtransport::server::WebTransportSession::accept(
|
||||
request, stream, h3,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(session) => Arc::new(session),
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "WebTransport session accept failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
tasks.spawn(run_session_requests(
|
||||
session.clone(),
|
||||
router.clone(),
|
||||
max_request_body,
|
||||
request_timeout,
|
||||
metrics.clone(),
|
||||
));
|
||||
let result =
|
||||
accept_web_connection(session, mtp_path, connection, send_pongs, policy)
|
||||
.await;
|
||||
let _ = mtp_tx.send(result).await;
|
||||
return;
|
||||
}
|
||||
let router = router.clone();
|
||||
let metrics = metrics.clone();
|
||||
tasks.spawn(async move {
|
||||
let path = request.uri().path().to_string();
|
||||
if let Some(ref m) = metrics {
|
||||
m.request_started(&path);
|
||||
}
|
||||
let req_start = std::time::Instant::now();
|
||||
let (response, status) = match tokio::time::timeout(
|
||||
request_timeout,
|
||||
handle_http_request(request, &mut stream, &router, max_request_body),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok((resp, status))) => (resp, status),
|
||||
Ok(Err(_)) => (
|
||||
Http3Response::new(StatusCode::BAD_GATEWAY),
|
||||
StatusCode::BAD_GATEWAY,
|
||||
),
|
||||
Err(_) => (
|
||||
Http3Response::new(StatusCode::REQUEST_TIMEOUT),
|
||||
StatusCode::REQUEST_TIMEOUT,
|
||||
),
|
||||
};
|
||||
let _ = write_response(&mut stream, response).await;
|
||||
if let Some(ref m) = metrics {
|
||||
m.request_completed(&path, status.as_u16(), req_start.elapsed());
|
||||
}
|
||||
});
|
||||
}
|
||||
tasks.join_all().await;
|
||||
if let Some(ref m) = metrics {
|
||||
m.connection_closed(connect_start.elapsed(), "normal");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Drain phase: wait for in-flight connections ---
|
||||
|
||||
endpoint.close(quinn::VarInt::from_u32(0), b"mtp-webserver shutdown");
|
||||
|
||||
let drain_start = std::time::Instant::now();
|
||||
while !connection_tasks.is_empty() {
|
||||
tokio::select! {
|
||||
Some(result) = connection_tasks.join_next() => {
|
||||
if let Err(e) = result {
|
||||
tracing::warn!("Connection task panicked: {}", e);
|
||||
}
|
||||
}
|
||||
_ = tokio::time::sleep(drain_timeout.saturating_sub(drain_start.elapsed())) => {
|
||||
tracing::warn!(
|
||||
"Drain timeout expired with {} connections still in flight",
|
||||
connection_tasks.len()
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connection_tasks.shutdown().await;
|
||||
}
|
||||
|
||||
async fn handle_http_request<S>(
|
||||
request: Request<()>,
|
||||
stream: &mut h3::server::RequestStream<S, Bytes>,
|
||||
router: &Router,
|
||||
max_request_body: usize,
|
||||
) -> Result<(Http3Response, StatusCode), WebServerError>
|
||||
where
|
||||
S: h3::quic::BidiStream<Bytes>,
|
||||
{
|
||||
let (request, too_large) = read_request(request, stream, max_request_body)
|
||||
.await
|
||||
.map_err(|e| WebServerError::Http(format!("request body read failed: {e}")))?;
|
||||
if too_large {
|
||||
return Ok((
|
||||
Http3Response::new(StatusCode::PAYLOAD_TOO_LARGE),
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
));
|
||||
}
|
||||
let path = request.uri.path().to_string();
|
||||
let handler = router
|
||||
.handler(&request.method, &path)
|
||||
.or_else(|| router.fallback_handler());
|
||||
match handler {
|
||||
Some(handler) => {
|
||||
let response = handler(request, Http3Response::default()).await;
|
||||
let status = response.status;
|
||||
Ok((response, status))
|
||||
}
|
||||
None => Ok((
|
||||
Http3Response::new(StatusCode::NOT_FOUND),
|
||||
StatusCode::NOT_FOUND,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_session_requests(
|
||||
session: Arc<h3_webtransport::server::WebTransportSession<h3_quinn::Connection, Bytes>>,
|
||||
router: Router,
|
||||
max_request_body: usize,
|
||||
request_timeout: Duration,
|
||||
metrics: Option<Arc<dyn WebServerMetrics>>,
|
||||
) {
|
||||
loop {
|
||||
match session.accept_bi().await {
|
||||
Ok(Some(h3_webtransport::server::AcceptedBi::Request(request, mut stream))) => {
|
||||
let router = router.clone();
|
||||
let metrics = metrics.clone();
|
||||
tokio::spawn(async move {
|
||||
let path = request.uri().path().to_string();
|
||||
if let Some(ref m) = metrics {
|
||||
m.request_started(&path);
|
||||
}
|
||||
let req_start = std::time::Instant::now();
|
||||
let (response, status) = match tokio::time::timeout(
|
||||
request_timeout,
|
||||
handle_http_request(request, &mut stream, &router, max_request_body),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok((resp, status))) => (resp, status),
|
||||
Ok(Err(_)) => (
|
||||
Http3Response::new(StatusCode::BAD_GATEWAY),
|
||||
StatusCode::BAD_GATEWAY,
|
||||
),
|
||||
Err(_) => (
|
||||
Http3Response::new(StatusCode::REQUEST_TIMEOUT),
|
||||
StatusCode::REQUEST_TIMEOUT,
|
||||
),
|
||||
};
|
||||
let _ = write_response(&mut stream, response).await;
|
||||
if let Some(ref m) = metrics {
|
||||
m.request_completed(&path, status.as_u16(), req_start.elapsed());
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(Some(h3_webtransport::server::AcceptedBi::BidiStream(_, _))) => {}
|
||||
Ok(None) | Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_request<S>(
|
||||
request: Request<()>,
|
||||
stream: &mut h3::server::RequestStream<S, Bytes>,
|
||||
max_body: usize,
|
||||
) -> Result<(Http3Request, bool), h3::error::StreamError>
|
||||
where
|
||||
S: h3::quic::BidiStream<Bytes>,
|
||||
{
|
||||
let (parts, _) = request.into_parts();
|
||||
let mut body = Vec::new();
|
||||
let mut too_large = false;
|
||||
while let Some(chunk) = stream.recv_data().await? {
|
||||
if body.len().saturating_add(chunk.remaining()) > max_body {
|
||||
too_large = true;
|
||||
break;
|
||||
}
|
||||
body.extend_from_slice(chunk.chunk());
|
||||
}
|
||||
Ok((
|
||||
Http3Request {
|
||||
method: parts.method,
|
||||
uri: parts.uri,
|
||||
headers: parts.headers,
|
||||
body: (!body.is_empty()).then(|| Bytes::from(body)),
|
||||
},
|
||||
too_large,
|
||||
))
|
||||
}
|
||||
|
||||
async fn write_response<S>(
|
||||
stream: &mut h3::server::RequestStream<S, Bytes>,
|
||||
response: Http3Response,
|
||||
) -> Result<(), h3::error::StreamError>
|
||||
where
|
||||
S: h3::quic::BidiStream<Bytes>,
|
||||
{
|
||||
let mut builder = Response::builder().status(response.status);
|
||||
for (name, value) in &response.headers {
|
||||
builder = builder.header(name, value);
|
||||
}
|
||||
stream
|
||||
.send_response(builder.body(()).expect("valid HTTP response"))
|
||||
.await?;
|
||||
for chunk in response.body {
|
||||
stream.send_data(chunk).await?;
|
||||
}
|
||||
if let Some(mut chunks) = response.stream {
|
||||
while let Some(chunk) = chunks.recv().await {
|
||||
stream.send_data(chunk).await?;
|
||||
}
|
||||
}
|
||||
stream.finish().await
|
||||
}
|
||||
92
mtp-webserver/src/stream.rs
Normal file
92
mtp-webserver/src/stream.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
use bytes::Bytes;
|
||||
use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// An owned HTTP/3 request passed to a route handler.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Http3Request {
|
||||
pub method: Method,
|
||||
pub uri: Uri,
|
||||
pub headers: HeaderMap,
|
||||
pub body: Option<Bytes>,
|
||||
}
|
||||
|
||||
/// A buffered HTTP/3 response returned from a route handler.
|
||||
pub struct Http3Response {
|
||||
pub status: StatusCode,
|
||||
pub headers: HeaderMap,
|
||||
pub body: Vec<Bytes>,
|
||||
pub(crate) stream: Option<mpsc::Receiver<Bytes>>,
|
||||
}
|
||||
|
||||
impl Http3Response {
|
||||
pub fn new(status: StatusCode) -> Self {
|
||||
Self {
|
||||
status,
|
||||
headers: HeaderMap::new(),
|
||||
body: Vec::new(),
|
||||
stream: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(mut self, status: StatusCode) -> Self {
|
||||
self.status = status;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn header(mut self, key: &str, value: &str) -> Self {
|
||||
match (key.parse::<HeaderName>(), value.parse::<HeaderValue>()) {
|
||||
(Ok(key), Ok(value)) => {
|
||||
self.headers.insert(key, value);
|
||||
}
|
||||
(Err(error), _) => {
|
||||
tracing::warn!(%error, key, "discarding invalid HTTP response header")
|
||||
}
|
||||
(_, Err(error)) => {
|
||||
tracing::warn!(%error, key, "discarding invalid HTTP response header")
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
pub fn try_header(mut self, key: &str, value: &str) -> Result<Self, String> {
|
||||
let key = key.parse::<HeaderName>().map_err(|e| e.to_string())?;
|
||||
let value = value.parse::<HeaderValue>().map_err(|e| e.to_string())?;
|
||||
self.headers.insert(key, value);
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn body(mut self, chunk: impl Into<Bytes>) -> Self {
|
||||
self.body.push(chunk.into());
|
||||
self
|
||||
}
|
||||
/// Stream response chunks as they become available instead of buffering them.
|
||||
pub fn stream(mut self, chunks: mpsc::Receiver<Bytes>) -> Self {
|
||||
self.stream = Some(chunks);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Http3Response {
|
||||
fn default() -> Self {
|
||||
Self::new(StatusCode::OK)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn response_collects_headers_and_body_chunks() {
|
||||
let response = Http3Response::new(StatusCode::CREATED)
|
||||
.header("content-type", "text/plain")
|
||||
.body("hello")
|
||||
.body(" world");
|
||||
assert_eq!(response.status, StatusCode::CREATED);
|
||||
assert_eq!(response.headers["content-type"], "text/plain");
|
||||
assert_eq!(
|
||||
response.body,
|
||||
vec![Bytes::from("hello"), Bytes::from(" world")]
|
||||
);
|
||||
}
|
||||
}
|
||||
198
mtp-webserver/src/transport.rs
Normal file
198
mtp-webserver/src/transport.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
use bytes::Bytes;
|
||||
use mtp_codec::{
|
||||
DataType, DataValue, Version,
|
||||
registry::{Registry, VersionedCodec},
|
||||
};
|
||||
use mtp_common::CommunicationError;
|
||||
use mtp_host::AcceptError;
|
||||
use mtp_transport::{
|
||||
GenericReceiver, GenericSender, Policy, TransportConnection, TransportRecvStream,
|
||||
TransportSendStream,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
type Session = h3_webtransport::server::WebTransportSession<h3_quinn::Connection, Bytes>;
|
||||
type H3SendStream = h3_webtransport::stream::SendStream<h3_quinn::SendStream<Bytes>, Bytes>;
|
||||
type H3RecvStream = h3_webtransport::stream::RecvStream<h3_quinn::RecvStream, Bytes>;
|
||||
|
||||
/// h3-webtransport implementation of MTP's transport connection boundary.
|
||||
///
|
||||
/// This is intentionally separate from [`WebMTPConnection`]: it is the
|
||||
/// adapter used by the in-progress migration of `mtp_transport::Sender` and
|
||||
/// `Receiver` away from concrete wtransport stream types.
|
||||
#[derive(Clone)]
|
||||
pub struct H3TransportConnection {
|
||||
session: Arc<Session>,
|
||||
quinn: quinn::Connection,
|
||||
}
|
||||
|
||||
pub struct H3TransportSender {
|
||||
stream: H3SendStream,
|
||||
}
|
||||
|
||||
pub struct H3TransportReceiver {
|
||||
stream: H3RecvStream,
|
||||
}
|
||||
|
||||
impl H3TransportConnection {
|
||||
pub(crate) fn new(session: Arc<Session>, quinn: quinn::Connection) -> Self {
|
||||
Self { session, quinn }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TransportSendStream for H3TransportSender {
|
||||
async fn write_all(&mut self, buf: &[u8]) -> Result<(), CommunicationError> {
|
||||
self.stream
|
||||
.write_all(buf)
|
||||
.await
|
||||
.map_err(|_| CommunicationError::StreamError)
|
||||
}
|
||||
|
||||
async fn finish(&mut self) -> Result<(), CommunicationError> {
|
||||
self.stream
|
||||
.shutdown()
|
||||
.await
|
||||
.map_err(|_| CommunicationError::StreamError)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TransportRecvStream for H3TransportReceiver {
|
||||
async fn read_exact(&mut self, buf: &mut [u8]) -> Result<(), CommunicationError> {
|
||||
self.stream
|
||||
.read_exact(buf)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|_| CommunicationError::StreamError)
|
||||
}
|
||||
|
||||
async fn read_chunk(&mut self, max: usize) -> Result<Option<Vec<u8>>, CommunicationError> {
|
||||
let mut buf = vec![0; max];
|
||||
match self.stream.read(&mut buf).await {
|
||||
Ok(0) => Ok(None),
|
||||
Ok(size) => {
|
||||
buf.truncate(size);
|
||||
Ok(Some(buf))
|
||||
}
|
||||
Err(_) => Err(CommunicationError::StreamError),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncWrite for H3TransportSender {
|
||||
fn poll_write(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
std::pin::Pin::new(&mut self.stream).poll_write(cx, buf)
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::pin::Pin::new(&mut self.stream).poll_flush(cx)
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::pin::Pin::new(&mut self.stream).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncRead for H3TransportReceiver {
|
||||
fn poll_read(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::pin::Pin::new(&mut self.stream).poll_read(cx, buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TransportConnection for H3TransportConnection {
|
||||
type SendStream = H3TransportSender;
|
||||
type RecvStream = H3TransportReceiver;
|
||||
|
||||
async fn open_uni(&self) -> Result<Self::SendStream, CommunicationError> {
|
||||
self.session
|
||||
.open_uni(self.session.session_id())
|
||||
.await
|
||||
.map(|stream| H3TransportSender { stream })
|
||||
.map_err(|_| CommunicationError::StreamError)
|
||||
}
|
||||
|
||||
async fn accept_uni(&self) -> Result<Self::RecvStream, CommunicationError> {
|
||||
loop {
|
||||
match self.session.accept_uni().await {
|
||||
Ok(Some((id, stream))) if id == self.session.session_id() => {
|
||||
return Ok(H3TransportReceiver { stream });
|
||||
}
|
||||
Ok(Some(_)) => continue,
|
||||
Ok(None) => return Err(CommunicationError::StreamClosed),
|
||||
Err(_) => return Err(CommunicationError::StreamError),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn close_reason(&self) -> Option<CommunicationError> {
|
||||
self.quinn
|
||||
.close_reason()
|
||||
.map(|_| CommunicationError::StreamClosed)
|
||||
}
|
||||
|
||||
fn close(&self, code: u32, reason: &[u8]) {
|
||||
self.quinn.close(quinn::VarInt::from_u32(code), reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared host MTP connection instantiated with HTTP/3 stream adapters.
|
||||
pub type WebMtpSender = GenericSender<H3TransportConnection>;
|
||||
pub type WebMtpReceiver = GenericReceiver<H3TransportConnection>;
|
||||
pub type WebMTPConnection = mtp_host::MTPConnection<WebMtpSender, WebMtpReceiver>;
|
||||
|
||||
pub(crate) async fn accept_web_connection(
|
||||
session: Arc<Session>,
|
||||
path: String,
|
||||
quinn: quinn::Connection,
|
||||
send_pongs: bool,
|
||||
policy: Policy,
|
||||
) -> Result<WebMTPConnection, AcceptError> {
|
||||
let transport = H3TransportConnection::new(session, quinn);
|
||||
let policy = Arc::new(policy);
|
||||
let receiver = WebMtpReceiver::new(transport.clone(), policy.clone());
|
||||
|
||||
let first = receiver.receive().await.map_err(AcceptError::Receive)?;
|
||||
let version = match first.get_data(DataType::Version) {
|
||||
DataValue::Str(value) => Version::parse(value).ok_or(AcceptError::MissingVersion)?,
|
||||
_ => return Err(AcceptError::MissingVersion),
|
||||
};
|
||||
let registry = Registry::builtin();
|
||||
let negotiated = registry
|
||||
.negotiate(std::slice::from_ref(&version))
|
||||
.ok_or_else(|| AcceptError::UnsupportedVersion(version.clone()))?;
|
||||
let codec = VersionedCodec::for_version(registry, negotiated.clone())
|
||||
.ok_or_else(|| AcceptError::UnsupportedVersion(negotiated.clone()))?;
|
||||
let description = match first.get_data(DataType::Description) {
|
||||
DataValue::Str(value) => Some(value.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let sender = WebMtpSender::new(transport, policy);
|
||||
if send_pongs {
|
||||
receiver.respond_to_pings(sender.clone()).await;
|
||||
}
|
||||
Ok(mtp_host::MTPConnection::from_transport_parts(
|
||||
negotiated,
|
||||
codec,
|
||||
sender,
|
||||
receiver,
|
||||
path,
|
||||
description,
|
||||
))
|
||||
}
|
||||
205
mtp-webserver/tests/integration.rs
Normal file
205
mtp-webserver/tests/integration.rs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
use http::StatusCode;
|
||||
use mtp_webserver::{MTPWebServer, WebServerConfig, WebServerError, WebServerMetrics};
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
fn generate_self_signed_cert() -> (Vec<u8>, Vec<u8>) {
|
||||
let key_pair = rcgen::KeyPair::generate().expect("failed to generate self-signed key pair");
|
||||
let params = rcgen::CertificateParams::new(vec!["localhost".into(), "127.0.0.1".into()])
|
||||
.expect("failed to build self-signed certificate params");
|
||||
let cert = params
|
||||
.self_signed(&key_pair)
|
||||
.expect("failed to self-sign certificate");
|
||||
(
|
||||
cert.pem().into_bytes(),
|
||||
key_pair.serialize_pem().into_bytes(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_builder_defaults() {
|
||||
let config = WebServerConfig::new();
|
||||
assert_eq!(config.max_request_body, 4 * 1024 * 1024);
|
||||
assert_eq!(config.max_connections, 256);
|
||||
assert_eq!(config.request_timeout, Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_builder_chain() {
|
||||
let config = WebServerConfig::new()
|
||||
.max_connections(64)
|
||||
.max_request_body(1024)
|
||||
.request_timeout(Duration::from_secs(5))
|
||||
.mtp_path("/ws");
|
||||
assert_eq!(config.max_connections, 64);
|
||||
assert_eq!(config.max_request_body, 1024);
|
||||
assert_eq!(config.request_timeout, Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_builder_routes() {
|
||||
let config = WebServerConfig::new()
|
||||
.route(
|
||||
"/health",
|
||||
|_, resp| async move { resp.status(StatusCode::OK) },
|
||||
)
|
||||
.unwrap()
|
||||
.route("/data", |_, resp| async move {
|
||||
resp.status(StatusCode::NO_CONTENT)
|
||||
})
|
||||
.unwrap();
|
||||
let config = config.mtp_path("/");
|
||||
drop(config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_duplicate_route_errors() {
|
||||
let result = WebServerConfig::new()
|
||||
.route("/dup", |_, resp| async move { resp })
|
||||
.unwrap()
|
||||
.route("/dup", |_, resp| async move { resp });
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_display() {
|
||||
let err = WebServerError::WebTransport("session rejected".into());
|
||||
assert_eq!(err.to_string(), "webtransport error: session rejected");
|
||||
|
||||
let err = WebServerError::NotFound("/api".into());
|
||||
assert_eq!(err.to_string(), "route not found: /api");
|
||||
|
||||
let err = WebServerError::Http("body too large".into());
|
||||
assert_eq!(err.to_string(), "HTTP error: body too large");
|
||||
|
||||
let err = WebServerError::Transport(mtp_common::CommunicationError::StreamClosed);
|
||||
assert_eq!(err.to_string(), "transport error: Stream Closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_from_communication_error() {
|
||||
let comm_err = mtp_common::CommunicationError::StreamError;
|
||||
let web_err: WebServerError = comm_err.into();
|
||||
assert!(matches!(web_err, WebServerError::Transport(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_source_chain() {
|
||||
let inner = mtp_common::CommunicationError::StreamClosed;
|
||||
let err = WebServerError::Transport(inner);
|
||||
let source = std::error::Error::source(&err);
|
||||
assert!(source.is_some());
|
||||
}
|
||||
|
||||
struct TestMetrics {
|
||||
connections_accepted: AtomicUsize,
|
||||
connections_closed: AtomicUsize,
|
||||
requests_started: AtomicUsize,
|
||||
requests_completed: AtomicUsize,
|
||||
errors: AtomicUsize,
|
||||
}
|
||||
|
||||
impl TestMetrics {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
connections_accepted: AtomicUsize::new(0),
|
||||
connections_closed: AtomicUsize::new(0),
|
||||
requests_started: AtomicUsize::new(0),
|
||||
requests_completed: AtomicUsize::new(0),
|
||||
errors: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WebServerMetrics for TestMetrics {
|
||||
fn connection_accepted(&self) {
|
||||
self.connections_accepted.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
fn connection_closed(&self, _duration: Duration, _reason: &str) {
|
||||
self.connections_closed.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
fn request_started(&self, _path: &str) {
|
||||
self.requests_started.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
fn request_completed(&self, _path: &str, _status: u16, _duration: Duration) {
|
||||
self.requests_completed.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
fn error_occurred(&self, _error: &WebServerError) {
|
||||
self.errors.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_trait_defaults_compile() {
|
||||
struct NoopMetrics;
|
||||
impl WebServerMetrics for NoopMetrics {}
|
||||
let m = NoopMetrics;
|
||||
m.connection_accepted();
|
||||
m.connection_closed(Duration::from_secs(1), "test");
|
||||
m.request_started("/test");
|
||||
m.request_completed("/test", 200, Duration::from_millis(50));
|
||||
m.error_occurred(&WebServerError::NotFound("x".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_with_metrics() {
|
||||
let metrics: Arc<dyn WebServerMetrics> = Arc::new(TestMetrics::new());
|
||||
let config = WebServerConfig::new().with_metrics(metrics);
|
||||
let metrics: Arc<dyn WebServerMetrics> = Arc::new(TestMetrics::new());
|
||||
let config = config.with_metrics(metrics);
|
||||
drop(config);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_constructs_with_self_signed_cert() {
|
||||
let (cert_pem, key_pem) = generate_self_signed_cert();
|
||||
let host_config = mtp_host::HostConfig::new(
|
||||
IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
|
||||
0,
|
||||
cert_pem,
|
||||
key_pem,
|
||||
);
|
||||
let web_config = WebServerConfig::new();
|
||||
let server = MTPWebServer::new(host_config, web_config).await;
|
||||
assert!(server.is_ok());
|
||||
let server = server.unwrap();
|
||||
let addr = server.local_addr();
|
||||
assert!(addr.port() > 0);
|
||||
server.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_with_metrics_constructs() {
|
||||
let (cert_pem, key_pem) = generate_self_signed_cert();
|
||||
let host_config = mtp_host::HostConfig::new(
|
||||
IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
|
||||
0,
|
||||
cert_pem,
|
||||
key_pem,
|
||||
);
|
||||
let metrics: Arc<dyn WebServerMetrics> = Arc::new(TestMetrics::new());
|
||||
let web_config = WebServerConfig::new()
|
||||
.max_connections(10)
|
||||
.request_timeout(Duration::from_secs(10))
|
||||
.with_metrics(metrics);
|
||||
let server = MTPWebServer::new(host_config, web_config).await;
|
||||
assert!(server.is_ok());
|
||||
server.unwrap().close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn graceful_shutdown_completes() {
|
||||
let (cert_pem, key_pem) = generate_self_signed_cert();
|
||||
let host_config = mtp_host::HostConfig::new(
|
||||
IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
|
||||
0,
|
||||
cert_pem,
|
||||
key_pem,
|
||||
);
|
||||
let server = MTPWebServer::new(host_config, WebServerConfig::new())
|
||||
.await
|
||||
.unwrap();
|
||||
server.shutdown().await;
|
||||
}
|
||||
Loading…
Reference in a new issue