ninox-server 0.1.0

HTTP/WebSocket server exposing the Ninox engine to the native app and web dashboard.
Documentation
use ninox_core::{events::Engine, types::Session};
use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::get,
    Json, Router,
};
use std::sync::Arc;

pub fn sessions_router(engine: Arc<Engine>) -> Router {
    Router::new()
        .route("/", get(list_sessions))
        .route("/:id", axum::routing::delete(terminate_session))
        .with_state(engine)
}

async fn list_sessions(State(e): State<Arc<Engine>>) -> Result<Json<Vec<Session>>, StatusCode> {
    e.store
        .list_sessions()
        .map(Json)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

async fn terminate_session(
    State(e): State<Arc<Engine>>,
    Path(id): Path<String>,
) -> StatusCode {
    match e.terminate_session(&id).await {
        Ok(_) => StatusCode::NO_CONTENT,
        Err(err) => {
            tracing::error!("terminate {id}: {err}");
            StatusCode::INTERNAL_SERVER_ERROR
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use ninox_core::{events::Engine, store::Store, types::*};
    use axum::body::Body;
    use http::{Request, StatusCode};
    use std::sync::Arc;
    use tempfile::tempdir;
    use tower::ServiceExt;

    fn test_engine() -> Arc<Engine> {
        let dir = tempdir().unwrap();
        let path = dir.path().join("t.db");
        let store = Arc::new(Store::open(&path).unwrap());
        // keep dir alive so the temp directory isn't removed before the test ends
        std::mem::forget(dir);
        Engine::new(store)
    }

    #[tokio::test]
    async fn list_empty() {
        let app = sessions_router(test_engine());
        let response = app
            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
            .await
            .unwrap();
        let sessions: Vec<Session> = serde_json::from_slice(&body).unwrap();
        assert!(sessions.is_empty());
    }

    #[tokio::test]
    async fn list_returns_stored() {
        let engine = test_engine();
        engine
            .store
            .upsert_session(&Session {
                id: "s1".into(),
                orchestrator_id: None,
                name: "w".into(),
                repo: "r".into(),
                status: SessionStatus::Working,
                agent_type: "c".into(),
                cost_usd: 0.0,
                started_at: 0,
                pr_number: None,
                pr_id: None,
                workspace_path: None,
                pid: None,
            })
            .unwrap();
        let app = sessions_router(engine);
        let response = app
            .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);
        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
            .await
            .unwrap();
        let sessions: Vec<Session> = serde_json::from_slice(&body).unwrap();
        assert_eq!(sessions.len(), 1);
        assert_eq!(sessions[0].id, "s1");
    }

    #[tokio::test]
    async fn delete_returns_no_content() {
        let engine = test_engine();
        engine
            .store
            .upsert_session(&Session {
                id: "s1".into(),
                orchestrator_id: None,
                name: "w".into(),
                repo: "r".into(),
                status: SessionStatus::Working,
                agent_type: "c".into(),
                cost_usd: 0.0,
                started_at: 0,
                pr_number: None,
                pr_id: None,
                workspace_path: None,
                pid: None,
            })
            .unwrap();
        let response = sessions_router(engine)
            .oneshot(
                Request::builder()
                    .method("DELETE")
                    .uri("/s1")
                    .body(Body::empty())
                    .unwrap(),
            )
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NO_CONTENT);
    }
}