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
+232 -64

No files matched your search

+16 -1
View File
@@ -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"
+216 -63
View File
@@ -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<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")]
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<String, FieldSpec>,
fields: std::collections::HashMap<String, FieldSpec>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -67,14 +68,15 @@ struct CreateRequest {
type ApiError = (StatusCode, String);
fn bad_request(message: impl Into<String>) -> ApiError {
(StatusCode::BAD_REQUEST, message.into())
fn api_error(status: StatusCode, message: impl Into<String>) -> 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,24 +94,8 @@ fn matches_type(value: &Value, expected: &DataType) -> bool {
}
}
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)
.ok_or_else(|| format!("Missing required field: {key}"))?;
if !matches_type(value, &spec.data_type) {
return Err(format!(
"Field '{key}' has the wrong type; expected {}",
match spec.data_type {
fn type_name(data_type: &DataType) -> &'static str {
match data_type {
DataType::String => "string",
DataType::Integer => "integer",
DataType::Number => "number",
@@ -118,12 +104,27 @@ fn validate_data(
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")?;
for (key, spec) in &schema.fields {
let value = object
.get(key)
.ok_or_else(|| format!("Missing required field: {key}"))?;
if !matches_type(value, &spec.data_type) {
return Err(format!(
"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<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(
State(state): State<AppState>,
payload: Result<Json<CreateRequest>, JsonRejection>,
) -> Result<(StatusCode, Json<ApiResponse>), 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,10 +191,14 @@ 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)
@@ -164,7 +209,16 @@ async fn create_record(
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)))
}
@@ -172,7 +226,12 @@ async fn create_record(
async fn list_records(
State(state): State<AppState>,
) -> 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
.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<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]
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