maincopy-server 0.1.0

Self-hosted publishing server with exact previews and explicit release approval
Documentation
use std::{io, net::SocketAddr};

use axum::Router;
use tokio::net::TcpListener;
use tokio_util::sync::CancellationToken;

use super::{PublicState, connection::PublicListener, public_router};

/// Bound public HTTP server and its request-facing dependencies.
pub(crate) struct PublicServer {
    pub(crate) local_addr: SocketAddr,
    listener: TcpListener,
    router: Router,
}

impl PublicServer {
    pub(crate) async fn bind(bind: SocketAddr, state: PublicState) -> io::Result<Self> {
        Self::bind_router(bind, public_router(state)).await
    }

    pub(crate) async fn bind_router(bind: SocketAddr, router: Router) -> io::Result<Self> {
        let listener = TcpListener::bind(bind).await?;
        let local_addr = listener.local_addr()?;
        Ok(Self {
            local_addr,
            listener,
            router,
        })
    }

    pub(crate) async fn serve(self, cancellation: CancellationToken) -> io::Result<()> {
        axum::serve(PublicListener::new(self.listener), self.router)
            .with_graceful_shutdown(cancellation.cancelled_owned())
            .await
    }
}

#[cfg(test)]
mod tests {
    use std::{net::Ipv4Addr, path::Path, sync::Arc, time::Duration};

    use tokio::{net::TcpSocket, time::timeout};

    use super::*;
    use crate::{
        domain::publication::PublicLedgerProjection,
        frontend_assets::embedded_manifest,
        render::{SiteSnapshotReader, compile_content_catalog, render_site_shell},
        web::Readiness,
    };
    use markdown_compiler::{ContentTreeLimits, discover_content_tree, prepare_content};

    fn public_state() -> PublicState {
        let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/content");
        let tree = discover_content_tree(&root, ContentTreeLimits::default()).unwrap();
        let content = prepare_content(&tree).unwrap();
        let catalog = Arc::new(compile_content_catalog(&content).unwrap());
        let ledger = PublicLedgerProjection::empty();
        let shell = render_site_shell(catalog, embedded_manifest(), &ledger).unwrap();
        let snapshot = shell.into_snapshot().unwrap();
        PublicState {
            snapshots: SiteSnapshotReader::from_snapshot(snapshot),
            readiness: Readiness::new(true),
        }
    }

    #[tokio::test]
    async fn ephemeral_listener_serves_the_public_router() {
        let cancellation = CancellationToken::new();
        let server = PublicServer::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), public_state())
            .await
            .unwrap();
        let address = server.local_addr;
        let serving = tokio::spawn(server.serve(cancellation.clone()));
        let client = reqwest::Client::builder()
            .no_proxy()
            .timeout(Duration::from_secs(2))
            .build()
            .unwrap();

        let response = client
            .get(format!("http://{address}/health/live"))
            .send()
            .await
            .unwrap();

        assert!(address.ip().is_loopback());
        assert_ne!(address.port(), 0);
        assert_eq!(response.status().as_u16(), 200);
        assert_eq!(response.text().await.unwrap(), r#"{"status":"live"}"#);

        cancellation.cancel();
        timeout(Duration::from_secs(2), serving)
            .await
            .unwrap()
            .unwrap()
            .unwrap();
    }

    #[tokio::test]
    async fn cancellation_stops_the_server_and_releases_its_address() {
        let cancellation = CancellationToken::new();
        // Retain the port across shutdown so another test's port-zero bind
        // cannot claim it before the release assertion.
        let reservation = TcpSocket::new_v4().unwrap();
        reservation.set_reuseaddr(true).unwrap();
        reservation.bind((Ipv4Addr::LOCALHOST, 0).into()).unwrap();
        let address = reservation.local_addr().unwrap();
        let server = PublicServer::bind(address, public_state()).await.unwrap();
        assert_eq!(server.local_addr, address);
        let serving = tokio::spawn(server.serve(cancellation.clone()));
        tokio::task::yield_now().await;

        cancellation.cancel();
        timeout(Duration::from_secs(2), serving)
            .await
            .unwrap()
            .unwrap()
            .unwrap();

        let rebound = TcpListener::bind(address).await.unwrap();
        assert_eq!(rebound.local_addr().unwrap(), address);
        drop(rebound);
        drop(reservation);
    }
}