Refactor CMS and IAM modules: restructure presentation and command layers

- Removed HTTP adapter module from CMS infrastructure.
- Updated CMS infrastructure module to exclude HTTP.
- Introduced presentation layer in CMS with DTOs and handlers for REST API.
- Added command types for CMS domain operations to encapsulate input data.
- Created typed error handling for CMS presentation layer.
- Implemented handlers for CMS REST API endpoints.
- Removed HTTP DTOs and handlers from IAM infrastructure.
- Introduced command types for IAM domain operations.
- Created presentation layer in IAM with DTOs and handlers for OAuth flow.
- Implemented typed error handling for IAM presentation layer.
This commit is contained in:
asepharyana
2026-07-20 06:53:01 +07:00
parent e9a8e93c83
commit 714b4617dd
22 changed files with 465 additions and 175 deletions
+153
View File
@@ -0,0 +1,153 @@
//! Command types for CMS domain operations.
//!
//! Following the `NewXxx` / `XxxPatch` pattern from clean architecture,
//! these types encapsulate the input data for create/update operations
//! on domain entities. They decouple presentation DTOs from the entity
//! mutation surface and provide a clear boundary for validation.
use std::collections::HashMap;
use super::settings::InternetMode;
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
/// Partial update command for `Settings`.
///
/// Every field is `Option`al — only non-`None` fields are applied to the
/// existing settings instance. Use `apply_to()` to merge into a `Settings`
/// value.
#[derive(Debug, Clone, Default)]
pub struct SettingsPatch {
/// Override the internet access mode.
pub internet_mode: Option<String>,
/// Override the active provider name.
pub provider: Option<String>,
/// Override the active model name.
pub model: Option<String>,
/// Replace the entire API-keys map.
pub api_keys: Option<HashMap<String, String>>,
/// Override the max tokens for completions.
pub max_tokens: Option<Option<u32>>,
/// Override the temperature for completions.
pub temperature: Option<Option<f32>>,
/// Override the review max lessons per run.
pub review_max_lessons_per_run: Option<usize>,
/// Override the adaptive review max skip count.
pub adaptive_review_max_skip: Option<u32>,
/// Override the verify shell command.
pub verify_command: Option<Option<String>>,
/// Override the verify timeout in milliseconds.
pub verify_timeout_ms: Option<u64>,
/// Override the max concurrency for workflow execution.
pub workflow_max_concurrency: Option<usize>,
/// Override the review-enabled flag.
pub review_enabled: Option<bool>,
/// Override the session-archive-enabled flag.
pub session_archive_enabled: Option<bool>,
/// Override the LSP auto-provision flag.
pub lsp_auto_provision: Option<bool>,
/// Override the list of LSP-managed languages.
pub lsp_languages: Option<Vec<String>>,
/// Override the hive-mind node timeout in milliseconds.
pub hive_mind_node_timeout_ms: Option<u64>,
}
impl SettingsPatch {
/// Merge this patch into `settings`, overwriting each non-`None` field.
///
/// Flow: for each optional field, if `Some`, assign it to the target.
///
/// ## Errors
/// Returns `Err` with a message if `internet_mode` is set to an
/// unrecognised value.
pub fn apply_to(&self, settings: &mut super::settings::Settings) -> Result<(), String> {
if let Some(ref val) = self.internet_mode {
settings.internet_mode = match val.as_str() {
"Off" => InternetMode::Off,
"ReadOnly" => InternetMode::ReadOnly,
"Full" => InternetMode::Full,
_ => return Err(format!("invalid internet_mode '{val}'; expected Off, ReadOnly, or Full")),
};
}
if let Some(ref val) = self.provider {
settings.provider = val.clone();
}
if let Some(ref val) = self.model {
settings.model = val.clone();
}
if let Some(ref val) = self.api_keys {
settings.api_keys = val.clone();
}
if let Some(val) = self.max_tokens {
settings.max_tokens = val;
}
if let Some(val) = self.temperature {
settings.temperature = val;
}
if let Some(val) = self.review_max_lessons_per_run {
settings.review_max_lessons_per_run = val;
}
if let Some(val) = self.adaptive_review_max_skip {
settings.adaptive_review_max_skip = val;
}
if let Some(ref val) = self.verify_command {
settings.verify_command = val.clone();
}
if let Some(val) = self.verify_timeout_ms {
settings.verify_timeout_ms = val;
}
if let Some(val) = self.workflow_max_concurrency {
settings.workflow_max_concurrency = val;
}
if let Some(val) = self.review_enabled {
settings.flags.review_enabled = val;
}
if let Some(val) = self.session_archive_enabled {
settings.flags.session_archive_enabled = val;
}
if let Some(val) = self.lsp_auto_provision {
settings.flags.lsp_auto_provision = val;
}
if let Some(ref val) = self.lsp_languages {
settings.lsp_languages = val.clone();
}
if let Some(val) = self.hive_mind_node_timeout_ms {
settings.hive_mind_node_timeout_ms = val;
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// Memory
// ---------------------------------------------------------------------------
/// Command to create a new memory entry.
///
/// All required fields are non-optional; optional fields use `Option`
/// and default to sensible values (empty or the service default).
#[derive(Debug, Clone)]
pub struct NewMemory {
/// Unique name / slug for the memory.
pub name: String,
/// One-line summary of what the memory captures.
pub description: String,
/// The full memory content.
pub content: String,
/// Category kind (defaults to "reference" in the handler).
pub kind: Option<String>,
/// Outcome of the remembered action.
pub outcome: Option<String>,
/// Lifecycle stage (defaults to "new" in the handler).
pub lifecycle: Option<String>,
/// Scope context for the memory.
pub scope: Option<String>,
/// Code snippet captured before the action.
pub before_snippet: Option<String>,
/// Code snippet captured after the action.
pub after_snippet: Option<String>,
/// Source provenances (files, conversations, etc.).
pub provenances: Option<Vec<String>>,
}
+1
View File
@@ -19,6 +19,7 @@
//! They contain no I/O, no framework imports, and no side effects.
pub mod app_config;
pub mod commands;
pub mod conversation;
pub mod edit_log;
pub mod error;
@@ -1,28 +0,0 @@
//! HTTP adapter — handler functions and DTOs for the CMS REST API.
//!
//! Provides hyper-based request handlers and serialisation types for
//! the CMS HTTP endpoints. Handlers receive domain service trait objects
//! via dependency injection (Arc-wrapped trait objects) and translate
//! between HTTP request/response formats and domain types.
//!
//! ## Sub-modules
//! - `dto` — request/response DTO types (JSON serialisation)
//! - `handlers` — hyper request handler functions
//!
//! ## Endpoints
//! - `GET /settings` — load current application settings
//! - `PUT /settings` — update application settings
//! - `GET /memories` — list all memory slugs
//! - `POST /memories` — create a new memory entry
//! - `POST /memories/{name}` — (future) update memory
pub mod dto;
pub mod handlers;
pub use dto::{
ConversationResponse, MemoryCreateRequest, MemoryResponse, SettingsResponse,
SettingsUpdateRequest,
};
pub use handlers::{
handle_create_memory, handle_get_settings, handle_list_memories, handle_update_settings,
};
@@ -6,7 +6,5 @@
//!
//! ## Sub-modules
//! - `persistence` — file-based repository implementations (JSON, markdown, SQLite)
//! - `http` — hyper-based HTTP API handlers and DTO types
pub mod http;
pub mod persistence;
+1
View File
@@ -23,3 +23,4 @@
pub mod application;
pub mod domain;
pub mod infrastructure;
pub mod presentation;
@@ -0,0 +1,48 @@
//! Typed presentation-layer error type for the CMS crate.
//!
//! `AppError` replaces bare `anyhow::Result` in handler signatures with a
//! structured enum that callers can match on for status-code selection
//! and structured error responses.
//!
//! `From<ServiceError>` auto-converts domain errors so handler code uses
//! the `?` operator throughout.
//!
//! # Variants
//!
//! - `BadRequest` — invalid input, validation failure
//! - `NotFound` — resource not found
//! - `Conflict` — resource already exists
//! - `Internal` — unexpected errors translated to a generic message
use crate::domain::error::ServiceError;
/// Typed presentation-layer error.
#[derive(Debug, thiserror::Error)]
pub enum AppError {
/// The request was malformed or contained invalid data.
#[error("Bad request: {0}")]
BadRequest(String),
/// The requested resource was not found.
#[error("Not found: {0}")]
NotFound(String),
/// The request conflicts with the current state.
#[error("Conflict: {0}")]
Conflict(String),
/// An unexpected internal error occurred.
#[error("Internal error: {0}")]
Internal(String),
}
impl From<ServiceError> for AppError {
fn from(e: ServiceError) -> Self {
match e {
ServiceError::Repository(repo_err) => match repo_err {
zesdex_utils::Error::NotFound(msg) => AppError::NotFound(msg),
zesdex_utils::Error::Conflict(msg) => AppError::Conflict(msg),
_ => AppError::Internal(repo_err.to_string()),
},
ServiceError::InvalidInput(msg) => AppError::BadRequest(msg),
ServiceError::Other(msg) => AppError::Internal(msg),
}
}
}
@@ -17,14 +17,15 @@
//! The caller (e.g. a hyper `Service`) is responsible for serialising the
//! response and setting HTTP status codes.
use anyhow::{Context, Result};
use tracing::instrument;
use crate::domain::commands::{NewMemory, SettingsPatch};
use crate::domain::memory::Memory;
use crate::domain::service::{MemoryService, SettingsService};
use crate::domain::settings::Settings;
use super::dto::{MemoryCreateRequest, MemoryResponse, SettingsResponse, SettingsUpdateRequest};
use super::error::AppError;
/// Handle `GET /settings`
///
@@ -32,8 +33,8 @@ use super::dto::{MemoryCreateRequest, MemoryResponse, SettingsResponse, Settings
///
/// Flow: load settings from service → convert to DTO → return.
#[instrument(skip(service))]
pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsResponse> {
let settings = service.load_settings().context("failed to load settings")?;
pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsResponse, AppError> {
let settings = service.load_settings()?;
Ok(SettingsResponse::from(settings))
}
@@ -42,83 +43,44 @@ pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsRe
/// Applies a partial update from `req` to the current settings, persists
/// the result, and returns the updated `SettingsResponse`.
///
/// Flow: load current settings → apply each optional field → save → return DTO.
/// Flow: build `SettingsPatch` from DTO → apply to current settings → save → return DTO.
///
/// ## Validation
/// - `internet_mode` must be "Off", "ReadOnly", or "Full" (case-sensitive).
/// - `internet_mode` is validated by `SettingsPatch::apply_to`.
#[instrument(skip(service))]
pub fn handle_update_settings<S: SettingsService>(
service: &S,
req: SettingsUpdateRequest,
) -> Result<SettingsResponse> {
) -> Result<SettingsResponse, AppError> {
// Build the domain patch command from the wire DTO
let patch = SettingsPatch {
internet_mode: req.internet_mode,
provider: req.provider,
model: req.model,
api_keys: req.api_keys,
max_tokens: req.max_tokens,
temperature: req.temperature,
review_max_lessons_per_run: req.review_max_lessons_per_run,
adaptive_review_max_skip: req.adaptive_review_max_skip,
verify_command: req.verify_command,
verify_timeout_ms: req.verify_timeout_ms,
workflow_max_concurrency: req.workflow_max_concurrency,
review_enabled: req.review_enabled,
session_archive_enabled: req.session_archive_enabled,
lsp_auto_provision: req.lsp_auto_provision,
lsp_languages: req.lsp_languages,
hive_mind_node_timeout_ms: req.hive_mind_node_timeout_ms,
};
// Load current settings as baseline for partial update
let mut settings: Settings = service
.load_settings()
.context("failed to load current settings for update")?;
let mut settings: Settings = service.load_settings()?;
// Apply each optional field from the request (None = skip, Some = overwrite)
if let Some(val) = req.internet_mode {
settings.internet_mode = match val.as_str() {
"Off" => crate::domain::settings::InternetMode::Off,
"ReadOnly" => crate::domain::settings::InternetMode::ReadOnly,
"Full" => crate::domain::settings::InternetMode::Full,
_ => {
return Err(anyhow::anyhow!(
"invalid internet_mode '{}'; expected Off, ReadOnly, or Full",
val
));
}
};
}
if let Some(val) = req.provider {
settings.provider = val;
}
if let Some(val) = req.model {
settings.model = val;
}
if let Some(val) = req.api_keys {
settings.api_keys = val;
}
if let Some(val) = req.max_tokens {
settings.max_tokens = val;
}
if let Some(val) = req.temperature {
settings.temperature = val;
}
if let Some(val) = req.review_max_lessons_per_run {
settings.review_max_lessons_per_run = val;
}
if let Some(val) = req.adaptive_review_max_skip {
settings.adaptive_review_max_skip = val;
}
if let Some(val) = req.verify_command {
settings.verify_command = val;
}
if let Some(val) = req.verify_timeout_ms {
settings.verify_timeout_ms = val;
}
if let Some(val) = req.workflow_max_concurrency {
settings.workflow_max_concurrency = val;
}
if let Some(val) = req.review_enabled {
settings.flags.review_enabled = val;
}
if let Some(val) = req.session_archive_enabled {
settings.flags.session_archive_enabled = val;
}
if let Some(val) = req.lsp_auto_provision {
settings.flags.lsp_auto_provision = val;
}
if let Some(val) = req.lsp_languages {
settings.lsp_languages = val;
}
if let Some(val) = req.hive_mind_node_timeout_ms {
settings.hive_mind_node_timeout_ms = val;
}
// Apply the patch via the domain command
patch
.apply_to(&mut settings)
.map_err(AppError::BadRequest)?;
service
.save_settings(&settings)
.context("failed to save updated settings")?;
service.save_settings(&settings)?;
Ok(SettingsResponse::from(settings))
}
@@ -131,12 +93,12 @@ pub fn handle_update_settings<S: SettingsService>(
///
/// Flow: list slugs from service → map each to minimal MemoryResponse → return.
#[instrument(skip(service))]
pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryResponse>> {
let slugs = service.list_memories().context("failed to list memories")?;
pub fn handle_list_memories<M: MemoryService>(
service: &M,
) -> Result<Vec<MemoryResponse>, AppError> {
let slugs = service.list_memories()?;
// We can't load individual memories without a load_memory method on the
// service. For now, list returns summary info; callers who need full
// content use a separate endpoint. Return minimal responses keyed by slug.
// Return minimal responses keyed by slug.
let responses: Vec<MemoryResponse> = slugs
.into_iter()
.map(|slug| MemoryResponse {
@@ -170,26 +132,38 @@ pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryR
pub fn handle_create_memory<M: MemoryService>(
service: &M,
req: MemoryCreateRequest,
) -> Result<MemoryResponse> {
let now = chrono::Utc::now().timestamp();
let memory = Memory {
) -> Result<MemoryResponse, AppError> {
// Build the domain command from the wire DTO
let cmd = NewMemory {
name: req.name,
description: req.description,
content: req.content,
kind: req.kind.unwrap_or_else(|| "reference".to_string()),
created_at: now,
updated_at: now,
kind: req.kind,
outcome: req.outcome,
lifecycle: req.lifecycle.unwrap_or_else(|| "new".to_string()),
lifecycle: req.lifecycle,
scope: req.scope,
before_snippet: req.before_snippet,
after_snippet: req.after_snippet,
provenances: req.provenances.unwrap_or_default(),
provenances: req.provenances,
};
service
.save_memory(&memory)
.context("failed to save memory")?;
let now = chrono::Utc::now().timestamp();
let memory = Memory {
name: cmd.name,
description: cmd.description,
content: cmd.content,
kind: cmd.kind.unwrap_or_else(|| "reference".to_string()),
created_at: now,
updated_at: now,
outcome: cmd.outcome,
lifecycle: cmd.lifecycle.unwrap_or_else(|| "new".to_string()),
scope: cmd.scope,
before_snippet: cmd.before_snippet,
after_snippet: cmd.after_snippet,
provenances: cmd.provenances.unwrap_or_default(),
};
service.save_memory(&memory)?;
Ok(MemoryResponse::from(memory))
}
+31
View File
@@ -0,0 +1,31 @@
//! HTTP presentation layer — handler functions and DTOs for the CMS crate.
//!
//! This is the outermost ring of the Clean Architecture onion. Handlers receive
//! domain service trait references via generics and translate between
//! request/response DTOs and domain types. They have **no dependency** on
//! any HTTP framework — callers (IPC server, Axum, etc.) are responsible for
//! mapping results into actual HTTP responses.
//!
//! # Sub-modules
//!
//! - [`dto`] — request/response DTO types (JSON serialisation)
//! - [`handlers`] — handler functions that accept service trait refs + DTOs
//! - [`error`] — typed presentation-layer error type
//!
//! # Dependency rule
//!
//! presentation → application → domain
//! presentation may also depend on infrastructure for wiring/composition.
pub mod dto;
pub mod error;
pub mod handlers;
pub use dto::{
ConversationResponse, MemoryCreateRequest, MemoryResponse, SettingsResponse,
SettingsUpdateRequest,
};
pub use error::AppError;
pub use handlers::{
handle_create_memory, handle_get_settings, handle_list_memories, handle_update_settings,
};