rs-zero 0.2.8

Rust-first microservice framework inspired by go-zero engineering practices
Documentation
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use axum::{Router, routing::get};
use jsonwebtoken::{EncodingKey, Header, encode};
use rs_zero::rest::{ApiResponse, AuthConfig, RestConfig, RestError, RestServer};
use serde::Serialize;
use tower::ServiceExt;

#[derive(Debug, Serialize)]
struct Claims {
    exp: usize,
}

fn test_config() -> RestConfig {
    RestConfig {
        timeout: Duration::from_secs(1),
        auth: Some(AuthConfig {
            secret: "test-secret".to_string(),
            public_paths: vec!["/ready".to_string(), "/public".to_string()],
        }),
        ..RestConfig::default()
    }
}

fn valid_token() -> String {
    let exp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("time")
        .as_secs() as usize
        + 3600;

    encode(
        &Header::default(),
        &Claims { exp },
        &EncodingKey::from_secret(b"test-secret"),
    )
    .expect("token")
}

async fn body_text(response: axum::response::Response) -> String {
    let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
        .await
        .expect("body");
    String::from_utf8(bytes.to_vec()).expect("utf8")
}

#[tokio::test]
async fn ready_path_is_public() {
    let router = Router::new().route("/ready", get(|| async { ApiResponse::success("ok") }));

    let app = RestServer::new(test_config(), router).into_router();
    let response = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/ready")
                .body(axum::body::Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");

    assert_eq!(response.status(), axum::http::StatusCode::OK);
    assert!(body_text(response).await.contains("\"success\":true"));
}

#[tokio::test]
async fn protected_route_requires_token() {
    let router = Router::new().route("/private", get(|| async { ApiResponse::success("secret") }));

    let app = RestServer::new(test_config(), router).into_router();
    let response = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/private")
                .body(axum::body::Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");

    let body = body_text(response).await;
    assert!(body.contains("\"success\":false"));
    assert!(body.contains("UNAUTHORIZED"));
}

#[tokio::test]
async fn protected_route_accepts_valid_token() {
    let router = Router::new().route("/private", get(|| async { ApiResponse::success("secret") }));

    let app = RestServer::new(test_config(), router).into_router();
    let response = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/private")
                .header("authorization", format!("Bearer {}", valid_token()))
                .body(axum::body::Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");

    assert!(body_text(response).await.contains("\"success\":true"));
}

#[tokio::test]
async fn handler_error_uses_uniform_response() {
    let router = Router::new().route(
        "/public",
        get(|| async { RestError::BadRequest("invalid input".to_string()) }),
    );

    let app = RestServer::new(test_config(), router).into_router();
    let response = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/public")
                .body(axum::body::Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");

    let body = body_text(response).await;
    assert!(body.contains("\"success\":false"));
    assert!(body.contains("BAD_REQUEST"));
}

#[tokio::test]
async fn request_id_is_propagated() {
    let router = Router::new().route("/ready", get(|| async { ApiResponse::success("ok") }));

    let app = RestServer::new(test_config(), router).into_router();
    let response = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/ready")
                .header("x-request-id", "req-1")
                .body(axum::body::Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");

    assert_eq!(
        response.headers().get("x-request-id").expect("request id"),
        "req-1"
    );
}

#[tokio::test]
async fn explicit_rest_layer_stack_matches_rest_server_defaults() {
    let config = RestConfig::default();
    let app = rs_zero::rest::RestLayerStack::new(config)
        .layer(Router::new().route("/ready", get(|| async { ApiResponse::success("ok") })));

    let response = app
        .oneshot(
            axum::http::Request::builder()
                .uri("/ready")
                .body(axum::body::Body::empty())
                .expect("request"),
        )
        .await
        .expect("response");

    assert_eq!(response.status(), axum::http::StatusCode::OK);
    assert!(response.headers().contains_key("x-request-id"));
}