artur 0.1.0

Config-driven Rust HTTP server
Documentation
use artur::{config::*, server::build_router};
use axum::{
    body::{Body, to_bytes},
    http::{Request, StatusCode},
};
use serde_json::Value;
use tower::ServiceExt;

fn test_config() -> AppConfig {
    AppConfig {
        version: 1,
        server: ServerConfig::default(),
        endpoints: vec![
            EndpointConfig {
                name: "hello".to_string(),
                method: HttpMethod::Get,
                path: "/hello".to_string(),
                action: EndpointAction::RespondStatic,
                process: None,
                response: Some(StaticResponseConfig {
                    status: 200,
                    body: serde_json::json!({ "ok": true, "name": "artur" }),
                    headers: Default::default(),
                }),
            },
            EndpointConfig {
                name: "echo".to_string(),
                method: HttpMethod::Post,
                path: "/echo/{id}".to_string(),
                action: EndpointAction::ProcessRun,
                process: Some("cat_json".to_string()),
                response: None,
            },
            EndpointConfig {
                name: "async".to_string(),
                method: HttpMethod::Post,
                path: "/async".to_string(),
                action: EndpointAction::ProcessRun,
                process: Some("async_json".to_string()),
                response: None,
            },
            EndpointConfig {
                name: "job".to_string(),
                method: HttpMethod::Get,
                path: "/jobs/{job_id}".to_string(),
                action: EndpointAction::JobGet,
                process: None,
                response: None,
            },
        ],
        processes: vec![
            ProcessConfig {
                name: "cat_json".to_string(),
                mode: ProcessMode::Sync,
                command: "sh".to_string(),
                args: vec!["-c".to_string(), "cat".to_string()],
                env: Default::default(),
                working_dir: None,
                timeout_ms: 5000,
                stdin: ProcessStdin::Body,
                stdout_format: ProcessOutputFormat::Json,
            },
            ProcessConfig {
                name: "async_json".to_string(),
                mode: ProcessMode::Async,
                command: "sh".to_string(),
                args: vec!["-c".to_string(), "printf '{\"ok\":true}'".to_string()],
                env: Default::default(),
                working_dir: None,
                timeout_ms: 5000,
                stdin: ProcessStdin::None,
                stdout_format: ProcessOutputFormat::Json,
            },
        ],
    }
}

#[tokio::test]
async fn static_endpoint_returns_configured_json() {
    let app = build_router(test_config()).await.unwrap();
    let response = app
        .oneshot(
            Request::builder()
                .method("GET")
                .uri("/hello")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::OK);
    let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
    let value: Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(value["ok"], true);
    assert_eq!(value["name"], "artur");
}

#[tokio::test]
async fn process_endpoint_can_read_body_and_return_parsed_json() {
    let app = build_router(test_config()).await.unwrap();
    let response = app
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/echo/abc?source=test")
                .header("content-type", "application/json")
                .body(Body::from(r#"{"hello":"world"}"#))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::OK);
    let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
    let value: Value = serde_json::from_slice(&bytes).unwrap();
    assert_eq!(value["ok"], true);
    assert_eq!(value["process"], "cat_json");
    assert_eq!(value["json"]["hello"], "world");
}

#[tokio::test]
async fn async_process_endpoint_returns_job_and_job_result() {
    let app = build_router(test_config()).await.unwrap();
    let response = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/async")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(response.status(), StatusCode::OK);
    let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
    let accepted: Value = serde_json::from_slice(&bytes).unwrap();
    let job_id = accepted["job_id"].as_str().unwrap();

    let mut last_status = String::new();
    for _ in 0..20 {
        let response = app
            .clone()
            .oneshot(
                Request::builder()
                    .method("GET")
                    .uri(format!("/jobs/{job_id}"))
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap();
        let job: Value = serde_json::from_slice(&bytes).unwrap();
        last_status = job["status"].as_str().unwrap().to_string();
        if last_status == "completed" {
            assert_eq!(job["result"]["json"]["ok"], true);
            return;
        }
        tokio::time::sleep(std::time::Duration::from_millis(25)).await;
    }
    panic!("job did not complete; last status was {last_status}");
}