sova-http 0.1.0

Outbound HTTP client bound to request deadline and tracing
Documentation
use http::Method;
use sova_core::extend::Deadline;
use sova_core::{App, IntoResponse, Request, Response};
use sova_http::{FakeTransport, Http, HttpError, HttpExt, StubBody};
use serde_json::json;
use std::time::{Duration, Instant};

#[tokio::test]
async fn fake_get_and_assert() {
    let fake = FakeTransport::new().get(
        "https://api.example.com/users/1",
        StubBody::Json(json!({ "id": 1 })),
    );
    let mut app = App::new();
    app.install(Http::new().with_fake(fake.clone()));
    app.get("/proxy", |req: Request| async move {
        let res = req
            .http()
            .get("https://api.example.com/users/1")
            .send()
            .await
            .unwrap();
        Response::json(&res.json::<serde_json::Value>().unwrap())
    });
    let server = app.build().unwrap();
    let res = server.handle_request(Method::GET, "/proxy", "").await;
    assert_eq!(res.status_code().as_u16(), 200);
    fake.assert_sent(Method::GET, "https://api.example.com/users/1");
    fake.assert_sent_count(1);
}

#[tokio::test]
async fn budget_caps_timeout() {
    let fake = FakeTransport::new().get("https://api.example.com/x", StubBody::Empty);
    let mut app = App::new();
    app.install(Http::new().with_fake(fake.clone()));
    app.get("/t", |req: Request| async move {
        let _ = req.http().get("https://api.example.com/x").send().await;
        Response::text("ok")
    });
    let server = app.build().unwrap();
    let mut req = Request::builder()
        .method(Method::GET)
        .path("/t")
        .build();
    req.set(Deadline(Instant::now() + Duration::from_millis(50)));
    let _ = server.handle(req).await;
    let sent = fake.sent();
    assert_eq!(sent.len(), 1);
    assert!(sent[0].timeout.unwrap() <= Duration::from_millis(50));
}

#[tokio::test]
async fn propagates_request_id() {
    let fake = FakeTransport::new().get("https://api.example.com/x", StubBody::Empty);
    let mut app = App::new();
    app.install(Http::new().with_fake(fake.clone()));
    app.get("/t", |req: Request| async move {
        let _ = req.http().get("https://api.example.com/x").send().await;
        Response::text("ok")
    });
    let server = app.build().unwrap();
    let req = Request::builder()
        .method(Method::GET)
        .path("/t")
        .header("x-request-id", "abc-123")
        .build();
    let _ = server.handle(req).await;
    let sent = fake.sent();
    assert_eq!(
        sent[0]
            .headers
            .get("x-request-id")
            .and_then(|v| v.to_str().ok()),
        Some("abc-123")
    );
}

#[tokio::test]
async fn four_xx_is_ok_error_for_status_errs() {
    let fake = FakeTransport::new().respond(
        Method::GET,
        "https://api.example.com/missing",
        404,
        StubBody::Empty,
    );
    let mut app = App::new();
    app.install(Http::new().with_fake(fake));
    app.get("/x", |req: Request| async move {
        let res = req
            .http()
            .get("https://api.example.com/missing")
            .send()
            .await
            .unwrap();
        assert_eq!(res.status_u16(), 404);
        match res.error_for_status() {
            Err(HttpError::Status(404)) => Response::text("mapped"),
            _ => Response::text("bad"),
        }
    });
    let server = app.build().unwrap();
    let res = server.handle_request(Method::GET, "/x", "").await;
    assert_eq!(res.body_bytes(), Some(b"mapped".as_slice()));
}

#[tokio::test]
async fn post_without_idempotency_does_not_retry() {
    let fake = FakeTransport::new().fail("https://api.example.com/pay", "timeout");
    let mut app = App::new();
    app.install(Http::new().with_fake(fake.clone()));
    app.post("/x", |req: Request| async move {
        let err = req
            .http()
            .post("https://api.example.com/pay")
            .retry(3)
            .send()
            .await
            .unwrap_err();
        assert!(matches!(err, HttpError::Timeout));
        Response::text("ok")
    });
    let server = app.build().unwrap();
    let _ = server
        .handle(
            Request::builder()
                .method(Method::POST)
                .path("/x")
                .build(),
        )
        .await;
    fake.assert_sent_count(1);
}

#[tokio::test]
async fn get_retries_on_timeout() {
    let fake = FakeTransport::new().fail("https://api.example.com/x", "timeout");
    let mut app = App::new();
    app.install(Http::new().with_fake(fake.clone()));
    app.get("/x", |req: Request| async move {
        let _ = req
            .http()
            .get("https://api.example.com/x")
            .retry(2)
            .send()
            .await;
        Response::text("ok")
    });
    let server = app.build().unwrap();
    let _ = server.handle_request(Method::GET, "/x", "").await;
    fake.assert_sent_count(3);
}

#[tokio::test]
async fn named_client_joins_base_url() {
    let fake = FakeTransport::new().post("https://api.stripe.com/v1/charges", 201);
    let mut app = App::new();
    app.configure_from_str(
        r#"
[default.http.payments]
base_url = "https://api.stripe.com"
"#,
    )
    .unwrap();
    app.install(Http::new().with_fake(fake.clone()));
    app.post("/pay", |req: Request| async move {
        let res = req
            .http()
            .named("payments")
            .post("/v1/charges")
            .send()
            .await
            .unwrap();
        Response::text(res.status_u16().to_string())
    });
    let server = app.build().unwrap();
    let res = server
        .handle(
            Request::builder()
                .method(Method::POST)
                .path("/pay")
                .build(),
        )
        .await;
    assert_eq!(res.body_bytes(), Some(b"201".as_slice()));
    fake.assert_sent(Method::POST, "https://api.stripe.com/v1/charges");
}

#[tokio::test]
async fn http_error_into_response_status() {
    assert_eq!(HttpError::Timeout.into_response().status_code().as_u16(), 504);
    assert_eq!(
        HttpError::Connect("x".into())
            .into_response()
            .status_code()
            .as_u16(),
        502
    );
}

#[tokio::test]
async fn with_config_from_app_named_timeout_and_bearer() {
    std::env::set_var("SOVA_TEST_HTTP_TOKEN", "secret-token");
    let fake = FakeTransport::new().get(
        "https://api.example.com/v1/me",
        StubBody::Json(json!({ "ok": true })),
    );
    let mut app = App::new();
    app.configure_from_str(
        r#"
[default.http.api]
base_url = "https://api.example.com"
timeout = "2s"
retries = 1
bearer_env = "SOVA_TEST_HTTP_TOKEN"
"#,
    )
    .unwrap();
    let http = Http::new()
        .with_fake(fake.clone())
        .with_config_from_app(&app);
    app.install(http);
    app.get("/me", |req: Request| async move {
        let res = req
            .http()
            .named("api")
            .get("/v1/me")
            .send()
            .await
            .unwrap();
        Response::text(res.status_u16().to_string())
    });
    let server = app.build().unwrap();
    let res = server.handle_request(Method::GET, "/me", "").await;
    assert_eq!(res.body_bytes(), Some(b"200".as_slice()));
    let sent = fake.sent();
    assert_eq!(sent.len(), 1);
    assert_eq!(sent[0].timeout, Some(Duration::from_secs(2)));
    assert_eq!(
        sent[0]
            .headers
            .get("authorization")
            .and_then(|v| v.to_str().ok()),
        Some("Bearer secret-token")
    );
    std::env::remove_var("SOVA_TEST_HTTP_TOKEN");
}

#[tokio::test]
async fn fake_builder_stubs_get_and_fail() {
    let mut app = App::new();
    app.install(
        Http::fake()
            .get(
                "https://api.example.com/ok",
                StubBody::Json(json!({ "n": 1 })),
            )
            .fail("https://api.example.com/boom", "down"),
    );
    app.get("/ok", |req: Request| async move {
        let v = req
            .http()
            .get("https://api.example.com/ok")
            .send()
            .await
            .unwrap()
            .json::<serde_json::Value>()
            .unwrap();
        Response::json(&v)
    });
    app.get("/boom", |req: Request| async move {
        let err = req
            .http()
            .get("https://api.example.com/boom")
            .send()
            .await
            .unwrap_err();
        Response::text(format!("{err}"))
    });
    let server = app.build().unwrap();
    let ok = server.handle_request(Method::GET, "/ok", "").await;
    assert_eq!(ok.status_code().as_u16(), 200);
    let boom = server.handle_request(Method::GET, "/boom", "").await;
    let body = String::from_utf8(boom.body_bytes().unwrap().to_vec()).unwrap();
    assert!(body.contains("down"), "{body}");
}