vantus 0.3.0

Macro-first async Rust backend framework with explicit composition, typed extraction, and hardened HTTP defaults.
Documentation
use vantus::{HostBuilder, Path, Request, Response, module};

#[derive(Clone, Default)]
struct RoutingModule;

#[derive(Clone, Default)]
struct AlternateRoutingModule;

#[derive(Clone, Default)]
struct CanonicalRoutingModule;

#[derive(Clone, Default)]
struct MethodModule;

#[module]
impl RoutingModule {
    #[vantus::get("/users/{id}")]
    fn show(&self, id: Path<u32>) -> Response {
        Response::text(format!("user {}", id.into_inner()))
    }
}

#[module]
impl AlternateRoutingModule {
    #[vantus::get("/users/{name}")]
    fn show(&self, name: Path<String>) -> Response {
        Response::text(format!("user {}", name.into_inner()))
    }
}

#[module]
impl CanonicalRoutingModule {
    #[vantus::get("/teams//{id}//")]
    fn team(&self, id: Path<u32>) -> Response {
        Response::text(format!("team {}", id.into_inner()))
    }

    #[vantus::get("/users/me")]
    fn me(&self) -> Response {
        Response::text("me")
    }

    #[vantus::get("/users/{id}")]
    fn user(&self, id: Path<String>) -> Response {
        Response::text(format!("user {}", id.into_inner()))
    }
}

#[module]
impl MethodModule {
    #[vantus::get("/items")]
    fn list(&self) -> Response {
        Response::text("items")
    }

    #[vantus::post("/items")]
    fn create(&self) -> Response {
        Response::text("created")
    }
}

#[tokio::test]
async fn macro_registered_route_matches_path_params() {
    let mut builder = HostBuilder::new();
    builder.module(RoutingModule);
    let host = builder.build();

    let response = host
        .handle(Request::from_bytes(b"GET /users/42 HTTP/1.1\r\nHost: local\r\n\r\n").unwrap())
        .await;

    assert_eq!(response.status_code, 200);
    assert_eq!(String::from_utf8(response.body).unwrap(), "user 42");
}

#[tokio::test]
async fn request_paths_are_normalized_before_routing() {
    let mut builder = HostBuilder::new();
    builder.module(CanonicalRoutingModule);
    let host = builder.build();

    let response = host
        .handle(Request::from_bytes(b"GET /teams//42// HTTP/1.1\r\nHost: local\r\n\r\n").unwrap())
        .await;

    assert_eq!(response.status_code, 200);
    assert_eq!(String::from_utf8(response.body).unwrap(), "team 42");
}

#[tokio::test]
async fn static_routes_win_over_dynamic_routes() {
    let mut builder = HostBuilder::new();
    builder.module(CanonicalRoutingModule);
    let host = builder.build();

    let response = host
        .handle(Request::from_bytes(b"GET /users/me HTTP/1.1\r\nHost: local\r\n\r\n").unwrap())
        .await;

    assert_eq!(response.status_code, 200);
    assert_eq!(String::from_utf8(response.body).unwrap(), "me");
}

#[tokio::test]
async fn wrong_method_returns_405_and_allow_header() {
    let mut builder = HostBuilder::new();
    builder.module(MethodModule);
    let host = builder.build();

    let response = host
        .handle(Request::from_bytes(b"PUT /items HTTP/1.1\r\nHost: local\r\n\r\n").unwrap())
        .await;

    assert_eq!(response.status_code, 405);
    assert!(
        response
            .headers
            .iter()
            .any(|(key, value)| key == "Allow" && value == "GET, POST")
    );
}

#[test]
#[should_panic(expected = "Module registration failed")]
fn duplicate_routes_fail_with_build_error() {
    let mut builder = HostBuilder::new();
    builder.module(RoutingModule);
    builder.module(RoutingModule);
    builder.build();
}

#[test]
fn semantically_equivalent_parameterized_routes_conflict() {
    let mut builder = HostBuilder::new();
    builder.module(RoutingModule);
    builder.module(AlternateRoutingModule);

    let error = builder.try_build().err().expect("build should fail");
    let message = error.to_string();

    assert!(message.contains("duplicate route registration"));
    assert!(message.contains("/users/{id}"));
    assert!(message.contains("/users/{name}"));
}