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,28 @@
|
||||
[package]
|
||||
name = "zesdex-api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# REST API interface — Axum HTTP server.
|
||||
# Provides RESTful endpoints for the application, enabling
|
||||
# web clients, mobile apps, and third-party integrations.
|
||||
[dependencies]
|
||||
zesdex-domain = { path = "../../domain" }
|
||||
zesdex-application = { path = "../../application" }
|
||||
zesdex-infrastructure = { path = "../../infrastructure" }
|
||||
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
axum.workspace = true
|
||||
tower.workspace = true
|
||||
tower-http.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
argon2.workspace = true
|
||||
thiserror.workspace = true
|
||||
futures-util.workspace = true
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Authentication DTOs — login, register, and token refresh payloads.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Request body for `POST /auth/login`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
/// Username or email identifier.
|
||||
pub username: String,
|
||||
/// Plaintext password.
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// Request body for `POST /auth/register`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
/// Desired username.
|
||||
pub username: String,
|
||||
/// Plaintext password (will be hashed server-side).
|
||||
pub password: String,
|
||||
/// Optional display name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Request body for `POST /auth/refresh`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RefreshRequest {
|
||||
/// The refresh token issued during login.
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// Response body for auth endpoints (login, register, refresh).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthResponse {
|
||||
/// JWT access token (short-lived, typically 1 hour).
|
||||
pub access_token: String,
|
||||
/// JWT refresh token (long-lived, typically 7 days).
|
||||
pub refresh_token: String,
|
||||
/// Token type (always `"Bearer"`).
|
||||
pub token_type: String,
|
||||
/// Expiry of the access token in seconds.
|
||||
pub expires_in: u64,
|
||||
}
|
||||
|
||||
/// Claims exposed in the JWT payload, returned from introspection.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClaimsResponse {
|
||||
/// Subject identifier (username).
|
||||
pub sub: String,
|
||||
/// Issued-at timestamp (epoch seconds).
|
||||
pub iat: u64,
|
||||
/// Expiry timestamp (epoch seconds).
|
||||
pub exp: u64,
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Conversation DTOs — message history read/write payloads.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zesdex_domain::core::{ChatMessage, Conversation};
|
||||
|
||||
/// Request body for appending a message to a conversation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddMessageRequest {
|
||||
/// Message role: `"user"` or `"assistant"`.
|
||||
pub role: String,
|
||||
/// Message content text.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Response body for a single conversation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConversationResponse {
|
||||
/// Session ID this conversation belongs to.
|
||||
pub session_id: String,
|
||||
/// Messages in the conversation.
|
||||
pub messages: Vec<MessageResponse>,
|
||||
/// Total message count.
|
||||
pub message_count: usize,
|
||||
/// Model identifier used for this conversation.
|
||||
pub model: String,
|
||||
/// System prompt in effect.
|
||||
pub system_prompt: String,
|
||||
/// Max tokens configuration.
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Temperature configuration.
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
impl From<Conversation> for ConversationResponse {
|
||||
fn from(c: Conversation) -> Self {
|
||||
let message_count = c.len();
|
||||
let messages: Vec<MessageResponse> =
|
||||
c.messages.into_iter().map(MessageResponse::from).collect();
|
||||
ConversationResponse {
|
||||
session_id: c.session_id,
|
||||
messages,
|
||||
message_count,
|
||||
model: c.model,
|
||||
system_prompt: c.system_prompt,
|
||||
max_tokens: c.max_tokens,
|
||||
temperature: c.temperature,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single message in a conversation response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageResponse {
|
||||
/// Message role.
|
||||
pub role: String,
|
||||
/// Message content (None for assistant messages with only tool calls).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
/// Optional tool call information.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCallResponse>>,
|
||||
/// Optional tool call result identifier.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
impl From<ChatMessage> for MessageResponse {
|
||||
fn from(m: ChatMessage) -> Self {
|
||||
let tool_calls = m.tool_calls.map(|calls| {
|
||||
calls
|
||||
.into_iter()
|
||||
.map(|tc| ToolCallResponse {
|
||||
id: tc.id,
|
||||
function: ToolFunctionResponse {
|
||||
name: tc.function.name,
|
||||
arguments: tc.function.arguments.to_string(),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
MessageResponse {
|
||||
role: m.role.to_string(),
|
||||
content: m.content,
|
||||
tool_calls,
|
||||
tool_call_id: m.tool_call_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tool call reference in a message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallResponse {
|
||||
/// Tool call ID.
|
||||
pub id: String,
|
||||
/// Function details.
|
||||
pub function: ToolFunctionResponse,
|
||||
}
|
||||
|
||||
/// A function invocation in a tool call.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunctionResponse {
|
||||
/// Function name.
|
||||
pub name: String,
|
||||
/// JSON-encoded arguments.
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// Request body for `POST /chat/completions`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatCompletionRequest {
|
||||
/// The session ID to attach this completion to.
|
||||
pub session_id: String,
|
||||
/// Message content (user message).
|
||||
pub message: String,
|
||||
/// Optional model override.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// Optional max tokens override.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Optional temperature override.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
/// Response body for `POST /chat/completions`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatCompletionResponse {
|
||||
/// The assistant's reply.
|
||||
pub reply: String,
|
||||
/// Total prompt tokens consumed.
|
||||
pub prompt_tokens: u64,
|
||||
/// Total completion tokens generated.
|
||||
pub completion_tokens: u64,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Error response DTO — JSON body returned for all API errors.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Standardised error response body.
|
||||
///
|
||||
/// Returned for all non-successful API responses. Contains a human-readable
|
||||
/// `message` and the HTTP status `code` for machine parsing.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
/// Human-readable error description.
|
||||
pub message: String,
|
||||
/// HTTP status code (mirrors the response status).
|
||||
pub code: u16,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Data Transfer Objects for the REST API.
|
||||
//!
|
||||
//! These types define the wire format for request bodies and response bodies.
|
||||
//! They are intentionally independent of domain entities so the API contract
|
||||
//! can evolve without coupling to the domain model.
|
||||
|
||||
pub mod auth;
|
||||
pub mod conversation;
|
||||
pub mod error;
|
||||
pub mod session;
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Session DTOs — create, list, and delete session payloads.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zesdex_domain::auth::Session;
|
||||
|
||||
/// Request body for `POST /sessions`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateSessionRequest {
|
||||
/// Human-readable session title.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// Response body for a single session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionResponse {
|
||||
/// Unique session identifier.
|
||||
pub id: String,
|
||||
/// Epoch-millis timestamp of creation.
|
||||
pub created_at: i64,
|
||||
/// Epoch-millis timestamp of last update.
|
||||
pub updated_at: i64,
|
||||
/// Human-readable title.
|
||||
pub title: String,
|
||||
/// Model identifier string.
|
||||
pub model: String,
|
||||
/// Number of messages in this session.
|
||||
pub message_count: u32,
|
||||
/// Whether the session has been archived.
|
||||
pub archived: bool,
|
||||
/// Optional AI-generated summary.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
impl From<Session> for SessionResponse {
|
||||
fn from(s: Session) -> Self {
|
||||
SessionResponse {
|
||||
id: s.id,
|
||||
created_at: s.created_at,
|
||||
updated_at: s.updated_at,
|
||||
title: s.title,
|
||||
model: s.model,
|
||||
message_count: s.message_count,
|
||||
archived: s.archived,
|
||||
summary: s.summary,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response body for `GET /sessions`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionListResponse {
|
||||
/// All (non-archived) sessions.
|
||||
pub sessions: Vec<SessionResponse>,
|
||||
/// Total count of sessions returned.
|
||||
pub total: usize,
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Typed API error type with automatic HTTP response conversion.
|
||||
//!
|
||||
//! `ApiError` represents all possible failure modes of the REST API.
|
||||
//! Each variant maps to an appropriate HTTP status code via `IntoResponse`,
|
||||
//! producing a JSON body with a `message` field and an optional `code`.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Handler returns `Result<T, ApiError>` → Axum calls `IntoResponse` →
|
||||
//! HTTP response with appropriate status code and JSON error body.
|
||||
//!
|
||||
//! # Error mapping
|
||||
//!
|
||||
//! - `BadRequest` → 400
|
||||
//! - `Unauthorized` → 401
|
||||
//! - `NotFound` → 404
|
||||
//! - `Conflict` → 409
|
||||
//! - `Internal` → 500 (with `tracing::error!` log)
|
||||
//! - `ChatProxy` → 502 (upstream LLM error)
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::dto::error::ErrorResponse;
|
||||
|
||||
/// Typed API error with HTTP status code mapping.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
/// The request was malformed or contained invalid data.
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
/// Authentication failed or credentials are missing/invalid.
|
||||
#[error("Unauthorized: {0}")]
|
||||
Unauthorized(String),
|
||||
|
||||
/// The requested resource was not found.
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// The request conflicts with the current server state.
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
/// An unexpected internal error occurred.
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
|
||||
/// The upstream LLM provider returned an error.
|
||||
#[error("Chat proxy error: {0}")]
|
||||
ChatProxy(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
/// Convert `ApiError` into an HTTP response with an appropriate status
|
||||
/// code and a structured JSON body.
|
||||
///
|
||||
/// Internal errors are logged at `error` level before returning a generic
|
||||
/// 500 response (to avoid leaking internal details).
|
||||
fn into_response(self) -> Response {
|
||||
let (status, user_message) = match &self {
|
||||
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||
ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
|
||||
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||
ApiError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
|
||||
ApiError::Internal(msg) => {
|
||||
tracing::error!(error = %msg, "Internal server error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"An internal error occurred".to_string(),
|
||||
)
|
||||
}
|
||||
ApiError::ChatProxy(msg) => {
|
||||
tracing::error!(error = %msg, "Chat proxy error");
|
||||
(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
format!("Upstream LLM error: {msg}"),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let body = ErrorResponse {
|
||||
message: user_message,
|
||||
code: status.as_u16(),
|
||||
};
|
||||
|
||||
(status, Json(json!(body))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// From impls — convert domain/infrastructure errors into ApiError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl From<zesdex_domain::error::DomainError> for ApiError {
|
||||
/// Map domain repository errors to API errors.
|
||||
///
|
||||
/// - `NotFound` → `ApiError::NotFound`
|
||||
/// - `Conflict` → `ApiError::Conflict`
|
||||
/// - `InvalidId` → `ApiError::BadRequest`
|
||||
/// - All others → `ApiError::Internal`
|
||||
fn from(e: zesdex_domain::error::DomainError) -> Self {
|
||||
match e {
|
||||
zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg),
|
||||
zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg),
|
||||
zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg),
|
||||
_ => ApiError::Internal(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<zesdex_domain::auth::ServiceError> for ApiError {
|
||||
/// Map IAM service errors to API errors.
|
||||
fn from(e: zesdex_domain::auth::ServiceError) -> Self {
|
||||
match e {
|
||||
zesdex_domain::auth::ServiceError::Repository(repo_err) => match repo_err {
|
||||
zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg),
|
||||
zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg),
|
||||
zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg),
|
||||
_ => ApiError::Internal(repo_err.to_string()),
|
||||
},
|
||||
zesdex_domain::auth::ServiceError::InvalidConfig(msg) => ApiError::BadRequest(msg),
|
||||
zesdex_domain::auth::ServiceError::StateMismatch => {
|
||||
ApiError::Unauthorized("OAuth state mismatch — possible CSRF attack".into())
|
||||
}
|
||||
zesdex_domain::auth::ServiceError::OAuthProvider(msg) => ApiError::ChatProxy(msg),
|
||||
zesdex_domain::auth::ServiceError::Other(msg) => ApiError::Internal(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<zesdex_domain::cms::ServiceError> for ApiError {
|
||||
/// Map CMS service errors to API errors.
|
||||
fn from(e: zesdex_domain::cms::ServiceError) -> Self {
|
||||
match e {
|
||||
zesdex_domain::cms::ServiceError::Repository(repo_err) => match repo_err {
|
||||
zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg),
|
||||
zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg),
|
||||
zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg),
|
||||
_ => ApiError::Internal(repo_err.to_string()),
|
||||
},
|
||||
zesdex_domain::cms::ServiceError::InvalidInput(msg) => ApiError::BadRequest(msg),
|
||||
zesdex_domain::cms::ServiceError::Other(msg) => ApiError::Internal(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for ApiError {
|
||||
/// Fallback conversion: log the error and return a generic internal error.
|
||||
fn from(e: anyhow::Error) -> Self {
|
||||
tracing::error!(error = %e, "Unhandled error");
|
||||
ApiError::Internal(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<jsonwebtoken::errors::Error> for ApiError {
|
||||
fn from(e: jsonwebtoken::errors::Error) -> Self {
|
||||
ApiError::Unauthorized(format!("Invalid token: {e}"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! # Zesdex REST API — Axum HTTP server
|
||||
//!
|
||||
//! Provides RESTful endpoints for the Zesdex application, enabling
|
||||
//! web clients, mobile apps, and third-party integrations.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! src/
|
||||
//! ├── lib.rs — Module declarations, re-exports, router builder
|
||||
//! ├── state.rs — ApiState with concrete service implementations
|
||||
//! ├── error.rs — ApiError enum + IntoResponse
|
||||
//! ├── dto/ — Request/response DTOs (serde)
|
||||
//! ├── handlers/ — Axum route handlers
|
||||
//! └── middleware/ — Tower layers (JWT auth, etc.)
|
||||
//! ```
|
||||
//!
|
||||
//! ## Flow
|
||||
//!
|
||||
//! 1. `build_router()` constructs an Axum `Router` with all routes nested.
|
||||
//! 2. Each handler receives `State<Arc<ApiState>>` or direct extractors.
|
||||
//! 3. Handlers delegate to application-layer service implementations.
|
||||
//! 4. Domain/infrastructure errors are mapped to `ApiError` → HTTP status codes.
|
||||
|
||||
pub mod dto;
|
||||
pub mod error;
|
||||
pub mod handlers;
|
||||
pub mod middleware;
|
||||
pub mod state;
|
||||
|
||||
pub use error::ApiError;
|
||||
pub use state::ApiState;
|
||||
|
||||
use std::sync::Arc;
|
||||
use axum::Router;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
/// Build the API router with all routes registered.
|
||||
///
|
||||
/// Flow: create CORS layer → build sub-routers for each resource → nest
|
||||
/// them under `/api/v1` → attach shared state → return.
|
||||
///
|
||||
/// ## Arguments
|
||||
/// * `state` — shared application state (wrapped in `Arc` for clone-free sharing)
|
||||
///
|
||||
/// ## Example
|
||||
/// ```ignore
|
||||
/// let state = ApiState::new("/path/to/data");
|
||||
/// let app = build_router(state);
|
||||
/// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||
/// axum::serve(listener, app).await.unwrap();
|
||||
/// ```
|
||||
pub fn build_router(state: ApiState) -> Router {
|
||||
let shared_state: Arc<ApiState> = Arc::new(state);
|
||||
|
||||
// CORS layer — permissive for local daemon / development use
|
||||
let cors = CorsLayer::permissive();
|
||||
|
||||
// Combine all sub-routers under a versioned prefix
|
||||
Router::new()
|
||||
.nest("/api/v1", api_v1_router())
|
||||
.layer(cors)
|
||||
.with_state(shared_state)
|
||||
}
|
||||
|
||||
/// Version 1 API sub-router.
|
||||
///
|
||||
/// Groups all resource routes under `/api/v1/*`.
|
||||
fn api_v1_router() -> Router<Arc<ApiState>> {
|
||||
use handlers::{auth, chat, conversations, health, sessions};
|
||||
|
||||
// Sessions router combines session CRUD + nested conversations
|
||||
let sessions_router = Router::new()
|
||||
.route("/", axum::routing::get(sessions::list_sessions_handler))
|
||||
.route("/", axum::routing::post(sessions::create_session_handler))
|
||||
.route("/{id}", axum::routing::delete(sessions::delete_session_handler))
|
||||
// Conversations are sub-resources of sessions
|
||||
.route(
|
||||
"/{id}/conversations",
|
||||
axum::routing::get(conversations::get_conversation_handler),
|
||||
)
|
||||
.route(
|
||||
"/{id}/conversations",
|
||||
axum::routing::post(conversations::add_message_handler),
|
||||
)
|
||||
.route(
|
||||
"/{id}/conversations/{cid}",
|
||||
axum::routing::delete(conversations::delete_message_handler),
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.route("/health", axum::routing::get(health::health))
|
||||
.nest("/auth", auth::router())
|
||||
.nest("/sessions", sessions_router)
|
||||
.nest("/chat", chat::router())
|
||||
}
|
||||
|
||||
// Re-export commonly used types at the crate root for ergonomic access.
|
||||
pub use axum::http::StatusCode;
|
||||
@@ -0,0 +1,140 @@
|
||||
//! JWT authentication middleware for Axum.
|
||||
//!
|
||||
//! Validates the `Authorization: Bearer <token>` header on every protected
|
||||
//! request. Injects the validated subject claim into request extensions for
|
||||
//! downstream handlers to consume.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! ```text
|
||||
//! Request → JwtAuthLayer → extract Bearer token → verify JWT → inject claims
|
||||
//! → inner service → Response
|
||||
//! ```
|
||||
//!
|
||||
//! If the token is missing, expired, or has an invalid signature the request
|
||||
//! is rejected with 401 Unauthorized before reaching any handler.
|
||||
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, Response, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use tower::{Layer, Service};
|
||||
|
||||
use crate::state::ApiState;
|
||||
|
||||
/// Claims extracted from a valid JWT, injected into request extensions.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JwtClaims {
|
||||
/// Subject identifier (username/user ID).
|
||||
pub sub: String,
|
||||
}
|
||||
|
||||
/// Tower Layer that produces `JwtAuthMiddleware` services.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JwtAuthLayer {
|
||||
/// HMAC secret used to verify JWT signatures (reference into `ApiState`).
|
||||
state: Arc<ApiState>,
|
||||
}
|
||||
|
||||
impl JwtAuthLayer {
|
||||
/// Create a new JWT auth layer with the given shared API state.
|
||||
pub fn new(state: Arc<ApiState>) -> Self {
|
||||
Self { state }
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for JwtAuthLayer {
|
||||
type Service = JwtAuthMiddleware<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
JwtAuthMiddleware {
|
||||
inner,
|
||||
state: self.state.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tower Service that validates JWT Bearer tokens before forwarding.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JwtAuthMiddleware<S> {
|
||||
inner: S,
|
||||
state: Arc<ApiState>,
|
||||
}
|
||||
|
||||
impl<S, ReqBody> Service<Request<ReqBody>> for JwtAuthMiddleware<S>
|
||||
where
|
||||
S: Service<Request<ReqBody>, Response = Response<Body>> + Send + 'static,
|
||||
S::Future: Send + 'static,
|
||||
ReqBody: Send + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
type Future =
|
||||
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
|
||||
// Extract the Authorization header
|
||||
let auth_header = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let secret = self.state.jwt_secret.clone();
|
||||
|
||||
if let Some(auth_value) = auth_header {
|
||||
// Expect "Bearer <token>"
|
||||
if let Some(token) = auth_value.strip_prefix("Bearer ") {
|
||||
match zesdex_infrastructure::auth::jwt::verify_token(&secret, token) {
|
||||
Ok(claims) => {
|
||||
// Inject claims as extension for downstream handlers
|
||||
let mut req = req;
|
||||
req.extensions_mut().insert(JwtClaims {
|
||||
sub: claims.sub,
|
||||
});
|
||||
let fut = self.inner.call(req);
|
||||
return Box::pin(fut);
|
||||
}
|
||||
Err(e) => {
|
||||
let response = (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "Invalid token",
|
||||
"detail": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
return Box::pin(async move { Ok(response) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No valid Authorization header
|
||||
let response = (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "Missing or invalid Authorization header"})),
|
||||
)
|
||||
.into_response();
|
||||
Box::pin(async move { Ok(response) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: check if a request has a valid JWT in its Authorization header.
|
||||
///
|
||||
/// Intended for use in middleware layers or route guards that need quick
|
||||
/// authentication verification without extracting the full claims.
|
||||
pub fn is_authenticated(req: &Request<Body>) -> bool {
|
||||
req.extensions().get::<JwtClaims>().is_some()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
//! Axum middleware layers for the REST API.
|
||||
//!
|
||||
//! Provides tower `Layer` implementations for cross-cutting concerns:
|
||||
//! - `auth` — JWT-based authentication layer
|
||||
|
||||
pub mod auth;
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Shared application state for the REST API server.
|
||||
//!
|
||||
//! `ApiState` holds concrete service implementations wired to infrastructure
|
||||
//! adapters. It is constructed at the composition root and shared across all
|
||||
//! handlers via Axum's `State` extractor (wrapped in `Arc`).
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! 1. `ApiState::new(base_dir)` creates all services with their concrete repos.
|
||||
//! 2. `build_router()` wraps it in `Arc` and passes it to the Axum `Router`.
|
||||
//! 3. Handlers extract `State<Arc<ApiState>>` and delegate to the services.
|
||||
//!
|
||||
//! # Port trait implementations
|
||||
//!
|
||||
//! This module also provides simple wrapper types that implement the
|
||||
//! application-layer port traits using infrastructure functions:
|
||||
//!
|
||||
//! - `Argon2PasswordService` — implements `PasswordService` via
|
||||
//! `infrastructure::auth::password`
|
||||
//! - `JwtTokenService` — implements `TokenService` via
|
||||
//! `infrastructure::auth::jwt`
|
||||
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use zesdex_application::ports::{PasswordService, TokenService};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port trait implementations (wrap infrastructure free functions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Password-hashing service backed by Argon2id (infrastructure).
|
||||
///
|
||||
/// Delegates to `zesdex_infrastructure::auth::password::{hash_password, verify_password}`.
|
||||
#[derive(Clone)]
|
||||
pub struct Argon2PasswordService;
|
||||
|
||||
impl fmt::Debug for Argon2PasswordService {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("Argon2PasswordService").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl PasswordService for Argon2PasswordService {
|
||||
/// Hash a plaintext password using Argon2id with a random salt.
|
||||
fn hash(&self, password: &str) -> impl Future<Output = anyhow::Result<String>> + Send {
|
||||
zesdex_infrastructure::auth::password::hash_password(password)
|
||||
}
|
||||
|
||||
/// Verify a plaintext password against a stored PHC string.
|
||||
fn verify(
|
||||
&self,
|
||||
password: &str,
|
||||
hash: &str,
|
||||
) -> impl Future<Output = anyhow::Result<bool>> + Send {
|
||||
zesdex_infrastructure::auth::password::verify_password(password, hash)
|
||||
}
|
||||
}
|
||||
|
||||
/// JWT token service backed by HS256 (infrastructure).
|
||||
///
|
||||
/// Delegates to `zesdex_infrastructure::auth::jwt::{create_token, verify_token}`.
|
||||
#[derive(Clone)]
|
||||
pub struct JwtTokenService {
|
||||
/// HMAC secret key used for signing and verification.
|
||||
pub secret: String,
|
||||
/// Token expiry in seconds (default: 3600 = 1 hour).
|
||||
pub access_token_expiry_secs: u64,
|
||||
/// Refresh token expiry in seconds (default: 604800 = 7 days).
|
||||
pub refresh_token_expiry_secs: u64,
|
||||
}
|
||||
|
||||
impl fmt::Debug for JwtTokenService {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("JwtTokenService")
|
||||
.field("access_token_expiry_secs", &self.access_token_expiry_secs)
|
||||
.field("refresh_token_expiry_secs", &self.refresh_token_expiry_secs)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl JwtTokenService {
|
||||
/// Create a new JWT service with the given HMAC secret.
|
||||
pub fn new(secret: impl Into<String>) -> Self {
|
||||
JwtTokenService {
|
||||
secret: secret.into(),
|
||||
access_token_expiry_secs: 3600,
|
||||
refresh_token_expiry_secs: 604800,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenService for JwtTokenService {
|
||||
/// Generate an access + refresh token pair for the given subject.
|
||||
fn generate_tokens(&self, sub: &str) -> anyhow::Result<(String, String)> {
|
||||
use zesdex_infrastructure::auth::jwt::{create_token, JwtClaims};
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
// Access token
|
||||
let access_claims = JwtClaims::new(sub.to_string(), now + self.access_token_expiry_secs, None);
|
||||
let access_token = create_token(&self.secret, access_claims)?;
|
||||
|
||||
// Refresh token (longer-lived)
|
||||
let refresh_claims =
|
||||
JwtClaims::new(sub.to_string(), now + self.refresh_token_expiry_secs, None);
|
||||
let refresh_token = create_token(&self.secret, refresh_claims)?;
|
||||
|
||||
Ok((access_token, refresh_token))
|
||||
}
|
||||
|
||||
/// Verify an access token and return the subject claim.
|
||||
fn verify_access_token(&self, token: &str) -> anyhow::Result<String> {
|
||||
use zesdex_infrastructure::auth::jwt::verify_token;
|
||||
|
||||
let claims = verify_token(&self.secret, token)?;
|
||||
Ok(claims.sub)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ApiState
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shared application state for the REST API server.
|
||||
///
|
||||
/// Holds all service implementations, repository instances, and configuration
|
||||
/// needed by the HTTP handlers. Constructed once at startup and shared
|
||||
/// across all requests via `Arc<ApiState>`.
|
||||
///
|
||||
/// `ApiState` does NOT derive `Clone` or `Debug` because the inner service
|
||||
/// types may not implement those traits. It is always wrapped in `Arc`.
|
||||
pub struct ApiState {
|
||||
/// Base directory for all Zesdex data stores (sessions, settings, etc.).
|
||||
pub store_base_dir: PathBuf,
|
||||
/// JWT secret key for token signing/verification.
|
||||
pub jwt_secret: String,
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Service implementations (application-layer use cases)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Session lifecycle management (create, list, archive).
|
||||
pub session_service:
|
||||
zesdex_application::auth::SessionServiceImpl<
|
||||
zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository,
|
||||
zesdex_infrastructure::persistence::iam::session_lock_repo::FileSystemSessionLockRepository,
|
||||
>,
|
||||
|
||||
/// Conversation message history CRUD.
|
||||
pub conversation_service:
|
||||
zesdex_application::cms::ConversationServiceImpl<
|
||||
zesdex_infrastructure::persistence::cms::conversation_repo::JsonConversationRepository,
|
||||
>,
|
||||
|
||||
/// Settings load/save.
|
||||
pub settings_service:
|
||||
zesdex_application::cms::SettingsServiceImpl<
|
||||
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository,
|
||||
zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository,
|
||||
>,
|
||||
|
||||
/// Long-term memory CRUD.
|
||||
pub memory_service:
|
||||
zesdex_application::cms::MemoryServiceImpl<
|
||||
zesdex_infrastructure::persistence::cms::memory_repo::MarkdownMemoryRepository,
|
||||
>,
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Port trait implementations (infrastructure wrappers)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Argon2id password hashing and verification.
|
||||
pub password_service: Argon2PasswordService,
|
||||
|
||||
/// HS256 JWT token generation and verification.
|
||||
pub token_service: JwtTokenService,
|
||||
|
||||
/// LLM provider client for chat completions.
|
||||
pub llm_client: zesdex_infrastructure::llm::LlmClient,
|
||||
}
|
||||
|
||||
impl fmt::Debug for ApiState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ApiState")
|
||||
.field("store_base_dir", &self.store_base_dir)
|
||||
.field("jwt_secret", &"**redacted**")
|
||||
.field("session_service", &"SessionServiceImpl { .. }")
|
||||
.field("conversation_service", &"ConversationServiceImpl { .. }")
|
||||
.field("settings_service", &"SettingsServiceImpl { .. }")
|
||||
.field("memory_service", &"MemoryServiceImpl { .. }")
|
||||
.field("password_service", &self.password_service)
|
||||
.field("token_service", &self.token_service)
|
||||
.field("llm_client", &"LlmClient { .. }")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ApiState {
|
||||
/// Construct a new API state with all services wired to their default
|
||||
/// infrastructure implementations.
|
||||
///
|
||||
/// ## Arguments
|
||||
/// * `base_dir` — the Zesdex data store root directory (sessions, settings, etc.)
|
||||
/// * `jwt_secret` — HMAC secret for JWT signing/verification
|
||||
/// * `llm_api_key` — API key for the LLM provider
|
||||
/// * `llm_model` — model identifier string
|
||||
/// * `llm_base_url` — optional custom API base URL
|
||||
///
|
||||
/// ## Flow
|
||||
///
|
||||
/// Creates concrete repository instances → wraps them in application-layer
|
||||
/// service implementations → stores everything in `ApiState`.
|
||||
pub fn new(
|
||||
base_dir: PathBuf,
|
||||
jwt_secret: impl Into<String>,
|
||||
llm_api_key: impl Into<String>,
|
||||
llm_model: impl Into<String>,
|
||||
llm_base_url: Option<String>,
|
||||
) -> Self {
|
||||
let jwt_secret = jwt_secret.into();
|
||||
let sessions_dir = base_dir.join("sessions");
|
||||
let memory_dir = base_dir.join("memories");
|
||||
|
||||
// IAM repositories
|
||||
let session_repo =
|
||||
zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository;
|
||||
let lock_repo =
|
||||
zesdex_infrastructure::persistence::iam::session_lock_repo::FileSystemSessionLockRepository;
|
||||
|
||||
// CMS repositories
|
||||
let conversation_repo =
|
||||
zesdex_infrastructure::persistence::cms::conversation_repo::JsonConversationRepository;
|
||||
let settings_repo =
|
||||
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository;
|
||||
let app_config_repo =
|
||||
zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository;
|
||||
let memory_repo =
|
||||
zesdex_infrastructure::persistence::cms::memory_repo::MarkdownMemoryRepository;
|
||||
|
||||
// Application-layer services
|
||||
let session_service = zesdex_application::auth::SessionServiceImpl::new(
|
||||
session_repo,
|
||||
lock_repo,
|
||||
base_dir.clone(),
|
||||
);
|
||||
let conversation_service =
|
||||
zesdex_application::cms::ConversationServiceImpl::new(conversation_repo, sessions_dir);
|
||||
let settings_service = zesdex_application::cms::SettingsServiceImpl::new(
|
||||
settings_repo,
|
||||
app_config_repo,
|
||||
base_dir.clone(),
|
||||
);
|
||||
let memory_service =
|
||||
zesdex_application::cms::MemoryServiceImpl::new(memory_repo, memory_dir);
|
||||
|
||||
let token_service = JwtTokenService::new(&jwt_secret);
|
||||
let llm_client = zesdex_infrastructure::llm::LlmClient::new(
|
||||
llm_api_key.into(),
|
||||
llm_model.into(),
|
||||
llm_base_url,
|
||||
);
|
||||
|
||||
ApiState {
|
||||
store_base_dir: base_dir,
|
||||
jwt_secret,
|
||||
session_service,
|
||||
conversation_service,
|
||||
settings_service,
|
||||
memory_service,
|
||||
password_service: Argon2PasswordService,
|
||||
token_service,
|
||||
llm_client,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user