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:
Generated
+4235
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
);
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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, ¶ms).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 })))
|
||||
}
|
||||
@@ -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, ¶ms).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 })))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
pub async fn health() -> impl IntoResponse {
|
||||
axum::Json(serde_json::json!({ "status": "ok" }))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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, ¶ms).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 })))
|
||||
}
|
||||
@@ -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 })))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod extractor;
|
||||
pub mod oidc;
|
||||
|
||||
pub use extractor::AuthUser;
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 (¶ms.species, ¶ms.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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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}")))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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 (¶ms.family, ¶ms.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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user