diff --git a/src/infrastructure/client/gear.rs b/src/infrastructure/client/gear.rs new file mode 100644 index 0000000..df28382 --- /dev/null +++ b/src/infrastructure/client/gear.rs @@ -0,0 +1,112 @@ +use anyhow::{Context, Result}; + +use crate::domain::gear::{Gear, UpdateGear}; +use crate::domain::ids::GearId; + +use super::BrewlogClient; + +pub struct GearClient<'a> { + inner: &'a BrewlogClient, +} + +impl<'a> GearClient<'a> { + pub(crate) fn new(inner: &'a BrewlogClient) -> Self { + Self { inner } + } + + pub async fn create( + &self, + category: &str, + make: String, + model: String, + notes: Option, + ) -> Result { + let url = self.inner.endpoint("api/v1/gear")?; + let payload = serde_json::json!({ + "category": category, + "make": make, + "model": model, + "notes": notes, + }); + + let response = self + .inner + .request(reqwest::Method::POST, url) + .json(&payload) + .send() + .await + .context("failed to issue create gear request")?; + + self.inner.handle_response(response).await + } + + pub async fn list(&self, category: Option) -> Result> { + let mut url = self.inner.endpoint("api/v1/gear")?; + if let Some(category) = category { + url.query_pairs_mut() + .append_pair("category", &category); + } + + let response = self + .inner + .request(reqwest::Method::GET, url) + .send() + .await + .context("failed to issue list gear request")?; + + self.inner.handle_response(response).await + } + + pub async fn get(&self, id: GearId) -> Result { + let url = self.inner.endpoint(&format!("api/v1/gear/{id}"))?; + let response = self + .inner + .request(reqwest::Method::GET, url) + .send() + .await + .context("failed to issue get gear request")?; + + self.inner.handle_response(response).await + } + + pub async fn update( + &self, + id: GearId, + make: Option, + model: Option, + notes: Option, + ) -> Result { + let url = self.inner.endpoint(&format!("api/v1/gear/{id}"))?; + let payload = UpdateGear { + make, + model, + notes, + }; + + let response = self + .inner + .request(reqwest::Method::PUT, url) + .json(&payload) + .send() + .await + .context("failed to issue update gear request")?; + + self.inner.handle_response(response).await + } + + pub async fn delete(&self, id: GearId) -> Result<()> { + let url = self.inner.endpoint(&format!("api/v1/gear/{id}"))?; + let response = self + .inner + .request(reqwest::Method::DELETE, url) + .send() + .await + .context("failed to issue delete gear request")?; + + if response.status().is_success() { + Ok(()) + } else { + Err(self.inner.response_error(response).await) + } + } +} diff --git a/src/infrastructure/client/mod.rs b/src/infrastructure/client/mod.rs index a37801a..43cacf0 100644 --- a/src/infrastructure/client/mod.rs +++ b/src/infrastructure/client/mod.rs @@ -1,4 +1,5 @@ pub mod bags; +pub mod gear; pub mod roasters; pub mod roasts; pub mod tokens; @@ -56,6 +57,10 @@ impl BrewlogClient { bags::BagsClient::new(self) } + pub fn gear(&self) -> gear::GearClient<'_> { + gear::GearClient::new(self) + } + pub(crate) fn endpoint(&self, path: &str) -> Result { self.base_url .join(path) diff --git a/src/infrastructure/repositories/gear.rs b/src/infrastructure/repositories/gear.rs new file mode 100644 index 0000000..2999e46 --- /dev/null +++ b/src/infrastructure/repositories/gear.rs @@ -0,0 +1,185 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::{query_as, QueryBuilder}; + +use super::macros::push_update_field; +use crate::domain::gear::{Gear, GearCategory, GearFilter, GearSortKey, NewGear, UpdateGear}; +use crate::domain::ids::GearId; +use crate::domain::listing::{ListRequest, Page, SortDirection}; +use crate::domain::repositories::GearRepository; +use crate::domain::RepositoryError; +use crate::infrastructure::database::DatabasePool; + +#[derive(Clone)] +pub struct SqlGearRepository { + pool: DatabasePool, +} + +impl SqlGearRepository { + pub fn new(pool: DatabasePool) -> Self { + Self { pool } + } + + fn order_clause(request: &ListRequest) -> String { + let dir_sql = match request.sort_direction() { + SortDirection::Asc => "ASC", + SortDirection::Desc => "DESC", + }; + + match request.sort_key() { + GearSortKey::Make => format!("LOWER(make) {dir_sql}, created_at DESC"), + GearSortKey::Model => format!("LOWER(model) {dir_sql}, created_at DESC"), + GearSortKey::Category => format!("category {dir_sql}, LOWER(make) ASC"), + GearSortKey::CreatedAt => format!("created_at {dir_sql}, id DESC"), + } + } + + fn to_domain(record: GearRecord) -> Result { + let category = GearCategory::from_str(&record.category).ok_or_else(|| { + RepositoryError::unexpected(format!("invalid category: {}", record.category)) + })?; + + Ok(Gear { + id: GearId::new(record.id), + category, + make: record.make, + model: record.model, + notes: record.notes, + created_at: record.created_at, + updated_at: record.updated_at, + }) + } + + fn build_where_clause(filter: &GearFilter) -> Option { + if let Some(category) = &filter.category { + // SAFETY: category.as_str() returns a fixed static string literal ('grinder' or 'brewer') + Some(format!("category = '{}'", category.as_str())) + } else { + None + } + } +} + +#[async_trait] +impl GearRepository for SqlGearRepository { + async fn insert(&self, gear: NewGear) -> Result { + let query = r#" + INSERT INTO gear (category, make, model, notes) + VALUES (?, ?, ?, ?) + RETURNING id, category, make, model, notes, created_at, updated_at + "#; + + let record = query_as::<_, GearRecord>(query) + .bind(gear.category.as_str()) + .bind(&gear.make) + .bind(&gear.model) + .bind(&gear.notes) + .fetch_one(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + Self::to_domain(record) + } + + async fn get(&self, id: GearId) -> Result { + let query = r#" + SELECT id, category, make, model, notes, created_at, updated_at + FROM gear + WHERE id = ? + "#; + + let record = query_as::<_, GearRecord>(query) + .bind(id.into_inner()) + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + Self::to_domain(record) + } + + async fn list( + &self, + filter: GearFilter, + request: &ListRequest, + ) -> Result, RepositoryError> { + let order_clause = Self::order_clause(request); + let where_clause = Self::build_where_clause(&filter); + + let base_query = match &where_clause { + Some(w) => format!( + "SELECT id, category, make, model, notes, created_at, updated_at FROM gear WHERE {}", + w + ), + None => "SELECT id, category, make, model, notes, created_at, updated_at FROM gear" + .to_string(), + }; + + let count_query = match &where_clause { + Some(w) => format!("SELECT COUNT(*) FROM gear WHERE {}", w), + None => "SELECT COUNT(*) FROM gear".to_string(), + }; + + crate::infrastructure::repositories::pagination::paginate( + &self.pool, + request, + &base_query, + &count_query, + &order_clause, + Self::to_domain, + ) + .await + } + + async fn update(&self, id: GearId, changes: UpdateGear) -> Result { + let mut builder = QueryBuilder::new("UPDATE gear SET updated_at = CURRENT_TIMESTAMP"); + let mut sep = true; // Already have updated_at + + push_update_field!(builder, sep, "make", changes.make); + push_update_field!(builder, sep, "model", changes.model); + push_update_field!(builder, sep, "notes", changes.notes); + let _ = sep; // Suppress unused_assignments warning + + builder.push(" WHERE id = "); + builder.push_bind(id.into_inner()); + builder.push( + " RETURNING id, category, make, model, notes, created_at, updated_at", + ); + + let record = builder + .build_query_as::() + .fetch_optional(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))? + .ok_or(RepositoryError::NotFound)?; + + Self::to_domain(record) + } + + async fn delete(&self, id: GearId) -> Result<(), RepositoryError> { + let query = "DELETE FROM gear WHERE id = ?"; + + let result = sqlx::query(query) + .bind(id.into_inner()) + .execute(&self.pool) + .await + .map_err(|err| RepositoryError::unexpected(err.to_string()))?; + + if result.rows_affected() == 0 { + return Err(RepositoryError::NotFound); + } + + Ok(()) + } +} + +#[derive(sqlx::FromRow)] +struct GearRecord { + id: i64, + category: String, + make: String, + model: String, + notes: Option, + created_at: DateTime, + updated_at: DateTime, +} diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index 9446da8..e2c7f90 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -1,4 +1,5 @@ pub mod bags; +pub mod gear; mod macros; pub mod pagination; pub mod roasters;