lenso-platform-http 0.1.1

HTTP primitives for the Lenso backend framework.
Documentation
use axum::Router;
use axum::body::Body;
use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware;
use axum::response::IntoResponse;
use axum::routing::{get, post};
use platform_core::{
    ActorContext, ActorResolutionRequest, ActorResolver, AppConfig, AppContext,
    LoggingEventPublisher,
};
use platform_http::{HttpRequestContext, JsonBody};
use serde::Deserialize;
use serde_json::Value;
use std::sync::Arc;
use tower::ServiceExt;

#[tokio::test]
async fn missing_headers_generate_request_context_ids() {
    let response = router()
        .oneshot(
            Request::builder()
                .uri("/context")
                .body(Body::empty())
                .expect("request should build"),
        )
        .await
        .expect("request should complete");

    let request_id = response.headers().get("x-request-id").cloned();
    let correlation_id = response.headers().get("x-correlation-id").cloned();
    let body = json_body(response).await;

    assert!(request_id.is_some());
    assert!(correlation_id.is_some());
    assert!(body["request_id"].as_str().unwrap().starts_with("req_"));
    assert!(
        body["correlation_id"]
            .as_str()
            .unwrap()
            .starts_with("corr_")
    );
}

#[tokio::test]
async fn provided_headers_are_preserved_in_request_context() {
    let response = router()
        .oneshot(
            Request::builder()
                .uri("/context")
                .header("x-request-id", "req-provided")
                .header("x-correlation-id", "corr-provided")
                .body(Body::empty())
                .expect("request should build"),
        )
        .await
        .expect("request should complete");

    assert_eq!(
        response.headers().get("x-request-id").unwrap(),
        "req-provided"
    );
    assert_eq!(
        response.headers().get("x-correlation-id").unwrap(),
        "corr-provided"
    );

    let body = json_body(response).await;
    assert_eq!(body["request_id"], "req-provided");
    assert_eq!(body["correlation_id"], "corr-provided");
}

#[tokio::test]
async fn dev_user_bearer_token_sets_user_actor_context() {
    let response = router()
        .oneshot(
            Request::builder()
                .uri("/context")
                .header("authorization", "Bearer dev-user:user_123")
                .body(Body::empty())
                .expect("request should build"),
        )
        .await
        .expect("request should complete");

    let body = json_body(response).await;
    assert_eq!(body["actor"]["kind"], "user");
    assert_eq!(body["actor"]["user_id"], "user_123");
}

#[tokio::test]
async fn dev_service_bearer_token_sets_service_actor_context() {
    let response = router()
        .oneshot(
            Request::builder()
                .uri("/context")
                .header("authorization", "Bearer dev-service:worker")
                .body(Body::empty())
                .expect("request should build"),
        )
        .await
        .expect("request should complete");

    let body = json_body(response).await;
    assert_eq!(body["actor"]["kind"], "service");
    assert_eq!(body["actor"]["service_id"], "worker");
    assert_eq!(body["actor"]["scopes"], serde_json::json!([]));
}

#[tokio::test]
async fn dev_service_bearer_token_can_set_scopes() {
    let response = router()
        .oneshot(
            Request::builder()
                .uri("/context")
                .header(
                    "authorization",
                    "Bearer dev-service:admin:remote_crm.contacts.read,other.scope",
                )
                .body(Body::empty())
                .expect("request should build"),
        )
        .await
        .expect("request should complete");

    let body = json_body(response).await;
    assert_eq!(body["actor"]["kind"], "service");
    assert_eq!(body["actor"]["service_id"], "admin");
    assert_eq!(
        body["actor"]["scopes"],
        serde_json::json!(["remote_crm.contacts.read", "other.scope"])
    );
}

#[tokio::test]
async fn dev_bearer_token_is_ignored_outside_local_environment() {
    let response = router_for_environment("production")
        .oneshot(
            Request::builder()
                .uri("/context")
                .header(
                    "authorization",
                    "Bearer dev-service:admin:remote_crm.contacts.read",
                )
                .body(Body::empty())
                .expect("request should build"),
        )
        .await
        .expect("request should complete");

    let body = json_body(response).await;
    assert_eq!(body["actor"]["kind"], "anonymous");
}

#[tokio::test]
async fn custom_actor_resolver_can_set_actor_context() {
    let response = router_with_actor_resolver(Arc::new(StaticActorResolver))
        .oneshot(
            Request::builder()
                .uri("/context")
                .header("authorization", "Bearer real-token")
                .header("cookie", "lenso_session=session_123")
                .body(Body::empty())
                .expect("request should build"),
        )
        .await
        .expect("request should complete");

    let body = json_body(response).await;
    assert_eq!(body["actor"]["kind"], "user");
    assert_eq!(body["actor"]["user_id"], "user_from_resolver");
    assert_eq!(body["actor"]["scopes"], serde_json::json!(["auth.test"]));
}

#[tokio::test]
async fn malformed_json_returns_standard_error_shape_with_request_context() {
    let response = router()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/json")
                .header("content-type", "application/json")
                .header("x-request-id", "req-json")
                .header("x-correlation-id", "corr-json")
                .body(Body::from(r#"{"name":"Ada""#))
                .expect("request should build"),
        )
        .await
        .expect("request should complete");

    assert_eq!(response.status(), StatusCode::BAD_REQUEST);

    let body = json_body(response).await;
    assert_eq!(body["error"]["code"], "validation_failed");
    assert_eq!(body["error"]["message"], "Request validation failed");
    assert_eq!(body["error"]["request_id"], "req-json");
    assert_eq!(body["error"]["correlation_id"], "corr-json");
    assert!(!body["error"]["details"].as_array().unwrap().is_empty());
}

fn router() -> Router {
    router_for_environment("local")
}

fn router_for_environment(environment: &str) -> Router {
    router_for_environment_with_actor_resolver(environment, None)
}

fn router_with_actor_resolver(actor_resolver: Arc<dyn ActorResolver>) -> Router {
    router_for_environment_with_actor_resolver("production", Some(actor_resolver))
}

fn router_for_environment_with_actor_resolver(
    environment: &str,
    actor_resolver: Option<Arc<dyn ActorResolver>>,
) -> Router {
    let mut config = AppConfig::from_env();
    config.service.environment = environment.to_owned();
    let mut ctx = AppContext::new(
        config,
        platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
            .expect("lazy db pool should construct"),
        Arc::new(LoggingEventPublisher),
    );
    if let Some(actor_resolver) = actor_resolver {
        ctx = ctx.with_actor_resolver(actor_resolver);
    }

    Router::new()
        .route("/context", get(context_handler))
        .route("/json", post(json_handler))
        .layer(middleware::from_fn_with_state(
            ctx,
            platform_http::request_context_middleware,
        ))
}

async fn context_handler(HttpRequestContext(ctx): HttpRequestContext) -> impl IntoResponse {
    axum::Json(serde_json::json!({
        "request_id": ctx.request_id.0,
        "correlation_id": ctx.correlation_id.0,
        "actor": ctx.actor,
    }))
}

async fn json_handler(JsonBody(input): JsonBody<JsonInput>) -> impl IntoResponse {
    axum::Json(serde_json::json!({ "name": input.name }))
}

#[derive(Debug, Deserialize)]
struct JsonInput {
    name: String,
}

#[derive(Debug)]
struct StaticActorResolver;

#[async_trait::async_trait]
impl ActorResolver for StaticActorResolver {
    async fn resolve_actor(&self, request: ActorResolutionRequest) -> ActorContext {
        assert_eq!(request.authorization.as_deref(), Some("Bearer real-token"));
        assert_eq!(request.cookie.as_deref(), Some("lenso_session=session_123"));
        ActorContext::User {
            user_id: "user_from_resolver".to_owned(),
            scopes: vec!["auth.test".to_owned()],
        }
    }
}

async fn json_body(response: axum::response::Response) -> Value {
    let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
        .await
        .expect("body should read");
    serde_json::from_slice(&bytes).expect("body should be json")
}