diff --git a/Makefile b/Makefile index f4c2f0b..064264a 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,14 @@ GUI := gui API := api INFRA := infra +# Local dev/test assets live under resources/dev. These are the defaults for +# the run/*-web targets; override any of them on the command line, e.g. +# make run GUI_ENV_FILE=/path/to/gui.env API_DESI_DATA=/path/to/data.json +RESOURCE_DIR := $(CURDIR)/resources/dev +GUI_ENV_FILE ?= $(RESOURCE_DIR)/gui.env.example +API_ENV_FILE ?= $(RESOURCE_DIR)/api.env.example +API_DESI_DATA ?= $(RESOURCE_DIR)/desi_subset.json + .PHONY: help setup run run-web build build-debug build-web test clean fmt \ renovate-validate @@ -32,9 +40,9 @@ setup: ## Setup all sub-projects (submodules, gui deps, api deps, infra deps) ## ---- Renderer (Odin) ----------------------------------------------------- run: ## Run the native app (gui/) - @$(MAKE) -C $(API) run & api_pid=$$!; \ + @API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" $(MAKE) -C $(API) run & api_pid=$$!; \ trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \ - $(MAKE) -C $(GUI) run; \ + GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) run; \ kill $$api_pid 2>/dev/null build: ## Release build (gui/ + api/) @@ -49,9 +57,9 @@ build-web: ## WebAssembly build -> build/web (gui/, needs emscripten) @$(MAKE) -C $(GUI) build-web run-web: ## Start WASM build + API server for web dev - @$(MAKE) -C $(API) run & api_pid=$$!; \ + @API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" $(MAKE) -C $(API) run & api_pid=$$!; \ trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \ - $(MAKE) -C $(GUI) build-web; \ + GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) build-web; \ kill $$api_pid 2>/dev/null ## ---- Aggregates ---------------------------------------------------------- diff --git a/README.md b/README.md index fe21849..af603a9 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,22 @@ make clean # remove build artifacts from all projects make fmt # format all projects ``` +`make run` / `make run-web` configure the API and renderer from the example +assets under `resources/dev/` (see the dotenv lib in `gui/lib/local/dotenv`). +Override any of them on the command line: + +```sh +make run \ + GUI_ENV_FILE=/path/to/gui.env \ + API_ENV_FILE=/path/to/api.env \ + API_DESI_DATA=/path/to/desi_data.json +``` + +The defaults point at `resources/dev/gui.env.example` (renderer's `API_URL`), +`resources/dev/api.env.example` (API `API_BIND_ADDR`), and +`resources/dev/desi_subset.json` (a small JSON catalog subset served by the +API's `/api/v1/catalogs` and `/api/v1/objects` endpoints). + Project-specific targets live in their own `Makefile` and are reached with `make -C `: diff --git a/api/Cargo.lock b/api/Cargo.lock index 3c2a245..c3b08b5 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -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" diff --git a/api/Cargo.toml b/api/Cargo.toml index 7dfe638..aff779a 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -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 diff --git a/api/src/config.rs b/api/src/config.rs index e168014..85141ef 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -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, } 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, + }) } } diff --git a/api/src/lib.rs b/api/src/lib.rs index b77fb25..2530105 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1,3 +1,4 @@ pub mod config; pub mod models; pub mod routes; +pub mod store; diff --git a/api/src/main.rs b/api/src/main.rs index fef0a0c..20261c2 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -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"); diff --git a/api/src/models.rs b/api/src/models.rs index 0090bcd..acdd6a6 100644 --- a/api/src/models.rs +++ b/api/src/models.rs @@ -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, } /// 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, diff --git a/api/src/routes/catalogs.rs b/api/src/routes/catalogs.rs index 5e5d220..c6fbc7d 100644 --- a/api/src/routes/catalogs.rs +++ b/api/src/routes/catalogs.rs @@ -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> = 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> { - Json(CATALOGS.clone()) +pub async fn list_catalogs(State(state): State>) -> Json> { + Json(state.catalogs.clone()) } #[derive(Debug, Deserialize)] @@ -38,17 +20,29 @@ pub struct ObjectQuery { limit: Option, } -/// 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) -> Response { +pub async fn list_objects( + State(state): State>, + Query(query): Query, +) -> Json> { 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 = Vec::new(); - (StatusCode::OK, Json(objects)).into_response() + let objects: Vec = 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) } diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index 2a7119f..6ec05d1 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -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) -> 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) } diff --git a/api/src/store.rs b/api/src/store.rs new file mode 100644 index 0000000..8db1fa7 --- /dev/null +++ b/api/src/store.rs @@ -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, + pub objects: Vec, +} + +/// JSON layout of the DESI data file referenced by `API_DESI_DATA`. +#[derive(Debug, Deserialize)] +pub struct DataFile { + pub catalogs: Vec, + #[serde(default)] + pub objects: Vec, +} + +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 { + 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()); + } +} diff --git a/api/tests/objects.rs b/api/tests/objects.rs new file mode 100644 index 0000000..d0a682d --- /dev/null +++ b/api/tests/objects.rs @@ -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 = 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 = 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 = serde_json::from_slice(&body).unwrap(); + assert_eq!(objects.len(), 1); +} diff --git a/gui/lib/local/dotenv/src/dotenv.odin b/gui/lib/local/dotenv/src/dotenv.odin index ebfccb8..9480bef 100644 --- a/gui/lib/local/dotenv/src/dotenv.odin +++ b/gui/lib/local/dotenv/src/dotenv.odin @@ -80,13 +80,26 @@ destroy :: proc(env: map[string]string) { delete(env) } +// env_key returns the env key that should bind to a struct field. It prefers +// an explicit `env:"NAME"` tag; when the tag is absent or empty it falls +// back to the field's name. Matching against the parsed map is +// case-insensitive, so API_URL maps onto api_url (or an `env:"API_URL"` tag). +@(private) +env_key_for_field :: proc(field: reflect.Struct_Field) -> string { + if tag_key, ok := reflect.struct_tag_lookup(field.tag, "env"); + ok && tag_key != "" { + return tag_key + } + return field.name +} + // decode populates dest's fields from env, matching each field by name -// (case-insensitively, so API_URL maps onto api_url). Values are converted -// to the field's type: string is cloned as-is into allocator, integers are -// parsed with strconv.parse_int (decimal/hex/negative), booleans with -// strconv.parse_bool, and floats with strconv.parse_f64. Keys missing from -// env leave the field at its zero value. It returns false if a present value -// cannot be converted to the field's type. +// (or by an `env:"NAME"` struct tag). Values are converted to the field's +// type: string is cloned as-is into allocator, integers are parsed with +// strconv.parse_int (decimal/hex/negative), booleans with strconv.parse_bool, +// and floats with strconv.parse_f64. Keys missing from env leave the field +// at its zero value. It returns false if a present value cannot be converted +// to the field's type. decode :: proc( env: map[string]string, dest: ^$T, @@ -98,11 +111,12 @@ decode :: proc( return false } - name: string value: string field_ptr := rawptr(dest) + st: reflect.Struct_Field for _, i in fields.names[:fields.field_count] { - name = fields.names[i] + st = reflect.struct_field_at(T, i) + name := env_key_for_field(st) value = "" found := false for key, v in env { @@ -157,7 +171,7 @@ decode :: proc( return false } case: - // unsupported field type (slices, pointers, ...) is left untouched + // unsupported field type (slices, pointers, ...) is left untouched } } diff --git a/gui/lib/local/dotenv/test/dotenv_test.odin b/gui/lib/local/dotenv/test/dotenv_test.odin index e0f17fa..b14fc73 100644 --- a/gui/lib/local/dotenv/test/dotenv_test.odin +++ b/gui/lib/local/dotenv/test/dotenv_test.odin @@ -12,6 +12,16 @@ Test_Config :: struct { ratio: f64, } +Tagged_Config :: struct { + api_url: string `env:"API_URL"`, + port: int `env:"PORT"`, + debug: bool `env:"DEBUG"`, +} + +Tagged_Empty :: struct { + api_url: string `env:""`, +} + // load_env writes src to a unique temp file and parses it via parse_file. // The returned map owns its strings; callers must destroy it. load_env :: proc(t: ^testing.T, src: string) -> map[string]string { @@ -199,3 +209,37 @@ test_decode_hex_and_negative_ints :: proc(t: ^testing.T) { testing.expect(t, dotenv.decode(env, &cfg)) testing.expect(t, cfg.port == 31, "hex int should decode") } + +@(test) +test_decode_honors_env_tags :: proc(t: ^testing.T) { + env := load_env(t, "API_URL=http://127.0.0.1:8080\nPORT=9090\nDEBUG=true\n") + defer dotenv.destroy(env) + + cfg := Tagged_Config{} + testing.expect(t, dotenv.decode(env, &cfg)) + defer delete(cfg.api_url) + + testing.expect( + t, + cfg.api_url == "http://127.0.0.1:8080", + "env tag should bind API_URL", + ) + testing.expect(t, cfg.port == 9090, "env tag should bind PORT") + testing.expect(t, cfg.debug == true, "env tag should bind DEBUG") +} + +@(test) +test_decode_empty_env_tag_falls_back_to_field_name :: proc(t: ^testing.T) { + env := load_env(t, "api_url=http://fallback\n") + defer dotenv.destroy(env) + + cfg := Tagged_Empty{} + testing.expect(t, dotenv.decode(env, &cfg)) + defer delete(cfg.api_url) + + testing.expect( + t, + cfg.api_url == "http://fallback", + "empty env tag should use field name", + ) +} diff --git a/gui/src/config.odin b/gui/src/config.odin index b5caa65..b6f06b7 100644 --- a/gui/src/config.odin +++ b/gui/src/config.odin @@ -4,11 +4,21 @@ import "core:os" import dotenv "lib:dotenv/src" Config :: struct { - api_url: string, + api_url: string `env:"API_URL"`, } get_config :: proc(env_file: ^string = nil) -> (^Config, ^Error) { - env, _ := dotenv.parse_file(".env", context.temp_allocator) + path := ".env" + if env_file != nil && env_file^ != "" { + path = env_file^ + } else if from_env, ok := os.lookup_env( + "GUI_ENV_FILE", + context.temp_allocator, + ); ok { + path = from_env + } + + env, _ := dotenv.parse_file(path, context.temp_allocator) defer dotenv.destroy(env) c := new(Config) diff --git a/resources/dev/api.env.example b/resources/dev/api.env.example new file mode 100644 index 0000000..4101f60 --- /dev/null +++ b/resources/dev/api.env.example @@ -0,0 +1,8 @@ +# Example API environment — passed via API_ENV_FILE (see root Makefile). +# +# The API also reads API_DESI_DATA from the environment (set by the root +# Makefile to resources/dev/desi_subset.json by default), so it is not +# repeated here. Values here win unless the same key is already set in the +# real process environment. + +API_BIND_ADDR=127.0.0.1:8080 \ No newline at end of file diff --git a/resources/dev/desi_subset.json b/resources/dev/desi_subset.json new file mode 100644 index 0000000..3df3715 --- /dev/null +++ b/resources/dev/desi_subset.json @@ -0,0 +1,58 @@ +{ + "catalogs": [ + { + "name": "edr", + "release": "EDR", + "description": "DESI Early Data Release (local dev subset)", + "object_count": 3 + }, + { + "name": "dr1", + "release": "DR1", + "description": "DESI Data Release 1 (local dev subset)", + "object_count": 2 + } + ], + "objects": [ + { + "id": "DESI_EDR_000000001", + "catalog": "edr", + "object_type": "GALAXY", + "ra": 150.123456, + "dec": 2.345678, + "redshift": 0.5521 + }, + { + "id": "DESI_EDR_000000002", + "catalog": "edr", + "object_type": "GALAXY", + "ra": 254.987654, + "dec": -15.203041, + "redshift": 1.1045 + }, + { + "id": "DESI_EDR_000000003", + "catalog": "edr", + "object_type": "QSO", + "ra": 75.001234, + "dec": 38.765432, + "redshift": 2.8756 + }, + { + "id": "DESI_DR1_000000001", + "catalog": "dr1", + "object_type": "STAR", + "ra": 188.556677, + "dec": 47.112233, + "redshift": 0.0001 + }, + { + "id": "DESI_DR1_000000002", + "catalog": "dr1", + "object_type": "GALAXY", + "ra": 300.445566, + "dec": 12.778899, + "redshift": 0.7742 + } + ] +} \ No newline at end of file diff --git a/resources/dev/gui.env.example b/resources/dev/gui.env.example new file mode 100644 index 0000000..7d1dabc --- /dev/null +++ b/resources/dev/gui.env.example @@ -0,0 +1,7 @@ +# Example GUI environment — passed via GUI_ENV_FILE (see root Makefile). +# +# Point the renderer at the local dev API: `make run` also starts the API +# server, so http://127.0.0.1:8080 is the default. Values here win unless +# the same key is already set in the real process environment. + +API_URL=http://127.0.0.1:8080 \ No newline at end of file