From 536d5b14d8d814a73af36878d1ada1ffb9ca26b6 Mon Sep 17 00:00:00 2001 From: Kalzu Rekku Date: Sun, 25 Oct 2026 11:07:36 +0200 Subject: [PATCH] logging and data is now generic json blob --- config.json | 17 ++- src/main.rs | 291 +++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 238 insertions(+), 70 deletions(-) diff --git a/config.json b/config.json index 1c95805..ae3893e 100644 --- a/config.json +++ b/config.json @@ -1,8 +1,23 @@ + { "mode": "strict", "fields": { "version": { - "type": "integer" + "type": "integer", + "value": 2 + }, + "message": { + "type": "string" + } + } +} + +{ + "mode": "strict", + "fields": { + "version": { + "type": "integer", + "value": 2 }, "message": { "type": "string" diff --git a/src/main.rs b/src/main.rs index 991945d..831135a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,26 +10,33 @@ use rusqlite::{params, Connection}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::{ - collections::HashMap, fs, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, RwLock}, }; +use tracing::{error, info, warn}; use uuid::Uuid; #[derive(Clone)] struct AppState { db: Arc>, - schema: Arc, + schema: Arc>, + config_path: Arc, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] enum ValidationMode { Strict, Loose, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FieldSpec { + #[serde(rename = "type")] + data_type: DataType, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] enum DataType { String, @@ -41,16 +48,10 @@ enum DataType { Null, } -#[derive(Debug, Deserialize)] -struct FieldSpec { - #[serde(rename = "type")] - data_type: DataType, -} - -#[derive(Debug, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] struct SchemaConfig { mode: ValidationMode, - fields: HashMap, + fields: std::collections::HashMap, } #[derive(Debug, Serialize, Deserialize)] @@ -67,14 +68,15 @@ struct CreateRequest { type ApiError = (StatusCode, String); -fn bad_request(message: impl Into) -> ApiError { - (StatusCode::BAD_REQUEST, message.into()) +fn api_error(status: StatusCode, message: impl Into) -> ApiError { + (status, message.into()) } -fn internal_error() -> ApiError { - ( +fn internal_error(context: &str) -> ApiError { + error!(context, "Internal server error"); + api_error( StatusCode::INTERNAL_SERVER_ERROR, - "Internal server error".to_string(), + "Internal server error", ) } @@ -92,15 +94,23 @@ fn matches_type(value: &Value, expected: &DataType) -> bool { } } -fn validate_data( - data: &Value, - schema: &SchemaConfig, -) -> Result<(), String> { +fn type_name(data_type: &DataType) -> &'static str { + match data_type { + DataType::String => "string", + DataType::Integer => "integer", + DataType::Number => "number", + DataType::Boolean => "boolean", + DataType::Object => "object", + DataType::Array => "array", + DataType::Null => "null", + } +} + +fn validate_data(data: &Value, schema: &SchemaConfig) -> Result<(), String> { let object = data .as_object() .ok_or("data must be a JSON object")?; - // Every configured field must exist and have the correct type. for (key, spec) in &schema.fields { let value = object .get(key) @@ -108,22 +118,13 @@ fn validate_data( if !matches_type(value, &spec.data_type) { return Err(format!( - "Field '{key}' has the wrong type; expected {}", - match spec.data_type { - DataType::String => "string", - DataType::Integer => "integer", - DataType::Number => "number", - DataType::Boolean => "boolean", - DataType::Object => "object", - DataType::Array => "array", - DataType::Null => "null", - } + "Field '{key}' must be of type {}", + type_name(&spec.data_type) )); } } - // Strict mode rejects any fields not defined in the config. - if let ValidationMode::Strict = schema.mode { + if matches!(schema.mode, ValidationMode::Strict) { for key in object.keys() { if !schema.fields.contains_key(key) { return Err(format!("Unknown field: {key}")); @@ -134,15 +135,55 @@ fn validate_data( Ok(()) } +fn load_config(path: &str) -> Result { + let contents = fs::read_to_string(path) + .map_err(|e| format!("Cannot read {path}: {e}"))?; + + let config: SchemaConfig = serde_json::from_str(&contents) + .map_err(|e| format!("Invalid configuration JSON: {e}"))?; + + if config.fields.is_empty() { + return Err("Configuration must define at least one field".into()); + } + + Ok(config) +} + +async fn get_info() -> Json { + info!(endpoint = "/api/now", "Request received"); + + Json(ApiResponse { + uuid: Uuid::new_v4(), + date: Utc::now().to_rfc3339(), + data: serde_json::json!({ + "version": 1, + "message": "Hello from Rust!" + }), + }) +} + async fn create_record( State(state): State, payload: Result, JsonRejection>, ) -> Result<(StatusCode, Json), ApiError> { - let Json(request) = - payload.map_err(|_| bad_request("Invalid JSON request body"))?; + info!(endpoint = "/api/records", "Create request received"); - validate_data(&request.data, &state.schema) - .map_err(bad_request)?; + let Json(request) = payload.map_err(|e| { + warn!(error = %e, "Invalid JSON request"); + api_error(StatusCode::BAD_REQUEST, "Invalid JSON request body") + })?; + + // Take a snapshot; reloads won't affect this request mid-validation. + let schema = state + .schema + .read() + .map_err(|_| internal_error("schema lock poisoned"))? + .clone(); + + if let Err(message) = validate_data(&request.data, &schema) { + warn!(reason = %message, "Schema validation failed"); + return Err(api_error(StatusCode::BAD_REQUEST, message)); + } let record = ApiResponse { uuid: Uuid::new_v4(), @@ -150,21 +191,34 @@ async fn create_record( data: request.data, }; - let data_json = - serde_json::to_string(&record.data).map_err(|_| internal_error())?; + let data_json = serde_json::to_string(&record.data) + .map_err(|_| internal_error("serialize data"))?; - let db = state.db.lock().map_err(|_| internal_error())?; + { + let db = state + .db + .lock() + .map_err(|_| internal_error("database lock poisoned"))?; - db.execute( - "INSERT INTO records (uuid, date, data) - VALUES (?1, ?2, ?3)", - params![ - record.uuid.to_string(), - record.date, - data_json, - ], - ) - .map_err(|_| internal_error())?; + db.execute( + "INSERT INTO records (uuid, date, data) + VALUES (?1, ?2, ?3)", + params![ + record.uuid.to_string(), + record.date, + data_json, + ], + ) + .map_err(|e| { + error!(error = %e, "Failed to insert record"); + api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to store record", + ) + })?; + } + + info!(uuid = %record.uuid, "Record stored"); Ok((StatusCode::CREATED, Json(record))) } @@ -172,7 +226,12 @@ async fn create_record( async fn list_records( State(state): State, ) -> Result>, ApiError> { - let db = state.db.lock().map_err(|_| internal_error())?; + info!(endpoint = "/api/records", "List request received"); + + let db = state + .db + .lock() + .map_err(|_| internal_error("database lock poisoned"))?; let mut statement = db .prepare( @@ -181,7 +240,13 @@ async fn list_records( ORDER BY date DESC LIMIT 100", ) - .map_err(|_| internal_error())?; + .map_err(|e| { + error!(error = %e, "Failed to prepare query"); + api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "Database query failed", + ) + })?; let rows = statement .query_map([], |row| { @@ -191,35 +256,115 @@ async fn list_records( row.get::<_, String>(2)?, )) }) - .map_err(|_| internal_error())?; + .map_err(|e| { + error!(error = %e, "Failed to query records"); + api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "Database query failed", + ) + })?; let mut records = Vec::new(); for row in rows { - let (uuid, date, data_json) = - row.map_err(|_| internal_error())?; + let (uuid, date, data_json) = row.map_err(|e| { + error!(error = %e, "Failed to read database row"); + api_error( + StatusCode::INTERNAL_SERVER_ERROR, + "Database read failed", + ) + })?; - let uuid = - Uuid::parse_str(&uuid).map_err(|_| internal_error())?; + let uuid = Uuid::parse_str(&uuid) + .map_err(|_| internal_error("invalid stored UUID"))?; - let data: Value = - serde_json::from_str(&data_json) - .map_err(|_| internal_error())?; + let data: Value = serde_json::from_str(&data_json) + .map_err(|_| internal_error("invalid stored JSON"))?; records.push(ApiResponse { uuid, date, data }); } + info!(count = records.len(), "Records returned"); + Ok(Json(records)) } +async fn get_config( + State(state): State, +) -> Result, ApiError> { + let schema = state + .schema + .read() + .map_err(|_| internal_error("schema lock poisoned"))? + .clone(); + + Ok(Json(schema)) +} + +async fn reload_config( + State(state): State, +) -> Result, ApiError> { + info!(path = %state.config_path, "Configuration reload requested"); + + // Parse and validate the new file before touching active configuration. + let new_schema = match load_config(&state.config_path) { + Ok(config) => config, + Err(message) => { + error!(reason = %message, "Configuration reload failed"); + return Err(api_error( + StatusCode::UNPROCESSABLE_ENTITY, + message, + )); + } + }; + + let mode = match new_schema.mode { + ValidationMode::Strict => "strict", + ValidationMode::Loose => "loose", + }; + + let field_count = new_schema.fields.len(); + + { + let mut active = state + .schema + .write() + .map_err(|_| internal_error("schema lock poisoned"))?; + + *active = new_schema; + } + + info!( + mode, + fields = field_count, + "Configuration reloaded successfully" + ); + + Ok(Json(serde_json::json!({ + "status": "ok", + "mode": mode, + "fields": field_count + }))) +} + #[tokio::main] async fn main() { - let config_text = fs::read_to_string("config.json") - .expect("Failed to read config.json"); + tracing_subscriber::fmt() + .with_writer(std::io::stdout) + .with_target(false) + .init(); - let schema: SchemaConfig = - serde_json::from_str(&config_text) - .expect("Invalid config.json"); + let config_path = + std::env::var("APP_CONFIG").unwrap_or_else(|_| "config.json".into()); + + let schema = load_config(&config_path) + .unwrap_or_else(|e| panic!("Failed to load configuration: {e}")); + + info!( + path = %config_path, + fields = schema.fields.len(), + "Configuration loaded" + ); let db = Connection::open("messages.db") .expect("Failed to open SQLite database"); @@ -235,11 +380,19 @@ async fn main() { let state = AppState { db: Arc::new(Mutex::new(db)), - schema: Arc::new(schema), + schema: Arc::new(RwLock::new(schema)), + config_path: Arc::new(config_path), }; let app = Router::new() + .route("/", get(get_info)) + .route("/api/now", get(get_info)) .route("/api/records", get(list_records).post(create_record)) + .route("/api/admin/config", get(get_config)) + .route( + "/api/admin/reload-config", + axum::routing::post(reload_config), + ) .with_state(state); let addr = "127.0.0.1:3000"; @@ -247,7 +400,7 @@ async fn main() { .await .expect("Failed to bind address"); - println!("Listening on http://{addr}"); + info!(address = addr, "HTTP server started"); axum::serve(listener, app) .await