# Todo — referenssitoteutus (Axum 0.8 + SQLx + SQLite) Tämä on täydellinen esimerkki. Generoi vastaava rakenne annetulle projektille. Käytä VAIN JSON-spekin kenttiä — älä lisää ylimääräisiä. ## Cargo.toml Axum 0.8, SQLx SQLite-featurella, serde JSON-serialisointiin, tower-http CORS-tukeen. ```toml [package] name = "todo-rs" version = "0.1.0" edition = "2024" [dependencies] axum = "0.8" tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio"] } tower-http = { version = "0.6", features = ["cors"] } [dev-dependencies] reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } tokio = { version = "1", features = ["full", "test-util"] } ``` ## src/models.rs Serde-rakenteet: `Todo` (FromRow), `CreateTodo` (POST), `UpdateTodo` (PUT, kaikki kentät valinnaisia). ```rust //! Tietomallit — Todo, CreateTodo, UpdateTodo serde-rakenteina. use serde::{Deserialize, Serialize}; /// Tehtävä — otsikko, kuvaus, deadline, prioriteetti ja status. #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Todo { pub id: i64, pub title: String, pub description: Option, pub due_date: Option, pub priority: i64, pub status: String, } /// Uuden tehtävän luonti. Pakolliset: title. #[derive(Debug, Deserialize)] pub struct CreateTodo { pub title: String, pub description: Option, pub due_date: Option, pub priority: Option, pub status: Option, } /// Tehtävän päivitys — kaikki kentät valinnaisia. #[derive(Debug, Deserialize)] pub struct UpdateTodo { pub title: Option, pub description: Option, pub due_date: Option, pub priority: Option, pub status: Option, } ``` ## src/handlers.rs CRUD-käsittelijät. Avainpatternit: INSERT RETURNING, fetch_optional+404, rows_affected+204. ```rust //! Käsittelijät — CRUD-operaatiot todo-entiteetille. use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::Json; use sqlx::SqlitePool; use crate::models::{CreateTodo, Todo, UpdateTodo}; /// POST — INSERT RETURNING, oletusarvot unwrap_or:lla. pub async fn create_todo( State(pool): State, Json(input): Json, ) -> Result<(StatusCode, Json), StatusCode> { let priority = input.priority.unwrap_or(1); let status = input.status.unwrap_or_else(|| "pending".to_string()); let result = sqlx::query_as::<_, Todo>( "INSERT INTO todos (title, description, due_date, priority, status) VALUES (?, ?, ?, ?, ?) RETURNING id, title, description, due_date, priority, status", ) .bind(&input.title) .bind(&input.description) .bind(&input.due_date) .bind(priority) .bind(&status) .fetch_one(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok((StatusCode::CREATED, Json(result))) } /// GET list — fetch_all. pub async fn list_todos( State(pool): State, ) -> Result>, StatusCode> { let todos = sqlx::query_as::<_, Todo>("SELECT id, title, description, due_date, priority, status FROM todos") .fetch_all(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(todos)) } /// GET by id — fetch_optional, None → 404. pub async fn get_todo( State(pool): State, Path(id): Path, ) -> Result, StatusCode> { let todo = sqlx::query_as::<_, Todo>( "SELECT id, title, description, due_date, priority, status FROM todos WHERE id = ?", ) .bind(id) .fetch_optional(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; match todo { Some(t) => Ok(Json(t)), None => Err(StatusCode::NOT_FOUND), } } /// PUT — hae olemassaoleva, merge kentät, UPDATE RETURNING. pub async fn update_todo( State(pool): State, Path(id): Path, Json(input): Json, ) -> Result, StatusCode> { let existing = sqlx::query_as::<_, Todo>( "SELECT id, title, description, due_date, priority, status FROM todos WHERE id = ?", ) .bind(id) .fetch_optional(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let existing = existing.ok_or(StatusCode::NOT_FOUND)?; let updated = sqlx::query_as::<_, Todo>( "UPDATE todos SET title = ?, description = ?, due_date = ?, priority = ?, status = ? WHERE id = ? RETURNING id, title, description, due_date, priority, status", ) .bind(input.title.unwrap_or(existing.title)) .bind(input.description.or(existing.description)) .bind(input.due_date.or(existing.due_date)) .bind(input.priority.unwrap_or(existing.priority)) .bind(input.status.unwrap_or(existing.status)) .bind(id) .fetch_one(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(updated)) } /// DELETE — rows_affected == 0 → 404, muuten 204. pub async fn delete_todo( State(pool): State, Path(id): Path, ) -> Result { let result = sqlx::query("DELETE FROM todos WHERE id = ?") .bind(id) .execute(&pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if result.rows_affected() == 0 { return Err(StatusCode::NOT_FOUND); } Ok(StatusCode::NO_CONTENT) } ``` ## src/lib.rs Kirjastomoduuli: reititin `app()` ja taulun alustus `init_db()` — julkinen API integraatiotesteille. ```rust //! Kirjastomoduuli — julkinen API integraatiotesteille. pub mod handlers; pub mod models; use axum::routing::{delete, get, post, put}; use axum::Router; use sqlx::SqlitePool; use tower_http::cors::CorsLayer; /// Luo reititin annetulla tietokantapoolilla. pub fn app(pool: SqlitePool) -> Router { Router::new() .route("/todos", post(handlers::create_todo)) .route("/todos", get(handlers::list_todos)) .route("/todos/{id}", get(handlers::get_todo)) .route("/todos/{id}", put(handlers::update_todo)) .route("/todos/{id}", delete(handlers::delete_todo)) .layer(CorsLayer::permissive()) .with_state(pool) } /// Alusta tietokantataulu. pub async fn init_db(pool: &SqlitePool) { sqlx::query( "CREATE TABLE IF NOT EXISTS todos ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, due_date TEXT, priority INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'pending' )", ) .execute(pool) .await .expect("Taulun luonti epäonnistui"); } ``` ## src/main.rs Käynnistyspiste: SQLite-pooli, taulun alustus, Axum-palvelin portissa 3000. ```rust //! Axum CRUD — yksi endpoint-setti per entiteetti, SQLite-tietokanta. use sqlx::sqlite::SqlitePoolOptions; use todo_rs::{app, init_db}; #[tokio::main] async fn main() { let pool = SqlitePoolOptions::new() .max_connections(5) .connect("sqlite:./app.db?mode=rwc") .await .expect("Tietokantayhteys epäonnistui"); init_db(&pool).await; let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await .expect("Portin kuuntelu epäonnistui"); println!("Palvelin käynnissä: http://127.0.0.1:3000"); axum::serve(listener, app(pool)).await.unwrap(); } ``` ## tests/api_test.rs Integraatiotestit: spawn_server (muistinvarainen SQLite, satunnaisportti), CRUD-testit uniikilla datalla. ```rust //! Integraatiotestit — muistinvarainen SQLite, uniikki data per testi. use axum::http::StatusCode; use reqwest::Client; use sqlx::sqlite::SqlitePoolOptions; use todo_rs::{app, init_db}; /// Käynnistä testipalvelin satunnaisessa portissa. async fn spawn_server() -> (Client, String) { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .expect("Testitietokanta epäonnistui"); init_db(&pool).await; let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("Testiportin kuuntelu epäonnistui"); let base_url = format!("http://{}", listener.local_addr().unwrap()); let router = app(pool); tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); (Client::new(), base_url) } #[tokio::test] async fn test_create_todo() { let (client, url) = spawn_server().await; let res = client.post(format!("{url}/todos")) .json(&serde_json::json!({"title": "Osta maitoa", "priority": 2})) .send().await.unwrap(); assert_eq!(res.status(), StatusCode::CREATED); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["title"], "Osta maitoa"); assert!(body["id"].is_number()); } #[tokio::test] async fn test_get_todo_by_id() { let (client, url) = spawn_server().await; let created: serde_json::Value = client.post(format!("{url}/todos")) .json(&serde_json::json!({"title": "Haettava tehtävä"})) .send().await.unwrap().json().await.unwrap(); let id = created["id"].as_i64().unwrap(); let res = client.get(format!("{url}/todos/{id}")).send().await.unwrap(); assert_eq!(res.status(), StatusCode::OK); let body: serde_json::Value = res.json().await.unwrap(); assert_eq!(body["id"], id); } #[tokio::test] async fn test_get_todo_not_found() { let (client, url) = spawn_server().await; let res = client.get(format!("{url}/todos/99999")).send().await.unwrap(); assert_eq!(res.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_delete_todo() { let (client, url) = spawn_server().await; let created: serde_json::Value = client.post(format!("{url}/todos")) .json(&serde_json::json!({"title": "Poistettava"})) .send().await.unwrap().json().await.unwrap(); let id = created["id"].as_i64().unwrap(); let res = client.delete(format!("{url}/todos/{id}")).send().await.unwrap(); assert_eq!(res.status(), StatusCode::NO_CONTENT); let res = client.get(format!("{url}/todos/{id}")).send().await.unwrap(); assert_eq!(res.status(), StatusCode::NOT_FOUND); } ```