//! Handler for creating votes. use axum::{extract::State, http::StatusCode, Json}; use tracing::instrument; use crate::{ dto::{CreateResponse, CreateVoteRequest, ErrorResponse}, error::{ApiError, Result}, hex, state::AppState, }; use stemedb_core::types::Vote; use stemedb_ingest::worker::serialize_vote; /// Create a new vote on an assertion. /// /// This endpoint accepts a vote DTO, validates weight bounds (0.0-1.0), /// converts hex-encoded fields to binary, serializes the vote to WAL format, /// and appends it to the journal. Returns the content-addressed BLAKE3 hash. /// /// # Validation /// - Weight must be between 0.0 and 1.0 /// - All hex fields must have correct lengths (32 bytes for agent_id/assertion_hash, 64 bytes for signature) #[utoipa::path( post, path = "/v1/vote", request_body = CreateVoteRequest, responses( (status = 201, description = "Vote created successfully", body = CreateResponse), (status = 400, description = "Invalid request", body = ErrorResponse), (status = 500, description = "Internal server error", body = ErrorResponse) ), tag = "votes" )] #[instrument(skip(state), fields(assertion_hash = %req.assertion_hash, weight = %req.weight))] pub async fn create_vote( State(state): State, Json(req): Json, ) -> Result<(StatusCode, Json)> { let start = std::time::Instant::now(); metrics::counter!("stemedb_http_requests_total", "method" => "POST", "path" => "/v1/vote") .increment(1); // Convert DTO to internal Vote type let vote = dto_to_vote(req)?; // Serialize to WAL format (includes record type header) let payload = serialize_vote(&vote) .map_err(|e| ApiError::Serialization(format!("Failed to serialize vote: {}", e)))?; // Compute the content-addressed hash let serialized_vote = stemedb_core::serde::serialize(&vote) .map_err(|e| ApiError::Serialization(format!("Failed to serialize for hash: {}", e)))?; let hash = blake3::hash(&serialized_vote); // Append to WAL via group commit buffer state.commit_buffer.append(payload).await?; let response = CreateResponse { hash: hash.to_hex().to_string(), status: "created".to_string() }; // Track request duration (success case) metrics::histogram!("stemedb_http_request_duration_seconds", "method" => "POST", "path" => "/v1/vote", "status" => "201" ) .record(start.elapsed().as_secs_f64()); Ok((StatusCode::CREATED, Json(response))) } /// Convert CreateVoteRequest DTO to internal Vote type. fn dto_to_vote(req: CreateVoteRequest) -> Result { // Validate weight bounds (0.0 to 1.0) if req.weight < 0.0 || req.weight > 1.0 { return Err(ApiError::InvalidRequest(format!( "Weight must be between 0.0 and 1.0, got {}", req.weight ))); } // Validate source_url if provided if let Some(ref url) = req.source_url { // Check if empty if url.is_empty() { return Err(ApiError::InvalidRequest( "source_url must not be empty if provided".to_string(), )); } // Check max length (2048 characters) if url.len() > 2048 { return Err(ApiError::InvalidRequest(format!( "source_url exceeds maximum length of 2048 characters, got {}", url.len() ))); } } // Validate observed_context size if provided let observed_context = if let Some(ref context_str) = req.observed_context { let context_bytes = context_str.as_bytes(); // Check max size (64KB) if context_bytes.len() > 65536 { return Err(ApiError::InvalidRequest(format!( "observed_context exceeds maximum size of 64KB, got {} bytes", context_bytes.len() ))); } Some(context_bytes.to_vec()) } else { None }; let assertion_hash = hex::decode_hash_32(&req.assertion_hash)?; let agent_id = hex::decode_hash_32(&req.agent_id)?; let signature = hex::decode_signature(&req.signature)?; // Get current timestamp let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|e| ApiError::Serialization(format!("Failed to get timestamp: {}", e)))? .as_secs(); Ok(Vote { assertion_hash, agent_id, weight: req.weight, signature, timestamp, source_url: req.source_url, observed_context, }) }