vyuh 0.2.11

Vyuh web framework for Axum and SQLx with handler-first APIs
Documentation
use std::{
    sync::{
        Arc,
        atomic::{AtomicUsize, Ordering},
    },
    time::Duration,
};

use vyuh::{
    Site, SiteConf, bundles,
    db::DbPool,
    routes::Html,
    services::{
        Service, ServiceBuildContext, ServiceError, ServiceExposer, ServiceInstance, ServiceRef,
        ServiceRunner,
    },
    testing::TestClient,
};

fn test_conf() -> SiteConf {
    SiteConf {
        log_init: false,
        logging: vyuh::logging::LoggingConf {
            env_prefix: None,
            rules: vec![],
        },
        ..SiteConf::default()
    }
}

#[derive(Default)]
struct CounterService {
    value: AtomicUsize,
}

impl CounterService {
    fn increment(&self) -> usize {
        self.value.fetch_add(1, Ordering::SeqCst) + 1
    }
}

impl Service for CounterService {}

#[bundles::service]
async fn macro_counter_service() -> ServiceInstance<CounterService> {
    CounterService::default().into()
}

async fn direct_counter_service() -> ServiceInstance<CounterService> {
    CounterService::default().into()
}

#[bundles::route(path = "/count")]
async fn count(counter: ServiceRef<CounterService>) -> Html<String> {
    Html(counter.increment().to_string())
}

#[tokio::test]
async fn services_can_be_retrieved_from_site() {
    let site = vyuh::Site::build(
        test_conf(),
        bundles::bundle! {
            macro_counter_service,
        },
    )
    .await
    .unwrap();

    let counter = site.service::<CounterService>().unwrap();
    assert_eq!(counter.increment(), 1);
    site.shutdown_and_wait().await;
}

#[tokio::test]
async fn services_ref_works_in_routes() {
    let site = vyuh::Site::build(
        test_conf(),
        bundles::bundle! {
            macro_counter_service,
            count,
        },
    )
    .await
    .unwrap();
    let client = TestClient::new(site.clone());

    client
        .get("/count")
        .send()
        .await
        .assert_text(axum::http::StatusCode::OK, "1")
        .await;
    client
        .get("/count")
        .send()
        .await
        .assert_text(axum::http::StatusCode::OK, "2")
        .await;
    site.shutdown_and_wait().await;
}

#[tokio::test]
async fn services_direct_registration_matches_macro_registration() {
    let site = vyuh::Site::build(
        test_conf(),
        bundles::bundle([bundles::service(direct_counter_service)]),
    )
    .await
    .unwrap();

    let counter = site.service::<CounterService>().unwrap();
    assert_eq!(counter.increment(), 1);
    site.shutdown_and_wait().await;
}

#[tokio::test]
async fn services_duplicate_concrete_services_fail_site_build() {
    async fn one() -> ServiceInstance<CounterService> {
        CounterService::default().into()
    }

    async fn two() -> ServiceInstance<CounterService> {
        CounterService::default().into()
    }

    let err = vyuh::Site::build(
        test_conf(),
        bundles::bundle([bundles::service(one), bundles::service(two)]),
    )
    .await
    .unwrap_err();

    assert!(format!("{err:?}").contains("AlreadyRegistered"));
}

trait Greeting: Send + Sync {
    fn greeting(&self) -> &'static str;
}

struct GreetingService;

impl Greeting for GreetingService {
    fn greeting(&self) -> &'static str {
        "hello"
    }
}

impl Service for GreetingService {
    fn expose(exposer: &mut ServiceExposer<Self>) -> Result<(), ServiceError> {
        exposer.expose(|service| service as Arc<dyn Greeting>)
    }
}

#[bundles::service]
async fn greeting_service() -> ServiceInstance<GreetingService> {
    GreetingService.into()
}

#[tokio::test]
async fn services_trait_facade_exposure_returns_trait_object() {
    let site = vyuh::Site::build(
        test_conf(),
        bundles::bundle! {
            greeting_service,
        },
    )
    .await
    .unwrap();

    let greeting = site.service::<dyn Greeting>().unwrap();
    assert_eq!(greeting.greeting(), "hello");
    site.shutdown_and_wait().await;
}

#[tokio::test]
async fn services_duplicate_trait_facades_fail_site_build() {
    struct OtherGreetingService;

    impl Greeting for OtherGreetingService {
        fn greeting(&self) -> &'static str {
            "other"
        }
    }

    impl Service for OtherGreetingService {
        fn expose(exposer: &mut ServiceExposer<Self>) -> Result<(), ServiceError> {
            exposer.expose(|service| service as Arc<dyn Greeting>)
        }
    }

    async fn other_greeting_service() -> ServiceInstance<OtherGreetingService> {
        OtherGreetingService.into()
    }

    let err = vyuh::Site::build(
        test_conf(),
        bundles::bundle([
            bundles::service(greeting_service),
            bundles::service(other_greeting_service),
        ]),
    )
    .await
    .unwrap_err();

    assert!(format!("{err:?}").contains("dyn services::Greeting"));
}

struct DbBackedService {
    _db: DbPool,
}

impl Service for DbBackedService {}

async fn db_backed_service(db: DbPool) -> ServiceInstance<DbBackedService> {
    DbBackedService { _db: db }.into()
}

struct ContextBuiltService {
    _db: DbPool,
}

impl Service for ContextBuiltService {}

async fn context_built_service(ctx: ServiceBuildContext) -> ServiceInstance<ContextBuiltService> {
    ContextBuiltService { _db: ctx.db() }.into()
}

#[tokio::test]
async fn services_build_handlers_can_extract_db_pool() {
    let site = vyuh::Site::build(
        test_conf(),
        bundles::bundle([bundles::service(db_backed_service)]),
    )
    .await
    .unwrap();

    assert!(site.service::<DbBackedService>().is_ok());
    site.shutdown_and_wait().await;
}

#[tokio::test]
async fn services_build_handlers_can_extract_build_context() {
    let site = vyuh::Site::build(
        test_conf(),
        bundles::bundle([bundles::service(context_built_service)]),
    )
    .await
    .unwrap();

    assert!(site.service::<ContextBuiltService>().is_ok());
    site.shutdown_and_wait().await;
}

struct WorkerProbe {
    calls: Arc<AtomicUsize>,
}

impl Service for WorkerProbe {
    fn run(&mut self, runner: &mut ServiceRunner) -> Result<(), ServiceError> {
        let calls = self.calls.clone();
        runner.run("probe-worker", move |site: Site| {
            let calls = calls.clone();
            async move {
                let _ = site.uptime();
                calls.fetch_add(1, Ordering::SeqCst);
                Ok(())
            }
        })
    }
}

async fn worker_probe_service() -> ServiceInstance<WorkerProbe> {
    WorkerProbe {
        calls: Arc::new(AtomicUsize::new(0)),
    }
    .into()
}

#[tokio::test]
async fn services_worker_starts_and_can_extract_site() {
    let site = vyuh::Site::build(
        test_conf(),
        bundles::bundle([bundles::service(worker_probe_service)]),
    )
    .await
    .unwrap();

    tokio::time::sleep(Duration::from_millis(50)).await;
    let probe = site.service::<WorkerProbe>().unwrap();
    assert_eq!(probe.calls.load(Ordering::SeqCst), 1);
    site.shutdown_and_wait().await;
}

struct FailingWorkerProbe {
    calls: Arc<AtomicUsize>,
}

impl Service for FailingWorkerProbe {
    fn run(&mut self, runner: &mut ServiceRunner) -> Result<(), ServiceError> {
        let calls = self.calls.clone();
        runner.run("failing-worker", move || {
            let calls = calls.clone();
            async move {
                calls.fetch_add(1, Ordering::SeqCst);
                Err(ServiceError::NotFound("expected worker failure".into()))
            }
        })
    }
}

async fn failing_worker_probe_service() -> ServiceInstance<FailingWorkerProbe> {
    FailingWorkerProbe {
        calls: Arc::new(AtomicUsize::new(0)),
    }
    .into()
}

#[tokio::test]
async fn services_worker_error_stops_worker_without_crashing_site() {
    let site = vyuh::Site::build(
        test_conf(),
        bundles::bundle([bundles::service(failing_worker_probe_service)]),
    )
    .await
    .unwrap();

    tokio::time::sleep(Duration::from_millis(50)).await;
    let probe = site.service::<FailingWorkerProbe>().unwrap();
    assert_eq!(probe.calls.load(Ordering::SeqCst), 1);
    site.shutdown_and_wait().await;
}