camber 0.2.0

Opinionated async Rust for IO-bound services on top of Tokio
Documentation
use crate::runtime_support as common;

use camber::http::{self, Request, Response, Router};
use camber::runtime;
use std::num::NonZeroUsize;
use std::time::Duration;

const GENERATED_ROUTE_CASES: u64 = 48;
const ROUTE_MATRIX_BOUND: NonZeroUsize = NonZeroUsize::new(5).unwrap();
const CAPTURE_BOUND: NonZeroUsize = NonZeroUsize::new(4).unwrap();
const CAPTURES: [&str; 4] = ["alpha", "bravo", "charlie", "delta"];

fn route_matrix_router() -> Router {
    let mut router = Router::new();

    // Register least-specific first. Lookup precedence, not insertion order,
    // must select static, then parameter, then wildcard routes.
    router.get("/matrix/*tail", |req: &Request| {
        let tail = req.param("tail").unwrap_or("missing").to_owned();
        async move { Response::text(200, &format!("wildcard:{tail}")) }
    });
    router.get("/matrix/:item/:leaf", |req: &Request| {
        let item = req.param("item").unwrap_or("missing").to_owned();
        let leaf = req.param("leaf").unwrap_or("missing").to_owned();
        async move { Response::text(200, &format!("pair:{item}:{leaf}")) }
    });
    router.get("/matrix/:item/detail", |req: &Request| {
        let item = req.param("item").unwrap_or("missing").to_owned();
        async move { Response::text(200, &format!("param:{item}")) }
    });
    router.get("/matrix/fixed/detail", |_req: &Request| async {
        Response::text(200, "static")
    });

    router
}

fn generated_route(case: &mut crate::deterministic::DeterministicCase) -> (String, String) {
    let first = CAPTURES[case.bounded(CAPTURE_BOUND)];
    let second = CAPTURES[case.bounded(CAPTURE_BOUND)];

    match case.bounded(ROUTE_MATRIX_BOUND) {
        0 => ("/matrix/fixed/detail".to_owned(), "static".to_owned()),
        1 => (format!("/matrix/{first}/detail"), format!("param:{first}")),
        2 => (
            format!("/matrix/{first}/{second}"),
            format!("pair:{first}:{second}"),
        ),
        3 => (
            format!("/matrix/fixed/{second}"),
            format!("pair:fixed:{second}"),
        ),
        _ => (
            format!("/matrix/{first}/{second}/extra"),
            format!("wildcard:{first}/{second}/extra"),
        ),
    }
}

#[tokio::test(flavor = "multi_thread")]
async fn generated_route_precedence_and_capture_matrix_is_stable() {
    let server =
        crate::http::spawn_server_ready(route_matrix_router(), Duration::from_secs(2)).unwrap();
    let generator = crate::deterministic::DeterministicGenerator::stable();

    for index in 0..GENERATED_ROUTE_CASES {
        let mut case = generator.case(index);
        let (path, expected) = generated_route(&mut case);
        let response = http::get(&format!("http://{}{path}", server.local_addr()))
            .await
            .unwrap();

        assert_eq!(response.status(), 200, "{case}: path={path}");
        assert_eq!(response.body(), expected, "{case}: path={path}");
    }

    server.shutdown_bounded(Duration::from_secs(2)).unwrap();
}

#[camber::test]
async fn route_extracts_single_path_param() {
    let mut router = Router::new();
    router.get("/users/:id", |req: &Request| {
        let id = req.param("id").unwrap_or("missing").to_owned();
        async move { Response::text(200, &id) }
    });

    let addr = common::spawn_server(router);
    let resp = http::get(&format!("http://{addr}/users/42")).await.unwrap();

    assert_eq!(resp.status(), 200);
    assert_eq!(resp.body(), "42");

    runtime::request_shutdown();
}

#[camber::test]
async fn route_extracts_multiple_path_params() {
    let mut router = Router::new();
    router.get("/users/:user_id/posts/:post_id", |req: &Request| {
        let user_id = req.param("user_id").unwrap_or("?").to_owned();
        let post_id = req.param("post_id").unwrap_or("?").to_owned();
        async move { Response::text(200, &format!("{user_id}:{post_id}")) }
    });

    let addr = common::spawn_server(router);
    let resp = http::get(&format!("http://{addr}/users/7/posts/99"))
        .await
        .unwrap();

    assert_eq!(resp.status(), 200);
    assert_eq!(resp.body(), "7:99");

    runtime::request_shutdown();
}

#[camber::test]
async fn dispatch_wildcard_route_captures_remainder() {
    let mut router = Router::new();
    router.get("/files/*path", |req: &Request| {
        let path = req.param("path").unwrap_or("missing").to_owned();
        async move { Response::text(200, &path) }
    });

    let addr = common::spawn_server(router);
    let resp = http::get(&format!("http://{addr}/files/a/b/c"))
        .await
        .unwrap();

    assert_eq!(resp.status(), 200);
    assert_eq!(resp.body(), "a/b/c");

    runtime::request_shutdown();
}

#[camber::test]
async fn static_routes_still_match_exactly() {
    let mut router = Router::new();
    router.get("/users/me", |_req: &Request| async {
        Response::text(200, "me-handler")
    });
    router.get("/users/:id", |req: &Request| {
        let id = req.param("id").unwrap_or("missing").to_owned();
        async move { Response::text(200, &id) }
    });

    let addr = common::spawn_server(router);

    let resp_static = http::get(&format!("http://{addr}/users/me")).await.unwrap();
    assert_eq!(resp_static.status(), 200);
    assert_eq!(resp_static.body(), "me-handler");

    let resp_param = http::get(&format!("http://{addr}/users/42")).await.unwrap();
    assert_eq!(resp_param.status(), 200);
    assert_eq!(resp_param.body(), "42");

    runtime::request_shutdown();
}