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