From 20ee8123932545e25d6d2e7fbf690b1420f10f6a Mon Sep 17 00:00:00 2001 From: Drew Galbraith Date: Sun, 24 Aug 2025 10:16:26 -0700 Subject: [PATCH 1/3] Create hurl tests for task creation and retrieval --- backend/tests/api/tasks.hurl | 117 +++++++++++++++++++++++++++++++++++ justfile | 15 +++++ 2 files changed, 132 insertions(+) create mode 100644 backend/tests/api/tasks.hurl diff --git a/backend/tests/api/tasks.hurl b/backend/tests/api/tasks.hurl new file mode 100644 index 0000000..100a058 --- /dev/null +++ b/backend/tests/api/tasks.hurl @@ -0,0 +1,117 @@ +# 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", + "priority": "medium", + "due_date": "2024-12-31", + "status": "todo" +} + +HTTP 201 +[Captures] +task_id: jsonpath "$.id" +[Asserts] +jsonpath "$.title" == "Test task" +jsonpath "$.description" == "A test task for the API" +jsonpath "$.priority" == "medium" +jsonpath "$.due_date" == "2024-12-31" +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 "$.priority" == "medium" +jsonpath "$.status" == "todo" +jsonpath "$.description" == null +jsonpath "$.due_date" == null + +# Test: Create task with invalid data (missing title) +POST {{host}}/api/tasks +Content-Type: application/json +{ + "description": "Task without title" +} + +HTTP 400 +[Asserts] +jsonpath "$.error" exists + +# Test: Create task with invalid priority +POST {{host}}/api/tasks +Content-Type: application/json +{ + "title": "Invalid priority task", + "priority": "invalid" +} + +HTTP 400 +[Asserts] +jsonpath "$.error" exists + +# Test: Create task with invalid status +POST {{host}}/api/tasks +Content-Type: application/json +{ + "title": "Invalid status task", + "status": "invalid" +} + +HTTP 400 +[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 "$.priority" == "medium" +jsonpath "$.due_date" == "2024-12-31" +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 "$.priority" == "medium" +jsonpath "$.status" == "todo" +jsonpath "$.description" == null +jsonpath "$.due_date" == 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..a2c57d7 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 --variable host=http://localhost:3000 tests/api/*.hurl + -- 2.49.1 From 2b2fc974a28d6bf34e6351deb2d966b4c0c58b8b Mon Sep 17 00:00:00 2001 From: Drew Galbraith Date: Sun, 24 Aug 2025 10:41:56 -0700 Subject: [PATCH 2/3] Implement POST task --- backend/.gitignore | 4 ++- backend/Cargo.lock | 37 ++++++++++++++++++++++ backend/Cargo.toml | 6 ++-- backend/src/database/connection.rs | 17 ----------- backend/src/main.rs | 14 +++++++-- backend/src/models/task.rs | 2 +- backend/src/services/mod.rs | 49 ++++++++++++++++++++++++++++++ backend/src/services/tasks.rs | 30 ++++++++++++++++++ backend/tests/api/tasks.hurl | 27 +++------------- justfile | 2 +- 10 files changed, 141 insertions(+), 47 deletions(-) create mode 100644 backend/src/services/mod.rs create mode 100644 backend/src/services/tasks.rs 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..dd3bbad 100644 --- a/backend/src/models/task.rs +++ b/backend/src/models/task.rs @@ -13,7 +13,7 @@ pub enum TaskStatus { Backlog, } -#[derive(sqlx::FromRow)] +#[derive(sqlx::FromRow, Serialize)] pub struct TaskModel { pub id: Uuid, pub title: String, diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs new file mode 100644 index 0000000..3cd2a6e --- /dev/null +++ b/backend/src/services/mod.rs @@ -0,0 +1,49 @@ +mod tasks; + +use axum::{Json, extract::rejection::JsonRejection, 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), +} + +#[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(), + } + } +} + +impl From for AppError { + fn from(value: JsonRejection) -> Self { + Self::JsonExtractError(value) + } +} + +impl From for AppError { + fn from(err: anyhow::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..c64c4ab --- /dev/null +++ b/backend/src/services/tasks.rs @@ -0,0 +1,30 @@ +use axum::{Json, Router, extract::State, http::StatusCode, routing::post}; +use axum_extra::extract::WithRejection; +use axum_macros::debug_handler; +use serde::Deserialize; +use sqlx::{Pool, Sqlite}; + +use crate::models::TaskModel; + +use super::AppError; + +pub fn create_task_router() -> Router> { + Router::new().route("/", post(create_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))) +} diff --git a/backend/tests/api/tasks.hurl b/backend/tests/api/tasks.hurl index 100a058..211ddf4 100644 --- a/backend/tests/api/tasks.hurl +++ b/backend/tests/api/tasks.hurl @@ -5,10 +5,7 @@ POST {{host}}/api/tasks Content-Type: application/json { "title": "Test task", - "description": "A test task for the API", - "priority": "medium", - "due_date": "2024-12-31", - "status": "todo" + "description": "A test task for the API" } HTTP 201 @@ -17,8 +14,6 @@ task_id: jsonpath "$.id" [Asserts] jsonpath "$.title" == "Test task" jsonpath "$.description" == "A test task for the API" -jsonpath "$.priority" == "medium" -jsonpath "$.due_date" == "2024-12-31" jsonpath "$.status" == "todo" jsonpath "$.id" exists jsonpath "$.created_at" exists @@ -36,10 +31,8 @@ HTTP 201 minimal_task_id: jsonpath "$.id" [Asserts] jsonpath "$.title" == "Minimal task" -jsonpath "$.priority" == "medium" jsonpath "$.status" == "todo" jsonpath "$.description" == null -jsonpath "$.due_date" == null # Test: Create task with invalid data (missing title) POST {{host}}/api/tasks @@ -48,7 +41,7 @@ Content-Type: application/json "description": "Task without title" } -HTTP 400 +HTTP 422 [Asserts] jsonpath "$.error" exists @@ -57,22 +50,10 @@ POST {{host}}/api/tasks Content-Type: application/json { "title": "Invalid priority task", - "priority": "invalid" + "priority": "This field does not exist." } -HTTP 400 -[Asserts] -jsonpath "$.error" exists - -# Test: Create task with invalid status -POST {{host}}/api/tasks -Content-Type: application/json -{ - "title": "Invalid status task", - "status": "invalid" -} - -HTTP 400 +HTTP 422 [Asserts] jsonpath "$.error" exists diff --git a/justfile b/justfile index a2c57d7..ed86590 100644 --- a/justfile +++ b/justfile @@ -46,5 +46,5 @@ test-integration: printf 'GET http://localhost:3000/health\nHTTP 200' | hurl --retry 30 > /dev/null echo "Running integration tests..." - hurl --test --variable host=http://localhost:3000 tests/api/*.hurl + hurl --test --error-format long --variable host=http://localhost:3000 tests/api/*.hurl -- 2.49.1 From 10efd3de8f5978e2895a17feef25a0f04aebd070 Mon Sep 17 00:00:00 2001 From: Drew Galbraith Date: Sat, 20 Sep 2025 11:11:40 -0700 Subject: [PATCH 3/3] Implement get task. --- backend/src/models/task.rs | 16 +++++++++++----- backend/src/services/mod.rs | 35 ++++++++++++++++++++++++++++++++++- backend/src/services/tasks.rs | 21 +++++++++++++++++++-- backend/tests/api/tasks.hurl | 4 ---- 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/backend/src/models/task.rs b/backend/src/models/task.rs index dd3bbad..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")] @@ -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 index 3cd2a6e..50b7492 100644 --- a/backend/src/services/mod.rs +++ b/backend/src/services/mod.rs @@ -1,12 +1,19 @@ mod tasks; -use axum::{Json, extract::rejection::JsonRejection, http::StatusCode, response::IntoResponse}; +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)] @@ -29,6 +36,20 @@ impl IntoResponse for AppError { }), ) .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(), } } } @@ -39,11 +60,23 @@ impl From for AppError { } } +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 index c64c4ab..ebf7f3e 100644 --- a/backend/src/services/tasks.rs +++ b/backend/src/services/tasks.rs @@ -1,15 +1,23 @@ -use axum::{Json, Router, extract::State, http::StatusCode, routing::post}; +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)) + Router::new() + .route("/", post(create_task)) + .route("/{task_id}", get(get_task)) } #[derive(Deserialize)] @@ -28,3 +36,12 @@ pub async fn create_task( 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 index 211ddf4..20e8806 100644 --- a/backend/tests/api/tasks.hurl +++ b/backend/tests/api/tasks.hurl @@ -65,8 +65,6 @@ HTTP 200 jsonpath "$.id" == "{{task_id}}" jsonpath "$.title" == "Test task" jsonpath "$.description" == "A test task for the API" -jsonpath "$.priority" == "medium" -jsonpath "$.due_date" == "2024-12-31" jsonpath "$.status" == "todo" jsonpath "$.created_at" exists jsonpath "$.updated_at" exists @@ -78,10 +76,8 @@ HTTP 200 [Asserts] jsonpath "$.id" == "{{minimal_task_id}}" jsonpath "$.title" == "Minimal task" -jsonpath "$.priority" == "medium" jsonpath "$.status" == "todo" jsonpath "$.description" == null -jsonpath "$.due_date" == null # Test: Get non-existent task GET {{host}}/api/tasks/00000000-0000-0000-0000-000000000000 -- 2.49.1