Files
hagfish/src/types.rs
T

352 lines
9.5 KiB
Rust

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub duckdb_path: String,
pub bind_address: String,
pub data_source_url: String,
pub log_file_path: Option<String>,
#[serde(default = "default_allowed_origins")]
pub allowed_origins: Vec<String>,
}
fn default_allowed_origins() -> Vec<String> {
vec![]
}
impl Default for Config {
fn default() -> Self {
Self {
duckdb_path: "hagfish.db".to_string(),
bind_address: "127.0.0.1:8090".to_string(),
data_source_url: "https://statbank.hagstova.fo/api/v1/fo/H2/VV/VV01/fisknv_md.px"
.to_string(),
log_file_path: Some("hagfish.log".to_string()),
allowed_origins: default_allowed_origins(),
}
}
}
impl Config {
pub fn load(path: &Path) -> anyhow::Result<Self> {
let contents = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("Failed to read config file {}: {e}", path.display()))?;
let config: Config = serde_json::from_str(&contents)
.map_err(|e| anyhow::anyhow!("Failed to parse config file {}: {e}", path.display()))?;
tracing::info!("Loaded config from {}", path.display());
Ok(config)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MetadataResponse {
#[serde(default)]
pub title: Option<String>,
pub variables: Vec<VariableMeta>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvailableFiltersDto {
pub species: Vec<String>,
pub zones: Vec<String>,
pub gear: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct VariableMeta {
pub code: String,
#[serde(default)]
pub text: String,
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub values: Vec<String>,
#[serde(default, rename = "valueTexts")]
pub value_texts: Vec<String>,
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
impl VariableMeta {
pub fn lookup_map(&self) -> HashMap<String, String> {
self.values
.iter()
.zip(self.value_texts.iter())
.map(|(code, label)| (code.clone(), label.clone()))
.collect()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Query {
pub query: Vec<QueryItem>,
pub response: QueryResponse,
}
#[derive(Debug, Clone, Serialize)]
pub struct QueryItem {
pub code: String,
pub selection: Selection,
}
#[derive(Debug, Clone, Serialize)]
pub struct Selection {
pub filter: String,
pub values: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct QueryResponse {
pub format: String,
}
impl Default for QueryResponse {
fn default() -> Self {
Self {
format: "json-stat2".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct DataResponse {
#[serde(flatten)]
pub dataset: Dataset,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Dataset {
#[serde(default)]
pub id: Vec<String>,
#[serde(default)]
pub size: Vec<usize>,
#[serde(flatten)]
pub extra_fields: HashMap<String, serde_json::Value>,
pub dimension: HashMap<String, Dimension>,
pub value: Vec<Option<f64>>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Dimension {
#[serde(default)]
pub label: String,
pub category: CategoryInfo,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CategoryInfo {
pub index: HashMap<String, usize>,
#[serde(default)]
pub label: HashMap<String, String>,
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct DataRow {
pub month: String,
pub species_code: String,
pub species_label: String,
pub gear_code: String,
pub gear_label: String,
pub zone_code: String,
pub zone_label: String,
pub processing_code: String,
pub processing_label: String,
pub preservation_code: String,
pub preservation_label: String,
pub shipsize_code: String,
pub shipsize_label: String,
pub measure_code: String,
pub measure_label: String,
pub value: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct Landing {
pub month: String,
pub species_code: String,
pub species_label: String,
pub gear_code: String,
pub zone_code: String,
pub processing_code: String,
pub preservation_code: String,
pub shipsize_code: String,
pub measure_code: String,
pub value: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpeciesDto {
pub code: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LookupDto {
pub code: String,
pub label: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LandingDto {
pub month: String,
pub species_code: String,
pub species_label: String,
pub gear_code: String,
pub zone_code: String,
pub processing_code: String,
pub preservation_code: String,
pub shipsize_code: String,
pub measure_code: String,
pub value: Option<f64>,
}
impl From<Landing> for LandingDto {
fn from(l: Landing) -> Self {
Self {
month: l.month,
species_code: l.species_code,
species_label: l.species_label,
gear_code: l.gear_code,
zone_code: l.zone_code,
processing_code: l.processing_code,
preservation_code: l.preservation_code,
shipsize_code: l.shipsize_code,
measure_code: l.measure_code,
value: l.value,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonthlyAggregate {
pub month: String,
pub total_mass: Option<f64>,
pub total_value: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopSpecies {
pub species_code: String,
pub species_label: String,
pub total_value: Option<f64>,
pub total_mass: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceTrend {
pub month: String,
pub price_per_kg: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SummaryDto {
pub monthly: Vec<MonthlyAggregate>,
pub top_species: Vec<TopSpecies>,
pub price_trend: Vec<PriceTrend>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonthlySpeciesBreakdown {
pub month: String,
pub species_code: String,
pub species_label: String,
pub total_value: Option<f64>,
pub total_mass: Option<f64>,
}
#[derive(Debug, thiserror::Error)]
pub enum IngestError {
#[error("HTTP request failed: {0}")]
HttpError(#[from] reqwest::Error),
#[error("JSON parsing failed: {0}")]
JsonError(#[from] serde_json::Error),
#[error("API returned status {status}: {body}")]
ApiStatus { status: u16, body: String },
#[error("Missing dimension in response: {0}")]
MissingDimension(String),
#[error("Invalid value code: {0}")]
InvalidValueCode(String),
#[error("API returned empty data set")]
EmptyDataset,
}
pub type LookupMap = HashMap<String, HashMap<String, String>>;
pub type Result<T> = std::result::Result<T, IngestError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_load_missing_file_hard_error() {
let result = Config::load(Path::new("/nonexistent/path/config.json"));
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Failed to read config file"));
}
#[test]
fn test_config_load_invalid_json_error() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "not valid json {{{").unwrap();
let result = Config::load(tmp.path());
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Failed to parse config file"));
}
#[test]
fn test_config_load_valid_file() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"duckdb_path": "test.db",
"bind_address": "127.0.0.1:9090",
"data_source_url": "http://example.com",
"log_file_path": null,
"allowed_origins": ["https://example.com"]
}"#;
std::fs::write(tmp.path(), content).unwrap();
let config = Config::load(tmp.path()).unwrap();
assert_eq!(config.duckdb_path, "test.db");
assert_eq!(config.bind_address, "127.0.0.1:9090");
assert_eq!(config.data_source_url, "http://example.com");
assert!(config.log_file_path.is_none());
assert_eq!(config.allowed_origins, vec!["https://example.com"]);
}
#[test]
fn test_config_load_defaults_allowed_origins() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let content = r#"{
"duckdb_path": "test.db",
"bind_address": "127.0.0.1:9090",
"data_source_url": "http://example.com"
}"#;
std::fs::write(tmp.path(), content).unwrap();
let config = Config::load(tmp.path()).unwrap();
assert!(config.allowed_origins.is_empty());
}
#[test]
fn test_config_default_values() {
let config = Config::default();
assert_eq!(config.duckdb_path, "hagfish.db");
assert_eq!(config.bind_address, "127.0.0.1:8090");
assert!(config.data_source_url.contains("statbank.hagstova.fo"));
assert!(config.log_file_path.is_some());
assert!(config.allowed_origins.is_empty());
}
}