352 lines
9.5 KiB
Rust
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());
|
|
}
|
|
}
|