state before logs!
This commit is contained in:
commit
857f21b319
5 files changed
+1231
No files matched your search
+255
@@ -0,0 +1,255 @@
|
||||
|
||||
use axum::{
|
||||
extract::{rejection::JsonRejection, State},
|
||||
http::StatusCode,
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
db: Arc<Mutex<Connection>>,
|
||||
schema: Arc<SchemaConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum ValidationMode {
|
||||
Strict,
|
||||
Loose,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum DataType {
|
||||
String,
|
||||
Integer,
|
||||
Number,
|
||||
Boolean,
|
||||
Object,
|
||||
Array,
|
||||
Null,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FieldSpec {
|
||||
#[serde(rename = "type")]
|
||||
data_type: DataType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SchemaConfig {
|
||||
mode: ValidationMode,
|
||||
fields: HashMap<String, FieldSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ApiResponse {
|
||||
uuid: Uuid,
|
||||
date: String,
|
||||
data: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateRequest {
|
||||
data: Value,
|
||||
}
|
||||
|
||||
type ApiError = (StatusCode, String);
|
||||
|
||||
fn bad_request(message: impl Into<String>) -> ApiError {
|
||||
(StatusCode::BAD_REQUEST, message.into())
|
||||
}
|
||||
|
||||
fn internal_error() -> ApiError {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal server error".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
fn matches_type(value: &Value, expected: &DataType) -> bool {
|
||||
match expected {
|
||||
DataType::String => value.is_string(),
|
||||
DataType::Integer => {
|
||||
value.as_i64().is_some() || value.as_u64().is_some()
|
||||
}
|
||||
DataType::Number => value.is_number(),
|
||||
DataType::Boolean => value.is_boolean(),
|
||||
DataType::Object => value.is_object(),
|
||||
DataType::Array => value.is_array(),
|
||||
DataType::Null => value.is_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)
|
||||
.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 {
|
||||
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 let ValidationMode::Strict = schema.mode {
|
||||
for key in object.keys() {
|
||||
if !schema.fields.contains_key(key) {
|
||||
return Err(format!("Unknown field: {key}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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"))?;
|
||||
|
||||
validate_data(&request.data, &state.schema)
|
||||
.map_err(bad_request)?;
|
||||
|
||||
let record = ApiResponse {
|
||||
uuid: Uuid::new_v4(),
|
||||
date: Utc::now().to_rfc3339(),
|
||||
data: request.data,
|
||||
};
|
||||
|
||||
let data_json =
|
||||
serde_json::to_string(&record.data).map_err(|_| internal_error())?;
|
||||
|
||||
let db = state.db.lock().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(|_| internal_error())?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(record)))
|
||||
}
|
||||
|
||||
async fn list_records(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<Vec<ApiResponse>>, ApiError> {
|
||||
let db = state.db.lock().map_err(|_| internal_error())?;
|
||||
|
||||
let mut statement = db
|
||||
.prepare(
|
||||
"SELECT uuid, date, data
|
||||
FROM records
|
||||
ORDER BY date DESC
|
||||
LIMIT 100",
|
||||
)
|
||||
.map_err(|_| internal_error())?;
|
||||
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(|_| internal_error())?;
|
||||
|
||||
let mut records = Vec::new();
|
||||
|
||||
for row in rows {
|
||||
let (uuid, date, data_json) =
|
||||
row.map_err(|_| internal_error())?;
|
||||
|
||||
let uuid =
|
||||
Uuid::parse_str(&uuid).map_err(|_| internal_error())?;
|
||||
|
||||
let data: Value =
|
||||
serde_json::from_str(&data_json)
|
||||
.map_err(|_| internal_error())?;
|
||||
|
||||
records.push(ApiResponse { uuid, date, data });
|
||||
}
|
||||
|
||||
Ok(Json(records))
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let config_text = fs::read_to_string("config.json")
|
||||
.expect("Failed to read config.json");
|
||||
|
||||
let schema: SchemaConfig =
|
||||
serde_json::from_str(&config_text)
|
||||
.expect("Invalid config.json");
|
||||
|
||||
let db = Connection::open("messages.db")
|
||||
.expect("Failed to open SQLite database");
|
||||
|
||||
db.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS records (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
date TEXT NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("Failed to initialize database");
|
||||
|
||||
let state = AppState {
|
||||
db: Arc::new(Mutex::new(db)),
|
||||
schema: Arc::new(schema),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/api/records", get(list_records).post(create_record))
|
||||
.with_state(state);
|
||||
|
||||
let addr = "127.0.0.1:3000";
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.expect("Failed to bind address");
|
||||
|
||||
println!("Listening on http://{addr}");
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("HTTP server failed");
|
||||
}
|
||||
Reference in new issue
Block a user