diff --git a/backend/.gitignore b/backend/.gitignore index 2430586..35b859b 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1,3 +1,5 @@ -/target +target *.db +*.db-shm +*.db-wal diff --git a/backend/Cargo.lock b/backend/Cargo.lock index b255661..c83be9d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -119,12 +119,47 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "backend" version = "0.1.0" dependencies = [ "anyhow", "axum", + "axum-extra", + "axum-macros", "chrono", "serde", "sqlx", @@ -222,6 +257,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -1856,6 +1892,7 @@ checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" dependencies = [ "getrandom 0.3.3", "js-sys", + "serde", "wasm-bindgen", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 78e617e..661bddf 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -6,11 +6,13 @@ edition = "2024" [dependencies] anyhow = "1.0.99" axum = "0.8.4" -chrono = "0.4.41" +axum-extra = "0.10.1" +axum-macros = "0.5.0" +chrono = { version = "0.4.41", features = ["serde"] } serde = "1.0.219" sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] } tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] } tracing = "0.1.41" tracing-subscriber = "0.3.19" -uuid = { version = "1.18.0", features = ["v4"] } +uuid = { version = "1.18.0", features = ["serde", "v4"] } diff --git a/backend/src/database/connection.rs b/backend/src/database/connection.rs index f2183ff..5cf2e21 100644 --- a/backend/src/database/connection.rs +++ b/backend/src/database/connection.rs @@ -2,20 +2,10 @@ use anyhow::Result; use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; use std::str::FromStr; -/// Database configuration pub struct DatabaseConfig { pub database_url: String, } -impl Default for DatabaseConfig { - fn default() -> Self { - Self { - database_url: "sqlite:local.db".to_string(), - } - } -} - -/// Create a SQLx connection pool pub async fn create_pool(config: &DatabaseConfig) -> Result { let options = SqliteConnectOptions::from_str(&config.database_url)?.create_if_missing(true); @@ -26,12 +16,6 @@ pub async fn create_pool(config: &DatabaseConfig) -> Result { Ok(pool) } -/// Initialize database with connection pool -pub async fn initialize_database() -> Result { - let config = DatabaseConfig::default(); - create_pool(&config).await -} - #[cfg(test)] pub async fn create_test_pool() -> Result { let options = SqliteConnectOptions::from_str("sqlite::memory:")?.create_if_missing(true); @@ -42,4 +26,3 @@ pub async fn create_test_pool() -> Result { Ok(pool) } - diff --git a/backend/src/main.rs b/backend/src/main.rs index 8148b77..2c1f671 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,13 +1,23 @@ use axum::{Router, routing::get}; -mod models; mod database; +mod models; +mod services; #[tokio::main] async fn main() { tracing_subscriber::fmt::init(); - let app = Router::new().route("/health", get(health)); + let binding = database::DatabaseConfig { + database_url: "sqlite:local.db".to_string(), + }; + let pool = database::create_pool(&binding) + .await + .expect("Failed to create database pool"); + let app = Router::new() + .route("/health", get(health)) + .nest("/api/tasks", services::create_task_router()) + .with_state(pool); let addr = "127.0.0.1:3000"; diff --git a/backend/src/models/task.rs b/backend/src/models/task.rs index a633a1b..685f11c 100644 --- a/backend/src/models/task.rs +++ b/backend/src/models/task.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; use sqlx::SqlitePool; use uuid::Uuid; +use crate::services::AppError; + #[derive(Debug, Serialize, Deserialize, PartialEq, Clone, Copy, sqlx::Type)] #[serde(rename_all = "snake_case")] #[sqlx(rename_all = "lowercase")] @@ -13,7 +15,7 @@ pub enum TaskStatus { Backlog, } -#[derive(sqlx::FromRow)] +#[derive(sqlx::FromRow, Serialize)] pub struct TaskModel { pub id: Uuid, pub title: String, @@ -46,15 +48,19 @@ impl TaskModel { Ok(result) } - pub async fn get_by_id(pool: &SqlitePool, id: Uuid) -> Result { - let result = sqlx::query_as("SELECT * FROM tasks WHERE id = $1") + pub async fn get_by_id(pool: &SqlitePool, id: Uuid) -> Result { + let model = sqlx::query_as("SELECT * FROM tasks WHERE id = $1") .bind(id) .fetch_one(pool) - .await?; - Ok(result) + .await + .map_err(|err| match err { + sqlx::Error::RowNotFound => AppError::NotFound, + e => anyhow::Error::from(e).into(), + })?; + Ok(model) } - pub async fn update(self, pool: &SqlitePool) -> Result { + pub async fn update(self, pool: &SqlitePool) -> Result { let now: DateTime = Utc::now(); let _ = sqlx::query( diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs new file mode 100644 index 0000000..50b7492 --- /dev/null +++ b/backend/src/services/mod.rs @@ -0,0 +1,82 @@ +mod tasks; + +use axum::{ + Json, + extract::rejection::{JsonRejection, PathRejection}, + http::StatusCode, + response::IntoResponse, +}; + +// AppError +// Borrowed from example: https://github.com/tokio-rs/axum/blob/axum-v0.8.4/examples/anyhow-error-response/src/main.rs +pub enum AppError { + InternalError(anyhow::Error), + JsonExtractError(JsonRejection), + PathError(PathRejection), + NotFound, +} + +#[derive(Serialize)] +pub struct ErrorJson { + error: String, +} + +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + match self { + Self::InternalError(anyhow_error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Something went wrong {}", anyhow_error), + ) + .into_response(), + Self::JsonExtractError(rej) => ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(ErrorJson { + error: rej.status().to_string(), + }), + ) + .into_response(), + Self::PathError(rej) => ( + StatusCode::BAD_REQUEST, + Json(ErrorJson { + error: rej.status().to_string(), + }), + ) + .into_response(), + Self::NotFound => ( + StatusCode::NOT_FOUND, + Json(ErrorJson { + error: "Requeted Entity Not Found".to_string(), + }), + ) + .into_response(), + } + } +} + +impl From for AppError { + fn from(value: JsonRejection) -> Self { + Self::JsonExtractError(value) + } +} + +impl From for AppError { + fn from(value: PathRejection) -> Self { + Self::PathError(value) + } +} + +impl From for AppError { + fn from(err: anyhow::Error) -> Self { + Self::InternalError(err.into()) + } +} + +impl From for AppError { + fn from(err: sqlx::Error) -> Self { + Self::InternalError(err.into()) + } +} + +use serde::Serialize; +pub use tasks::*; diff --git a/backend/src/services/tasks.rs b/backend/src/services/tasks.rs new file mode 100644 index 0000000..ebf7f3e --- /dev/null +++ b/backend/src/services/tasks.rs @@ -0,0 +1,47 @@ +use axum::{ + Json, Router, + extract::{Path, State}, + http::StatusCode, + routing::{get, post}, +}; +use axum_extra::extract::WithRejection; +use axum_macros::debug_handler; +use serde::Deserialize; +use sqlx::{Pool, Sqlite}; +use uuid::Uuid; + +use crate::models::TaskModel; + +use super::AppError; + +pub fn create_task_router() -> Router> { + Router::new() + .route("/", post(create_task)) + .route("/{task_id}", get(get_task)) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateTaskRequest { + title: String, + description: Option, +} + +#[debug_handler] +pub async fn create_task( + State(pool): State>, + WithRejection(Json(input), _): WithRejection, AppError>, +) -> Result<(StatusCode, Json), AppError> { + let model = TaskModel::insert(&pool, &input.title, input.description.as_deref()).await?; + + Ok((StatusCode::CREATED, Json(model))) +} + +pub async fn get_task( + State(pool): State>, + WithRejection(Path(task_id), _): WithRejection, AppError>, +) -> Result<(StatusCode, Json), AppError> { + let model = TaskModel::get_by_id(&pool, task_id).await?; + + Ok((StatusCode::OK, Json(model))) +} diff --git a/backend/tests/api/tasks.hurl b/backend/tests/api/tasks.hurl new file mode 100644 index 0000000..20e8806 --- /dev/null +++ b/backend/tests/api/tasks.hurl @@ -0,0 +1,94 @@ +# Task API Tests + +# Test: Create a new task (POST /api/tasks) +POST {{host}}/api/tasks +Content-Type: application/json +{ + "title": "Test task", + "description": "A test task for the API" +} + +HTTP 201 +[Captures] +task_id: jsonpath "$.id" +[Asserts] +jsonpath "$.title" == "Test task" +jsonpath "$.description" == "A test task for the API" +jsonpath "$.status" == "todo" +jsonpath "$.id" exists +jsonpath "$.created_at" exists +jsonpath "$.updated_at" exists + +# Test: Create a minimal task (only required fields) +POST {{host}}/api/tasks +Content-Type: application/json +{ + "title": "Minimal task" +} + +HTTP 201 +[Captures] +minimal_task_id: jsonpath "$.id" +[Asserts] +jsonpath "$.title" == "Minimal task" +jsonpath "$.status" == "todo" +jsonpath "$.description" == null + +# Test: Create task with invalid data (missing title) +POST {{host}}/api/tasks +Content-Type: application/json +{ + "description": "Task without title" +} + +HTTP 422 +[Asserts] +jsonpath "$.error" exists + +# Test: Create task with invalid priority +POST {{host}}/api/tasks +Content-Type: application/json +{ + "title": "Invalid priority task", + "priority": "This field does not exist." +} + +HTTP 422 +[Asserts] +jsonpath "$.error" exists + +# Test: Get a specific task by ID (GET /api/tasks/{id}) +GET {{host}}/api/tasks/{{task_id}} + +HTTP 200 +[Asserts] +jsonpath "$.id" == "{{task_id}}" +jsonpath "$.title" == "Test task" +jsonpath "$.description" == "A test task for the API" +jsonpath "$.status" == "todo" +jsonpath "$.created_at" exists +jsonpath "$.updated_at" exists + +# Test: Get a minimal task by ID +GET {{host}}/api/tasks/{{minimal_task_id}} + +HTTP 200 +[Asserts] +jsonpath "$.id" == "{{minimal_task_id}}" +jsonpath "$.title" == "Minimal task" +jsonpath "$.status" == "todo" +jsonpath "$.description" == null + +# Test: Get non-existent task +GET {{host}}/api/tasks/00000000-0000-0000-0000-000000000000 + +HTTP 404 +[Asserts] +jsonpath "$.error" exists + +# Test: Get task with invalid UUID format +GET {{host}}/api/tasks/invalid-uuid + +HTTP 400 +[Asserts] +jsonpath "$.error" exists diff --git a/justfile b/justfile index 93b00fa..ed86590 100644 --- a/justfile +++ b/justfile @@ -33,3 +33,18 @@ migrate: migrate-revert: sqlx migrate revert +test-integration: + #!/usr/bin/env bash + set -e + + cargo run & + SERVER_PID=$! + + trap 'echo "Stopping server..."; kill -TERM $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true' EXIT + + echo "Waiting for server to start..." + printf 'GET http://localhost:3000/health\nHTTP 200' | hurl --retry 30 > /dev/null + + echo "Running integration tests..." + hurl --test --error-format long --variable host=http://localhost:3000 tests/api/*.hurl +