serverust-core 0.1.0

Core do framework serverust: App builder, roteamento, validação, DI, OpenAPI, config.
Documentation
use axum::body::Body;
use http::{Method, Request, StatusCode};
use http_body_util::BodyExt;
use serverust_core::App;
use serverust_core::extract::{Json, Path};
use serverust_macros::{get, post};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tower::ServiceExt;
use utoipa::ToSchema;
use validator::Validate;

#[get("/hello")]
async fn hello() -> &'static str {
    "hi"
}

#[get("/users/{id}")]
async fn get_user(Path(id): Path<u32>) -> String {
    format!("user-{id}")
}

#[derive(Deserialize, Serialize, Validate, ToSchema)]
struct CreateUser {
    #[validate(length(min = 3, max = 50))]
    #[schema(min_length = 3, max_length = 50)]
    name: String,
}

#[post("/users")]
async fn create_user(Json(body): Json<CreateUser>) -> Json<CreateUser> {
    Json(body)
}

async fn body_json(resp: axum::response::Response) -> Value {
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    serde_json::from_slice(&bytes).unwrap()
}

async fn body_string(resp: axum::response::Response) -> String {
    let bytes = resp.into_body().collect().await.unwrap().to_bytes();
    String::from_utf8(bytes.to_vec()).unwrap()
}

#[tokio::test]
async fn openapi_json_lists_registered_endpoints() {
    let router = App::new()
        .route(hello)
        .route(get_user)
        .route(create_user)
        .into_router();

    let resp = router
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/openapi.json")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::OK);
    let json = body_json(resp).await;

    assert_eq!(json["openapi"], Value::String("3.1.0".into()));
    let paths = json["paths"].as_object().expect("paths object");
    assert!(paths.contains_key("/hello"), "missing /hello: {json}");
    assert!(
        paths.contains_key("/users/{id}"),
        "missing /users/{{id}}: {json}"
    );
    assert!(paths.contains_key("/users"), "missing /users: {json}");

    assert!(paths["/hello"].get("get").is_some());
    assert!(paths["/users/{id}"].get("get").is_some());
    assert!(paths["/users"].get("post").is_some());
}

#[tokio::test]
async fn openapi_info_customizes_title_and_version() {
    let router = App::new()
        .openapi_info("Funds API", "2.4.0")
        .route(hello)
        .into_router();

    let resp = router
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/openapi.json")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    let json = body_json(resp).await;
    assert_eq!(json["info"]["title"], Value::String("Funds API".into()));
    assert_eq!(json["info"]["version"], Value::String("2.4.0".into()));
}

#[tokio::test]
async fn registered_schemas_include_validate_constraints() {
    let router = App::new()
        .register_schema::<CreateUser>()
        .route(create_user)
        .into_router();

    let resp = router
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/openapi.json")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    let json = body_json(resp).await;
    let schema = &json["components"]["schemas"]["CreateUser"];
    assert!(
        schema.is_object(),
        "CreateUser schema missing: {}",
        serde_json::to_string_pretty(&json).unwrap()
    );

    let required = schema["required"].as_array().expect("required array");
    assert!(
        required.iter().any(|r| r == "name"),
        "required should contain name; got: {schema}"
    );

    let name_prop = &schema["properties"]["name"];
    assert_eq!(name_prop["minLength"], Value::Number(3.into()));
    assert_eq!(name_prop["maxLength"], Value::Number(50.into()));
}

#[tokio::test]
async fn swagger_ui_is_served_at_docs() {
    let router = App::new().route(hello).into_router();

    let resp = router
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/docs")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::OK);
    let ct = resp
        .headers()
        .get("content-type")
        .map(|v| v.to_str().unwrap().to_string())
        .unwrap_or_default();
    assert!(ct.starts_with("text/html"), "content-type was {ct}");

    let body = body_string(resp).await;
    assert!(body.contains("swagger"), "body should mention swagger");
    assert!(
        body.contains("/openapi.json"),
        "swagger should point to /openapi.json"
    );
}

#[tokio::test]
async fn redoc_is_served_at_redoc() {
    let router = App::new().route(hello).into_router();

    let resp = router
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/redoc")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::OK);
    let body = body_string(resp).await;
    assert!(body.to_lowercase().contains("redoc"));
    assert!(body.contains("/openapi.json"));
}

#[tokio::test]
async fn docs_path_can_be_customized() {
    let router = App::new().docs("/swagger").route(hello).into_router();

    let resp = router
        .clone()
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/swagger")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    let resp = router
        .oneshot(
            Request::builder()
                .method(Method::GET)
                .uri("/docs")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}