Files
agentic-studio/kipina-codebench/golden-examples/todo-rs.md
jaakko 477c21efd0 CodeBench: Rust golden example — todo-rs.md + kielitietoinen valinta
- Luotu todo-rs.md golden example Rust-referenssitoteutuksesta
- getGoldenForModel() huomioi nyt LANG: todo.md → todo-rs.md Rust-moodissa
- Korjattu golden-compact-rs.md /:id → /{id} bugi
- Juurisyy: malli sai Python golden examplen mutta piti generoida Rustia
2026-04-14 17:37:38 +03:00

14 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.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).

//! 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: POST 201 RETURNING, GET lista, GET by id 404, PUT (merge olemassaolevaan), DELETE 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};

/// Luo uusi tehtävä.
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)))
}

/// Listaa kaikki tehtävät.
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))
}

/// Hae tehtävä id:llä.
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),
    }
}

/// Päivitä tehtävä id:llä.
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 title = input.title.unwrap_or(existing.title);
    let description = input.description.or(existing.description);
    let due_date = input.due_date.or(existing.due_date);
    let priority = input.priority.unwrap_or(existing.priority);
    let status = input.status.unwrap_or(existing.status);

    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(&title)
    .bind(&description)
    .bind(&due_date)
    .bind(priority)
    .bind(&status)
    .bind(id)
    .fetch_one(&pool)
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Json(updated))
}

/// Poista tehtävä id:llä.
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: muistinvarainen SQLite, spawn_server satunnaisportissa, uniikki suomenkielinen data per testi.

//! 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 addr = listener.local_addr().unwrap();
    let base_url = format!("http://{addr}");

    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_eq!(body["priority"], 2);
    assert!(body["id"].is_number());
}

#[tokio::test]
async fn test_create_todo_defaults() {
    let (client, url) = spawn_server().await;

    let res = client
        .post(format!("{url}/todos"))
        .json(&serde_json::json!({"title": "Oletusarvotesti"}))
        .send()
        .await
        .unwrap();

    assert_eq!(res.status(), StatusCode::CREATED);
    let body: serde_json::Value = res.json().await.unwrap();
    assert_eq!(body["priority"], 1);
    assert_eq!(body["status"], "pending");
    assert!(body["description"].is_null());
}

#[tokio::test]
async fn test_list_todos() {
    let (client, url) = spawn_server().await;

    client
        .post(format!("{url}/todos"))
        .json(&serde_json::json!({"title": "Listattava tehtävä"}))
        .send()
        .await
        .unwrap();

    let res = client.get(format!("{url}/todos")).send().await.unwrap();
    assert_eq!(res.status(), StatusCode::OK);

    let body: Vec<serde_json::Value> = res.json().await.unwrap();
    assert!(body.len() >= 1);
}

#[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);
    assert_eq!(body["title"], "Haettava tehtävä");
}

#[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_update_todo() {
    let (client, url) = spawn_server().await;

    let created: serde_json::Value = client
        .post(format!("{url}/todos"))
        .json(&serde_json::json!({"title": "Vanha otsikko"}))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();

    let id = created["id"].as_i64().unwrap();
    let res = client
        .put(format!("{url}/todos/{id}"))
        .json(&serde_json::json!({"title": "Uusi otsikko"}))
        .send()
        .await
        .unwrap();

    assert_eq!(res.status(), StatusCode::OK);
    let body: serde_json::Value = res.json().await.unwrap();
    assert_eq!(body["title"], "Uusi otsikko");
}

#[tokio::test]
async fn test_update_todo_not_found() {
    let (client, url) = spawn_server().await;

    let res = client
        .put(format!("{url}/todos/99999"))
        .json(&serde_json::json!({"title": "Ei löydy"}))
        .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);
}

#[tokio::test]
async fn test_delete_todo_not_found() {
    let (client, url) = spawn_server().await;

    let res = client
        .delete(format!("{url}/todos/99999"))
        .send()
        .await
        .unwrap();

    assert_eq!(res.status(), StatusCode::NOT_FOUND);
}

#[tokio::test]
async fn test_full_lifecycle() {
    let (client, url) = spawn_server().await;

    // Luo
    let created: serde_json::Value = client
        .post(format!("{url}/todos"))
        .json(&serde_json::json!({
            "title": "Elinkaaritesti",
            "description": "Testataan koko CRUD-kierto",
            "due_date": "2026-12-31",
            "priority": 3,
            "status": "in_progress"
        }))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();

    let id = created["id"].as_i64().unwrap();
    assert_eq!(created["title"], "Elinkaaritesti");
    assert_eq!(created["description"], "Testataan koko CRUD-kierto");
    assert_eq!(created["due_date"], "2026-12-31");
    assert_eq!(created["priority"], 3);
    assert_eq!(created["status"], "in_progress");

    // Päivitä
    let updated: serde_json::Value = client
        .put(format!("{url}/todos/{id}"))
        .json(&serde_json::json!({"status": "done"}))
        .send()
        .await
        .unwrap()
        .json()
        .await
        .unwrap();

    assert_eq!(updated["status"], "done");
    assert_eq!(updated["title"], "Elinkaaritesti");

    // Poista
    let res = client
        .delete(format!("{url}/todos/{id}"))
        .send()
        .await
        .unwrap();

    assert_eq!(res.status(), StatusCode::NO_CONTENT);
}