feat(infrastructure): implement Gear repository and HTTP client

Add SQL repository and HTTP client implementations for Gear entity.

SQL Repository (infrastructure/repositories/gear.rs):
- SqlGearRepository with CRUD operations
- order_clause() for sorting: Make/Model (case-insensitive), Category, CreatedAt
- build_where_clause() for category filtering
- to_domain() converts GearRecord to domain Gear with category parsing
- Uses push_update_field! macro for partial updates
- Proper error handling with RepositoryError types

HTTP Client (infrastructure/client/gear.rs):
- GearClient for CLI access to API endpoints
- Methods: create(), list(), get(), update(), delete()
- Supports optional category filter in list()
- Error context with anyhow for user-friendly messages

Module Registration:
- Register gear module in infrastructure/repositories/mod.rs
- Register gear module and gear() method in infrastructure/client/mod.rs

Follows the exact patterns from BagRepository and BagsClient.
This commit is contained in:
Jon Seager 2026-02-02 15:19:19 +00:00
parent b794d8a63f
commit 2309a696cc
No known key found for this signature in database
4 changed files with 303 additions and 0 deletions

View file

@ -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<String>,
) -> Result<Gear> {
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<String>) -> Result<Vec<Gear>> {
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<Gear> {
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<String>,
model: Option<String>,
notes: Option<String>,
) -> Result<Gear> {
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)
}
}
}

View file

@ -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<Url> {
self.base_url
.join(path)

View file

@ -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<GearSortKey>) -> 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<Gear, RepositoryError> {
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<String> {
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<Gear, RepositoryError> {
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<Gear, RepositoryError> {
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<GearSortKey>,
) -> Result<Page<Gear>, 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<Gear, RepositoryError> {
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::<GearRecord>()
.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<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}

View file

@ -1,4 +1,5 @@
pub mod bags;
pub mod gear;
mod macros;
pub mod pagination;
pub mod roasters;