a3s-boot 0.1.1

Adapter-first modular Rust web framework for A3S inspired by Nest.js
Documentation
#![cfg(feature = "http-client")]

use a3s_boot::{
    BootApplication, BoxFuture, HttpClientBackend, HttpClientOptions, HttpClientRequest,
    HttpClientResponse, HttpMethod, HttpModule, HttpService, Result,
};
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
use std::time::Duration;

#[tokio::test]
async fn http_module_exports_service_and_applies_default_request_options() {
    let backend =
        RecordingBackend::new(HttpClientResponse::json(&json!({ "name": "Milo" })).unwrap());
    let app = BootApplication::builder()
        .import(HttpModule::with_backend_and_options(
            "http",
            backend.clone(),
            HttpClientOptions::new()
                .with_base_url("https://api.example/v1")
                .with_header("x-api-key", "secret")
                .with_timeout(Duration::from_secs(3)),
        ))
        .build()
        .unwrap();

    let client = app.get::<HttpService>().unwrap();
    let cat: Value = client.get_json("/cats/1").await.unwrap();
    let request = backend.requests()[0].clone();

    assert_eq!(cat, json!({ "name": "Milo" }));
    assert_eq!(request.method(), HttpMethod::Get);
    assert_eq!(request.url(), "https://api.example/v1/cats/1");
    assert_eq!(request.header("x-api-key"), Some("secret"));
    assert_eq!(request.timeout(), Some(Duration::from_secs(3)));
}

#[tokio::test]
async fn http_service_json_helpers_send_and_decode_json_bodies() {
    let backend = RecordingBackend::new(
        HttpClientResponse::json_with_status(201, &json!({ "id": 7 })).unwrap(),
    );
    let client = HttpService::from_backend(backend.clone(), HttpClientOptions::default());

    let value: Value = client
        .post_json("https://api.example/cats", &json!({ "name": "Milo" }))
        .await
        .unwrap();
    let request = backend.requests()[0].clone();

    assert_eq!(value, json!({ "id": 7 }));
    assert_eq!(request.method(), HttpMethod::Post);
    assert_eq!(request.url(), "https://api.example/cats");
    assert_eq!(request.header("content-type"), Some("application/json"));
    assert_eq!(request.body(), br#"{"name":"Milo"}"#);
}

#[tokio::test]
async fn http_module_supports_named_global_exports() {
    let app = BootApplication::builder()
        .import(
            HttpModule::with_backend(
                "http",
                RecordingBackend::new(HttpClientResponse::new(204, [])),
            )
            .named("external-http")
            .global(),
        )
        .build()
        .unwrap();

    let client = app.get_named::<HttpService>("external-http").unwrap();

    assert_eq!(
        client
            .get("https://api.example/ping")
            .await
            .unwrap()
            .status(),
        204
    );
}

#[tokio::test]
async fn http_module_async_options_builds_service_during_async_graph_build() {
    let app = BootApplication::builder()
        .import(HttpModule::async_options("http", |_| async {
            Ok(HttpClientOptions::new().with_base_url("https://async.example"))
        }))
        .build_async()
        .await
        .unwrap();

    let service = app.get::<HttpService>().unwrap();

    assert_eq!(service.options().base_url(), Some("https://async.example"));
}

#[tokio::test]
async fn http_service_rejects_relative_urls_without_base_url() {
    let client = HttpService::from_backend(
        RecordingBackend::new(HttpClientResponse::new(200, [])),
        HttpClientOptions::default(),
    );

    let error = client.get("/cats").await.unwrap_err();

    assert!(error
        .to_string()
        .contains("relative HTTP client URL `/cats` requires a base URL"));
}

#[derive(Clone)]
struct RecordingBackend {
    requests: Arc<Mutex<Vec<HttpClientRequest>>>,
    response: HttpClientResponse,
}

impl RecordingBackend {
    fn new(response: HttpClientResponse) -> Self {
        Self {
            requests: Arc::new(Mutex::new(Vec::new())),
            response,
        }
    }

    fn requests(&self) -> Vec<HttpClientRequest> {
        self.requests.lock().unwrap().clone()
    }
}

impl HttpClientBackend for RecordingBackend {
    fn send(&self, request: HttpClientRequest) -> BoxFuture<'static, Result<HttpClientResponse>> {
        let requests = Arc::clone(&self.requests);
        let response = self.response.clone();
        Box::pin(async move {
            requests.lock().unwrap().push(request);
            Ok(response)
        })
    }
}