add dev resources; plumb GUI_ENV_FILE/API_ENV_FILE/API_DESI_DATA into run targets
This commit is contained in:
+14
-1
@@ -1,5 +1,10 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct Config {
|
||||
pub bind_addr: String,
|
||||
/// Path to a DESI data file (JSON) to serve; `None` falls back to the
|
||||
/// built-in placeholder catalogs.
|
||||
pub desi_data: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -7,6 +12,14 @@ impl Config {
|
||||
let bind_addr =
|
||||
std::env::var("API_BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string());
|
||||
|
||||
Ok(Self { bind_addr })
|
||||
let desi_data = std::env::var("API_DESI_DATA")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from);
|
||||
|
||||
Ok(Self {
|
||||
bind_addr,
|
||||
desi_data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod config;
|
||||
pub mod models;
|
||||
pub mod routes;
|
||||
pub mod store;
|
||||
|
||||
+40
-2
@@ -1,4 +1,7 @@
|
||||
use desi_explorer_api::{config, routes};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use desi_explorer_api::{config, routes, store};
|
||||
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
@@ -11,8 +14,22 @@ async fn main() -> anyhow::Result<()> {
|
||||
)
|
||||
.init();
|
||||
|
||||
load_env_file()?;
|
||||
|
||||
let config = config::Config::from_env()?;
|
||||
let app = routes::app();
|
||||
|
||||
let catalog_store = match &config.desi_data {
|
||||
Some(path) => {
|
||||
tracing::info!(path = %path.display(), "loading DESI data");
|
||||
store::CatalogStore::load(Path::new(path))?
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("API_DESI_DATA not set, serving placeholder catalogs");
|
||||
store::CatalogStore::placeholder()
|
||||
}
|
||||
};
|
||||
|
||||
let app = routes::app_with_state(Arc::new(catalog_store));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&config.bind_addr).await?;
|
||||
tracing::info!("DESI Explorer API listening on {}", config.bind_addr);
|
||||
@@ -24,6 +41,27 @@ async fn main() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Loads the env file named by `API_ENV_FILE` (if set) into the process
|
||||
/// environment. Existing env vars are not overridden, so values passed
|
||||
/// directly on the command line or by the Makefile take precedence.
|
||||
fn load_env_file() -> anyhow::Result<()> {
|
||||
let path = std::env::var("API_ENV_FILE").unwrap_or_default();
|
||||
if path.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match dotenvy::from_path(&path) {
|
||||
Ok(_) => tracing::info!(%path, "loaded env file"),
|
||||
Err(err) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"failed to load API_ENV_FILE {path:?}: {err}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
tracing::info!("shutting down");
|
||||
|
||||
+4
-4
@@ -1,17 +1,17 @@
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Catalog metadata for a DESI data release/survey.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Catalog {
|
||||
pub name: String,
|
||||
pub release: String,
|
||||
pub description: &'static str,
|
||||
pub description: String,
|
||||
pub object_count: Option<u64>,
|
||||
}
|
||||
|
||||
/// A single catalog object (galaxy / quasar / star) with its survey
|
||||
/// coordinates. `ra` and `dec` are in degrees; `redshift` is dimensionless.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct CatalogObject {
|
||||
pub id: String,
|
||||
pub catalog: String,
|
||||
|
||||
+24
-30
@@ -1,34 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::Query,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::models::{Catalog, CatalogObject};
|
||||
use crate::store::CatalogStore;
|
||||
|
||||
/// Placeholder catalogs until real DESI EDR/DR1 ingestion lands.
|
||||
static CATALOGS: LazyLock<Vec<Catalog>> = LazyLock::new(|| {
|
||||
vec![
|
||||
Catalog {
|
||||
name: "edr".to_string(),
|
||||
release: "EDR".to_string(),
|
||||
description: "DESI Early Data Release",
|
||||
object_count: None,
|
||||
},
|
||||
Catalog {
|
||||
name: "dr1".to_string(),
|
||||
release: "DR1".to_string(),
|
||||
description: "DESI Data Release 1",
|
||||
object_count: None,
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
pub async fn list_catalogs() -> Json<Vec<Catalog>> {
|
||||
Json(CATALOGS.clone())
|
||||
pub async fn list_catalogs(State(state): State<Arc<CatalogStore>>) -> Json<Vec<Catalog>> {
|
||||
Json(state.catalogs.clone())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -38,17 +20,29 @@ pub struct ObjectQuery {
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Placeholder object query. Real implementation will page through the
|
||||
/// centralized DESI catalog store rather than return an empty result set.
|
||||
pub async fn list_objects(Query(query): Query<ObjectQuery>) -> Response {
|
||||
pub async fn list_objects(
|
||||
State(state): State<Arc<CatalogStore>>,
|
||||
Query(query): Query<ObjectQuery>,
|
||||
) -> Json<Vec<CatalogObject>> {
|
||||
let limit = query.limit.unwrap_or(100).min(10_000);
|
||||
|
||||
tracing::debug!(
|
||||
%limit,
|
||||
catalog = query.catalog.as_deref().unwrap_or("all"),
|
||||
"querying catalog objects (placeholder)"
|
||||
objects = state.objects.len(),
|
||||
"querying catalog objects"
|
||||
);
|
||||
|
||||
let objects: Vec<CatalogObject> = Vec::new();
|
||||
(StatusCode::OK, Json(objects)).into_response()
|
||||
let objects: Vec<CatalogObject> = match &query.catalog {
|
||||
Some(catalog) => state
|
||||
.objects
|
||||
.iter()
|
||||
.filter(|o| &o.catalog == catalog)
|
||||
.take(limit)
|
||||
.cloned()
|
||||
.collect(),
|
||||
None => state.objects.iter().take(limit).cloned().collect(),
|
||||
};
|
||||
|
||||
Json(objects)
|
||||
}
|
||||
|
||||
+12
-2
@@ -1,13 +1,23 @@
|
||||
pub mod catalogs;
|
||||
pub mod health;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
|
||||
/// Builds the application router. Kept separate from `main` so tests can
|
||||
/// construct it without binding a socket.
|
||||
use crate::store::CatalogStore;
|
||||
|
||||
/// Builds the application router with a static placeholder store. Kept
|
||||
/// separate from `main` so tests can construct it without binding a socket.
|
||||
pub fn app() -> Router {
|
||||
app_with_state(Arc::new(CatalogStore::placeholder()))
|
||||
}
|
||||
|
||||
/// Builds the application router serving the given catalog store.
|
||||
pub fn app_with_state(state: Arc<CatalogStore>) -> Router {
|
||||
Router::new()
|
||||
.route("/health", get(health::health))
|
||||
.route("/api/v1/catalogs", get(catalogs::list_catalogs))
|
||||
.route("/api/v1/objects", get(catalogs::list_objects))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::models::{Catalog, CatalogObject};
|
||||
|
||||
/// In-memory catalog store, shared (via `Arc`) across routes. Populated either
|
||||
/// from a DESI data file loaded at startup or from `placeholder`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CatalogStore {
|
||||
pub catalogs: Vec<Catalog>,
|
||||
pub objects: Vec<CatalogObject>,
|
||||
}
|
||||
|
||||
/// JSON layout of the DESI data file referenced by `API_DESI_DATA`.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DataFile {
|
||||
pub catalogs: Vec<Catalog>,
|
||||
#[serde(default)]
|
||||
pub objects: Vec<CatalogObject>,
|
||||
}
|
||||
|
||||
impl CatalogStore {
|
||||
/// Static fallback catalogs used when no `API_DESI_DATA` file is given
|
||||
/// (and by the `routes::app()` test helper).
|
||||
pub fn placeholder() -> Self {
|
||||
Self {
|
||||
catalogs: vec![
|
||||
Catalog {
|
||||
name: "edr".to_string(),
|
||||
release: "EDR".to_string(),
|
||||
description: "DESI Early Data Release".to_string(),
|
||||
object_count: None,
|
||||
},
|
||||
Catalog {
|
||||
name: "dr1".to_string(),
|
||||
release: "DR1".to_string(),
|
||||
description: "DESI Data Release 1".to_string(),
|
||||
object_count: None,
|
||||
},
|
||||
],
|
||||
objects: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads catalogs and objects from a JSON data file. Errors on unreadable
|
||||
/// files or malformed JSON so the caller can fail loudly instead of
|
||||
/// silently serving empty data.
|
||||
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
let file: DataFile = serde_json::from_str(&text)?;
|
||||
Ok(Self {
|
||||
catalogs: file.catalogs,
|
||||
objects: file.objects,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn load_parses_catalogs_and_objects() {
|
||||
let json = r#"{
|
||||
"catalogs": [
|
||||
{"name":"edr","release":"EDR","description":"test","object_count":2}
|
||||
],
|
||||
"objects": [
|
||||
{"id":"o1","catalog":"edr","object_type":"GALAXY","ra":1.5,"dec":2.5,"redshift":0.8}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"desi_explorer_store_{}_{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("data.json");
|
||||
std::fs::write(&path, json).unwrap();
|
||||
|
||||
let store = CatalogStore::load(&path).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
assert_eq!(store.catalogs.len(), 1);
|
||||
assert_eq!(store.catalogs[0].name, "edr");
|
||||
assert_eq!(store.catalogs[0].object_count, Some(2));
|
||||
assert_eq!(store.objects.len(), 1);
|
||||
assert_eq!(store.objects[0].id, "o1");
|
||||
assert_eq!(store.objects[0].ra, 1.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_malformed_json() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"desi_explorer_store_{}_{}",
|
||||
std::process::id(),
|
||||
line!()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("data.json");
|
||||
std::fs::write(&path, "not json").unwrap();
|
||||
|
||||
let result = CatalogStore::load(&path);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user