pub mod routes;
use crate::config::Config;
use crate::session::store::SessionStore;
use anyhow::Result;
use axum::Router;
use std::collections::HashSet;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::sync::Mutex;
use tower_http::cors::CorsLayer;
pub struct AppState {
pub store: Mutex<SessionStore>,
pub config: Config,
pub active_sessions: Mutex<HashSet<String>>,
}
impl AppState {
pub fn new(config: Config) -> Result<Self> {
let db_path = crate::session::SessionStore::default_path()?;
let store = SessionStore::new(&db_path)?;
Ok(Self {
store: Mutex::new(store),
config,
active_sessions: Mutex::new(HashSet::new()),
})
}
}
pub async fn start_server(config: Config, addr: SocketAddr) -> Result<()> {
let state = Arc::new(AppState::new(config)?);
let app = Router::new()
.merge(routes::session_router())
.layer(CorsLayer::permissive())
.with_state(state);
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!("xcodeai HTTP server listening on {}", addr);
axum::serve(listener, app).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
pub fn test_state() -> Arc<AppState> {
let config = Config::default();
let store = SessionStore::new(std::path::Path::new(":memory:")).unwrap();
Arc::new(AppState {
store: Mutex::new(store),
config,
active_sessions: Mutex::new(HashSet::new()),
})
}
pub fn test_app(state: Arc<AppState>) -> Router {
Router::new()
.merge(routes::session_router())
.layer(CorsLayer::permissive())
.with_state(state)
}
#[tokio::test]
async fn test_list_sessions_empty() {
let app = test_app(test_state());
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/sessions")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json.is_array());
assert_eq!(json.as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn test_create_session_returns_id() {
let app = test_app(test_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from(r#"{"title":"my task"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert!(json["session_id"].is_string());
assert!(!json["session_id"].as_str().unwrap().is_empty());
}
#[tokio::test]
async fn test_get_session_not_found() {
let app = test_app(test_state());
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/sessions/does-not-exist")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_create_then_get_session() {
let state = test_state();
let app = test_app(Arc::clone(&state));
let create_resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from(r#"{"title":"hello"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(create_resp.status(), StatusCode::CREATED);
let create_body = axum::body::to_bytes(create_resp.into_body(), usize::MAX)
.await
.unwrap();
let create_json: serde_json::Value = serde_json::from_slice(&create_body).unwrap();
let session_id = create_json["session_id"].as_str().unwrap().to_string();
let app2 = test_app(Arc::clone(&state));
let get_resp = app2
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/sessions/{session_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(get_resp.status(), StatusCode::OK);
let get_body = axum::body::to_bytes(get_resp.into_body(), usize::MAX)
.await
.unwrap();
let get_json: serde_json::Value = serde_json::from_slice(&get_body).unwrap();
assert_eq!(get_json["id"].as_str().unwrap(), session_id);
}
#[tokio::test]
async fn test_delete_session() {
let state = test_state();
let app = test_app(Arc::clone(&state));
let create_resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from(r#"{}"#))
.unwrap(),
)
.await
.unwrap();
let create_body = axum::body::to_bytes(create_resp.into_body(), usize::MAX)
.await
.unwrap();
let session_id = serde_json::from_slice::<serde_json::Value>(&create_body).unwrap()
["session_id"]
.as_str()
.unwrap()
.to_string();
let app2 = test_app(Arc::clone(&state));
let del_resp = app2
.oneshot(
Request::builder()
.method("DELETE")
.uri(format!("/sessions/{session_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(del_resp.status(), StatusCode::NO_CONTENT);
let app3 = test_app(Arc::clone(&state));
let get_resp = app3
.oneshot(
Request::builder()
.method("GET")
.uri(format!("/sessions/{session_id}"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(get_resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_cors_header_present() {
let app = test_app(test_state());
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/sessions")
.header("origin", "http://localhost:3000")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(resp.headers().contains_key("access-control-allow-origin"));
}
#[tokio::test]
async fn test_post_message_session_not_found() {
let app = test_app(test_state());
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/sessions/nonexistent-session-id/messages")
.header("content-type", "application/json")
.body(Body::from(r#"{"content":"hello"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn test_post_message_conflict_when_active() {
let state = test_state();
let app = test_app(Arc::clone(&state));
let create_resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/sessions")
.header("content-type", "application/json")
.body(Body::from(r#"{}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(create_resp.status(), StatusCode::CREATED);
let create_body = axum::body::to_bytes(create_resp.into_body(), usize::MAX)
.await
.unwrap();
let session_id = serde_json::from_slice::<serde_json::Value>(&create_body).unwrap()
["session_id"]
.as_str()
.unwrap()
.to_string();
state
.active_sessions
.lock()
.await
.insert(session_id.clone());
let app2 = test_app(Arc::clone(&state));
let resp = app2
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/sessions/{session_id}/messages"))
.header("content-type", "application/json")
.body(Body::from(r#"{"content":"duplicate task"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CONFLICT);
}
}