diff --git a/.gitignore b/.gitignore index 02b0b5c..ba285ec 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ target/ # MSVC Windows builds of rustc generate these, which store debugging information *.pdb - +idea/ # RustRover # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b158fc3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "minecraft_computer_craft_api_backend" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.8" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "macros"] } +dotenvy = "0.15" diff --git a/README.md b/README.md index ca26432..392ee0c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,45 @@ # Minecraft-Computer-Craft-API-Backend +Ein minimales Rust-API-Backend mit `axum` und SQLite (`sqlx`). + +## Voraussetzungen + +- Rust (stable) +- Cargo + +## Starten + +```powershell +cargo run +``` + +Standardwerte: + +- `PORT=3000` +- `DATABASE_URL=sqlite://data/app.db` + +Optional per Umgebungsvariablen setzen: + +```powershell +$env:PORT="3000" +$env:DATABASE_URL="sqlite://data/app.db" +cargo run +``` + +## Endpunkte + +- `GET /health` -> API-Status +- `GET /db-test` -> Testet DB-Verbindung (`SELECT 1`) + +Beispielaufrufe: + +```powershell +curl http://127.0.0.1:3000/health +curl http://127.0.0.1:3000/db-test +``` + +## Projektstruktur + +- `src/main.rs` - Serverstart, Router, App-State +- `src/db.rs` - SQLite-Pool + Basisschema +- `src/routes.rs` - API-Handler diff --git a/data/app.db b/data/app.db new file mode 100644 index 0000000..fa88c1a Binary files /dev/null and b/data/app.db differ diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..6e18a2b --- /dev/null +++ b/src/db.rs @@ -0,0 +1,219 @@ +use sqlx::{sqlite::SqlitePoolOptions, SqlitePool, QueryBuilder, Sqlite}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct MeSystemLive { + pub name: String, + pub amount: i64, + #[serde(rename = "displayName")] + pub display_name: Option, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct MeSystemHistory { + pub id: i64, + pub name: String, + pub amount: i64, + pub recorded_at: i64, +} + +pub async fn init_db(database_url: &str) -> Result { + if let Some(parent) = database_url + .strip_prefix("sqlite://") + .and_then(|path| std::path::Path::new(path).parent()) + { + if !parent.as_os_str().is_empty() { + let _ = std::fs::create_dir_all(parent); + } + } + + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect(database_url) + .await?; + + // Minimales Basisschema fuer Verbindungs- und Schreibtest. + sqlx::query( + "-- Aktuelle Bestände + -- Wir führen eine ID ein, um in der History Platz zu sparen. +CREATE TABLE IF NOT EXISTS me_system_live ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, -- 'minecraft:iron_ingot' + amount INTEGER NOT NULL, + display_name TEXT +); + +-- Historische Aufzeichnung +-- Hier speichern wir nur noch die item_id statt den vollen Namen. +-- recorded_at als Unix Timestamp (INTEGER) +CREATE TABLE IF NOT EXISTS me_system_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item_id INTEGER NOT NULL, + amount INTEGER NOT NULL, + recorded_at INTEGER NOT NULL DEFAULT (unixepoch()), + FOREIGN KEY (item_id) REFERENCES me_system_live(id) +); + +-- Index für schnelle Zeitabfragen und Joins +CREATE INDEX IF NOT EXISTS idx_history_item_time ON me_system_history (item_id, recorded_at); +-- Index auf Name in Live Tabelle ist durch UNIQUE constraint implizit vorhanden, aber für FK lookups gut zu wissen. +", + ) + .execute(&pool) + .await?; + + Ok(pool) +} + +pub async fn insert_me_system_live( + pool: &SqlitePool, + items: &[MeSystemLive], +) -> Result<(), sqlx::Error> { + let mut transaction = pool.begin().await?; + + // 1. Alles auf 0 setzen (Reset) + sqlx::query("UPDATE me_system_live SET amount = 0 WHERE amount > 0") + .execute(&mut *transaction) + .await?; + + // 2. Neue Daten einfügen/aktualisieren (Upsert) + // Wir nutzen hier name als unique key + if !items.is_empty() { + for chunk in items.chunks(4000) { + let mut query_builder: QueryBuilder = QueryBuilder::new( + "INSERT INTO me_system_live (name, amount, display_name) " + ); + + query_builder.push_values(chunk, |mut b, item| { + b.push_bind(&item.name) + .push_bind(item.amount) + .push_bind(&item.display_name); + }); + + query_builder.push( + " ON CONFLICT(name) DO UPDATE SET + amount = excluded.amount, + display_name = excluded.display_name" + ); + + query_builder.build().execute(&mut *transaction).await?; + } + } + + transaction.commit().await?; + Ok(()) +} + +pub async fn insert_me_system_history( + pool: &SqlitePool, + items: &[MeSystemLive], +) -> Result<(), sqlx::Error> { + if items.is_empty() { + return Ok(()); + } + + let mut transaction = pool.begin().await?; + + // 1. Temporäre Tabelle für den Batch-Input erstellen + sqlx::query("CREATE TEMPORARY TABLE IF NOT EXISTS temp_history_input (name TEXT, amount INTEGER)") + .execute(&mut *transaction) + .await?; + + // Tabelle leeren + sqlx::query("DELETE FROM temp_history_input") + .execute(&mut *transaction) + .await?; + + // 2. Input Daten in Temp Tabelle schreiben + for chunk in items.chunks(4000) { + let mut query_builder: QueryBuilder = QueryBuilder::new( + "INSERT INTO temp_history_input (name, amount) " + ); + query_builder.push_values(chunk, |mut b, item| { + b.push_bind(&item.name) + .push_bind(item.amount); + }); + query_builder.build().execute(&mut *transaction).await?; + } + + // 3. Insert in History Tabelle durch Join mit Live Tabelle, um die IDs zu bekommen + sqlx::query( + "INSERT INTO me_system_history (item_id, amount) + SELECT l.id, t.amount + FROM temp_history_input t + JOIN me_system_live l ON t.name = l.name" + ) + .execute(&mut *transaction) + .await?; + + // 4. Cleanup + sqlx::query("DROP TABLE temp_history_input") + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + Ok(()) +} + +pub async fn get_all_me_system_live(pool: &SqlitePool) -> Result, sqlx::Error> { + sqlx::query_as::<_, MeSystemLive>( + r#" + SELECT name, amount, display_name + FROM me_system_live + "#, + ) + .fetch_all(pool) + .await +} + +pub async fn get_me_system_live( + pool: &SqlitePool, + name: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, MeSystemLive>( + r#" + SELECT name, amount, display_name + FROM me_system_live + WHERE name = ? + "#, + ) + .bind(name) + .fetch_optional(pool) + .await +} + +pub async fn get_me_system_history( + pool: &SqlitePool, + name: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, MeSystemHistory>( + r#" + SELECT h.id, l.name, h.amount, h.recorded_at + FROM me_system_history h + JOIN me_system_live l ON h.item_id = l.id + WHERE l.name = ? + ORDER BY h.recorded_at DESC + "#, + ) + .bind(name) + .fetch_all(pool) + .await +} + +pub async fn get_me_system_hourely_change( + pool: &SqlitePool, + names: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, MeSystemHistory>( + r#" + SELECT h.id, l.name, h.amount, h.recorded_at + FROM me_system_history h + JOIN me_system_live l ON h.item_id = l.id + WHERE l.name IN (??) + ORDER BY h.recorded_at DESC + "#, + ) + .bind(names) + .fetch_all(pool) + .await +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..8454e36 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,45 @@ +mod db; +mod routes; + +use axum::{routing::{get, post}, Router}; +use sqlx::SqlitePool; +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::AtomicI64; + +#[derive(Clone)] +pub struct AppState { + pub db_pool: SqlitePool, + pub last_history_update: Arc, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + dotenvy::dotenv().ok(); + + let database_url = std::env::var("DATABASE_URL") + .unwrap_or_else(|_| "sqlite://data/app.db".to_string()); + let port = std::env::var("PORT") + .unwrap_or_else(|_| "3000".to_string()) + .parse::()?; + + let db_pool = db::init_db(&database_url).await?; + let state = AppState { + db_pool, + last_history_update: Arc::new(AtomicI64::new(0)), + }; + + let app = Router::new() + .route("/health", get(routes::health)) + .route("/db-test", get(routes::db_test)) + .route("/updateMeSystem", post(routes::update_me_system)) + .with_state(state); + + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + println!("Server laeuft auf http://{}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..c9d9f78 --- /dev/null +++ b/src/routes.rs @@ -0,0 +1,104 @@ +use axum::{extract::State, Json}; +use serde::Serialize; +use serde_json::Value; + +use crate::AppState; +use crate::db::{self, MeSystemLive}; +use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::atomic::Ordering; + +#[derive(Serialize)] +pub struct HealthResponse { + status: &'static str, +} + +#[derive(Serialize)] +pub struct DbTestResponse { + status: &'static str, + db_ok: bool, + result: i64, +} + +pub async fn health() -> Json { + Json(HealthResponse { status: "ok" }) +} + +pub async fn db_test( + State(state): State, +) -> Result, (axum::http::StatusCode, String)> { + let value: i64 = sqlx::query_scalar("SELECT 1") + .fetch_one(&state.db_pool) + .await + .map_err(internal_error)?; + + Ok(Json(DbTestResponse { + status: "ok", + db_ok: value == 1, + result: value, + })) +} + +pub async fn update_me_system( + State(state): State, + Json(payload): Json> +) -> Json { + println!("Received update request with {} items", payload.len()); + + let count = payload.len(); + + // 1. Immer Live-Daten aktualisieren + if let Err(e) = db::insert_me_system_live(&state.db_pool, &payload).await { + eprintln!("Error updating live data: {}", e); + return Json(serde_json::json!({ + "status": "error", + "message": format!("DB Error (Live): {}", e), + "success": 0, + "errors": count + })); + } + + // 2. Historie nur alle X Sekunden aktualisieren (z.B. 300 Sekunden) + let history_interval_seconds = 300; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + let last_update = state.last_history_update.load(Ordering::Relaxed); + + // Wenn genug Zeit vergangen ist seit dem letzten Update + if now - last_update >= history_interval_seconds { + // Versuchen, den Zeitstempel zu aktualisieren (verhindert doppelte Ausführung bei gleichzeitigen Requests) + if state.last_history_update.compare_exchange( + last_update, + now, + Ordering::Relaxed, + Ordering::Relaxed + ).is_ok() { + println!("Writing history snapshot..."); + // Asynchron in den Hintergrund verschieben? Nein, wir warten hier, damit wir Fehler mitbekommen. + // Performance: Das kann dauern, aber der Client wartet eh. + if let Err(e) = db::insert_me_system_history(&state.db_pool, &payload).await { + eprintln!("Error writing history: {}", e); + // Wir returnen trotzdem OK, weil Live-Daten wichtig sind. + } else { + println!("History snapshot saved."); + } + } + } + + Json(serde_json::json!({ + "status": "ok", + "message": format!("Processed {} items", count), + "success": count, + "errors": 0 + })) +} + +fn internal_error(err: sqlx::Error) -> (axum::http::StatusCode, String) { + ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + format!("DB error: {err}"), + ) +}