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:
parent
b794d8a63f
commit
2309a696cc
4 changed files with 303 additions and 0 deletions
112
src/infrastructure/client/gear.rs
Normal file
112
src/infrastructure/client/gear.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
185
src/infrastructure/repositories/gear.rs
Normal file
185
src/infrastructure/repositories/gear.rs
Normal 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>,
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
pub mod bags;
|
||||
pub mod gear;
|
||||
mod macros;
|
||||
pub mod pagination;
|
||||
pub mod roasters;
|
||||
|
|
|
|||
Loading…
Reference in a new issue