Initial HerbAPI implementation

Rust/Axum REST API (herbapi-api) with PostgreSQL, S3/Garage, OIDC auth.
Dioxus 0.7 WASM frontend (herbapi-ui) with sidebar layout and botanical reference style.
9 SQL migrations covering families, species, cultivars, suppliers, companions, images, users, API tokens.
This commit is contained in:
2026-03-14 00:02:29 +01:00
commit 484979ad53
56 changed files with 12792 additions and 0 deletions
+4235
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "herbapi-api"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
axum = { version = "0.8", features = ["macros", "multipart"] }
axum-extra = { version = "0.10", features = ["typed-header"] }
axum-server = { version = "0.7", features = ["tls-rustls"] }
aws-sdk-s3 = "1"
aws-credential-types = "1"
chrono = { version = "0.4", features = ["serde"] }
hex = "0.4"
once_cell = "1"
rand = "0.8"
reqwest = { version = "0.12", features = ["json"] }
rustls = { version = "0.23", default-features = false, features = ["aws-lc-rs"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
slug = "0.1"
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls-aws-lc-rs", "postgres", "uuid", "chrono", "migrate"] }
thiserror = "2"
tokio = { version = "1", features = ["full"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "fs"] }
tower-sessions = { version = "0.15", features = ["private"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4", "v7", "serde"] }
@@ -0,0 +1,16 @@
CREATE TABLE families (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name_scientific TEXT NOT NULL,
name_en TEXT,
name_de TEXT,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_families_search ON families
USING GIN (to_tsvector('english',
coalesce(name_scientific,'') || ' ' ||
coalesce(name_en,'') || ' ' ||
coalesce(name_de,'')));
@@ -0,0 +1,77 @@
CREATE TYPE invasiveness_level AS ENUM ('none', 'watch_list', 'invasive', 'banned');
CREATE TYPE plant_layer AS ENUM ('canopy', 'understory', 'shrub', 'herbaceous', 'ground_cover', 'vine', 'root');
CREATE TYPE succession_stage AS ENUM ('pioneer', 'early', 'mid', 'climax');
CREATE TYPE drought_tolerance AS ENUM ('none', 'low', 'moderate', 'high');
CREATE TYPE salt_tolerance AS ENUM ('none', 'low', 'moderate', 'high');
CREATE TABLE species (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
family_id UUID NOT NULL REFERENCES families(id) ON DELETE RESTRICT,
name_scientific TEXT NOT NULL,
name_en TEXT,
name_de TEXT,
description TEXT,
soil_moisture TEXT,
drainage_requirement TEXT,
organic_matter_pct NUMERIC(5,2),
nitrogen_ppm INTEGER,
phosphorus_ppm INTEGER,
potassium_ppm INTEGER,
boron_ppm NUMERIC(8,2),
calcium_ppm INTEGER,
copper_ppm NUMERIC(8,2),
iron_ppm NUMERIC(8,2),
magnesium_ppm INTEGER,
manganese_ppm NUMERIC(8,2),
molybdenum_ppm NUMERIC(8,2),
sulfur_ppm INTEGER,
zinc_ppm NUMERIC(8,2),
ph_min NUMERIC(4,2),
ph_max NUMERIC(4,2),
soil_texture_preference TEXT[],
hardiness_zone_usda TEXT,
hardiness_zone_at TEXT,
min_temp NUMERIC(5,2),
max_temp NUMERIC(5,2),
drought_tolerance drought_tolerance,
water_requirement_mm_week NUMERIC(5,2),
waterlogging_tolerance BOOLEAN,
salt_tolerance salt_tolerance,
edibility_rating SMALLINT,
food_uses TEXT,
medicinal_uses TEXT,
other_uses TEXT,
native_range TEXT,
invasiveness invasiveness_level DEFAULT 'none',
pollination_type TEXT,
plant_layer plant_layer,
nitrogen_fixer BOOLEAN,
dynamic_accumulator BOOLEAN,
dynamic_accumulator_nutrients TEXT[],
attracts_pollinators BOOLEAN,
attracts_beneficial_insects BOOLEAN,
wildlife_value TEXT,
mulch_plant BOOLEAN,
ground_cover_quality TEXT,
allelopathic BOOLEAN,
guild_role TEXT[],
succession_stage succession_stage,
heavy_metal_tolerance BOOLEAN,
wikidata_qid TEXT,
gbif_id TEXT,
eppo_code TEXT,
pfaf_url TEXT,
primary_image_key TEXT,
source_urls TEXT[],
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_species_family ON species(family_id);
CREATE INDEX idx_species_search ON species
USING GIN (to_tsvector('english',
coalesce(name_scientific,'') || ' ' ||
coalesce(name_en,'') || ' ' ||
coalesce(name_de,'') || ' ' ||
coalesce(description,'')));
@@ -0,0 +1,78 @@
CREATE TYPE frost_tolerance AS ENUM ('none', 'light_frost', 'moderate_frost', 'hardy');
CREATE TABLE cultivars (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
species_id UUID NOT NULL REFERENCES species(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
name_en TEXT,
name_de TEXT,
name_scientific TEXT,
description TEXT,
is_organic BOOLEAN NOT NULL DEFAULT FALSE,
perennial BOOLEAN NOT NULL DEFAULT FALSE,
growing_time_days INTEGER,
planting_depth_cm NUMERIC(5,2),
row_spacing_cm NUMERIC(5,2),
plant_spacing_cm NUMERIC(5,2),
days_to_germination INTEGER,
germination_temp_c NUMERIC(5,2),
light_requirement TEXT,
stratification_required BOOLEAN,
stratification_days INTEGER,
scarification_required BOOLEAN,
seed_viability_years INTEGER,
storage_temp_c NUMERIC(5,2),
storage_humidity TEXT,
storage_notes TEXT,
min_temp NUMERIC(5,2),
max_temp NUMERIC(5,2),
humidity TEXT,
light TEXT,
frost_tolerance frost_tolerance,
min_light_hours_day NUMERIC(4,1),
optimal_light_hours_day NUMERIC(4,1),
greenhouse_min_temp_c NUMERIC(5,2),
indoor_season_extension_weeks INTEGER,
ventilation_requirement TEXT,
heating_required BOOLEAN,
indoor_sowing_months INTEGER[],
direct_sowing_months INTEGER[],
transplanting_months INTEGER[],
glasshouse_months INTEGER[],
harvesting_months INTEGER[],
succession_planting_days INTEGER,
planting_notes TEXT,
propagation_methods TEXT[],
cutting_season TEXT,
rootstock_species_id UUID REFERENCES species(id),
years_to_first_harvest INTEGER,
productive_lifespan_years INTEGER,
expected_yield_kg_per_m2 NUMERIC(8,2),
yield_unit TEXT,
expected_yield_value NUMERIC(8,2),
harvest_window_days INTEGER,
storage_method TEXT[],
shelf_life_days INTEGER,
cold_storage_days INTEGER,
pollination_group TEXT,
self_fertile BOOLEAN,
rootstock_compatibility TEXT,
wikidata_qid TEXT,
gbif_id TEXT,
pfaf_url TEXT,
primary_image_key TEXT,
source_urls TEXT[],
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_cultivars_species ON cultivars(species_id);
CREATE INDEX idx_cultivars_rootstock ON cultivars(rootstock_species_id) WHERE rootstock_species_id IS NOT NULL;
CREATE INDEX idx_cultivars_search ON cultivars
USING GIN (to_tsvector('english',
coalesce(name,'') || ' ' ||
coalesce(name_en,'') || ' ' ||
coalesce(name_de,'') || ' ' ||
coalesce(name_scientific,'') || ' ' ||
coalesce(description,'')));
@@ -0,0 +1,13 @@
CREATE TABLE images (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
s3_key TEXT NOT NULL,
caption TEXT,
source_url TEXT,
license TEXT,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_images_entity ON images(entity_type, entity_id);
@@ -0,0 +1,12 @@
CREATE TABLE scrape_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_name TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id UUID NOT NULL,
source_url TEXT NOT NULL,
last_scraped_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
raw_data JSONB
);
CREATE INDEX idx_scrape_entity ON scrape_log(entity_type, entity_id);
CREATE INDEX idx_scrape_source ON scrape_log(source_name);
@@ -0,0 +1,29 @@
CREATE TABLE suppliers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
url TEXT,
is_organic BOOLEAN NOT NULL DEFAULT FALSE,
is_demeter BOOLEAN NOT NULL DEFAULT FALSE,
country TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE cultivar_suppliers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cultivar_id UUID NOT NULL REFERENCES cultivars(id) ON DELETE CASCADE,
supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE,
article_number TEXT,
product_url TEXT,
price_eur NUMERIC(8,2),
pack_size NUMERIC(8,2),
pack_unit TEXT,
last_checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(cultivar_id, supplier_id, article_number)
);
CREATE INDEX idx_cs_cultivar ON cultivar_suppliers(cultivar_id);
CREATE INDEX idx_cs_supplier ON cultivar_suppliers(supplier_id);
@@ -0,0 +1,16 @@
CREATE TYPE companion_type AS ENUM ('beneficial', 'neutral', 'antagonistic');
CREATE TABLE companion_relationships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
species_a_id UUID NOT NULL REFERENCES species(id) ON DELETE CASCADE,
species_b_id UUID NOT NULL REFERENCES species(id) ON DELETE CASCADE,
relationship companion_type NOT NULL,
mechanism TEXT,
source_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(species_a_id, species_b_id),
CHECK (species_a_id < species_b_id)
);
CREATE INDEX idx_companion_a ON companion_relationships(species_a_id);
CREATE INDEX idx_companion_b ON companion_relationships(species_b_id);
@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR NOT NULL,
name VARCHAR,
nickname VARCHAR,
avatar_url VARCHAR,
provider VARCHAR NOT NULL DEFAULT 'authentik',
provider_id VARCHAR,
admin BOOLEAN NOT NULL DEFAULT FALSE,
inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS users_email_index ON users (email);
CREATE UNIQUE INDEX IF NOT EXISTS users_provider_provider_id_index ON users (provider, provider_id);
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS api_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scopes TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
+40
View File
@@ -0,0 +1,40 @@
use axum::extract::{Path, State};
use axum::Json;
use crate::auth::AuthUser;
use crate::db::{companions as db, models::*};
use crate::error::{AppError, Result};
use crate::state::AppState;
pub async fn list_for_species(
State(state): State<AppState>,
Path(r): Path<String>,
) -> Result<Json<Vec<CompanionRelationship>>> {
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
let companions = db::list_for_species(&state.pool, id).await?;
Ok(Json(companions))
}
pub async fn create(
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<CreateCompanion>,
) -> Result<Json<CompanionRelationship>> {
if !auth.can_write() {
return Err(AppError::Forbidden);
}
let companion = db::create(&state.pool, &req).await?;
Ok(Json(companion))
}
pub async fn remove(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<serde_json::Value>> {
if !auth.can_write() {
return Err(AppError::Forbidden);
}
db::delete(&state.pool, id).await?;
Ok(Json(serde_json::json!({ "deleted": true })))
}
+62
View File
@@ -0,0 +1,62 @@
use axum::extract::{Path, Query, State};
use axum::Json;
use crate::auth::AuthUser;
use crate::db::{cultivars as db, models::*};
use crate::error::{AppError, Result};
use crate::state::AppState;
pub async fn list(
State(state): State<AppState>,
Query(params): Query<db::CultivarListParams>,
) -> Result<Json<PaginatedResponse<Cultivar>>> {
let result = db::list(&state.pool, &params).await?;
Ok(Json(result))
}
pub async fn get_by_slug(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Json<Cultivar>> {
let cultivar = db::get_by_slug(&state.pool, &slug).await?;
Ok(Json(cultivar))
}
pub async fn create(
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<CreateCultivar>,
) -> Result<Json<Cultivar>> {
if !auth.can_write() {
return Err(AppError::Forbidden);
}
let cultivar = db::create(&state.pool, &req).await?;
Ok(Json(cultivar))
}
pub async fn update(
State(state): State<AppState>,
auth: AuthUser,
Path(r): Path<String>,
Json(req): Json<CreateCultivar>,
) -> Result<Json<Cultivar>> {
if !auth.can_write() {
return Err(AppError::Forbidden);
}
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
let cultivar = db::update(&state.pool, id, &req).await?;
Ok(Json(cultivar))
}
pub async fn remove(
State(state): State<AppState>,
auth: AuthUser,
Path(r): Path<String>,
) -> Result<Json<serde_json::Value>> {
if !auth.is_admin() {
return Err(AppError::Forbidden);
}
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
db::delete(&state.pool, id).await?;
Ok(Json(serde_json::json!({ "deleted": true })))
}
+62
View File
@@ -0,0 +1,62 @@
use axum::extract::{Path, Query, State};
use axum::Json;
use crate::auth::AuthUser;
use crate::db::{families as db, models::*};
use crate::error::{AppError, Result};
use crate::state::AppState;
pub async fn list(
State(state): State<AppState>,
Query(params): Query<PaginationParams>,
) -> Result<Json<PaginatedResponse<Family>>> {
let result = db::list(&state.pool, &params).await?;
Ok(Json(result))
}
pub async fn get_by_slug(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Json<Family>> {
let family = db::get_by_slug(&state.pool, &slug).await?;
Ok(Json(family))
}
pub async fn create(
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<CreateFamily>,
) -> Result<Json<Family>> {
if !auth.can_write() {
return Err(AppError::Forbidden);
}
let family = db::create(&state.pool, &req).await?;
Ok(Json(family))
}
pub async fn update(
State(state): State<AppState>,
auth: AuthUser,
Path(r): Path<String>,
Json(req): Json<UpdateFamily>,
) -> Result<Json<Family>> {
if !auth.can_write() {
return Err(AppError::Forbidden);
}
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
let family = db::update(&state.pool, id, &req).await?;
Ok(Json(family))
}
pub async fn remove(
State(state): State<AppState>,
auth: AuthUser,
Path(r): Path<String>,
) -> Result<Json<serde_json::Value>> {
if !auth.is_admin() {
return Err(AppError::Forbidden);
}
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
db::delete(&state.pool, id).await?;
Ok(Json(serde_json::json!({ "deleted": true })))
}
+5
View File
@@ -0,0 +1,5 @@
use axum::response::IntoResponse;
pub async fn health() -> impl IntoResponse {
axum::Json(serde_json::json!({ "status": "ok" }))
}
+114
View File
@@ -0,0 +1,114 @@
use axum::extract::{Multipart, Path, State};
use axum::Json;
use crate::auth::AuthUser;
use crate::db::{images as db, s3};
use crate::db::models::Image;
use crate::error::{AppError, Result};
use crate::state::AppState;
pub async fn list_for_entity(
State(state): State<AppState>,
Path((entity_type, entity_id)): Path<(String, uuid::Uuid)>,
) -> Result<Json<Vec<Image>>> {
let images = db::list_for_entity(&state.pool, &entity_type, entity_id).await?;
Ok(Json(images))
}
pub async fn upload(
State(state): State<AppState>,
auth: AuthUser,
mut multipart: Multipart,
) -> Result<Json<Image>> {
if !auth.can_write() {
return Err(AppError::Forbidden);
}
let mut entity_type = None;
let mut entity_id = None;
let mut caption = None;
let mut source_url = None;
let mut license = None;
let mut is_primary = false;
let mut file_data = None;
let mut content_type = "application/octet-stream".to_string();
let mut file_name = String::new();
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| AppError::BadRequest(format!("Multipart error: {e}")))?
{
let name = field.name().unwrap_or_default().to_string();
match name.as_str() {
"entity_type" => {
entity_type = Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
}
"entity_id" => {
let text = field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?;
entity_id = Some(text.parse::<uuid::Uuid>().map_err(|e| AppError::BadRequest(format!("Invalid UUID: {e}")))?);
}
"caption" => {
caption = Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
}
"source_url" => {
source_url = Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
}
"license" => {
license = Some(field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?);
}
"is_primary" => {
let text = field.text().await.map_err(|e| AppError::BadRequest(e.to_string()))?;
is_primary = text == "true" || text == "1";
}
"file" => {
content_type = field.content_type().unwrap_or("application/octet-stream").to_string();
file_name = field.file_name().unwrap_or("upload").to_string();
file_data = Some(field.bytes().await.map_err(|e| AppError::BadRequest(format!("File read error: {e}")))?);
}
_ => {}
}
}
let entity_type = entity_type.ok_or_else(|| AppError::BadRequest("entity_type required".into()))?;
let entity_id = entity_id.ok_or_else(|| AppError::BadRequest("entity_id required".into()))?;
let data = file_data.ok_or_else(|| AppError::BadRequest("file required".into()))?;
// Generate S3 key
let ext = file_name.rsplit('.').next().unwrap_or("bin");
let s3_key = format!("{entity_type}/{entity_id}/{}.{ext}", uuid::Uuid::now_v7());
// Upload to S3
s3::upload(&state.s3, &state.config.s3_bucket, &s3_key, data.to_vec(), &content_type).await?;
// Record in DB
let image = db::create(
&state.pool,
&entity_type,
entity_id,
&s3_key,
caption.as_deref(),
source_url.as_deref(),
license.as_deref(),
is_primary,
)
.await?;
Ok(Json(image))
}
pub async fn remove(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<serde_json::Value>> {
if !auth.is_admin() {
return Err(AppError::Forbidden);
}
let image = db::delete(&state.pool, id).await?;
// Delete from S3
s3::delete(&state.s3, &state.config.s3_bucket, &image.s3_key).await?;
Ok(Json(serde_json::json!({ "deleted": true })))
}
+67
View File
@@ -0,0 +1,67 @@
mod companions;
mod cultivars;
mod families;
mod health;
mod images;
mod search;
mod species;
mod suppliers;
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse};
use axum::routing::{delete, get, post, put};
use axum::Router;
use tower_http::services::ServeDir;
use crate::state::AppState;
pub fn router(state: AppState) -> Router {
Router::new()
// Health
.route("/health", get(health::health))
// Families
.route("/api/v1/families", get(families::list).post(families::create))
.route("/api/v1/families/{ref}", get(families::get_by_slug).put(families::update).delete(families::remove))
// Species
.route("/api/v1/species", get(species::list).post(species::create))
.route("/api/v1/species/{ref}", get(species::get_by_slug).put(species::update).delete(species::remove))
.route("/api/v1/species/{ref}/companions", get(companions::list_for_species))
// Cultivars
.route("/api/v1/cultivars", get(cultivars::list).post(cultivars::create))
.route("/api/v1/cultivars/{ref}", get(cultivars::get_by_slug).put(cultivars::update).delete(cultivars::remove))
.route("/api/v1/cultivars/{ref}/suppliers", get(suppliers::list_for_cultivar).post(suppliers::link_cultivar))
.route("/api/v1/cultivars/{cid}/suppliers/{sid}", delete(suppliers::unlink_cultivar))
// Suppliers
.route("/api/v1/suppliers", get(suppliers::list).post(suppliers::create))
.route("/api/v1/suppliers/{ref}", get(suppliers::get_by_slug).put(suppliers::update).delete(suppliers::remove))
// Companions
.route("/api/v1/companions", post(companions::create))
.route("/api/v1/companions/{id}", delete(companions::remove))
// Images
.route("/api/v1/images/{entity_type}/{entity_id}", get(images::list_for_entity))
.route("/api/v1/images", post(images::upload))
.route("/api/v1/images/{id}", delete(images::remove))
// Search
.route("/api/v1/search", get(search::search))
// OIDC auth
.route("/auth/oidc/login", get(crate::auth::oidc::login))
.route("/auth/oidc/callback", get(crate::auth::oidc::callback))
.route("/auth/oidc/logout", get(crate::auth::oidc::logout))
.route("/auth/me", get(crate::auth::oidc::me))
// SPA fallback
.fallback_service(
ServeDir::new("frontend")
.fallback(tower::service_fn(|_req| async {
match tokio::fs::read_to_string("frontend/index.html").await {
Ok(html) => Ok(Html(html).into_response()),
Err(_) => Ok((
StatusCode::OK,
[(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))],
"HerbAPI — frontend not found, use /health or /api/v1/*",
)
.into_response()),
}
})),
)
.with_state(state)
}
+99
View File
@@ -0,0 +1,99 @@
use axum::extract::{Query, State};
use axum::Json;
use serde::Deserialize;
use crate::db::models::SearchResult;
use crate::error::Result;
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct SearchParams {
pub q: String,
pub limit: Option<i64>,
}
pub async fn search(
State(state): State<AppState>,
Query(params): Query<SearchParams>,
) -> Result<Json<Vec<SearchResult>>> {
let limit = params.limit.unwrap_or(20).min(100);
let tsquery = params.q.split_whitespace().collect::<Vec<_>>().join(" & ");
// Search across families, species, cultivars
let mut results = Vec::new();
// Families
let families: Vec<(uuid::Uuid, String, String, Option<String>, f32)> = sqlx::query_as(
"SELECT id, slug, name_scientific, description,
ts_rank(to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'')),
to_tsquery('english', $1)) AS rank
FROM families
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,''))
@@ to_tsquery('english', $1)
ORDER BY rank DESC LIMIT $2"
)
.bind(&tsquery).bind(limit)
.fetch_all(&state.pool)
.await?;
for (id, slug, name, desc, rank) in families {
results.push(SearchResult {
entity_type: "family".to_string(),
id, slug, name,
description: desc,
rank,
});
}
// Species
let species: Vec<(uuid::Uuid, String, String, Option<String>, f32)> = sqlx::query_as(
"SELECT id, slug, name_scientific, description,
ts_rank(to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(description,'')),
to_tsquery('english', $1)) AS rank
FROM species
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(description,''))
@@ to_tsquery('english', $1)
ORDER BY rank DESC LIMIT $2"
)
.bind(&tsquery).bind(limit)
.fetch_all(&state.pool)
.await?;
for (id, slug, name, desc, rank) in species {
results.push(SearchResult {
entity_type: "species".to_string(),
id, slug, name,
description: desc,
rank,
});
}
// Cultivars
let cultivars: Vec<(uuid::Uuid, String, String, Option<String>, f32)> = sqlx::query_as(
"SELECT id, slug, name, description,
ts_rank(to_tsvector('english', coalesce(name,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(name_scientific,'') || ' ' || coalesce(description,'')),
to_tsquery('english', $1)) AS rank
FROM cultivars
WHERE to_tsvector('english', coalesce(name,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(name_scientific,'') || ' ' || coalesce(description,''))
@@ to_tsquery('english', $1)
ORDER BY rank DESC LIMIT $2"
)
.bind(&tsquery).bind(limit)
.fetch_all(&state.pool)
.await?;
for (id, slug, name, desc, rank) in cultivars {
results.push(SearchResult {
entity_type: "cultivar".to_string(),
id, slug, name,
description: desc,
rank,
});
}
// Sort all by rank descending, take limit
results.sort_by(|a, b| b.rank.partial_cmp(&a.rank).unwrap_or(std::cmp::Ordering::Equal));
results.truncate(limit as usize);
Ok(Json(results))
}
+62
View File
@@ -0,0 +1,62 @@
use axum::extract::{Path, Query, State};
use axum::Json;
use crate::auth::AuthUser;
use crate::db::{species as db, models::*};
use crate::error::{AppError, Result};
use crate::state::AppState;
pub async fn list(
State(state): State<AppState>,
Query(params): Query<db::SpeciesListParams>,
) -> Result<Json<PaginatedResponse<Species>>> {
let result = db::list(&state.pool, &params).await?;
Ok(Json(result))
}
pub async fn get_by_slug(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Json<Species>> {
let species = db::get_by_slug(&state.pool, &slug).await?;
Ok(Json(species))
}
pub async fn create(
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<CreateSpecies>,
) -> Result<Json<Species>> {
if !auth.can_write() {
return Err(AppError::Forbidden);
}
let species = db::create(&state.pool, &req).await?;
Ok(Json(species))
}
pub async fn update(
State(state): State<AppState>,
auth: AuthUser,
Path(r): Path<String>,
Json(req): Json<CreateSpecies>,
) -> Result<Json<Species>> {
if !auth.can_write() {
return Err(AppError::Forbidden);
}
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
let species = db::update(&state.pool, id, &req).await?;
Ok(Json(species))
}
pub async fn remove(
State(state): State<AppState>,
auth: AuthUser,
Path(r): Path<String>,
) -> Result<Json<serde_json::Value>> {
if !auth.is_admin() {
return Err(AppError::Forbidden);
}
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
db::delete(&state.pool, id).await?;
Ok(Json(serde_json::json!({ "deleted": true })))
}
+96
View File
@@ -0,0 +1,96 @@
use axum::extract::{Path, State};
use axum::Json;
use crate::auth::AuthUser;
use crate::db::{suppliers as db, models::*};
use crate::error::{AppError, Result};
use crate::state::AppState;
pub async fn list(State(state): State<AppState>) -> Result<Json<Vec<Supplier>>> {
let suppliers = db::list(&state.pool).await?;
Ok(Json(suppliers))
}
pub async fn get_by_slug(
State(state): State<AppState>,
Path(slug): Path<String>,
) -> Result<Json<Supplier>> {
let supplier = db::get_by_slug(&state.pool, &slug).await?;
Ok(Json(supplier))
}
pub async fn create(
State(state): State<AppState>,
auth: AuthUser,
Json(req): Json<CreateSupplier>,
) -> Result<Json<Supplier>> {
if !auth.can_write() {
return Err(AppError::Forbidden);
}
let supplier = db::create(&state.pool, &req).await?;
Ok(Json(supplier))
}
pub async fn update(
State(state): State<AppState>,
auth: AuthUser,
Path(r): Path<String>,
Json(req): Json<CreateSupplier>,
) -> Result<Json<Supplier>> {
if !auth.can_write() {
return Err(AppError::Forbidden);
}
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
let supplier = db::update(&state.pool, id, &req).await?;
Ok(Json(supplier))
}
pub async fn remove(
State(state): State<AppState>,
auth: AuthUser,
Path(r): Path<String>,
) -> Result<Json<serde_json::Value>> {
if !auth.is_admin() {
return Err(AppError::Forbidden);
}
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
db::delete(&state.pool, id).await?;
Ok(Json(serde_json::json!({ "deleted": true })))
}
// Cultivar-supplier links
pub async fn list_for_cultivar(
State(state): State<AppState>,
Path(r): Path<String>,
) -> Result<Json<Vec<CultivarSupplier>>> {
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
let links = db::list_for_cultivar(&state.pool, id).await?;
Ok(Json(links))
}
pub async fn link_cultivar(
State(state): State<AppState>,
auth: AuthUser,
Path(r): Path<String>,
Json(req): Json<CreateCultivarSupplier>,
) -> Result<Json<CultivarSupplier>> {
if !auth.can_write() {
return Err(AppError::Forbidden);
}
let id: uuid::Uuid = r.parse().map_err(|_| AppError::NotFound("invalid id".into()))?;
let link = db::link_cultivar(&state.pool, id, &req).await?;
Ok(Json(link))
}
pub async fn unlink_cultivar(
State(state): State<AppState>,
auth: AuthUser,
Path((cid, sid)): Path<(uuid::Uuid, uuid::Uuid)>,
) -> Result<Json<serde_json::Value>> {
if !auth.can_write() {
return Err(AppError::Forbidden);
}
db::unlink_cultivar(&state.pool, cid, sid).await?;
Ok(Json(serde_json::json!({ "deleted": true })))
}
+238
View File
@@ -0,0 +1,238 @@
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum_extra::headers::authorization::Bearer;
use axum_extra::headers::Authorization;
use axum_extra::TypedHeader;
use chrono::{DateTime, Utc};
use once_cell::sync::Lazy;
use sha2::{Digest, Sha256};
use sqlx::FromRow;
use std::collections::HashMap;
use std::sync::RwLock;
use tower_sessions::Session;
use uuid::Uuid;
use crate::state::AppState;
const CACHE_TTL_SECS: i64 = 60;
struct CachedToken {
user: AuthUser,
cached_at: DateTime<Utc>,
}
static TOKEN_CACHE: Lazy<RwLock<HashMap<String, CachedToken>>> =
Lazy::new(|| RwLock::new(HashMap::new()));
#[derive(Debug, Clone)]
pub struct AuthUser {
pub name: String,
pub scopes: Vec<String>,
pub groups: Vec<String>,
}
impl AuthUser {
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|s| s == "*" || s == scope)
}
pub fn can_write(&self) -> bool {
self.has_scope("write") || self.has_scope("admin") || self.has_scope("*")
}
#[allow(dead_code)]
pub fn can_read(&self) -> bool {
self.has_scope("read") || self.can_write()
}
pub fn is_admin(&self) -> bool {
self.has_scope("admin")
|| self.has_scope("*")
|| self.groups.iter().any(|g| g == "g-sn-herbapi-admin")
}
}
#[derive(Debug, FromRow)]
struct ApiTokenRow {
name: String,
scopes: Vec<String>,
}
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
#[derive(Debug)]
pub enum AuthError {
MissingAuth,
InvalidToken,
InvalidSession,
}
impl IntoResponse for AuthError {
fn into_response(self) -> Response {
let (status, message) = match self {
AuthError::MissingAuth => (StatusCode::UNAUTHORIZED, "Authentication required"),
AuthError::InvalidToken => (StatusCode::UNAUTHORIZED, "Invalid authorization token"),
AuthError::InvalidSession => {
(StatusCode::UNAUTHORIZED, "Invalid or expired session")
}
};
(status, message).into_response()
}
}
impl FromRequestParts<AppState> for AuthUser {
type Rejection = AuthError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
// Try Bearer token first
if let Ok(TypedHeader(Authorization(bearer))) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state).await
{
let token = bearer.token();
// Check admin token
if let Some(ref admin) = state.config.admin_token
&& token == admin
{
return Ok(AuthUser {
name: "admin".into(),
scopes: vec!["*".into()],
groups: vec![],
});
}
// Check cache
let token_hash = hash_token(token);
{
let cache = TOKEN_CACHE.read().unwrap();
if let Some(cached) = cache.get(&token_hash)
&& (Utc::now() - cached.cached_at).num_seconds() < CACHE_TTL_SECS
{
return Ok(cached.user.clone());
}
}
// Look up in DB
let row = sqlx::query_as::<_, ApiTokenRow>(
"SELECT name, scopes FROM api_tokens WHERE token_hash = $1",
)
.bind(&token_hash)
.fetch_optional(&state.pool)
.await
.map_err(|e| {
tracing::error!("Token lookup failed: {e}");
AuthError::InvalidToken
})?
.ok_or(AuthError::InvalidToken)?;
// Update last_used_at (fire and forget)
let pool = state.pool.clone();
let hash = token_hash.clone();
tokio::spawn(async move {
let _ =
sqlx::query("UPDATE api_tokens SET last_used_at = NOW() WHERE token_hash = $1")
.bind(&hash)
.execute(&pool)
.await;
});
let user = AuthUser {
name: row.name,
scopes: row.scopes,
groups: vec![],
};
// Cache it
{
let mut cache = TOKEN_CACHE.write().unwrap();
cache.insert(
token_hash,
CachedToken {
user: user.clone(),
cached_at: Utc::now(),
},
);
}
return Ok(user);
}
// Try session cookie (for web UI via OIDC)
let session = Session::from_request_parts(parts, state)
.await
.map_err(|_| AuthError::MissingAuth)?;
let user_id_str: Option<String> = session
.get("user_id")
.await
.map_err(|_| AuthError::InvalidSession)?;
if let Some(user_id_str) = user_id_str {
let user_id: Uuid = user_id_str.parse().map_err(|_| AuthError::InvalidSession)?;
let email: Option<String> = session
.get("user_email")
.await
.map_err(|_| AuthError::InvalidSession)?;
let name: Option<String> = session
.get("user_name")
.await
.map_err(|_| AuthError::InvalidSession)?;
let nickname: Option<String> = session
.get("user_nickname")
.await
.map_err(|_| AuthError::InvalidSession)?;
let admin: Option<bool> = session
.get("user_admin")
.await
.map_err(|_| AuthError::InvalidSession)?;
let groups: Option<Vec<String>> = session
.get("user_groups")
.await
.map_err(|_| AuthError::InvalidSession)?;
let (email, name, nickname, admin) = if email.is_some() {
(email, name, nickname, admin.unwrap_or(false))
} else {
let user = crate::db::users::find_user_by_id(&state.pool, user_id)
.await
.map_err(|_| AuthError::InvalidSession)?
.ok_or(AuthError::InvalidSession)?;
(
Some(user.email),
user.name,
user.nickname,
user.admin,
)
};
let display_name = nickname
.or(name)
.or(email)
.unwrap_or_else(|| "user".to_string());
let scopes = if admin {
vec!["*".to_string()]
} else {
vec!["read".to_string()]
};
return Ok(AuthUser {
name: display_name,
scopes,
groups: groups.unwrap_or_default(),
});
}
Err(AuthError::MissingAuth)
}
}
+4
View File
@@ -0,0 +1,4 @@
pub mod extractor;
pub mod oidc;
pub use extractor::AuthUser;
+255
View File
@@ -0,0 +1,255 @@
use axum::extract::{Query, State};
use axum::response::{IntoResponse, Redirect};
use serde::Deserialize;
use std::collections::BTreeSet;
use tower_sessions::Session;
use crate::error::{AppError, Result};
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct CallbackQuery {
pub code: String,
pub state: String,
}
pub async fn login(State(state): State<AppState>, session: Session) -> Result<impl IntoResponse> {
if !state.config.oidc_enabled() {
return Err(AppError::BadRequest("OIDC not configured".into()));
}
let client_id = state.config.oidc_client_id.as_ref().unwrap();
let issuer = &state.config.oidc_issuer;
let redirect_uri = &state.config.oidc_redirect_uri;
let oauth_state = generate_random_string();
let nonce = generate_random_string();
session
.insert("oauth_state", &oauth_state)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
session
.insert("oauth_nonce", &nonce)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
let base_url = authentik_base_url(issuer);
let auth_url = format!(
"{}authorize/?response_type=code&client_id={}&redirect_uri={}&scope=openid+email+profile&state={}&nonce={}",
base_url,
urlencoding(client_id),
urlencoding(redirect_uri),
oauth_state,
nonce,
);
Ok(Redirect::temporary(&auth_url))
}
pub async fn callback(
State(state): State<AppState>,
session: Session,
Query(query): Query<CallbackQuery>,
) -> Result<impl IntoResponse> {
if !state.config.oidc_enabled() {
return Err(AppError::BadRequest("OIDC not configured".into()));
}
let saved_state: Option<String> = session
.get("oauth_state")
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
if saved_state.as_deref() != Some(&query.state) {
return Err(AppError::BadRequest("Invalid OAuth state".into()));
}
let client_id = state.config.oidc_client_id.as_ref().unwrap();
let client_secret = state.config.oidc_client_secret.as_ref().unwrap();
let issuer = &state.config.oidc_issuer;
let redirect_uri = &state.config.oidc_redirect_uri;
let base_url = authentik_base_url(issuer);
let token_url = format!("{}token/", base_url);
let client = reqwest::Client::new();
let resp = client
.post(&token_url)
.form(&[
("grant_type", "authorization_code"),
("code", &query.code),
("redirect_uri", redirect_uri),
("client_id", client_id),
("client_secret", client_secret),
])
.send()
.await
.map_err(|e| AppError::Internal(format!("Token exchange failed: {e}")))?;
let token_data: serde_json::Value = resp
.json()
.await
.map_err(|e| AppError::Internal(format!("Token parse failed: {e}")))?;
let access_token = token_data
.get("access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::Internal("No access_token in response".into()))?;
let userinfo_url = format!("{}userinfo/", base_url);
let userinfo: serde_json::Value = client
.get(&userinfo_url)
.bearer_auth(access_token)
.send()
.await
.map_err(|e| AppError::Internal(format!("Userinfo failed: {e}")))?
.json()
.await
.map_err(|e| AppError::Internal(format!("Userinfo parse failed: {e}")))?;
let email = userinfo
.get("email")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::Internal("No email in userinfo".into()))?;
let name = userinfo.get("name").and_then(|v| v.as_str());
let nickname = userinfo
.get("nickname")
.and_then(|v| v.as_str())
.or_else(|| userinfo.get("preferred_username").and_then(|v| v.as_str()));
let sub = userinfo
.get("sub")
.and_then(|v| v.as_str())
.ok_or_else(|| AppError::Internal("No sub in userinfo".into()))?;
let groups_claim = userinfo.get("groups");
let groups: Vec<String> = groups_claim
.and_then(|v| v.as_array())
.map(|arr| {
let mut set = BTreeSet::new();
for g in arr.iter().filter_map(|v| v.as_str()) {
set.insert(g.to_string());
}
set.into_iter().collect()
})
.unwrap_or_default();
if groups_claim.is_none() {
tracing::warn!("OIDC userinfo missing 'groups' claim; HerbAPI admin access requires Authentik groups mapping");
}
let user = crate::db::users::upsert_oidc_user(&state.pool, email, name, nickname, sub).await?;
session
.insert("user_id", user.id.to_string())
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
session
.insert("user_email", email.to_string())
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
session
.insert("user_name", name.map(|s| s.to_string()))
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
session
.insert("user_nickname", nickname.map(|s| s.to_string()))
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
let is_admin = user.admin || groups.iter().any(|g| g == "g-sn-herbapi-admin");
session
.insert("user_admin", is_admin)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
session
.insert("user_groups", groups)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
session
.remove::<String>("oauth_state")
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
session
.remove::<String>("oauth_nonce")
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Redirect::temporary("/"))
}
pub async fn logout(session: Session) -> impl IntoResponse {
let _ = session.delete().await;
Redirect::temporary("/")
}
#[derive(Debug, serde::Serialize)]
pub struct MeResponse {
pub id: uuid::Uuid,
pub email: String,
pub name: Option<String>,
pub nickname: Option<String>,
pub admin: bool,
}
pub async fn me(State(state): State<AppState>, session: Session) -> impl IntoResponse {
async fn inner(
state: &AppState,
session: Session,
) -> std::result::Result<MeResponse, axum::http::StatusCode> {
let user_id_str: Option<String> = session
.get("user_id")
.await
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
let user_id_str = user_id_str.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let user_id: uuid::Uuid = user_id_str
.parse()
.map_err(|_| axum::http::StatusCode::UNAUTHORIZED)?;
let user = crate::db::users::find_user_by_id(&state.pool, user_id)
.await
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let session_admin: Option<bool> = session
.get("user_admin")
.await
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(MeResponse {
id: user.id,
email: user.email,
name: user.name,
nickname: user.nickname,
admin: session_admin.unwrap_or(user.admin),
})
}
match inner(&state, session).await {
Ok(response) => axum::Json(response).into_response(),
Err(status) => status.into_response(),
}
}
fn authentik_base_url(issuer: &str) -> String {
issuer
.trim_end_matches('/')
.rsplit_once('/')
.map(|(base, _)| format!("{}/", base))
.unwrap_or_else(|| issuer.to_string())
}
fn generate_random_string() -> String {
use rand::Rng;
let bytes: [u8; 16] = rand::thread_rng().r#gen();
hex::encode(bytes)
}
fn urlencoding(s: &str) -> String {
s.replace(':', "%3A")
.replace('/', "%2F")
.replace('?', "%3F")
.replace('&', "%26")
.replace('=', "%3D")
}
+62
View File
@@ -0,0 +1,62 @@
use std::env;
use anyhow::{Context as _, Result};
#[derive(Debug, Clone)]
pub struct Config {
pub port: u16,
pub database_url: String,
pub admin_token: Option<String>,
pub tls_cert_path: Option<String>,
pub tls_key_path: Option<String>,
// OIDC (Authentik SSO)
pub oidc_client_id: Option<String>,
pub oidc_client_secret: Option<String>,
pub oidc_issuer: String,
pub oidc_redirect_uri: String,
// S3 (Garage)
pub s3_endpoint: String,
pub s3_region: String,
pub s3_access_key: String,
pub s3_secret_key: String,
pub s3_bucket: String,
}
impl Config {
pub fn from_env() -> Result<Self> {
Ok(Self {
port: env::var("PORT")
.unwrap_or_else(|_| "8080".into())
.parse()
.context("PORT must be a valid u16")?,
database_url: env::var("DATABASE_URL").context("DATABASE_URL is required")?,
admin_token: env::var("ADMIN_TOKEN").ok().filter(|s| !s.is_empty()),
tls_cert_path: env::var("TLS_CERT_PATH").ok().filter(|s| !s.is_empty()),
tls_key_path: env::var("TLS_KEY_PATH").ok().filter(|s| !s.is_empty()),
oidc_client_id: env::var("OIDC_CLIENT_ID").ok().filter(|s| !s.is_empty()),
oidc_client_secret: env::var("OIDC_CLIENT_SECRET")
.ok()
.filter(|s| !s.is_empty()),
oidc_issuer: env::var("OIDC_ISSUER").unwrap_or_else(|_| {
"https://auth.sub-net.at/application/o/herbapi/".into()
}),
oidc_redirect_uri: env::var("OIDC_REDIRECT_URI").unwrap_or_else(|_| {
"https://herbapi.naturalised.at/auth/oidc/callback".into()
}),
s3_endpoint: env::var("S3_ENDPOINT")
.unwrap_or_else(|_| "https://s3.sub-net.at".into()),
s3_region: env::var("S3_REGION").unwrap_or_else(|_| "garage".into()),
s3_access_key: env::var("S3_ACCESS_KEY").unwrap_or_default(),
s3_secret_key: env::var("S3_SECRET_KEY").unwrap_or_default(),
s3_bucket: env::var("S3_BUCKET").unwrap_or_else(|_| "herbapi".into()),
})
}
pub fn tls_enabled(&self) -> bool {
self.tls_cert_path.is_some() && self.tls_key_path.is_some()
}
pub fn oidc_enabled(&self) -> bool {
self.oidc_client_id.is_some() && self.oidc_client_secret.is_some()
}
}
+46
View File
@@ -0,0 +1,46 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{AppError, Result};
use super::models::{CompanionRelationship, CreateCompanion};
pub async fn list_for_species(pool: &PgPool, species_id: Uuid) -> Result<Vec<CompanionRelationship>> {
sqlx::query_as::<_, CompanionRelationship>(
"SELECT * FROM companion_relationships
WHERE species_a_id = $1 OR species_b_id = $1
ORDER BY relationship, created_at"
)
.bind(species_id)
.fetch_all(pool)
.await
.map_err(Into::into)
}
pub async fn create(pool: &PgPool, req: &CreateCompanion) -> Result<CompanionRelationship> {
let id = Uuid::now_v7();
// Enforce ordering: species_a_id < species_b_id
let (a, b) = if req.species_a_id < req.species_b_id {
(req.species_a_id, req.species_b_id)
} else {
(req.species_b_id, req.species_a_id)
};
sqlx::query_as::<_, CompanionRelationship>(
"INSERT INTO companion_relationships (id, species_a_id, species_b_id, relationship, mechanism, source_url)
VALUES ($1, $2, $3, $4::companion_type, $5, $6) RETURNING *"
)
.bind(id).bind(a).bind(b).bind(&req.relationship)
.bind(&req.mechanism).bind(&req.source_url)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
let result = sqlx::query("DELETE FROM companion_relationships WHERE id = $1")
.bind(id).execute(pool).await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(format!("Relationship not found: {id}")));
}
Ok(())
}
+235
View File
@@ -0,0 +1,235 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{AppError, Result};
use super::models::{CreateCultivar, Cultivar, PaginatedResponse};
#[derive(Debug, serde::Deserialize)]
pub struct CultivarListParams {
pub page: Option<i64>,
pub per_page: Option<i64>,
pub search: Option<String>,
pub species: Option<String>,
}
impl CultivarListParams {
pub fn limit(&self) -> i64 { self.per_page.unwrap_or(25).min(100) }
pub fn offset(&self) -> i64 { (self.page.unwrap_or(1) - 1).max(0) * self.limit() }
}
pub async fn list(pool: &PgPool, params: &CultivarListParams) -> Result<PaginatedResponse<Cultivar>> {
let limit = params.limit();
let offset = params.offset();
let (rows, total) = match (&params.species, &params.search) {
(Some(species_slug), Some(search)) => {
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
let rows = sqlx::query_as::<_, Cultivar>(
"SELECT c.* FROM cultivars c JOIN species s ON c.species_id = s.id
WHERE s.slug = $1
AND to_tsvector('english', coalesce(c.name,'') || ' ' || coalesce(c.name_en,'') || ' ' || coalesce(c.name_de,'') || ' ' || coalesce(c.name_scientific,'') || ' ' || coalesce(c.description,''))
@@ to_tsquery('english', $2)
ORDER BY c.name LIMIT $3 OFFSET $4"
)
.bind(species_slug).bind(&tsquery).bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM cultivars c JOIN species s ON c.species_id = s.id
WHERE s.slug = $1
AND to_tsvector('english', coalesce(c.name,'') || ' ' || coalesce(c.name_en,'') || ' ' || coalesce(c.name_de,'') || ' ' || coalesce(c.name_scientific,'') || ' ' || coalesce(c.description,''))
@@ to_tsquery('english', $2)"
)
.bind(species_slug).bind(&tsquery)
.fetch_one(pool).await?;
(rows, count)
}
(Some(species_slug), None) => {
let rows = sqlx::query_as::<_, Cultivar>(
"SELECT c.* FROM cultivars c JOIN species s ON c.species_id = s.id
WHERE s.slug = $1 ORDER BY c.name LIMIT $2 OFFSET $3"
)
.bind(species_slug).bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM cultivars c JOIN species s ON c.species_id = s.id WHERE s.slug = $1"
)
.bind(species_slug).fetch_one(pool).await?;
(rows, count)
}
(None, Some(search)) => {
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
let rows = sqlx::query_as::<_, Cultivar>(
"SELECT * FROM cultivars
WHERE to_tsvector('english', coalesce(name,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(name_scientific,'') || ' ' || coalesce(description,''))
@@ to_tsquery('english', $1)
ORDER BY name LIMIT $2 OFFSET $3"
)
.bind(&tsquery).bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM cultivars
WHERE to_tsvector('english', coalesce(name,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(name_scientific,'') || ' ' || coalesce(description,''))
@@ to_tsquery('english', $1)"
)
.bind(&tsquery).fetch_one(pool).await?;
(rows, count)
}
(None, None) => {
let rows = sqlx::query_as::<_, Cultivar>(
"SELECT * FROM cultivars ORDER BY name LIMIT $1 OFFSET $2"
)
.bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cultivars")
.fetch_one(pool).await?;
(rows, count)
}
};
Ok(PaginatedResponse {
data: rows,
total,
page: params.page.unwrap_or(1),
per_page: limit,
})
}
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> Result<Cultivar> {
sqlx::query_as::<_, Cultivar>("SELECT * FROM cultivars WHERE slug = $1")
.bind(slug)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Cultivar not found: {slug}")))
}
pub async fn create(pool: &PgPool, req: &CreateCultivar) -> Result<Cultivar> {
let id = Uuid::now_v7();
// Get species slug for cultivar slug prefix
let species_slug: (String,) = sqlx::query_as("SELECT slug FROM species WHERE id = $1")
.bind(req.species_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::BadRequest("Species not found".into()))?;
let slug = format!("{}-{}", species_slug.0, slug::slugify(&req.name));
sqlx::query_as::<_, Cultivar>(
"INSERT INTO cultivars (id, slug, species_id, name, name_en, name_de, name_scientific,
description, is_organic, perennial, growing_time_days, planting_depth_cm, row_spacing_cm,
plant_spacing_cm, days_to_germination, germination_temp_c, light_requirement,
stratification_required, stratification_days, scarification_required,
seed_viability_years, storage_temp_c, storage_humidity, storage_notes,
min_temp, max_temp, humidity, light, frost_tolerance,
min_light_hours_day, optimal_light_hours_day, greenhouse_min_temp_c,
indoor_season_extension_weeks, ventilation_requirement, heating_required,
indoor_sowing_months, direct_sowing_months, transplanting_months, glasshouse_months,
harvesting_months, succession_planting_days, planting_notes,
propagation_methods, cutting_season, rootstock_species_id,
years_to_first_harvest, productive_lifespan_years,
expected_yield_kg_per_m2, yield_unit, expected_yield_value,
harvest_window_days, storage_method, shelf_life_days, cold_storage_days,
pollination_group, self_fertile, rootstock_compatibility,
wikidata_qid, gbif_id, pfaf_url, source_urls)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
$21,$22,$23,$24,$25,$26,$27,$28,$29::frost_tolerance,$30,$31,$32,$33,$34,$35,
$36,$37,$38,$39,$40,$41,$42,$43,$44,$45,$46,$47,$48,$49,$50,$51,$52,$53,$54,
$55,$56,$57,$58,$59,$60,$61)
RETURNING *"
)
.bind(id).bind(&slug).bind(req.species_id).bind(&req.name)
.bind(&req.name_en).bind(&req.name_de).bind(&req.name_scientific)
.bind(&req.description).bind(req.is_organic.unwrap_or(false)).bind(req.perennial.unwrap_or(false))
.bind(req.growing_time_days).bind(req.planting_depth_cm).bind(req.row_spacing_cm)
.bind(req.plant_spacing_cm).bind(req.days_to_germination).bind(req.germination_temp_c)
.bind(&req.light_requirement).bind(req.stratification_required).bind(req.stratification_days)
.bind(req.scarification_required).bind(req.seed_viability_years).bind(req.storage_temp_c)
.bind(&req.storage_humidity).bind(&req.storage_notes)
.bind(req.min_temp).bind(req.max_temp).bind(&req.humidity).bind(&req.light)
.bind(&req.frost_tolerance)
.bind(req.min_light_hours_day).bind(req.optimal_light_hours_day).bind(req.greenhouse_min_temp_c)
.bind(req.indoor_season_extension_weeks).bind(&req.ventilation_requirement).bind(req.heating_required)
.bind(&req.indoor_sowing_months).bind(&req.direct_sowing_months)
.bind(&req.transplanting_months).bind(&req.glasshouse_months)
.bind(&req.harvesting_months).bind(req.succession_planting_days).bind(&req.planting_notes)
.bind(&req.propagation_methods).bind(&req.cutting_season).bind(req.rootstock_species_id)
.bind(req.years_to_first_harvest).bind(req.productive_lifespan_years)
.bind(req.expected_yield_kg_per_m2).bind(&req.yield_unit).bind(req.expected_yield_value)
.bind(req.harvest_window_days).bind(&req.storage_method)
.bind(req.shelf_life_days).bind(req.cold_storage_days)
.bind(&req.pollination_group).bind(req.self_fertile).bind(&req.rootstock_compatibility)
.bind(&req.wikidata_qid).bind(&req.gbif_id).bind(&req.pfaf_url).bind(&req.source_urls)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn update(pool: &PgPool, id: Uuid, req: &CreateCultivar) -> Result<Cultivar> {
let _existing = sqlx::query("SELECT id FROM cultivars WHERE id = $1")
.bind(id).fetch_optional(pool).await?
.ok_or_else(|| AppError::NotFound(format!("Cultivar not found: {id}")))?;
let species_slug: (String,) = sqlx::query_as("SELECT slug FROM species WHERE id = $1")
.bind(req.species_id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::BadRequest("Species not found".into()))?;
let slug = format!("{}-{}", species_slug.0, slug::slugify(&req.name));
sqlx::query_as::<_, Cultivar>(
"UPDATE cultivars SET slug=$2, species_id=$3, name=$4, name_en=$5, name_de=$6,
name_scientific=$7, description=$8, is_organic=$9, perennial=$10,
growing_time_days=$11, planting_depth_cm=$12, row_spacing_cm=$13, plant_spacing_cm=$14,
days_to_germination=$15, germination_temp_c=$16, light_requirement=$17,
stratification_required=$18, stratification_days=$19, scarification_required=$20,
seed_viability_years=$21, storage_temp_c=$22, storage_humidity=$23, storage_notes=$24,
min_temp=$25, max_temp=$26, humidity=$27, light=$28, frost_tolerance=$29::frost_tolerance,
min_light_hours_day=$30, optimal_light_hours_day=$31, greenhouse_min_temp_c=$32,
indoor_season_extension_weeks=$33, ventilation_requirement=$34, heating_required=$35,
indoor_sowing_months=$36, direct_sowing_months=$37, transplanting_months=$38,
glasshouse_months=$39, harvesting_months=$40, succession_planting_days=$41,
planting_notes=$42, propagation_methods=$43, cutting_season=$44,
rootstock_species_id=$45, years_to_first_harvest=$46, productive_lifespan_years=$47,
expected_yield_kg_per_m2=$48, yield_unit=$49, expected_yield_value=$50,
harvest_window_days=$51, storage_method=$52, shelf_life_days=$53, cold_storage_days=$54,
pollination_group=$55, self_fertile=$56, rootstock_compatibility=$57,
wikidata_qid=$58, gbif_id=$59, pfaf_url=$60, source_urls=$61, updated_at=NOW()
WHERE id=$1 RETURNING *"
)
.bind(id).bind(&slug).bind(req.species_id).bind(&req.name)
.bind(&req.name_en).bind(&req.name_de).bind(&req.name_scientific)
.bind(&req.description).bind(req.is_organic.unwrap_or(false)).bind(req.perennial.unwrap_or(false))
.bind(req.growing_time_days).bind(req.planting_depth_cm).bind(req.row_spacing_cm)
.bind(req.plant_spacing_cm).bind(req.days_to_germination).bind(req.germination_temp_c)
.bind(&req.light_requirement).bind(req.stratification_required).bind(req.stratification_days)
.bind(req.scarification_required).bind(req.seed_viability_years).bind(req.storage_temp_c)
.bind(&req.storage_humidity).bind(&req.storage_notes)
.bind(req.min_temp).bind(req.max_temp).bind(&req.humidity).bind(&req.light)
.bind(&req.frost_tolerance)
.bind(req.min_light_hours_day).bind(req.optimal_light_hours_day).bind(req.greenhouse_min_temp_c)
.bind(req.indoor_season_extension_weeks).bind(&req.ventilation_requirement).bind(req.heating_required)
.bind(&req.indoor_sowing_months).bind(&req.direct_sowing_months)
.bind(&req.transplanting_months).bind(&req.glasshouse_months)
.bind(&req.harvesting_months).bind(req.succession_planting_days).bind(&req.planting_notes)
.bind(&req.propagation_methods).bind(&req.cutting_season).bind(req.rootstock_species_id)
.bind(req.years_to_first_harvest).bind(req.productive_lifespan_years)
.bind(req.expected_yield_kg_per_m2).bind(&req.yield_unit).bind(req.expected_yield_value)
.bind(req.harvest_window_days).bind(&req.storage_method)
.bind(req.shelf_life_days).bind(req.cold_storage_days)
.bind(&req.pollination_group).bind(req.self_fertile).bind(&req.rootstock_compatibility)
.bind(&req.wikidata_qid).bind(&req.gbif_id).bind(&req.pfaf_url).bind(&req.source_urls)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
let result = sqlx::query("DELETE FROM cultivars WHERE id = $1")
.bind(id).execute(pool).await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(format!("Cultivar not found: {id}")));
}
Ok(())
}
+126
View File
@@ -0,0 +1,126 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{AppError, Result};
use super::models::{CreateFamily, Family, PaginatedResponse, PaginationParams, UpdateFamily};
pub async fn list(pool: &PgPool, params: &PaginationParams) -> Result<PaginatedResponse<Family>> {
let limit = params.limit();
let offset = params.offset();
let (rows, total) = if let Some(ref search) = params.search {
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
let rows = sqlx::query_as::<_, Family>(
"SELECT * FROM families
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,''))
@@ to_tsquery('english', $1)
ORDER BY name_scientific LIMIT $2 OFFSET $3"
)
.bind(&tsquery)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM families
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,''))
@@ to_tsquery('english', $1)"
)
.bind(&tsquery)
.fetch_one(pool)
.await?;
(rows, count)
} else {
let rows = sqlx::query_as::<_, Family>(
"SELECT * FROM families ORDER BY name_scientific LIMIT $1 OFFSET $2"
)
.bind(limit)
.bind(offset)
.fetch_all(pool)
.await?;
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM families")
.fetch_one(pool)
.await?;
(rows, count)
};
Ok(PaginatedResponse {
data: rows,
total,
page: params.page.unwrap_or(1),
per_page: limit,
})
}
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> Result<Family> {
sqlx::query_as::<_, Family>("SELECT * FROM families WHERE slug = $1")
.bind(slug)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Family not found: {slug}")))
}
pub async fn create(pool: &PgPool, req: &CreateFamily) -> Result<Family> {
let id = Uuid::now_v7();
let slug = slug::slugify(&req.name_scientific);
sqlx::query_as::<_, Family>(
"INSERT INTO families (id, slug, name_scientific, name_en, name_de, description)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *"
)
.bind(id)
.bind(&slug)
.bind(&req.name_scientific)
.bind(&req.name_en)
.bind(&req.name_de)
.bind(&req.description)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn update(pool: &PgPool, id: Uuid, req: &UpdateFamily) -> Result<Family> {
let existing = sqlx::query_as::<_, Family>("SELECT * FROM families WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Family not found: {id}")))?;
let name_sci = req.name_scientific.as_deref().unwrap_or(&existing.name_scientific);
let new_slug = if req.name_scientific.is_some() {
slug::slugify(name_sci)
} else {
existing.slug.clone()
};
sqlx::query_as::<_, Family>(
"UPDATE families SET slug = $2, name_scientific = $3,
name_en = COALESCE($4, name_en), name_de = COALESCE($5, name_de),
description = COALESCE($6, description), updated_at = NOW()
WHERE id = $1 RETURNING *"
)
.bind(id)
.bind(&new_slug)
.bind(name_sci)
.bind(&req.name_en)
.bind(&req.name_de)
.bind(&req.description)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
let result = sqlx::query("DELETE FROM families WHERE id = $1")
.bind(id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(format!("Family not found: {id}")));
}
Ok(())
}
+45
View File
@@ -0,0 +1,45 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{AppError, Result};
use super::models::Image;
pub async fn list_for_entity(pool: &PgPool, entity_type: &str, entity_id: Uuid) -> Result<Vec<Image>> {
sqlx::query_as::<_, Image>(
"SELECT * FROM images WHERE entity_type = $1 AND entity_id = $2 ORDER BY is_primary DESC, created_at"
)
.bind(entity_type).bind(entity_id)
.fetch_all(pool)
.await
.map_err(Into::into)
}
pub async fn create(
pool: &PgPool,
entity_type: &str,
entity_id: Uuid,
s3_key: &str,
caption: Option<&str>,
source_url: Option<&str>,
license: Option<&str>,
is_primary: bool,
) -> Result<Image> {
let id = Uuid::now_v7();
sqlx::query_as::<_, Image>(
"INSERT INTO images (id, entity_type, entity_id, s3_key, caption, source_url, license, is_primary)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *"
)
.bind(id).bind(entity_type).bind(entity_id).bind(s3_key)
.bind(caption).bind(source_url).bind(license).bind(is_primary)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<Image> {
sqlx::query_as::<_, Image>("DELETE FROM images WHERE id = $1 RETURNING *")
.bind(id)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Image not found: {id}")))
}
+9
View File
@@ -0,0 +1,9 @@
pub mod companions;
pub mod cultivars;
pub mod families;
pub mod images;
pub mod models;
pub mod s3;
pub mod species;
pub mod suppliers;
pub mod users;
+427
View File
@@ -0,0 +1,427 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
// --- Pagination ---
#[derive(Debug, Deserialize)]
pub struct PaginationParams {
pub page: Option<i64>,
pub per_page: Option<i64>,
pub search: Option<String>,
}
impl PaginationParams {
pub fn limit(&self) -> i64 {
self.per_page.unwrap_or(25).min(100)
}
pub fn offset(&self) -> i64 {
(self.page.unwrap_or(1) - 1).max(0) * self.limit()
}
}
#[derive(Debug, Serialize)]
pub struct PaginatedResponse<T: Serialize> {
pub data: Vec<T>,
pub total: i64,
pub page: i64,
pub per_page: i64,
}
// --- Families ---
#[derive(Debug, FromRow, Serialize)]
pub struct Family {
pub id: Uuid,
pub slug: String,
pub name_scientific: String,
pub name_en: Option<String>,
pub name_de: Option<String>,
pub description: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateFamily {
pub name_scientific: String,
pub name_en: Option<String>,
pub name_de: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateFamily {
pub name_scientific: Option<String>,
pub name_en: Option<String>,
pub name_de: Option<String>,
pub description: Option<String>,
}
// --- Species ---
#[derive(Debug, FromRow, Serialize)]
pub struct Species {
pub id: Uuid,
pub slug: String,
pub family_id: Uuid,
pub name_scientific: String,
pub name_en: Option<String>,
pub name_de: Option<String>,
pub description: Option<String>,
pub soil_moisture: Option<String>,
pub drainage_requirement: Option<String>,
pub organic_matter_pct: Option<f64>,
pub nitrogen_ppm: Option<i32>,
pub phosphorus_ppm: Option<i32>,
pub potassium_ppm: Option<i32>,
pub boron_ppm: Option<f64>,
pub calcium_ppm: Option<i32>,
pub copper_ppm: Option<f64>,
pub iron_ppm: Option<f64>,
pub magnesium_ppm: Option<i32>,
pub manganese_ppm: Option<f64>,
pub molybdenum_ppm: Option<f64>,
pub sulfur_ppm: Option<i32>,
pub zinc_ppm: Option<f64>,
pub ph_min: Option<f64>,
pub ph_max: Option<f64>,
pub soil_texture_preference: Option<Vec<String>>,
pub hardiness_zone_usda: Option<String>,
pub hardiness_zone_at: Option<String>,
pub min_temp: Option<f64>,
pub max_temp: Option<f64>,
pub drought_tolerance: Option<String>,
pub water_requirement_mm_week: Option<f64>,
pub waterlogging_tolerance: Option<bool>,
pub salt_tolerance: Option<String>,
pub edibility_rating: Option<i16>,
pub food_uses: Option<String>,
pub medicinal_uses: Option<String>,
pub other_uses: Option<String>,
pub native_range: Option<String>,
pub invasiveness: Option<String>,
pub pollination_type: Option<String>,
pub plant_layer: Option<String>,
pub nitrogen_fixer: Option<bool>,
pub dynamic_accumulator: Option<bool>,
pub dynamic_accumulator_nutrients: Option<Vec<String>>,
pub attracts_pollinators: Option<bool>,
pub attracts_beneficial_insects: Option<bool>,
pub wildlife_value: Option<String>,
pub mulch_plant: Option<bool>,
pub ground_cover_quality: Option<String>,
pub allelopathic: Option<bool>,
pub guild_role: Option<Vec<String>>,
pub succession_stage: Option<String>,
pub heavy_metal_tolerance: Option<bool>,
pub wikidata_qid: Option<String>,
pub gbif_id: Option<String>,
pub eppo_code: Option<String>,
pub pfaf_url: Option<String>,
pub primary_image_key: Option<String>,
pub source_urls: Option<Vec<String>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateSpecies {
pub family_id: Uuid,
pub name_scientific: String,
pub name_en: Option<String>,
pub name_de: Option<String>,
pub description: Option<String>,
pub soil_moisture: Option<String>,
pub drainage_requirement: Option<String>,
pub ph_min: Option<f64>,
pub ph_max: Option<f64>,
pub soil_texture_preference: Option<Vec<String>>,
pub hardiness_zone_usda: Option<String>,
pub hardiness_zone_at: Option<String>,
pub min_temp: Option<f64>,
pub max_temp: Option<f64>,
pub drought_tolerance: Option<String>,
pub salt_tolerance: Option<String>,
pub edibility_rating: Option<i16>,
pub food_uses: Option<String>,
pub medicinal_uses: Option<String>,
pub other_uses: Option<String>,
pub native_range: Option<String>,
pub invasiveness: Option<String>,
pub pollination_type: Option<String>,
pub plant_layer: Option<String>,
pub nitrogen_fixer: Option<bool>,
pub dynamic_accumulator: Option<bool>,
pub dynamic_accumulator_nutrients: Option<Vec<String>>,
pub attracts_pollinators: Option<bool>,
pub attracts_beneficial_insects: Option<bool>,
pub wildlife_value: Option<String>,
pub mulch_plant: Option<bool>,
pub ground_cover_quality: Option<String>,
pub allelopathic: Option<bool>,
pub guild_role: Option<Vec<String>>,
pub succession_stage: Option<String>,
pub heavy_metal_tolerance: Option<bool>,
pub wikidata_qid: Option<String>,
pub gbif_id: Option<String>,
pub eppo_code: Option<String>,
pub pfaf_url: Option<String>,
pub source_urls: Option<Vec<String>>,
}
pub type UpdateSpecies = CreateSpecies;
// --- Cultivars ---
#[derive(Debug, FromRow, Serialize)]
pub struct Cultivar {
pub id: Uuid,
pub slug: String,
pub species_id: Uuid,
pub name: String,
pub name_en: Option<String>,
pub name_de: Option<String>,
pub name_scientific: Option<String>,
pub description: Option<String>,
pub is_organic: bool,
pub perennial: bool,
pub growing_time_days: Option<i32>,
pub planting_depth_cm: Option<f64>,
pub row_spacing_cm: Option<f64>,
pub plant_spacing_cm: Option<f64>,
pub days_to_germination: Option<i32>,
pub germination_temp_c: Option<f64>,
pub light_requirement: Option<String>,
pub stratification_required: Option<bool>,
pub stratification_days: Option<i32>,
pub scarification_required: Option<bool>,
pub seed_viability_years: Option<i32>,
pub storage_temp_c: Option<f64>,
pub storage_humidity: Option<String>,
pub storage_notes: Option<String>,
pub min_temp: Option<f64>,
pub max_temp: Option<f64>,
pub humidity: Option<String>,
pub light: Option<String>,
pub frost_tolerance: Option<String>,
pub min_light_hours_day: Option<f64>,
pub optimal_light_hours_day: Option<f64>,
pub greenhouse_min_temp_c: Option<f64>,
pub indoor_season_extension_weeks: Option<i32>,
pub ventilation_requirement: Option<String>,
pub heating_required: Option<bool>,
pub indoor_sowing_months: Option<Vec<i32>>,
pub direct_sowing_months: Option<Vec<i32>>,
pub transplanting_months: Option<Vec<i32>>,
pub glasshouse_months: Option<Vec<i32>>,
pub harvesting_months: Option<Vec<i32>>,
pub succession_planting_days: Option<i32>,
pub planting_notes: Option<String>,
pub propagation_methods: Option<Vec<String>>,
pub cutting_season: Option<String>,
pub rootstock_species_id: Option<Uuid>,
pub years_to_first_harvest: Option<i32>,
pub productive_lifespan_years: Option<i32>,
pub expected_yield_kg_per_m2: Option<f64>,
pub yield_unit: Option<String>,
pub expected_yield_value: Option<f64>,
pub harvest_window_days: Option<i32>,
pub storage_method: Option<Vec<String>>,
pub shelf_life_days: Option<i32>,
pub cold_storage_days: Option<i32>,
pub pollination_group: Option<String>,
pub self_fertile: Option<bool>,
pub rootstock_compatibility: Option<String>,
pub wikidata_qid: Option<String>,
pub gbif_id: Option<String>,
pub pfaf_url: Option<String>,
pub primary_image_key: Option<String>,
pub source_urls: Option<Vec<String>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCultivar {
pub species_id: Uuid,
pub name: String,
pub name_en: Option<String>,
pub name_de: Option<String>,
pub name_scientific: Option<String>,
pub description: Option<String>,
pub is_organic: Option<bool>,
pub perennial: Option<bool>,
pub growing_time_days: Option<i32>,
pub planting_depth_cm: Option<f64>,
pub row_spacing_cm: Option<f64>,
pub plant_spacing_cm: Option<f64>,
pub days_to_germination: Option<i32>,
pub germination_temp_c: Option<f64>,
pub light_requirement: Option<String>,
pub stratification_required: Option<bool>,
pub stratification_days: Option<i32>,
pub scarification_required: Option<bool>,
pub seed_viability_years: Option<i32>,
pub storage_temp_c: Option<f64>,
pub storage_humidity: Option<String>,
pub storage_notes: Option<String>,
pub min_temp: Option<f64>,
pub max_temp: Option<f64>,
pub humidity: Option<String>,
pub light: Option<String>,
pub frost_tolerance: Option<String>,
pub min_light_hours_day: Option<f64>,
pub optimal_light_hours_day: Option<f64>,
pub greenhouse_min_temp_c: Option<f64>,
pub indoor_season_extension_weeks: Option<i32>,
pub ventilation_requirement: Option<String>,
pub heating_required: Option<bool>,
pub indoor_sowing_months: Option<Vec<i32>>,
pub direct_sowing_months: Option<Vec<i32>>,
pub transplanting_months: Option<Vec<i32>>,
pub glasshouse_months: Option<Vec<i32>>,
pub harvesting_months: Option<Vec<i32>>,
pub succession_planting_days: Option<i32>,
pub planting_notes: Option<String>,
pub propagation_methods: Option<Vec<String>>,
pub cutting_season: Option<String>,
pub rootstock_species_id: Option<Uuid>,
pub years_to_first_harvest: Option<i32>,
pub productive_lifespan_years: Option<i32>,
pub expected_yield_kg_per_m2: Option<f64>,
pub yield_unit: Option<String>,
pub expected_yield_value: Option<f64>,
pub harvest_window_days: Option<i32>,
pub storage_method: Option<Vec<String>>,
pub shelf_life_days: Option<i32>,
pub cold_storage_days: Option<i32>,
pub pollination_group: Option<String>,
pub self_fertile: Option<bool>,
pub rootstock_compatibility: Option<String>,
pub wikidata_qid: Option<String>,
pub gbif_id: Option<String>,
pub pfaf_url: Option<String>,
pub source_urls: Option<Vec<String>>,
}
pub type UpdateCultivar = CreateCultivar;
// --- Suppliers ---
#[derive(Debug, FromRow, Serialize)]
pub struct Supplier {
pub id: Uuid,
pub slug: String,
pub name: String,
pub url: Option<String>,
pub is_organic: bool,
pub is_demeter: bool,
pub country: Option<String>,
pub notes: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateSupplier {
pub name: String,
pub url: Option<String>,
pub is_organic: Option<bool>,
pub is_demeter: Option<bool>,
pub country: Option<String>,
pub notes: Option<String>,
}
pub type UpdateSupplier = CreateSupplier;
#[derive(Debug, FromRow, Serialize)]
pub struct CultivarSupplier {
pub id: Uuid,
pub cultivar_id: Uuid,
pub supplier_id: Uuid,
pub article_number: Option<String>,
pub product_url: Option<String>,
pub price_eur: Option<f64>,
pub pack_size: Option<f64>,
pub pack_unit: Option<String>,
pub last_checked_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCultivarSupplier {
pub supplier_id: Uuid,
pub article_number: Option<String>,
pub product_url: Option<String>,
pub price_eur: Option<f64>,
pub pack_size: Option<f64>,
pub pack_unit: Option<String>,
}
// --- Companion Relationships ---
#[derive(Debug, FromRow, Serialize)]
pub struct CompanionRelationship {
pub id: Uuid,
pub species_a_id: Uuid,
pub species_b_id: Uuid,
pub relationship: String,
pub mechanism: Option<String>,
pub source_url: Option<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateCompanion {
pub species_a_id: Uuid,
pub species_b_id: Uuid,
pub relationship: String,
pub mechanism: Option<String>,
pub source_url: Option<String>,
}
// --- Images ---
#[derive(Debug, FromRow, Serialize)]
pub struct Image {
pub id: Uuid,
pub entity_type: String,
pub entity_id: Uuid,
pub s3_key: String,
pub caption: Option<String>,
pub source_url: Option<String>,
pub license: Option<String>,
pub is_primary: bool,
pub created_at: DateTime<Utc>,
}
// --- Users ---
#[derive(Debug, FromRow, Serialize)]
pub struct User {
pub id: Uuid,
pub email: String,
pub name: Option<String>,
pub nickname: Option<String>,
pub avatar_url: Option<String>,
pub provider: String,
pub provider_id: Option<String>,
pub admin: bool,
pub inserted_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
// --- Search ---
#[derive(Debug, Serialize)]
pub struct SearchResult {
pub entity_type: String,
pub id: Uuid,
pub slug: String,
pub name: String,
pub description: Option<String>,
pub rank: f32,
}
+75
View File
@@ -0,0 +1,75 @@
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::Client as S3Client;
use crate::config::Config;
use crate::error::{AppError, Result};
pub fn build_client(config: &Config) -> S3Client {
let creds = aws_credential_types::Credentials::new(
&config.s3_access_key,
&config.s3_secret_key,
None,
None,
"herbapi-static",
);
let s3_config = aws_sdk_s3::config::Builder::new()
.endpoint_url(&config.s3_endpoint)
.region(aws_sdk_s3::config::Region::new(config.s3_region.clone()))
.credentials_provider(creds)
.force_path_style(true)
.behavior_version_latest()
.build();
S3Client::from_conf(s3_config)
}
pub async fn upload(
client: &S3Client,
bucket: &str,
key: &str,
data: Vec<u8>,
content_type: &str,
) -> Result<()> {
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(data))
.content_type(content_type)
.send()
.await
.map_err(|e| AppError::S3(format!("Upload failed for {key}: {e}")))?;
Ok(())
}
pub async fn download(client: &S3Client, bucket: &str, key: &str) -> Result<Vec<u8>> {
let resp = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.map_err(|e| AppError::S3(format!("Download failed for {key}: {e}")))?;
let bytes = resp
.body
.collect()
.await
.map_err(|e| AppError::S3(format!("Stream read failed for {key}: {e}")))?
.into_bytes()
.to_vec();
Ok(bytes)
}
pub async fn delete(client: &S3Client, bucket: &str, key: &str) -> Result<()> {
client
.delete_object()
.bucket(bucket)
.key(key)
.send()
.await
.map_err(|e| AppError::S3(format!("Delete failed for {key}: {e}")))?;
Ok(())
}
+203
View File
@@ -0,0 +1,203 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{AppError, Result};
use super::models::{CreateSpecies, PaginatedResponse, Species};
#[derive(Debug, serde::Deserialize)]
pub struct SpeciesListParams {
pub page: Option<i64>,
pub per_page: Option<i64>,
pub search: Option<String>,
pub family: Option<String>,
}
impl SpeciesListParams {
pub fn limit(&self) -> i64 { self.per_page.unwrap_or(25).min(100) }
pub fn offset(&self) -> i64 { (self.page.unwrap_or(1) - 1).max(0) * self.limit() }
}
pub async fn list(pool: &PgPool, params: &SpeciesListParams) -> Result<PaginatedResponse<Species>> {
let limit = params.limit();
let offset = params.offset();
let (rows, total) = match (&params.family, &params.search) {
(Some(family_slug), Some(search)) => {
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
let rows = sqlx::query_as::<_, Species>(
"SELECT s.* FROM species s JOIN families f ON s.family_id = f.id
WHERE f.slug = $1
AND to_tsvector('english', coalesce(s.name_scientific,'') || ' ' || coalesce(s.name_en,'') || ' ' || coalesce(s.name_de,'') || ' ' || coalesce(s.description,''))
@@ to_tsquery('english', $2)
ORDER BY s.name_scientific LIMIT $3 OFFSET $4"
)
.bind(family_slug).bind(&tsquery).bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM species s JOIN families f ON s.family_id = f.id
WHERE f.slug = $1
AND to_tsvector('english', coalesce(s.name_scientific,'') || ' ' || coalesce(s.name_en,'') || ' ' || coalesce(s.name_de,'') || ' ' || coalesce(s.description,''))
@@ to_tsquery('english', $2)"
)
.bind(family_slug).bind(&tsquery)
.fetch_one(pool).await?;
(rows, count)
}
(Some(family_slug), None) => {
let rows = sqlx::query_as::<_, Species>(
"SELECT s.* FROM species s JOIN families f ON s.family_id = f.id
WHERE f.slug = $1 ORDER BY s.name_scientific LIMIT $2 OFFSET $3"
)
.bind(family_slug).bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM species s JOIN families f ON s.family_id = f.id WHERE f.slug = $1"
)
.bind(family_slug).fetch_one(pool).await?;
(rows, count)
}
(None, Some(search)) => {
let tsquery = search.split_whitespace().collect::<Vec<_>>().join(" & ");
let rows = sqlx::query_as::<_, Species>(
"SELECT * FROM species
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(description,''))
@@ to_tsquery('english', $1)
ORDER BY name_scientific LIMIT $2 OFFSET $3"
)
.bind(&tsquery).bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM species
WHERE to_tsvector('english', coalesce(name_scientific,'') || ' ' || coalesce(name_en,'') || ' ' || coalesce(name_de,'') || ' ' || coalesce(description,''))
@@ to_tsquery('english', $1)"
)
.bind(&tsquery).fetch_one(pool).await?;
(rows, count)
}
(None, None) => {
let rows = sqlx::query_as::<_, Species>(
"SELECT * FROM species ORDER BY name_scientific LIMIT $1 OFFSET $2"
)
.bind(limit).bind(offset)
.fetch_all(pool).await?;
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM species")
.fetch_one(pool).await?;
(rows, count)
}
};
Ok(PaginatedResponse {
data: rows,
total,
page: params.page.unwrap_or(1),
per_page: limit,
})
}
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> Result<Species> {
sqlx::query_as::<_, Species>("SELECT * FROM species WHERE slug = $1")
.bind(slug)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Species not found: {slug}")))
}
pub async fn create(pool: &PgPool, req: &CreateSpecies) -> Result<Species> {
let id = Uuid::now_v7();
let slug = slug::slugify(&req.name_scientific);
sqlx::query_as::<_, Species>(
"INSERT INTO species (id, slug, family_id, name_scientific, name_en, name_de, description,
soil_moisture, drainage_requirement, ph_min, ph_max, soil_texture_preference,
hardiness_zone_usda, hardiness_zone_at, min_temp, max_temp,
drought_tolerance, salt_tolerance, edibility_rating,
food_uses, medicinal_uses, other_uses, native_range, invasiveness, pollination_type,
plant_layer, nitrogen_fixer, dynamic_accumulator, dynamic_accumulator_nutrients,
attracts_pollinators, attracts_beneficial_insects, wildlife_value, mulch_plant,
ground_cover_quality, allelopathic, guild_role, succession_stage, heavy_metal_tolerance,
wikidata_qid, gbif_id, eppo_code, pfaf_url, source_urls)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,
$17::drought_tolerance,$18::salt_tolerance,$19,$20,$21,$22,$23,
$24::invasiveness_level,$25,$26::plant_layer,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,
$37::succession_stage,$38,$39,$40,$41,$42,$43)
RETURNING *"
)
.bind(id).bind(&slug).bind(req.family_id).bind(&req.name_scientific)
.bind(&req.name_en).bind(&req.name_de).bind(&req.description)
.bind(&req.soil_moisture).bind(&req.drainage_requirement)
.bind(req.ph_min).bind(req.ph_max).bind(&req.soil_texture_preference)
.bind(&req.hardiness_zone_usda).bind(&req.hardiness_zone_at)
.bind(req.min_temp).bind(req.max_temp)
.bind(&req.drought_tolerance).bind(&req.salt_tolerance).bind(req.edibility_rating)
.bind(&req.food_uses).bind(&req.medicinal_uses).bind(&req.other_uses)
.bind(&req.native_range).bind(&req.invasiveness).bind(&req.pollination_type)
.bind(&req.plant_layer).bind(req.nitrogen_fixer).bind(req.dynamic_accumulator)
.bind(&req.dynamic_accumulator_nutrients)
.bind(req.attracts_pollinators).bind(req.attracts_beneficial_insects)
.bind(&req.wildlife_value).bind(req.mulch_plant)
.bind(&req.ground_cover_quality).bind(req.allelopathic).bind(&req.guild_role)
.bind(&req.succession_stage).bind(req.heavy_metal_tolerance)
.bind(&req.wikidata_qid).bind(&req.gbif_id).bind(&req.eppo_code).bind(&req.pfaf_url)
.bind(&req.source_urls)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn update(pool: &PgPool, id: Uuid, req: &CreateSpecies) -> Result<Species> {
let _existing = sqlx::query("SELECT id FROM species WHERE id = $1")
.bind(id).fetch_optional(pool).await?
.ok_or_else(|| AppError::NotFound(format!("Species not found: {id}")))?;
let slug = slug::slugify(&req.name_scientific);
sqlx::query_as::<_, Species>(
"UPDATE species SET slug=$2, family_id=$3, name_scientific=$4, name_en=$5, name_de=$6,
description=$7, soil_moisture=$8, drainage_requirement=$9, ph_min=$10, ph_max=$11,
soil_texture_preference=$12, hardiness_zone_usda=$13, hardiness_zone_at=$14,
min_temp=$15, max_temp=$16,
drought_tolerance=$17::drought_tolerance, salt_tolerance=$18::salt_tolerance,
edibility_rating=$19, food_uses=$20, medicinal_uses=$21, other_uses=$22,
native_range=$23, invasiveness=$24::invasiveness_level, pollination_type=$25,
plant_layer=$26::plant_layer, nitrogen_fixer=$27, dynamic_accumulator=$28,
dynamic_accumulator_nutrients=$29, attracts_pollinators=$30, attracts_beneficial_insects=$31,
wildlife_value=$32, mulch_plant=$33, ground_cover_quality=$34, allelopathic=$35,
guild_role=$36, succession_stage=$37::succession_stage, heavy_metal_tolerance=$38,
wikidata_qid=$39, gbif_id=$40, eppo_code=$41, pfaf_url=$42, source_urls=$43,
updated_at=NOW()
WHERE id=$1 RETURNING *"
)
.bind(id).bind(&slug).bind(req.family_id).bind(&req.name_scientific)
.bind(&req.name_en).bind(&req.name_de).bind(&req.description)
.bind(&req.soil_moisture).bind(&req.drainage_requirement)
.bind(req.ph_min).bind(req.ph_max).bind(&req.soil_texture_preference)
.bind(&req.hardiness_zone_usda).bind(&req.hardiness_zone_at)
.bind(req.min_temp).bind(req.max_temp)
.bind(&req.drought_tolerance).bind(&req.salt_tolerance).bind(req.edibility_rating)
.bind(&req.food_uses).bind(&req.medicinal_uses).bind(&req.other_uses)
.bind(&req.native_range).bind(&req.invasiveness).bind(&req.pollination_type)
.bind(&req.plant_layer).bind(req.nitrogen_fixer).bind(req.dynamic_accumulator)
.bind(&req.dynamic_accumulator_nutrients)
.bind(req.attracts_pollinators).bind(req.attracts_beneficial_insects)
.bind(&req.wildlife_value).bind(req.mulch_plant)
.bind(&req.ground_cover_quality).bind(req.allelopathic).bind(&req.guild_role)
.bind(&req.succession_stage).bind(req.heavy_metal_tolerance)
.bind(&req.wikidata_qid).bind(&req.gbif_id).bind(&req.eppo_code).bind(&req.pfaf_url)
.bind(&req.source_urls)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
let result = sqlx::query("DELETE FROM species WHERE id = $1")
.bind(id).execute(pool).await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(format!("Species not found: {id}")));
}
Ok(())
}
+102
View File
@@ -0,0 +1,102 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::{AppError, Result};
use super::models::{CreateCultivarSupplier, CreateSupplier, CultivarSupplier, Supplier};
pub async fn list(pool: &PgPool) -> Result<Vec<Supplier>> {
sqlx::query_as::<_, Supplier>("SELECT * FROM suppliers ORDER BY name")
.fetch_all(pool)
.await
.map_err(Into::into)
}
pub async fn get_by_slug(pool: &PgPool, slug: &str) -> Result<Supplier> {
sqlx::query_as::<_, Supplier>("SELECT * FROM suppliers WHERE slug = $1")
.bind(slug)
.fetch_optional(pool)
.await?
.ok_or_else(|| AppError::NotFound(format!("Supplier not found: {slug}")))
}
pub async fn create(pool: &PgPool, req: &CreateSupplier) -> Result<Supplier> {
let id = Uuid::now_v7();
let s = slug::slugify(&req.name);
sqlx::query_as::<_, Supplier>(
"INSERT INTO suppliers (id, slug, name, url, is_organic, is_demeter, country, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *"
)
.bind(id).bind(&s).bind(&req.name).bind(&req.url)
.bind(req.is_organic.unwrap_or(false)).bind(req.is_demeter.unwrap_or(false))
.bind(&req.country).bind(&req.notes)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn update(pool: &PgPool, id: Uuid, req: &CreateSupplier) -> Result<Supplier> {
let s = slug::slugify(&req.name);
sqlx::query_as::<_, Supplier>(
"UPDATE suppliers SET slug=$2, name=$3, url=$4, is_organic=$5, is_demeter=$6,
country=$7, notes=$8, updated_at=NOW() WHERE id=$1 RETURNING *"
)
.bind(id).bind(&s).bind(&req.name).bind(&req.url)
.bind(req.is_organic.unwrap_or(false)).bind(req.is_demeter.unwrap_or(false))
.bind(&req.country).bind(&req.notes)
.fetch_one(pool)
.await
.map_err(|e| match e {
sqlx::Error::RowNotFound => AppError::NotFound(format!("Supplier not found: {id}")),
other => AppError::Database(other),
})
}
pub async fn delete(pool: &PgPool, id: Uuid) -> Result<()> {
let result = sqlx::query("DELETE FROM suppliers WHERE id = $1")
.bind(id).execute(pool).await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(format!("Supplier not found: {id}")));
}
Ok(())
}
// Cultivar-Supplier links
pub async fn list_for_cultivar(pool: &PgPool, cultivar_id: Uuid) -> Result<Vec<CultivarSupplier>> {
sqlx::query_as::<_, CultivarSupplier>(
"SELECT * FROM cultivar_suppliers WHERE cultivar_id = $1 ORDER BY created_at"
)
.bind(cultivar_id)
.fetch_all(pool)
.await
.map_err(Into::into)
}
pub async fn link_cultivar(pool: &PgPool, cultivar_id: Uuid, req: &CreateCultivarSupplier) -> Result<CultivarSupplier> {
let id = Uuid::now_v7();
sqlx::query_as::<_, CultivarSupplier>(
"INSERT INTO cultivar_suppliers (id, cultivar_id, supplier_id, article_number, product_url,
price_eur, pack_size, pack_unit)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *"
)
.bind(id).bind(cultivar_id).bind(req.supplier_id)
.bind(&req.article_number).bind(&req.product_url)
.bind(req.price_eur).bind(req.pack_size).bind(&req.pack_unit)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn unlink_cultivar(pool: &PgPool, cultivar_id: Uuid, supplier_id: Uuid) -> Result<()> {
let result = sqlx::query(
"DELETE FROM cultivar_suppliers WHERE cultivar_id = $1 AND supplier_id = $2"
)
.bind(cultivar_id).bind(supplier_id)
.execute(pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound("Link not found".into()));
}
Ok(())
}
+33
View File
@@ -0,0 +1,33 @@
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::Result;
use super::models::User;
pub async fn upsert_oidc_user(
pool: &PgPool,
email: &str,
name: Option<&str>,
nickname: Option<&str>,
provider_id: &str,
) -> Result<User> {
sqlx::query_as::<_, User>(
"INSERT INTO users (email, name, nickname, provider, provider_id)
VALUES ($1, $2, $3, 'authentik', $4)
ON CONFLICT (provider, provider_id)
DO UPDATE SET email = $1, name = $2, nickname = $3, updated_at = NOW()
RETURNING *"
)
.bind(email).bind(name).bind(nickname).bind(provider_id)
.fetch_one(pool)
.await
.map_err(Into::into)
}
pub async fn find_user_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>> {
sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
.map_err(Into::into)
}
+62
View File
@@ -0,0 +1,62 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
pub type Result<T> = std::result::Result<T, AppError>;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("Not found: {0}")]
NotFound(String),
#[error("Unauthorized: {0}")]
#[allow(dead_code)]
Unauthorized(String),
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Forbidden")]
Forbidden,
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("S3 error: {0}")]
S3(String),
#[error("Internal error: {0}")]
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden".to_string()),
AppError::Database(e) => {
tracing::error!("Database error: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Database error".to_string(),
)
}
AppError::S3(msg) => {
tracing::error!("S3 error: {msg}");
(StatusCode::INTERNAL_SERVER_ERROR, "Storage error".to_string())
}
AppError::Internal(msg) => {
tracing::error!("Internal error: {msg}");
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal error".to_string(),
)
}
};
let body = axum::Json(json!({ "error": message }));
(status, body).into_response()
}
}
+101
View File
@@ -0,0 +1,101 @@
mod api;
mod auth;
mod config;
mod db;
mod error;
mod state;
use std::net::SocketAddr;
use std::sync::Arc;
use sqlx::postgres::PgPoolOptions;
use tower_http::cors::CorsLayer;
use tower_sessions::cookie::time::Duration;
use tower_sessions::{Expiry, MemoryStore, SessionManagerLayer};
use tracing_subscriber::EnvFilter;
use crate::config::Config;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new("herbapi_api=info,sqlx=warn,tower_http=info")
}))
.init();
let config = Config::from_env()?;
tracing::info!("HerbAPI starting on port {}", config.port);
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&config.database_url)
.await?;
tracing::info!("Running migrations...");
sqlx::migrate!("./migrations").run(&pool).await?;
tracing::info!("Migrations complete");
let s3 = db::s3::build_client(&config);
let state = AppState {
pool,
config: Arc::new(config.clone()),
s3,
};
// Session layer for OIDC
let session_store = MemoryStore::default();
let session_layer = SessionManagerLayer::new(session_store)
.with_expiry(Expiry::OnInactivity(Duration::hours(24)))
.with_same_site(tower_sessions::cookie::SameSite::Lax)
.with_secure(config.tls_enabled());
if config.oidc_enabled() {
tracing::info!("OIDC enabled (issuer: {})", config.oidc_issuer);
}
let app = api::router(state.clone())
.layer(session_layer)
.layer(CorsLayer::permissive());
let addr = SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0], config.port));
if config.tls_enabled() {
let cert_path = config.tls_cert_path.as_ref().unwrap();
let key_path = config.tls_key_path.as_ref().unwrap();
tracing::info!("TLS enabled, loading certificates from {} and {}", cert_path, key_path);
let rustls_config = load_rustls_config(cert_path, key_path)?;
tracing::info!("Listening on {} (TLS)", addr);
axum_server::bind_rustls(addr, rustls_config)
.serve(app.into_make_service())
.await?;
} else {
tracing::info!("Listening on {} (plain HTTP)", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
}
Ok(())
}
fn load_rustls_config(
cert_path: &str,
key_path: &str,
) -> anyhow::Result<axum_server::tls_rustls::RustlsConfig> {
use axum_server::tls_rustls::RustlsConfig;
let config = RustlsConfig::from_pem_file(cert_path, key_path);
let config =
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(config))?;
Ok(config)
}
+13
View File
@@ -0,0 +1,13 @@
use std::sync::Arc;
use aws_sdk_s3::Client as S3Client;
use sqlx::PgPool;
use crate::config::Config;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub config: Arc<Config>,
pub s3: S3Client,
}