add dev resources; plumb GUI_ENV_FILE/API_ENV_FILE/API_DESI_DATA into run targets
This commit is contained in:
Generated
+7
@@ -99,6 +99,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"dotenvy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -108,6 +109,12 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenvy"
|
||||
version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
|
||||
+5
-4
@@ -5,17 +5,18 @@ edition = "2021"
|
||||
description = "Backend API for DESI Explorer — serves DESI survey catalog data"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
axum = "0.8"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
|
||||
dotenvy = "0.15"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
|
||||
tower-http = { version = "0.7", features = ["cors", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tower-http = { version = "0.7", features = ["cors", "trace"] }
|
||||
anyhow = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
||||
+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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use desi_explorer_api::models::CatalogObject;
|
||||
use desi_explorer_api::routes;
|
||||
use desi_explorer_api::store::CatalogStore;
|
||||
|
||||
fn sample_store() -> CatalogStore {
|
||||
CatalogStore {
|
||||
catalogs: Vec::new(),
|
||||
objects: vec![
|
||||
CatalogObject {
|
||||
id: "o1".to_string(),
|
||||
catalog: "edr".to_string(),
|
||||
object_type: "GALAXY".to_string(),
|
||||
ra: 1.5,
|
||||
dec: 2.5,
|
||||
redshift: 0.8,
|
||||
},
|
||||
CatalogObject {
|
||||
id: "o2".to_string(),
|
||||
catalog: "dr1".to_string(),
|
||||
object_type: "STAR".to_string(),
|
||||
ra: 3.5,
|
||||
dec: 4.5,
|
||||
redshift: 0.0,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn objects_returns_all_when_no_filter() {
|
||||
let app = routes::app_with_state(Arc::new(sample_store()));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/objects")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let objects: Vec<CatalogObject> = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(objects.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn objects_filters_by_catalog() {
|
||||
let app = routes::app_with_state(Arc::new(sample_store()));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/objects?catalog=edr")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let objects: Vec<CatalogObject> = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(objects.len(), 1);
|
||||
assert_eq!(objects[0].id, "o1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn objects_respects_limit() {
|
||||
let app = routes::app_with_state(Arc::new(sample_store()));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/v1/objects?limit=1")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let objects: Vec<CatalogObject> = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(objects.len(), 1);
|
||||
}
|
||||
Reference in New Issue
Block a user