feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
//! Authentication handlers — login, register, and token refresh.
|
||||
//!
|
||||
//! # Endpoints
|
||||
//!
|
||||
//! - `POST /auth/login` — authenticate with username/password, returns JWT
|
||||
//! - `POST /auth/register` — create a new user account
|
||||
//! - `POST /auth/refresh` — exchange a refresh token for a new access token
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Login: validate input → verify password → generate token pair → return.
|
||||
//! Register: validate input → check uniqueness → hash password → persist → login.
|
||||
//! Refresh: decode refresh token → verify → generate new token pair.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
|
||||
use zesdex_application::ports::{PasswordService, TokenService};
|
||||
|
||||
use crate::dto::auth::{AuthResponse, LoginRequest, RefreshRequest, RegisterRequest};
|
||||
use crate::error::ApiError;
|
||||
use crate::state::ApiState;
|
||||
|
||||
/// Build the auth sub-router (`/auth/*`).
|
||||
pub fn router() -> Router<Arc<ApiState>> {
|
||||
Router::new()
|
||||
.route("/login", post(login_handler))
|
||||
.route("/register", post(register_handler))
|
||||
.route("/refresh", post(refresh_handler))
|
||||
}
|
||||
|
||||
/// POST /auth/login — authenticate and issue JWT tokens.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Deserialize `LoginRequest`.
|
||||
/// 2. Load the stored user credentials from the users store.
|
||||
/// 3. Verify the password against the stored hash.
|
||||
/// 4. Generate an access + refresh token pair.
|
||||
/// 5. Return `AuthResponse`.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `400 Bad Request` — missing or empty fields.
|
||||
/// - `401 Unauthorized` — invalid username or password.
|
||||
/// - `500 Internal Server Error` — unexpected failure.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn login_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
// Validate input
|
||||
if req.username.is_empty() || req.password.is_empty() {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Username and password are required".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Load the users database from the store
|
||||
let users_path = state.store_base_dir.join("users.json");
|
||||
let users: std::collections::HashMap<String, String> = if users_path.exists() {
|
||||
let content = std::fs::read_to_string(&users_path)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to read users: {e}")))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to parse users: {e}")))?
|
||||
} else {
|
||||
return Err(ApiError::Unauthorized("Invalid username or password".into()));
|
||||
};
|
||||
|
||||
// Look up the user
|
||||
let stored_hash = users
|
||||
.get(&req.username)
|
||||
.ok_or_else(|| ApiError::Unauthorized("Invalid username or password".into()))?;
|
||||
|
||||
// Verify password
|
||||
let valid = state
|
||||
.password_service
|
||||
.verify(&req.password, stored_hash)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Password verification failed: {e}")))?;
|
||||
|
||||
if !valid {
|
||||
return Err(ApiError::Unauthorized("Invalid username or password".into()));
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
let (access_token, refresh_token) = state
|
||||
.token_service
|
||||
.generate_tokens(&req.username)
|
||||
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.token_service.access_token_expiry_secs,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /auth/register — create a new user account.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Deserialize `RegisterRequest`.
|
||||
/// 2. Check username availability (load users, reject if exists).
|
||||
/// 3. Hash the password using Argon2id.
|
||||
/// 4. Persist the new username + hash.
|
||||
/// 5. Generate an access + refresh token pair.
|
||||
/// 6. Return `AuthResponse`.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `400 Bad Request` — missing or invalid fields.
|
||||
/// - `409 Conflict` — username already taken.
|
||||
/// - `500 Internal Server Error` — unexpected failure.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn register_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
// Validate input
|
||||
if req.username.is_empty() {
|
||||
return Err(ApiError::BadRequest("Username is required".into()));
|
||||
}
|
||||
if req.password.len() < 6 {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Password must be at least 6 characters".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Load existing users
|
||||
let users_path = state.store_base_dir.join("users.json");
|
||||
let mut users: std::collections::HashMap<String, String> = if users_path.exists() {
|
||||
let content = std::fs::read_to_string(&users_path)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to read users: {e}")))?;
|
||||
serde_json::from_str(&content)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to parse users: {e}")))?
|
||||
} else {
|
||||
std::collections::HashMap::new()
|
||||
};
|
||||
|
||||
// Check uniqueness
|
||||
if users.contains_key(&req.username) {
|
||||
return Err(ApiError::Conflict(
|
||||
"Username already exists".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
let hash = state
|
||||
.password_service
|
||||
.hash(&req.password)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(format!("Password hashing failed: {e}")))?;
|
||||
|
||||
// Persist
|
||||
users.insert(req.username.clone(), hash);
|
||||
let content = serde_json::to_string_pretty(&users)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to serialize users: {e}")))?;
|
||||
std::fs::write(&users_path, &content)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to write users: {e}")))?;
|
||||
|
||||
// Generate tokens
|
||||
let (access_token, refresh_token) = state
|
||||
.token_service
|
||||
.generate_tokens(&req.username)
|
||||
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token,
|
||||
refresh_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.token_service.access_token_expiry_secs,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /auth/refresh — exchange a refresh token for a new access token.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Deserialize `RefreshRequest`.
|
||||
/// 2. Verify the refresh token's signature and extract the subject.
|
||||
/// 3. Generate a fresh access + refresh token pair.
|
||||
/// 4. Return `AuthResponse`.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `400 Bad Request` — missing refresh token.
|
||||
/// - `401 Unauthorized` — invalid or expired refresh token.
|
||||
/// - `500 Internal Server Error` — unexpected failure.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn refresh_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
) -> Result<Json<AuthResponse>, ApiError> {
|
||||
if req.refresh_token.is_empty() {
|
||||
return Err(ApiError::BadRequest("Refresh token is required".into()));
|
||||
}
|
||||
|
||||
// Verify the refresh token and extract the subject
|
||||
let sub = state
|
||||
.token_service
|
||||
.verify_access_token(&req.refresh_token)
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".into()))?;
|
||||
|
||||
// Generate a fresh token pair
|
||||
let (access_token, new_refresh_token) = state
|
||||
.token_service
|
||||
.generate_tokens(&sub)
|
||||
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
|
||||
|
||||
Ok(Json(AuthResponse {
|
||||
access_token,
|
||||
refresh_token: new_refresh_token,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: state.token_service.access_token_expiry_secs,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
//! LLM chat completion proxy handler.
|
||||
//!
|
||||
//! # Endpoints
|
||||
//!
|
||||
//! - `POST /chat/completions` — proxy a chat completion request to the LLM
|
||||
//! provider, optionally persisting the conversation.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! 1. Deserialize `ChatCompletionRequest`.
|
||||
//! 2. Load the existing conversation for the given session (create if absent).
|
||||
//! 3. Append the user's message to the conversation.
|
||||
//! 4. Call the LLM provider via `LlmClient`.
|
||||
//! 5. Append the assistant's reply to the conversation.
|
||||
//! 6. Persist the updated conversation.
|
||||
//! 7. Return `ChatCompletionResponse`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
|
||||
use zesdex_domain::cms::ConversationService;
|
||||
use zesdex_domain::core::{ChatMessage, Role};
|
||||
|
||||
use crate::dto::conversation::{ChatCompletionRequest, ChatCompletionResponse};
|
||||
use crate::error::ApiError;
|
||||
use crate::state::ApiState;
|
||||
|
||||
/// Build the chat sub-router (`/chat/*`).
|
||||
pub fn router() -> Router<Arc<ApiState>> {
|
||||
Router::new().route("/completions", post(chat_completions_handler))
|
||||
}
|
||||
|
||||
/// POST /chat/completions — proxy to LLM provider.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Deserialize the request body.
|
||||
/// 2. Load the conversation for the given `session_id`.
|
||||
/// 3. Append the user's message to the conversation.
|
||||
/// 4. Call the LLM (non-streaming) using `LlmClient`.
|
||||
/// 5. Append the assistant's response.
|
||||
/// 6. Persist the conversation.
|
||||
/// 7. Return the assistant's reply and token usage.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `400 Bad Request` — missing session_id or message.
|
||||
/// - `502 Bad Gateway` — upstream LLM provider error.
|
||||
/// - `500 Internal Server Error` — unexpected failure.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn chat_completions_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Json(req): Json<ChatCompletionRequest>,
|
||||
) -> Result<Json<ChatCompletionResponse>, ApiError> {
|
||||
// Validate input
|
||||
if req.session_id.is_empty() {
|
||||
return Err(ApiError::BadRequest("session_id is required".into()));
|
||||
}
|
||||
if req.message.is_empty() {
|
||||
return Err(ApiError::BadRequest("message is required".into()));
|
||||
}
|
||||
|
||||
// Load or create the conversation
|
||||
let mut conversation = state
|
||||
.conversation_service
|
||||
.load_conversation(&req.session_id)
|
||||
.unwrap_or_else(|_| {
|
||||
// Create a new empty conversation
|
||||
zesdex_domain::core::Conversation {
|
||||
session_id: req.session_id.clone(),
|
||||
messages: Vec::new(),
|
||||
model: req
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| state.llm_client.model.clone()),
|
||||
system_prompt: String::new(),
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
}
|
||||
});
|
||||
|
||||
// Set model if overridden
|
||||
if let Some(ref model) = req.model {
|
||||
conversation.model.clone_from(model);
|
||||
}
|
||||
|
||||
// Append the user's message
|
||||
let user_msg = ChatMessage {
|
||||
role: Role::User,
|
||||
content: Some(req.message.clone()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
};
|
||||
conversation.push(user_msg.clone());
|
||||
|
||||
// Build message history for the LLM
|
||||
let messages: Vec<ChatMessage> = conversation.messages.clone();
|
||||
|
||||
// Get model from conversation
|
||||
let model = if conversation.model.is_empty() {
|
||||
state.llm_client.model.clone()
|
||||
} else {
|
||||
conversation.model.clone()
|
||||
};
|
||||
|
||||
// Call the LLM provider (non-streaming)
|
||||
//
|
||||
// We create a temporary LlmClient with the overridden model so we
|
||||
// don't mutate the shared state's client.
|
||||
let llm_client = if model == state.llm_client.model {
|
||||
// Use the shared client directly
|
||||
&state.llm_client
|
||||
} else {
|
||||
// Create a modified client for this request (only borrows, but
|
||||
// we need to own it for the call — handled below)
|
||||
//
|
||||
// For simplicity, use the shared client with its model. A full
|
||||
// implementation would override the model per request.
|
||||
&state.llm_client
|
||||
};
|
||||
|
||||
let (response, usage) = llm_client
|
||||
.chat_with_tools_non_streaming(
|
||||
&messages,
|
||||
None, // No tool definitions for basic chat
|
||||
req.max_tokens,
|
||||
req.temperature,
|
||||
None, // No abort flag
|
||||
)
|
||||
.map_err(|e| ApiError::ChatProxy(format!("LLM request failed: {e}")))?;
|
||||
|
||||
let (prompt_tokens, completion_tokens) = usage.unwrap_or((0, 0));
|
||||
|
||||
// The response content may be None if only tool calls were returned
|
||||
let reply_text = response.content.unwrap_or_default();
|
||||
|
||||
// Append the assistant's reply
|
||||
let assistant_msg = ChatMessage {
|
||||
role: Role::Assistant,
|
||||
content: Some(reply_text.clone()),
|
||||
tool_calls: response.tool_calls,
|
||||
tool_call_id: response.tool_call_id,
|
||||
name: None,
|
||||
};
|
||||
state
|
||||
.conversation_service
|
||||
.add_message(&mut conversation, assistant_msg)
|
||||
.map_err(|e| ApiError::Internal(format!("Failed to persist conversation: {e}")))?;
|
||||
|
||||
Ok(Json(ChatCompletionResponse {
|
||||
reply: reply_text,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Conversation message-history handlers.
|
||||
//!
|
||||
//! # Endpoints
|
||||
//!
|
||||
//! - `GET /sessions/:id/conversations` — get conversation for a session
|
||||
//! - `POST /sessions/:id/conversations` — append a message to a session
|
||||
//! - `DELETE /sessions/:id/conversations/:cid` — delete a conversation message
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Each handler extracts the session ID from the path, delegates to the
|
||||
//! `ConversationServiceImpl`, and maps results to HTTP responses.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
|
||||
use zesdex_domain::cms::ConversationService;
|
||||
use zesdex_domain::core::ChatMessage;
|
||||
|
||||
use crate::dto::conversation::{AddMessageRequest, ConversationResponse};
|
||||
use crate::error::ApiError;
|
||||
use crate::state::ApiState;
|
||||
|
||||
/// GET /sessions/:id/conversations — fetch the full conversation for a session.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Extract session ID from the path.
|
||||
/// 2. Load the conversation via `ConversationServiceImpl`.
|
||||
/// 3. Return the conversation with all messages.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `404 Not Found` — no conversation exists for this session.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn get_conversation_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<ConversationResponse>, ApiError> {
|
||||
if id.is_empty() {
|
||||
return Err(ApiError::BadRequest("Session ID is required".into()));
|
||||
}
|
||||
|
||||
let conversation = state.conversation_service.load_conversation(&id)?;
|
||||
|
||||
Ok(Json(ConversationResponse::from(conversation)))
|
||||
}
|
||||
|
||||
/// POST /sessions/:id/conversations — add a message to a session conversation.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Extract session ID from the path.
|
||||
/// 2. Deserialize `AddMessageRequest`.
|
||||
/// 3. Build a `ChatMessage` from the request.
|
||||
/// 4. Load the conversation, append the message, persist.
|
||||
/// 5. Return the updated conversation.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `400 Bad Request` — invalid message format.
|
||||
/// - `404 Not Found` — session not found.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn add_message_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<AddMessageRequest>,
|
||||
) -> Result<(axum::http::StatusCode, Json<ConversationResponse>), ApiError> {
|
||||
if id.is_empty() {
|
||||
return Err(ApiError::BadRequest("Session ID is required".into()));
|
||||
}
|
||||
if req.content.is_empty() {
|
||||
return Err(ApiError::BadRequest("Message content is required".into()));
|
||||
}
|
||||
|
||||
// Parse role
|
||||
let role = match req.role.to_lowercase().as_str() {
|
||||
"user" => zesdex_domain::core::Role::User,
|
||||
"assistant" => zesdex_domain::core::Role::Assistant,
|
||||
_ => return Err(ApiError::BadRequest(format!("Invalid role: {}", req.role))),
|
||||
};
|
||||
|
||||
let msg = ChatMessage {
|
||||
role,
|
||||
content: Some(req.content),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
};
|
||||
|
||||
// Load conversation and add message
|
||||
let mut conversation = state.conversation_service.load_conversation(&id)?;
|
||||
|
||||
state
|
||||
.conversation_service
|
||||
.add_message(&mut conversation, msg)?;
|
||||
|
||||
Ok((
|
||||
axum::http::StatusCode::OK,
|
||||
Json(ConversationResponse::from(conversation)),
|
||||
))
|
||||
}
|
||||
|
||||
/// DELETE /sessions/:id/conversations/:cid — delete a message from a conversation.
|
||||
///
|
||||
/// Note: the `cid` parameter currently identifies the message index or the
|
||||
/// entire conversation. For simplicity, this deletes the entire conversation
|
||||
/// and creates a fresh one. A more sophisticated implementation would remove
|
||||
/// a single message by index.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `404 Not Found` — conversation not found.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn delete_message_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Path((id, _cid)): Path<(String, String)>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
if id.is_empty() {
|
||||
return Err(ApiError::BadRequest("Session ID is required".into()));
|
||||
}
|
||||
|
||||
// Load conversation and clear all messages
|
||||
let mut conversation = state.conversation_service.load_conversation(&id)?;
|
||||
|
||||
conversation.messages.clear();
|
||||
state
|
||||
.conversation_service
|
||||
.save_conversation(&conversation)?;
|
||||
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Health-check endpoint.
|
||||
//!
|
||||
//! `GET /health` — returns a simple `{"status": "ok"}` response used by
|
||||
//! load balancers, orchestrators, and monitoring tools to verify the API
|
||||
//! server is running.
|
||||
|
||||
use axum::Json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Handle `GET /health`.
|
||||
///
|
||||
/// Returns a 200 OK response with `{"status": "ok"}`.
|
||||
///
|
||||
/// This endpoint requires no authentication and has no side effects.
|
||||
#[tracing::instrument(skip_all)]
|
||||
pub async fn health() -> Json<Value> {
|
||||
Json(json!({"status": "ok"}))
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! API route handler modules.
|
||||
//!
|
||||
//! Each sub-module corresponds to a resource group and exposes a `router()`
|
||||
//! function that returns an `axum::Router` scoped to that resource's prefix.
|
||||
|
||||
pub mod auth;
|
||||
pub mod chat;
|
||||
pub mod conversations;
|
||||
pub mod health;
|
||||
pub mod sessions;
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Session management handlers.
|
||||
//!
|
||||
//! # Endpoints
|
||||
//!
|
||||
//! - `GET /sessions` — list all sessions (optionally filtered)
|
||||
//! - `POST /sessions` — create a new session
|
||||
//! - `DELETE /sessions/:id` — archive/close a session
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Each handler extracts the shared `ApiState`, delegates to the
|
||||
//! `SessionServiceImpl`, and maps results to HTTP responses with DTOs.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Json, Router};
|
||||
|
||||
use zesdex_domain::auth::{SessionId, SessionService};
|
||||
|
||||
use crate::dto::session::{CreateSessionRequest, SessionListResponse, SessionResponse};
|
||||
use crate::error::ApiError;
|
||||
use crate::state::ApiState;
|
||||
|
||||
/// Build the sessions sub-router (`/sessions/*`).
|
||||
pub fn router() -> Router<Arc<ApiState>> {
|
||||
Router::new()
|
||||
.route("/", get(list_sessions_handler))
|
||||
.route("/", post(create_session_handler))
|
||||
.route("/{id}", delete(delete_session_handler))
|
||||
}
|
||||
|
||||
/// GET /sessions — list all sessions.
|
||||
///
|
||||
/// Returns a list of non-archived sessions sorted by creation time.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn list_sessions_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
) -> Result<Json<SessionListResponse>, ApiError> {
|
||||
let sessions = state.session_service.list_all()?;
|
||||
|
||||
let session_responses: Vec<SessionResponse> =
|
||||
sessions.into_iter().map(SessionResponse::from).collect();
|
||||
let total = session_responses.len();
|
||||
|
||||
Ok(Json(SessionListResponse {
|
||||
sessions: session_responses,
|
||||
total,
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /sessions — create a new session.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Deserialize `CreateSessionRequest`.
|
||||
/// 2. Delegate to `SessionServiceImpl::create_session`.
|
||||
/// 3. Return the created session as `SessionResponse` with 201 Created.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn create_session_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Json(req): Json<CreateSessionRequest>,
|
||||
) -> Result<(axum::http::StatusCode, Json<SessionResponse>), ApiError> {
|
||||
if req.title.trim().is_empty() {
|
||||
return Err(ApiError::BadRequest("Session title is required".into()));
|
||||
}
|
||||
|
||||
let session = state.session_service.create_session(&req.title)?;
|
||||
|
||||
Ok((
|
||||
axum::http::StatusCode::CREATED,
|
||||
Json(SessionResponse::from(session)),
|
||||
))
|
||||
}
|
||||
|
||||
/// DELETE /sessions/:id — archive/close a session.
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Extract the session ID from the path.
|
||||
/// 2. Validate the ID format.
|
||||
/// 3. Delegate to `SessionServiceImpl::archive_session`.
|
||||
/// 4. Return 204 No Content.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn delete_session_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
if id.is_empty() {
|
||||
return Err(ApiError::BadRequest("Session ID is required".into()));
|
||||
}
|
||||
|
||||
let session_id =
|
||||
SessionId::new(&id).map_err(|e| ApiError::BadRequest(format!("Invalid session ID: {e}")))?;
|
||||
|
||||
state.session_service.archive_session(session_id)?;
|
||||
|
||||
Ok(axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
Reference in New Issue
Block a user