256 lines
6.2 KiB
Rust
256 lines
6.2 KiB
Rust
|
|
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");
|
|
}
|