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 bags;
|
||||||
|
pub mod gear;
|
||||||
pub mod roasters;
|
pub mod roasters;
|
||||||
pub mod roasts;
|
pub mod roasts;
|
||||||
pub mod tokens;
|
pub mod tokens;
|
||||||
|
|
@ -56,6 +57,10 @@ impl BrewlogClient {
|
||||||
bags::BagsClient::new(self)
|
bags::BagsClient::new(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn gear(&self) -> gear::GearClient<'_> {
|
||||||
|
gear::GearClient::new(self)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn endpoint(&self, path: &str) -> Result<Url> {
|
pub(crate) fn endpoint(&self, path: &str) -> Result<Url> {
|
||||||
self.base_url
|
self.base_url
|
||||||
.join(path)
|
.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 bags;
|
||||||
|
pub mod gear;
|
||||||
mod macros;
|
mod macros;
|
||||||
pub mod pagination;
|
pub mod pagination;
|
||||||
pub mod roasters;
|
pub mod roasters;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue