logging and data is now generic json blob

This commit is contained in:
Kalzu Rekku committed 2026-10-25 11:07:36 +02:00
1 parent 857f21b319
commit 536d5b14d8
2 files changed
+238 -70

No files matched your search

+16 -1
View File
@@ -1,8 +1,23 @@
{ {
"mode": "strict", "mode": "strict",
"fields": { "fields": {
"version": { "version": {
"type": "integer" "type": "integer",
"value": 2
},
"message": {
"type": "string"
}
}
}
{
"mode": "strict",
"fields": {
"version": {
"type": "integer",
"value": 2
}, },
"message": { "message": {
"type": "string" "type": "string"
+222 -69
View File
@@ -10,26 +10,33 @@ use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::{ use std::{
collections::HashMap,
fs, fs,
sync::{Arc, Mutex}, sync::{Arc, Mutex, RwLock},
}; };
use tracing::{error, info, warn};
use uuid::Uuid; use uuid::Uuid;
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
db: Arc<Mutex<Connection>>, db: Arc<Mutex<Connection>>,
schema: Arc<SchemaConfig>, schema: Arc<RwLock<SchemaConfig>>,
config_path: Arc<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
enum ValidationMode { enum ValidationMode {
Strict, Strict,
Loose, 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")] #[serde(rename_all = "lowercase")]
enum DataType { enum DataType {
String, String,
@@ -41,16 +48,10 @@ enum DataType {
Null, Null,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct FieldSpec {
#[serde(rename = "type")]
data_type: DataType,
}
#[derive(Debug, Deserialize)]
struct SchemaConfig { struct SchemaConfig {
mode: ValidationMode, mode: ValidationMode,
fields: HashMap<String, FieldSpec>, fields: std::collections::HashMap<String, FieldSpec>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@@ -67,14 +68,15 @@ struct CreateRequest {
type ApiError = (StatusCode, String); type ApiError = (StatusCode, String);
fn bad_request(message: impl Into<String>) -> ApiError { fn api_error(status: StatusCode, message: impl Into<String>) -> ApiError {
(StatusCode::BAD_REQUEST, message.into()) (status, message.into())
} }
fn internal_error() -> ApiError { fn internal_error(context: &str) -> ApiError {
( error!(context, "Internal server error");
api_error(
StatusCode::INTERNAL_SERVER_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( fn type_name(data_type: &DataType) -> &'static str {
data: &Value, match data_type {
schema: &SchemaConfig, DataType::String => "string",
) -> Result<(), 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 let object = data
.as_object() .as_object()
.ok_or("data must be a JSON 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 { for (key, spec) in &schema.fields {
let value = object let value = object
.get(key) .get(key)
@@ -108,22 +118,13 @@ fn validate_data(
if !matches_type(value, &spec.data_type) { if !matches_type(value, &spec.data_type) {
return Err(format!( return Err(format!(
"Field '{key}' has the wrong type; expected {}", "Field '{key}' must be of type {}",
match spec.data_type { type_name(&spec.data_type)
DataType::String => "string",
DataType::Integer => "integer",
DataType::Number => "number",
DataType::Boolean => "boolean",
DataType::Object => "object",
DataType::Array => "array",
DataType::Null => "null",
}
)); ));
} }
} }
// Strict mode rejects any fields not defined in the config. if matches!(schema.mode, ValidationMode::Strict) {
if let ValidationMode::Strict = schema.mode {
for key in object.keys() { for key in object.keys() {
if !schema.fields.contains_key(key) { if !schema.fields.contains_key(key) {
return Err(format!("Unknown field: {key}")); return Err(format!("Unknown field: {key}"));
@@ -134,15 +135,55 @@ fn validate_data(
Ok(()) Ok(())
} }
fn load_config(path: &str) -> Result<SchemaConfig, String> {
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<ApiResponse> {
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( async fn create_record(
State(state): State<AppState>, State(state): State<AppState>,
payload: Result<Json<CreateRequest>, JsonRejection>, payload: Result<Json<CreateRequest>, JsonRejection>,
) -> Result<(StatusCode, Json<ApiResponse>), ApiError> { ) -> Result<(StatusCode, Json<ApiResponse>), ApiError> {
let Json(request) = info!(endpoint = "/api/records", "Create request received");
payload.map_err(|_| bad_request("Invalid JSON request body"))?;
validate_data(&request.data, &state.schema) let Json(request) = payload.map_err(|e| {
.map_err(bad_request)?; 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 { let record = ApiResponse {
uuid: Uuid::new_v4(), uuid: Uuid::new_v4(),
@@ -150,21 +191,34 @@ async fn create_record(
data: request.data, data: request.data,
}; };
let data_json = let data_json = serde_json::to_string(&record.data)
serde_json::to_string(&record.data).map_err(|_| internal_error())?; .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( db.execute(
"INSERT INTO records (uuid, date, data) "INSERT INTO records (uuid, date, data)
VALUES (?1, ?2, ?3)", VALUES (?1, ?2, ?3)",
params![ params![
record.uuid.to_string(), record.uuid.to_string(),
record.date, record.date,
data_json, data_json,
], ],
) )
.map_err(|_| internal_error())?; .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))) Ok((StatusCode::CREATED, Json(record)))
} }
@@ -172,7 +226,12 @@ async fn create_record(
async fn list_records( async fn list_records(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<ApiResponse>>, ApiError> { ) -> Result<Json<Vec<ApiResponse>>, 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 let mut statement = db
.prepare( .prepare(
@@ -181,7 +240,13 @@ async fn list_records(
ORDER BY date DESC ORDER BY date DESC
LIMIT 100", 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 let rows = statement
.query_map([], |row| { .query_map([], |row| {
@@ -191,35 +256,115 @@ async fn list_records(
row.get::<_, String>(2)?, 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(); let mut records = Vec::new();
for row in rows { for row in rows {
let (uuid, date, data_json) = let (uuid, date, data_json) = row.map_err(|e| {
row.map_err(|_| internal_error())?; error!(error = %e, "Failed to read database row");
api_error(
StatusCode::INTERNAL_SERVER_ERROR,
"Database read failed",
)
})?;
let uuid = let uuid = Uuid::parse_str(&uuid)
Uuid::parse_str(&uuid).map_err(|_| internal_error())?; .map_err(|_| internal_error("invalid stored UUID"))?;
let data: Value = let data: Value = serde_json::from_str(&data_json)
serde_json::from_str(&data_json) .map_err(|_| internal_error("invalid stored JSON"))?;
.map_err(|_| internal_error())?;
records.push(ApiResponse { uuid, date, data }); records.push(ApiResponse { uuid, date, data });
} }
info!(count = records.len(), "Records returned");
Ok(Json(records)) Ok(Json(records))
} }
async fn get_config(
State(state): State<AppState>,
) -> Result<Json<SchemaConfig>, ApiError> {
let schema = state
.schema
.read()
.map_err(|_| internal_error("schema lock poisoned"))?
.clone();
Ok(Json(schema))
}
async fn reload_config(
State(state): State<AppState>,
) -> Result<Json<Value>, 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] #[tokio::main]
async fn main() { async fn main() {
let config_text = fs::read_to_string("config.json") tracing_subscriber::fmt()
.expect("Failed to read config.json"); .with_writer(std::io::stdout)
.with_target(false)
.init();
let schema: SchemaConfig = let config_path =
serde_json::from_str(&config_text) std::env::var("APP_CONFIG").unwrap_or_else(|_| "config.json".into());
.expect("Invalid config.json");
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") let db = Connection::open("messages.db")
.expect("Failed to open SQLite database"); .expect("Failed to open SQLite database");
@@ -235,11 +380,19 @@ async fn main() {
let state = AppState { let state = AppState {
db: Arc::new(Mutex::new(db)), 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() 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/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); .with_state(state);
let addr = "127.0.0.1:3000"; let addr = "127.0.0.1:3000";
@@ -247,7 +400,7 @@ async fn main() {
.await .await
.expect("Failed to bind address"); .expect("Failed to bind address");
println!("Listening on http://{addr}"); info!(address = addr, "HTTP server started");
axum::serve(listener, app) axum::serve(listener, app)
.await .await