Compare commits

...

1 commit

Author SHA1 Message Date
f529bf4552 Create Project APIs.
All checks were successful
Check / Backend (pull_request) Successful in 7m9s
Check / Frontend (pull_request) Successful in 2m1s
2025-10-27 20:48:53 -07:00
13 changed files with 296 additions and 7 deletions

View file

@ -51,7 +51,9 @@ jobs:
cargo test --locked -- --skip api cargo test --locked -- --skip api
working-directory: backend working-directory: backend
- name: "Integration Tests" - name: "Integration Tests"
run: cargo test --locked --test api run: |
mkdir -p reports/store
cargo test --locked --test api
working-directory: backend working-directory: backend
check-frontend: check-frontend:

1
backend/.gitignore vendored
View file

@ -2,6 +2,7 @@ target
coverage coverage
reports reports
*.db *.db
*.db-shm *.db-shm
*.db-wal *.db-wal

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE projects \n SET title=$1, color=$2, folder_id=$3, status=$4, sort_order=$5, updated_at=$6, completed_at=$7 \n WHERE id=$8",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "3fbe5a29facacd03360f017a3ab078c9ae067aafcaa2e7ca29d597c0a74ac48d"
}

View file

@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT MAX(sort_order) as max_order FROM projects;",
"describe": {
"columns": [
{
"name": "max_order",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
true
]
},
"hash": "93c31908b7409b034b143447cd8a1635f191f0abd4d547835c0f05aa403f96e0"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM projects WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "a5ba908419fb3e456bdd2daca41ba06cc3212ffffb8520fc7dbbcc8b60ada314"
}

2
backend/Cargo.lock generated
View file

@ -3500,9 +3500,11 @@ dependencies = [
"bitflags 2.9.3", "bitflags 2.9.3",
"bytes", "bytes",
"http 1.3.1", "http 1.3.1",
"http-body 1.0.1",
"pin-project-lite", "pin-project-lite",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]

View file

@ -12,7 +12,7 @@ chrono = { version = "0.4.41", features = ["serde"] }
serde = "1.0.219" serde = "1.0.219"
sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono", "migrate"] } sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono", "migrate"] }
tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] } tokio = { version = "1.47.1", features = ["rt-multi-thread", "tracing"] }
tower-http = { version = "0.6.0", features = ["cors"] } tower-http = { version = "0.6.0", features = ["cors", "trace"] }
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = "0.3.19" tracing-subscriber = "0.3.19"
uuid = { version = "1.18.0", features = ["serde", "v4"] } uuid = { version = "1.18.0", features = ["serde", "v4"] }

View file

@ -32,6 +32,7 @@ async fn main() {
let app = Router::new() let app = Router::new()
.route("/health", get(health)) .route("/health", get(health))
.nest("/api/tasks", services::create_task_router()) .nest("/api/tasks", services::create_task_router())
.nest("/api/projects", services::create_project_router())
.layer(cors) .layer(cors)
.with_state(pool); .with_state(pool);

View file

@ -1,3 +1,5 @@
mod project;
mod task; mod task;
pub use project::*;
pub use task::*; pub use task::*;

View file

@ -0,0 +1,122 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Row, Sqlite};
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")]
pub enum ProjectStatus {
Active,
Done,
Backlog,
}
#[derive(sqlx::FromRow, Serialize)]
pub struct ProjectModel {
pub id: Uuid,
pub title: String,
pub color: String,
pub folder_id: Option<Uuid>,
pub sort_order: i64,
pub status: ProjectStatus,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
impl ProjectModel {
async fn get_new_sort_order(pool: &Pool<Sqlite>) -> anyhow::Result<i64> {
let result = sqlx::query!("SELECT MAX(sort_order) as max_order FROM projects;")
.fetch_one(pool)
.await?;
match result.max_order {
Some(i) => Ok(i + 1000),
None => Ok(0),
}
}
pub async fn insert(
pool: &Pool<Sqlite>,
title: String,
color: String,
) -> anyhow::Result<ProjectModel, AppError> {
if title.is_empty() {
return Err(AppError::Unprocessable(
"Title must not be empty".to_string(),
));
}
if color.len() != 7 || color.chars().next() != Some('#') {
return Err(AppError::Unprocessable(
"Color is not a valid hex".to_string(),
));
}
let id = Uuid::new_v4();
let sort_order = ProjectModel::get_new_sort_order(pool).await?;
let result = sqlx::query_as(
"INSERT INTO projects(id, title, color, sort_order) VALUES($1, $2, $3, $4) RETURNING *",
)
.bind(id)
.bind(title)
.bind(color)
.bind(sort_order)
.fetch_one(pool)
.await?;
Ok(result)
}
pub async fn get_by_id(
pool: &Pool<Sqlite>,
id: Uuid,
) -> anyhow::Result<ProjectModel, AppError> {
sqlx::query_as("SELECT * FROM projects WHERE id = $1")
.bind(id)
.fetch_one(pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => AppError::NotFound,
e => e.into(),
})
}
pub async fn list(pool: &Pool<Sqlite>) -> anyhow::Result<Vec<ProjectModel>> {
sqlx::query_as("SELECT * FROM projects;")
.fetch_all(pool)
.await
.map_err(|e| e.into())
}
pub async fn update(self, pool: &Pool<Sqlite>) -> anyhow::Result<ProjectModel, AppError> {
let now: DateTime<Utc> = Utc::now();
let _ = sqlx::query!(
"UPDATE projects
SET title=$1, color=$2, folder_id=$3, status=$4, sort_order=$5, updated_at=$6, completed_at=$7
WHERE id=$8",
self.title,
self.color,
self.folder_id,
self.status,
self.sort_order,
now,
self.completed_at,
self.id,
)
.execute(pool)
.await?;
ProjectModel::get_by_id(pool, self.id).await
}
pub async fn delete(pool: &Pool<Sqlite>, id: Uuid) -> anyhow::Result<()> {
sqlx::query!("DELETE FROM projects WHERE id = $1", id)
.execute(pool)
.await?;
Ok(())
}
}

View file

@ -1,3 +1,4 @@
mod projects;
mod tasks; mod tasks;
use axum::{ use axum::{
@ -26,11 +27,14 @@ pub struct ErrorJson {
impl IntoResponse for AppError { impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
match self { match self {
Self::InternalError(anyhow_error) => ( Self::InternalError(anyhow_error) => {
StatusCode::INTERNAL_SERVER_ERROR, error!(err = ?anyhow_error, "Internal Server Error");
format!("Something went wrong {}", anyhow_error), (
) StatusCode::INTERNAL_SERVER_ERROR,
.into_response(), format!("Something went wrong {}", anyhow_error),
)
.into_response()
}
Self::JsonExtractError(rej) => ( Self::JsonExtractError(rej) => (
StatusCode::UNPROCESSABLE_ENTITY, StatusCode::UNPROCESSABLE_ENTITY,
Json(ErrorJson { Json(ErrorJson {
@ -85,5 +89,7 @@ impl From<sqlx::Error> for AppError {
} }
} }
pub use projects::*;
use serde::Serialize; use serde::Serialize;
pub use tasks::*; pub use tasks::*;
use tracing::error;

View file

@ -0,0 +1,106 @@
use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
routing::{get, post},
};
use axum_extra::extract::WithRejection;
use serde::Deserialize;
use sqlx::{Pool, Sqlite};
use uuid::Uuid;
use crate::models::{ProjectModel, ProjectStatus};
use super::AppError;
pub fn create_project_router() -> Router<Pool<Sqlite>> {
Router::new()
.route("/", post(create_project).get(list_projects))
.route(
"/{project_id}",
get(get_project).put(update_project).delete(delete_project),
)
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateProjectRequest {
title: String,
color: String,
}
pub async fn create_project(
State(pool): State<Pool<Sqlite>>,
WithRejection(Json(input), _): WithRejection<Json<CreateProjectRequest>, AppError>,
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
Ok((
StatusCode::CREATED,
Json(ProjectModel::insert(&pool, input.title, input.color).await?),
))
}
pub async fn list_projects(
State(pool): State<Pool<Sqlite>>,
) -> Result<(StatusCode, Json<Vec<ProjectModel>>), AppError> {
Ok((StatusCode::OK, Json(ProjectModel::list(&pool).await?)))
}
pub async fn get_project(
State(pool): State<Pool<Sqlite>>,
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
Ok((
StatusCode::OK,
Json(ProjectModel::get_by_id(&pool, project_id).await?),
))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpdateProjectRequest {
title: Option<String>,
color: Option<String>,
status: Option<ProjectStatus>,
}
pub async fn update_project(
State(pool): State<Pool<Sqlite>>,
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
WithRejection(Json(input), _): WithRejection<Json<UpdateProjectRequest>, AppError>,
) -> Result<(StatusCode, Json<ProjectModel>), AppError> {
let mut project = ProjectModel::get_by_id(&pool, project_id).await?;
if let Some(new_title) = input.title {
if new_title.len() == 0 {
return Err(AppError::Unprocessable(
"Title must not be empty".to_string(),
));
}
project.title = new_title;
}
if let Some(color) = input.color {
if color.len() != 7 || color.chars().next() != Some('#') {
return Err(AppError::Unprocessable(
"Color is not a valid hex".to_string(),
));
}
project.color = color;
}
if let Some(status) = input.status {
project.status = status;
}
Ok((StatusCode::OK, Json(project.update(&pool).await?)))
}
pub async fn delete_project(
State(pool): State<Pool<Sqlite>>,
WithRejection(Path(project_id), _): WithRejection<Path<Uuid>, AppError>,
) -> Result<StatusCode, AppError> {
ProjectModel::get_by_id(&pool, project_id).await?;
ProjectModel::delete(&pool, project_id).await?;
Ok(StatusCode::NO_CONTENT)
}

View file

@ -9,6 +9,7 @@ use hurl_core::input::Input;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
async fn create_app() -> Router { async fn create_app() -> Router {
tracing_subscriber::fmt::try_init();
use backend::database::{DatabaseConfig, create_pool}; use backend::database::{DatabaseConfig, create_pool};
use backend::services; use backend::services;
@ -29,6 +30,8 @@ async fn create_app() -> Router {
Router::new() Router::new()
.route("/health", get(health)) .route("/health", get(health))
.nest("/api/tasks", services::create_task_router()) .nest("/api/tasks", services::create_task_router())
.nest("/api/projects", services::create_project_router())
.layer(TraceLayer::new_for_http())
.with_state(pool) .with_state(pool)
} }