logging and data is now generic json blob
This commit is contained in:
1 parent
857f21b319
commit
536d5b14d8
2 files changed
+232
-64
No files matched your search
+16
-1
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user