- reqwest 0.12 → 0.13, rustls-tls → rustls (golden, Dockerfile, promptit) - Docker volume cache: kipina-cargo-registry + kipina-cargo-target - rust:latest (1.94) + cmake (aws-lc-sys vaatii) - Dockerfile yksinkertaistettu — esikäännös ei toimi, volume hoitaa - Golden example 10/10 testattu uudella setupilla
10 KiB
10 KiB
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.
[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.13", default-features = false, features = ["json", "rustls"] }
tokio = { version = "1", features = ["full", "test-util"] }
src/models.rs
Serde-rakenteet: Todo (FromRow), CreateTodo (POST), UpdateTodo (PUT, kaikki kentät valinnaisia).
//! 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<String>,
pub due_date: Option<String>,
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<String>,
pub due_date: Option<String>,
pub priority: Option<i64>,
pub status: Option<String>,
}
/// Tehtävän päivitys — kaikki kentät valinnaisia.
#[derive(Debug, Deserialize)]
pub struct UpdateTodo {
pub title: Option<String>,
pub description: Option<String>,
pub due_date: Option<String>,
pub priority: Option<i64>,
pub status: Option<String>,
}
src/handlers.rs
CRUD-käsittelijät. Avainpatternit: INSERT RETURNING, fetch_optional+404, rows_affected+204.
//! 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<SqlitePool>,
Json(input): Json<CreateTodo>,
) -> Result<(StatusCode, Json<Todo>), 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<SqlitePool>,
) -> Result<Json<Vec<Todo>>, 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<SqlitePool>,
Path(id): Path<i64>,
) -> Result<Json<Todo>, 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<SqlitePool>,
Path(id): Path<i64>,
Json(input): Json<UpdateTodo>,
) -> Result<Json<Todo>, 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<SqlitePool>,
Path(id): Path<i64>,
) -> Result<StatusCode, StatusCode> {
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.
//! 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.
//! 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.
//! 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);
}