Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d227296e77 | |||
| 060098c673 |
@@ -66,15 +66,6 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
# Cache the extracted Odin install so only the first run downloads the
|
||||
# ~60 MB tarball; keyed on the pinned version (cache paths live outside
|
||||
# the checkout, so no sharing between jobs/refs).
|
||||
- name: Cache Odin
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/odin
|
||||
key: odin-${{ env.ODIN_VERSION }}-${{ runner.arch }}
|
||||
|
||||
- name: Install Odin ${{ env.ODIN_VERSION }}
|
||||
run: scripts/install_odin.sh
|
||||
|
||||
|
||||
@@ -34,12 +34,6 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Cache Odin
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/odin
|
||||
key: odin-${{ env.ODIN_VERSION }}-${{ runner.arch }}
|
||||
|
||||
- name: Install Odin ${{ env.ODIN_VERSION }}
|
||||
run: scripts/install_odin.sh
|
||||
|
||||
@@ -82,12 +76,6 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Cache Odin
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/odin
|
||||
key: odin-${{ env.ODIN_VERSION }}-${{ runner.arch }}
|
||||
|
||||
- name: Install Odin ${{ env.ODIN_VERSION }}
|
||||
run: scripts/install_odin.sh
|
||||
|
||||
@@ -151,12 +139,6 @@ jobs:
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: Cache Odin
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /tmp/odin
|
||||
key: odin-${{ env.ODIN_VERSION }}-${{ runner.arch }}
|
||||
|
||||
- name: Install Odin ${{ env.ODIN_VERSION }}
|
||||
run: scripts/install_odin.sh
|
||||
|
||||
|
||||
@@ -7,6 +7,3 @@ resources/ai/sessions
|
||||
infra/Pulumi.*.yaml.backup
|
||||
infra/desi-explorer-infra
|
||||
api/target/
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
|
||||
@@ -25,7 +25,6 @@ The root `Makefile` is a lean delegator: base commands (`run`, `build`, `test`,
|
||||
- Odin code lives in `gui/src/`; external deps go in `gui/lib/` and are wired via `-collection:lib=lib/local` (or a git submodule imported by relative path).
|
||||
- Everything is plain `make` — no Taskfile — so CI (Gitea Actions) can call `make` directly.
|
||||
- Keep the renderer (gui/), API (api/), and infra (infra/) logically separated; each owns its own Makefile, and the root Makefile is the only place that ties them together.
|
||||
- **Diagrams in this repo's documentation are Mermaid flowcharts.** Gitea renders ` ```mermaid ` fenced blocks natively. Prefer a Mermaid flowchart over ASCII art / box-drawing diagrams; if a diagram genuinely can't be expressed as a flowchart, fall back to a plain text-based markdown diagram (e.g. a code block or table) rather than hand-rawn ASCII boxes.
|
||||
|
||||
## Gotchas
|
||||
- Odin version is pinned in `.gitea/workflows/*.yml` (`ODIN_VERSION`) and defaults in `scripts/install_odin.sh`; bump both together when tracking a new release.
|
||||
|
||||
@@ -9,14 +9,6 @@ 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
|
||||
|
||||
@@ -40,9 +32,9 @@ setup: ## Setup all sub-projects (submodules, gui deps, api deps, infra deps)
|
||||
## ---- Renderer (Odin) -----------------------------------------------------
|
||||
|
||||
run: ## Run the native app (gui/)
|
||||
@API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" $(MAKE) -C $(API) run & api_pid=$$!; \
|
||||
@$(MAKE) -C $(API) run & api_pid=$$!; \
|
||||
trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \
|
||||
GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) run; \
|
||||
$(MAKE) -C $(GUI) run; \
|
||||
kill $$api_pid 2>/dev/null
|
||||
|
||||
build: ## Release build (gui/ + api/)
|
||||
@@ -57,9 +49,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
|
||||
@API_ENV_FILE="$(API_ENV_FILE)" API_DESI_DATA="$(API_DESI_DATA)" $(MAKE) -C $(API) run & api_pid=$$!; \
|
||||
@$(MAKE) -C $(API) run & api_pid=$$!; \
|
||||
trap 'kill $$api_pid 2>/dev/null' INT TERM EXIT; \
|
||||
GUI_ENV_FILE="$(GUI_ENV_FILE)" $(MAKE) -C $(GUI) build-web; \
|
||||
$(MAKE) -C $(GUI) build-web; \
|
||||
kill $$api_pid 2>/dev/null
|
||||
|
||||
## ---- Aggregates ----------------------------------------------------------
|
||||
|
||||
@@ -79,22 +79,6 @@ 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 <dir> <target>`:
|
||||
|
||||
|
||||
Generated
+2
-10
@@ -99,7 +99,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"dotenvy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -109,12 +108,6 @@ 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"
|
||||
@@ -565,15 +558,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tower-http"
|
||||
version = "0.7.1"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||
checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"http",
|
||||
"http-body",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
||||
+4
-5
@@ -5,18 +5,17 @@ edition = "2021"
|
||||
description = "Backend API for DESI Explorer — serves DESI survey catalog data"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
axum = "0.8"
|
||||
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"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "trace"] }
|
||||
anyhow = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
||||
@@ -4,20 +4,6 @@
|
||||
# `api-*` convenience targets).
|
||||
|
||||
CARGO ?= cargo
|
||||
ROOT := ..
|
||||
|
||||
# Normalize API_ENV_FILE / API_DESI_DATA (given relative to the repo root) to
|
||||
# absolute paths so the API process can open them regardless of its working
|
||||
# directory. "override" is required because they are usually passed as
|
||||
# command-line/env vars, which would otherwise override any assignment here.
|
||||
define normalize_path
|
||||
ifdef $1
|
||||
ifneq ($(abspath $($1)),$($1))
|
||||
override $1 := $(abspath $(ROOT)/$($1))
|
||||
endif
|
||||
endif
|
||||
endef
|
||||
$(foreach v,API_ENV_FILE API_DESI_DATA,$(eval $(call normalize_path,$v)))
|
||||
|
||||
.PHONY: help setup run build test check fmt clean
|
||||
|
||||
|
||||
+1
-14
@@ -1,10 +1,5 @@
|
||||
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 {
|
||||
@@ -12,14 +7,6 @@ impl Config {
|
||||
let bind_addr =
|
||||
std::env::var("API_BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string());
|
||||
|
||||
let desi_data = std::env::var("API_DESI_DATA")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from);
|
||||
|
||||
Ok(Self {
|
||||
bind_addr,
|
||||
desi_data,
|
||||
})
|
||||
Ok(Self { bind_addr })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod config;
|
||||
pub mod models;
|
||||
pub mod routes;
|
||||
pub mod store;
|
||||
|
||||
+2
-40
@@ -1,7 +1,4 @@
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use desi_explorer_api::{config, routes, store};
|
||||
use desi_explorer_api::{config, routes};
|
||||
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
@@ -14,22 +11,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
)
|
||||
.init();
|
||||
|
||||
load_env_file()?;
|
||||
|
||||
let config = config::Config::from_env()?;
|
||||
|
||||
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 app = routes::app();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&config.bind_addr).await?;
|
||||
tracing::info!("DESI Explorer API listening on {}", config.bind_addr);
|
||||
@@ -41,27 +24,6 @@ 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::{Deserialize, Serialize};
|
||||
use serde::Serialize;
|
||||
|
||||
/// Catalog metadata for a DESI data release/survey.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Catalog {
|
||||
pub name: String,
|
||||
pub release: String,
|
||||
pub description: String,
|
||||
pub description: &'static str,
|
||||
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, Clone, Deserialize, Serialize)]
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CatalogObject {
|
||||
pub id: String,
|
||||
pub catalog: String,
|
||||
|
||||
+30
-24
@@ -1,16 +1,34 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
extract::Query,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::models::{Catalog, CatalogObject};
|
||||
use crate::store::CatalogStore;
|
||||
|
||||
pub async fn list_catalogs(State(state): State<Arc<CatalogStore>>) -> Json<Vec<Catalog>> {
|
||||
Json(state.catalogs.clone())
|
||||
/// 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())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -20,29 +38,17 @@ pub struct ObjectQuery {
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn list_objects(
|
||||
State(state): State<Arc<CatalogStore>>,
|
||||
Query(query): Query<ObjectQuery>,
|
||||
) -> Json<Vec<CatalogObject>> {
|
||||
/// 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 {
|
||||
let limit = query.limit.unwrap_or(100).min(10_000);
|
||||
|
||||
tracing::debug!(
|
||||
%limit,
|
||||
catalog = query.catalog.as_deref().unwrap_or("all"),
|
||||
objects = state.objects.len(),
|
||||
"querying catalog objects"
|
||||
"querying catalog objects (placeholder)"
|
||||
);
|
||||
|
||||
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)
|
||||
let objects: Vec<CatalogObject> = Vec::new();
|
||||
(StatusCode::OK, Json(objects)).into_response()
|
||||
}
|
||||
|
||||
+2
-12
@@ -1,23 +1,13 @@
|
||||
pub mod catalogs;
|
||||
pub mod health;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{routing::get, Router};
|
||||
|
||||
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.
|
||||
/// Builds the application router. 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)
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
use desi_explorer_api::store::CatalogStore;
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
+2
-27
@@ -8,28 +8,13 @@
|
||||
# output dirs live at the repo root and are referenced through `$(ROOT)`.
|
||||
|
||||
ODIN ?= odin
|
||||
GDB ?= gdb
|
||||
ROOT := ..
|
||||
BIN := $(ROOT)/bin
|
||||
BINARY := $(BIN)/desi_explorer
|
||||
ODIN_FLAGS := -collection:lib=lib/local
|
||||
WASM_DEFINE := RAYLIB_WASM_LIB=env.o
|
||||
|
||||
# Normalize GUI_ENV_FILE (given relative to the repo root) to an absolute path
|
||||
# so the Odin process can open it regardless of its working directory.
|
||||
# "override" is required because GUI_ENV_FILE is usually set on the command
|
||||
# line (or passed as an env var to this sub-make), which would otherwise
|
||||
# override any assignment made here.
|
||||
ifdef GUI_ENV_FILE
|
||||
ifneq ($(abspath $(GUI_ENV_FILE)),$(GUI_ENV_FILE))
|
||||
override GUI_ENV_FILE := $(abspath $(ROOT)/$(GUI_ENV_FILE))
|
||||
endif
|
||||
endif
|
||||
# "override" on a command-line variable silently drops it from the recipe
|
||||
# environment; re-export it so the app can find its env file (run + gdb).
|
||||
export GUI_ENV_FILE
|
||||
|
||||
.PHONY: help setup add-dep run build build-debug gdb build-web test clean fmt
|
||||
.PHONY: help setup add-dep run build build-debug build-web test clean fmt
|
||||
|
||||
help: ## List available targets
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
|
||||
@@ -56,22 +41,12 @@ build-debug: ## Debug build -> bin/desi_explorer
|
||||
@mkdir -p lib/local $(BIN)
|
||||
$(ODIN) build src $(ODIN_FLAGS) -o:none -debug -out:$(BINARY)
|
||||
|
||||
gdb: build-debug ## Run the native app under gdb (type 'run', then 'bt' on a crash)
|
||||
$(GDB) -q --args $(BINARY) $(ARGS)
|
||||
|
||||
build-web: ## WebAssembly build -> build/web (needs emscripten)
|
||||
@scripts/build_web.sh
|
||||
|
||||
test: ## Run Odin unit tests
|
||||
@mkdir -p lib/local
|
||||
@if ls test/*.odin >/dev/null 2>&1; then \
|
||||
echo "== gui/test =="; \
|
||||
$(ODIN) test test $(ODIN_FLAGS); \
|
||||
fi
|
||||
@for dir in $$(find lib/local -name '*_test.odin' -exec dirname {} \; | sort -u); do \
|
||||
echo "== $$dir =="; \
|
||||
$(ODIN) test "$$dir" $(ODIN_FLAGS); \
|
||||
done
|
||||
$(ODIN) test src $(ODIN_FLAGS)
|
||||
|
||||
clean: ## Remove build artifacts
|
||||
rm -rf $(BIN) build
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
package dotenv
|
||||
|
||||
import "base:runtime"
|
||||
import "core:os"
|
||||
import "core:reflect"
|
||||
import "core:strconv"
|
||||
import "core:strings"
|
||||
|
||||
// parse parses dotenv-format source (KEY=VALUE lines) into a map allocated
|
||||
// with allocator. Blank lines, lines starting with '#', and lines without a
|
||||
// '=' are skipped. Keys and values are trimmed; values may be wrapped in
|
||||
// double quotes. Real process environment variables take precedence over the
|
||||
// file. The returned map owns its keys/values; release it with destroy.
|
||||
@(private)
|
||||
parse :: proc(src: string, allocator := context.allocator) -> map[string]string {
|
||||
result := make(map[string]string, allocator)
|
||||
|
||||
it := src
|
||||
for line in strings.split_lines_iterator(&it) {
|
||||
tr := strings.trim_space(line)
|
||||
if len(tr) == 0 || strings.has_prefix(tr, "#") {
|
||||
continue
|
||||
}
|
||||
|
||||
eq := strings.index_byte(tr, '=')
|
||||
if eq < 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := strings.trim_space(tr[:eq])
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
value := strings.trim_space(tr[eq + 1:])
|
||||
if len(value) >= 2 && value[0] == '"' && value[len(value) - 1] == '"' {
|
||||
value = value[1:len(value) - 1]
|
||||
}
|
||||
|
||||
// real process env vars win over the file
|
||||
if override, found := os.lookup_env(key, allocator); found {
|
||||
result[strings.clone(key, allocator)] = override
|
||||
continue
|
||||
}
|
||||
|
||||
// clone so the map outlives the source buffer (e.g. a freed file read)
|
||||
result[strings.clone(key, allocator)] = strings.clone(value, allocator)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// parse_file reads a dotenv file from disk and parses it into a map. It
|
||||
// returns (nil, false) when the file cannot be read (e.g. it does not exist).
|
||||
parse_file :: proc(filename: string, allocator := context.allocator) -> (map[string]string, bool) {
|
||||
data, err := os.read_entire_file(filename, allocator)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
defer delete(data, allocator)
|
||||
|
||||
return parse(string(data), allocator), true
|
||||
}
|
||||
|
||||
// destroy frees the cloned keys/values and the map itself. Use it to release
|
||||
// a map returned by parse/parse_file (plain delete does not free the strings).
|
||||
// Any allocator passed to parse/parse_file must be passed here too.
|
||||
destroy :: proc(env: map[string]string, allocator := context.allocator) {
|
||||
for key, value in env {
|
||||
delete(key, allocator)
|
||||
delete(value, allocator)
|
||||
}
|
||||
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
|
||||
// (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, allocator := context.allocator) -> bool {
|
||||
ti := reflect.type_info_base(type_info_of(T))
|
||||
fields, ok := ti.variant.(runtime.Type_Info_Struct)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
value: string
|
||||
field_ptr := rawptr(dest)
|
||||
st: reflect.Struct_Field
|
||||
for _, i in fields.names[:fields.field_count] {
|
||||
st = reflect.struct_field_at(T, i)
|
||||
name := env_key_for_field(st)
|
||||
value = ""
|
||||
found := false
|
||||
for key, v in env {
|
||||
if key == name || strings.equal_fold(key, name) {
|
||||
value, found = v, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
|
||||
field_ptr = rawptr(uintptr(dest) + fields.offsets[i])
|
||||
field_ti := reflect.type_info_base(fields.types[i])
|
||||
#partial switch variant in field_ti.variant {
|
||||
case runtime.Type_Info_String:
|
||||
(^string)(field_ptr)^ = strings.clone(value, allocator)
|
||||
case runtime.Type_Info_Integer:
|
||||
parsed, err := strconv.parse_int(value)
|
||||
if !err {
|
||||
return false
|
||||
}
|
||||
switch field_ti.size {
|
||||
case 1:
|
||||
(^i8)(field_ptr)^ = cast(i8)parsed
|
||||
case 2:
|
||||
(^i16)(field_ptr)^ = cast(i16)parsed
|
||||
case 4:
|
||||
(^i32)(field_ptr)^ = cast(i32)parsed
|
||||
case 8:
|
||||
(^i64)(field_ptr)^ = cast(i64)parsed
|
||||
case:
|
||||
return false
|
||||
}
|
||||
case runtime.Type_Info_Boolean:
|
||||
parsed, err := strconv.parse_bool(value)
|
||||
if !err {
|
||||
return false
|
||||
}
|
||||
(^bool)(field_ptr)^ = parsed
|
||||
case runtime.Type_Info_Float:
|
||||
parsed, err := strconv.parse_f64(value)
|
||||
if !err {
|
||||
return false
|
||||
}
|
||||
switch field_ti.size {
|
||||
case 4:
|
||||
(^f32)(field_ptr)^ = cast(f32)parsed
|
||||
case 8:
|
||||
(^f64)(field_ptr)^ = parsed
|
||||
case:
|
||||
return false
|
||||
}
|
||||
case:
|
||||
// unsupported field type (slices, pointers, ...) is left untouched
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
package dotenv_tests
|
||||
|
||||
import "core:os"
|
||||
import "core:strings"
|
||||
import "core:testing"
|
||||
import dotenv "lib:dotenv/src"
|
||||
|
||||
Test_Config :: struct {
|
||||
api_url: string,
|
||||
debug: bool,
|
||||
port: int,
|
||||
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 {
|
||||
dir, err := os.make_directory_temp("", "dotenv_test_*", context.allocator)
|
||||
testing.expect(t, err == nil, "expected temp dir to be created")
|
||||
defer os.remove_all(dir)
|
||||
defer delete(dir)
|
||||
|
||||
path := strings.concatenate({dir, "/.env"})
|
||||
defer delete(path)
|
||||
|
||||
testing.expect(
|
||||
t,
|
||||
os.write_entire_file(path, src) == nil,
|
||||
"expected file write to succeed",
|
||||
)
|
||||
|
||||
env, ok := dotenv.parse_file(path)
|
||||
testing.expect(t, ok, "expected parse_file to succeed")
|
||||
return env
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_basic :: proc(t: ^testing.T) {
|
||||
env := load_env(t, "API_URL=http://127.0.0.1:8080\nDEBUG=true\nPORT=8080\n")
|
||||
defer dotenv.destroy(env)
|
||||
|
||||
testing.expect(t, env["API_URL"] == "http://127.0.0.1:8080")
|
||||
testing.expect(t, env["DEBUG"] == "true")
|
||||
testing.expect(t, env["PORT"] == "8080")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_ignores_comments_and_blank_lines :: proc(t: ^testing.T) {
|
||||
env := load_env(t, "# leading comment\n\n \nFOO=bar \nBAZ = qux \n")
|
||||
defer dotenv.destroy(env)
|
||||
|
||||
testing.expect(t, env["FOO"] == "bar", "value should be trimmed")
|
||||
testing.expect(
|
||||
t,
|
||||
env["BAZ"] == "qux",
|
||||
"key and value should be trimmed around '='",
|
||||
)
|
||||
testing.expect(
|
||||
t,
|
||||
"API_URL" not_in env,
|
||||
"comment-only lines should not be parsed",
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_quoted_values :: proc(t: ^testing.T) {
|
||||
env := load_env(t, "GREETING=\"hello world\"\nEMPTY=\"\"\n")
|
||||
defer dotenv.destroy(env)
|
||||
|
||||
testing.expect(
|
||||
t,
|
||||
env["GREETING"] == "hello world",
|
||||
"quoted value with inner space",
|
||||
)
|
||||
testing.expect(t, env["EMPTY"] == "", "double-quoted empty value")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_skips_lines_without_equals :: proc(t: ^testing.T) {
|
||||
env := load_env(t, "not-an-assignment\nOK=yep\n")
|
||||
defer dotenv.destroy(env)
|
||||
|
||||
testing.expect(t, env["OK"] == "yep")
|
||||
testing.expect(
|
||||
t,
|
||||
"not-an-assignment" not_in env,
|
||||
"line without '=' should be skipped",
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_parse_missing_file :: proc(t: ^testing.T) {
|
||||
env, ok := dotenv.parse_file("/nonexistent/dotenv_test_does_not_exist.env")
|
||||
testing.expect(t, !ok, "missing file should report failure")
|
||||
testing.expect(t, env == nil, "missing file should return nil map")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_real_env_overrides_file :: proc(t: ^testing.T) {
|
||||
testing.expect(t, os.set_env("DESI_EXPLORER_TEST_FOO", "from_env") == nil)
|
||||
defer os.unset_env("DESI_EXPLORER_TEST_FOO")
|
||||
|
||||
env := load_env(t, "DESI_EXPLORER_TEST_FOO=from_file\n")
|
||||
defer dotenv.destroy(env)
|
||||
|
||||
testing.expect(
|
||||
t,
|
||||
env["DESI_EXPLORER_TEST_FOO"] == "from_env",
|
||||
"real env var should win over file",
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_decode_maps_fields_case_insensitively :: proc(t: ^testing.T) {
|
||||
env := load_env(
|
||||
t,
|
||||
"API_URL=http://127.0.0.1:8080\nDEBUG=true\nPORT=8080\nRATIO=0.5\n",
|
||||
)
|
||||
defer dotenv.destroy(env)
|
||||
|
||||
cfg := Test_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",
|
||||
"API_URL maps onto api_url",
|
||||
)
|
||||
testing.expect(t, cfg.debug == true, "DEBUG=true should decode to true")
|
||||
testing.expect(t, cfg.port == 8080, "PORT=8080 should decode to int 8080")
|
||||
testing.expect(t, cfg.ratio == 0.5, "RATIO=0.5 should decode to f64 0.5")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_decode_matches_exact_and_lowercase_keys :: proc(t: ^testing.T) {
|
||||
env := load_env(t, "api_url=http://exact\nPort=9090\n")
|
||||
defer dotenv.destroy(env)
|
||||
|
||||
cfg := Test_Config{}
|
||||
testing.expect(t, dotenv.decode(env, &cfg))
|
||||
defer delete(cfg.api_url)
|
||||
|
||||
testing.expect(
|
||||
t,
|
||||
cfg.api_url == "http://exact",
|
||||
"exact-case key should match",
|
||||
)
|
||||
testing.expect(t, cfg.port == 9090, "mixed-case key should match field")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_decode_missing_keys_leave_zero_values :: proc(t: ^testing.T) {
|
||||
env := load_env(t, "UNRELATED=value\n")
|
||||
defer dotenv.destroy(env)
|
||||
|
||||
cfg := Test_Config{}
|
||||
testing.expect(t, dotenv.decode(env, &cfg))
|
||||
|
||||
testing.expect(t, cfg.api_url == "")
|
||||
testing.expect(t, !cfg.debug)
|
||||
testing.expect(t, cfg.port == 0)
|
||||
testing.expect(t, cfg.ratio == 0)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_decode_unparsable_int_fails :: proc(t: ^testing.T) {
|
||||
env := load_env(t, "PORT=oops\n")
|
||||
defer dotenv.destroy(env)
|
||||
|
||||
cfg := Test_Config{}
|
||||
testing.expect(
|
||||
t,
|
||||
!dotenv.decode(env, &cfg),
|
||||
"unparsable int should make decode fail",
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_decode_unparsable_bool_fails :: proc(t: ^testing.T) {
|
||||
env := load_env(t, "DEBUG=maybe\nAPI_URL=http://127.0.0.1:8080\n")
|
||||
defer dotenv.destroy(env)
|
||||
|
||||
cfg := Test_Config{}
|
||||
testing.expect(
|
||||
t,
|
||||
!dotenv.decode(env, &cfg),
|
||||
"unparsable bool should make decode fail",
|
||||
)
|
||||
defer delete(cfg.api_url)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_decode_hex_and_negative_ints :: proc(t: ^testing.T) {
|
||||
env := load_env(t, "PORT=0x1F\n")
|
||||
defer dotenv.destroy(env)
|
||||
|
||||
cfg := Test_Config{}
|
||||
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",
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package main
|
||||
|
||||
import "core:log"
|
||||
import "core:os"
|
||||
import dotenv "lib:dotenv/src"
|
||||
|
||||
Config :: struct {
|
||||
api_url: string `env:"API_URL"`,
|
||||
}
|
||||
|
||||
get_config :: proc(env_file: ^string = nil) -> (^Config, ^Error) {
|
||||
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
|
||||
}
|
||||
|
||||
log.debugf("getting config from file: %s", path)
|
||||
|
||||
env, _ := dotenv.parse_file(path, context.temp_allocator)
|
||||
defer dotenv.destroy(env, context.temp_allocator)
|
||||
|
||||
c := new(Config)
|
||||
|
||||
if env == nil {
|
||||
c.api_url = os.get_env("API_URL", context.temp_allocator)
|
||||
} else if !dotenv.decode(env, c) {
|
||||
return nil, new_clone(Error{.Config, "failed to decode .env into Config"})
|
||||
}
|
||||
|
||||
if err := validate_config(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
validate_config :: proc(c: ^Config) -> (err: ^Error) {
|
||||
if c.api_url == "" {
|
||||
err = new_clone(Error{.Config, "'API_URL' is required"})
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
package main
|
||||
|
||||
import "core:net"
|
||||
import "vendor:curl"
|
||||
|
||||
Catalog :: struct {
|
||||
name: string,
|
||||
release: string,
|
||||
@@ -25,10 +22,6 @@ APIError :: struct {
|
||||
}
|
||||
|
||||
get_catalogs :: proc(url: string) -> ([dynamic]Catalog, ^APIError) {
|
||||
ucurl := curl.url()
|
||||
|
||||
|
||||
defer curl.url_cleanup(ucurl)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package main
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
ErrorType :: enum {
|
||||
Config,
|
||||
API,
|
||||
}
|
||||
|
||||
Error :: struct {
|
||||
type: ErrorType,
|
||||
message: string,
|
||||
}
|
||||
|
||||
format_error :: proc(err: ^Error) -> string {
|
||||
sb := strings.builder_make(context.temp_allocator)
|
||||
|
||||
return fmt.sbprintf(&sb, "[%s] => %s", err.type, err.message)
|
||||
}
|
||||
|
||||
+7
-13
@@ -1,9 +1,7 @@
|
||||
package main
|
||||
|
||||
import "base:runtime"
|
||||
import "core:math"
|
||||
import "core:math/rand"
|
||||
import "core:os"
|
||||
import rl "vendor:raylib"
|
||||
|
||||
WIDTH :: 1280
|
||||
@@ -26,16 +24,6 @@ universe: [dynamic]Galaxy
|
||||
rng: rand.Default_Random_State
|
||||
|
||||
main :: proc() {
|
||||
|
||||
c: ^Config
|
||||
err: ^Error
|
||||
|
||||
s := os.get_env("GUI_ENV_FILE", context.temp_allocator)
|
||||
|
||||
if c, err = get_config(&s); err != nil {
|
||||
panic(format_error(err))
|
||||
}
|
||||
|
||||
rng = rand.create(0xDE51_0000)
|
||||
context.random_generator = rand.default_random_generator(&rng)
|
||||
|
||||
@@ -115,5 +103,11 @@ draw :: proc() {
|
||||
}
|
||||
|
||||
rl.DrawFPS(10, 10)
|
||||
rl.DrawText("DESI Explorer — drag to rotate, scroll to zoom", 10, 34, 18, rl.RAYWHITE)
|
||||
rl.DrawText(
|
||||
"DESI Explorer — drag to rotate, scroll to zoom",
|
||||
10,
|
||||
34,
|
||||
18,
|
||||
rl.RAYWHITE,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,13 +59,11 @@ get_catalog_objects :: proc(url: string, catalog_name: string) // nil
|
||||
|
||||
## Data flow gap
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["DESI catalog store"]
|
||||
B["Rust / axum API<br/><i>serde JSON models</i>"]
|
||||
C["Odin + raylib GUI<br/><i>hand-mirrored structs<br/>stubs, never used</i>"]
|
||||
A -. "future ingestion" .-> B
|
||||
B --x|"nothing today"| C
|
||||
```
|
||||
[DESI catalog store] --(future)--> [Rust/axum API] --(nothing today)--> [Odin + raylib GUI]
|
||||
^ ^
|
||||
| serde JSON models | hand-mirrored structs
|
||||
| | (stubs, never used)
|
||||
```
|
||||
|
||||
There is **no live data flow**. The API currently returns JSON placeholders; the
|
||||
@@ -118,16 +116,17 @@ response protocol with a cheap binary payload would fit this well.
|
||||
|
||||
## High-level target architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
S["catalog.fbs<br/><i>single source of truth<br/>checked into repo</i>"]
|
||||
R["flatc --rust"]
|
||||
C["flatcc --c<br/><i>or hand-rolled Odin reader</i>"]
|
||||
API["api/ · Rust"]
|
||||
GUI["gui/ · Odin + raylib"]
|
||||
S --> R --> API
|
||||
S --> C --> GUI
|
||||
API <-->|"HTTP / WebSocket<br/>framed FlatBuffer binary stream"| GUI
|
||||
```
|
||||
catalog.fbs (single source of truth, checked into repo)
|
||||
|
|
||||
+--------+---------+
|
||||
| |
|
||||
flatc --rust flatcc --c (or hand-rolled Odin reader)
|
||||
| |
|
||||
api/ (Rust) gui/ (Odin + raylib)
|
||||
| ^
|
||||
| HTTP / WebSocket (framed FlatBuffer binary stream)
|
||||
+------------------+
|
||||
```
|
||||
|
||||
- One schema file. Two generators. Byte-for-byte identical wire format.
|
||||
|
||||
@@ -105,28 +105,19 @@ These are the "thou shalt" rules for keeping buffers compatible:
|
||||
|
||||
## Reading a buffer (conceptual)
|
||||
|
||||
Buffer layout:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph BUF["bytes: &[u8]"]
|
||||
O["uoffset<br/>root table offset"]
|
||||
FI["file_identifier<br/>(optional)"]
|
||||
D["tables · vtables · data"]
|
||||
end
|
||||
O --> FI --> D
|
||||
```
|
||||
bytes: &[u8]
|
||||
|
||||
Access sequence — each field is a few offset dereferences and a read:
|
||||
┌─────────────────────────────┐
|
||||
│ uoffset (root table offset) │
|
||||
│ file_identifier (optional) │
|
||||
│ ... tables, vtables, data ...│
|
||||
└─────────────────────────────┘
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
R["root = follow(bytes)<br/>jump to root table via uoffset"]
|
||||
V["vtable = root − root.vtable_off<br/>locate vtable for this table"]
|
||||
Q{"field slot present?"}
|
||||
R --> V --> Q
|
||||
Q -- "no" --> DEF["use schema default"]
|
||||
Q -- "yes" --> RD["ra = read_f64(bytes, root + slot_ra)"]
|
||||
root = follow(bytes) // jump to root table via uoffset
|
||||
vtable = root - root.vtable_off // locate vtable for this table
|
||||
field_ra = vtable.slot_ra != 0 // present?
|
||||
if present: ra = read_f64(bytes, root + slot_ra)
|
||||
```
|
||||
|
||||
There is **no parsing loop**. Each accessor is a few offset dereferences and a
|
||||
|
||||
@@ -196,18 +196,6 @@ Notes:
|
||||
|
||||
## Building buffers efficiently (Rust specifics)
|
||||
|
||||
Build order is **back-to-front** (children before parents):
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A["create_string / create_vector<br/>children first"]
|
||||
B["create nested child tables"]
|
||||
C["create parent table<br/>ObjectBatch::create(&args)"]
|
||||
D["builder.finish(root, Some("DESI"))"]
|
||||
E["Bytes::copy_from_slice(fbb.finished_data())<br/>→ HTTP / WebSocket response"]
|
||||
A --> B --> C --> D --> E
|
||||
```
|
||||
|
||||
- `FlatBufferBuilder::with_capacity(n)` pre-allocates; `reset()` reuses the
|
||||
buffer across messages. In a loop streaming batches, create one builder, reuse
|
||||
it — avoid repeated reallocation.
|
||||
|
||||
@@ -17,21 +17,6 @@ has two C-related access points:
|
||||
schema plus a small `libflatccrt.a` runtime. Works via the C ABI, so Odin's
|
||||
`foreign import` can consume it.
|
||||
|
||||
Choosing a path:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
NAT{"primary target is native desktop?"}
|
||||
NAT -- "yes" --> CGO{"want to avoid C in the build?"}
|
||||
CGO -- "yes" --> PATHA["Path B · pure-Odin reader<br/>hand-rolled, no C dependency"]
|
||||
CGO -- "no" --> PATHA
|
||||
CGO -- "prefer proven lib / less maintenance" --> PATHC["Path A · FFI to FlatCC<br/>bind generated C headers"]
|
||||
PATHC --> REUSE["Path C · OdinArrow reuse<br/>or borrow its decode patterns"]
|
||||
NAT -- "no · browser/WASM" --> PATHD["Path D · TS/JS interop<br/>official JS lib → typed arrays into WASM"]
|
||||
```
|
||||
|
||||
Index of paths:
|
||||
|
||||
| Path | Effort | Zero-copy on reads | Notes |
|
||||
|---|---|---|---|
|
||||
| A: FFI to FlatCC (C runtime) | Medium | ✅ | Bind generated C headers to Odin `foreign` |
|
||||
@@ -190,14 +175,12 @@ typed Odin slices.
|
||||
The rendering win only materializes if data stays zero-copy **into the frame
|
||||
loop**:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["fetch frame bytes → [dynamic]u8<br/>or a slice pinned for the frame"]
|
||||
B["verify the buffer once"]
|
||||
C["ObjectBatch.ra(&buf) → []f64 view"]
|
||||
D["per object in update()/draw()<br/>ra[i] · dec[i] · z[i] → rl.Vector3 → DrawPoint3D"]
|
||||
A --> B --> C --> D
|
||||
```
|
||||
1. Fetch frame bytes → owned `[dynamic]u8` (or a slice pinned for the lifetime
|
||||
of the frame).
|
||||
2. `verify` the buffer once.
|
||||
3. Get `ra_slice := ObjectBatch.ra(&buf)` → `[]f64` view.
|
||||
4. Per object in `update()`/`draw()`: read `ra[i]`, `dec[i]`, `z[i]` straight
|
||||
from that slice; build `rl.Vector3`; `DrawPoint3D`.
|
||||
|
||||
No per-object allocation. The current `Galaxy { position, color }` dynamic array
|
||||
in `main.odin` is the data structure you'd replace with *slices into the
|
||||
|
||||
@@ -24,17 +24,14 @@ offers two built-in mechanisms plus the community pattern:
|
||||
|
||||
### Option 1: Size-prefixed FlatBuffers (built-in)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["u32 LE<br/>total buffer len<br/><i>size prefix</i>"]
|
||||
B["u32 LE<br/>root table offset"]
|
||||
C["file identifier<br/>(4 bytes)"]
|
||||
D["tables · vtables · data"]
|
||||
A --> B --> C --> D
|
||||
```
|
||||
|
||||
```rust
|
||||
builder.finish_size_prefixed(root, Some("DESI"));
|
||||
// +---------------------------+
|
||||
// | u32 LE: total buffer len | <-- size prefix
|
||||
// | u32 LE: root table offset |
|
||||
// | file identifier (4 bytes) |
|
||||
// | ... data ... |
|
||||
// +---------------------------+
|
||||
```
|
||||
|
||||
Reader side:
|
||||
@@ -56,12 +53,8 @@ message kinds).
|
||||
|
||||
### Option 2: Custom length-prefix framing (like `flatstream`)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["u32 LE<br/>message_len"]
|
||||
B["optional checksum<br/>(u32 crc / xxhash)"]
|
||||
C["FlatBuffer payload"]
|
||||
A --> B --> C
|
||||
```
|
||||
[ u32 LE: message_len ] [ optional checksum (e.g. u32 crc/xxhash) ] [ flatbuffer payload ]
|
||||
```
|
||||
|
||||
- `flatstream-rs` (see `03-rust-integration.md`) is a reference implementation
|
||||
@@ -267,16 +260,6 @@ FlatBuffers long-term (mmap-friendly, page-in-what-you-touch).
|
||||
|
||||
## Decision summary for this repo
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["HTTP GET → one FlatBuffer body per batch<br/>validate Rust builder + Odin reader"]
|
||||
B["WebSocket → one Binary message per batch<br/>interactive path · no custom framing"]
|
||||
C["Self-identifying messages<br/>file_identifier "DESI""]
|
||||
D["size-prefixed / flatstream-style framing<br/>or HTTP-range + mmap for static catalogs"]
|
||||
A --> B --> C
|
||||
C -. "later, if needed" .-> D
|
||||
```
|
||||
|
||||
1. Start with **HTTP GET → one FlatBuffer body per batch** to validate the Rust
|
||||
builder + Odin reader (no protocol work at all).
|
||||
2. Then add **WebSocket** with one `Binary` message per batch (no custom framing)
|
||||
|
||||
@@ -108,15 +108,6 @@ message shape — see `10-performance-benchmarks.md`.)
|
||||
|
||||
## Bottom line
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Q1{"zero-copy reads<br/>in the per-frame render loop?"}
|
||||
Q1 -- "no" --> PB["Protobuf / gRPC<br/>decode once into draw buffers"]
|
||||
Q1 -- "yes" --> Q2{"truly columnar?<br/>millions of rows"}
|
||||
Q2 -- "yes" --> ARR["Apache Arrow IPC<br/>via OdinArrow"]
|
||||
Q2 -- "no · batched vectors" --> FB["FlatBuffers · this proposal"]
|
||||
```
|
||||
|
||||
- **FlatBuffers is the best default** for this project: the zero-copy read model
|
||||
matches the render loop, the wire format is compact for numeric vectors, schema
|
||||
evolution fits DESI's release cadence, and the Rust + WASM/JS official story
|
||||
|
||||
@@ -139,23 +139,7 @@ side / flatc* — see the "cross-language fixture" section below.
|
||||
|
||||
### 4. Cross-language conformance suite (THE key integration test)
|
||||
|
||||
This is the test that actually catches incompatibility. Pipeline:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
S["schema/catalog.fbs"]
|
||||
J["testdata/catalog_sample.json"]
|
||||
S --> F["flatc --binary"]
|
||||
J --> F
|
||||
F --> BIN["committed .bin fixtures<br/>repo-checked-in"]
|
||||
BIN --> OT["Odin tests<br/>assert identical values"]
|
||||
BIN --> RT["Rust tests<br/>assert expected values"]
|
||||
RT -. "deterministic builder" .-> PAR["byte-for-byte parity"]
|
||||
OT -. "reads it" .-> PAR
|
||||
PAR -. "catch drift" .-> F
|
||||
```
|
||||
|
||||
Design:
|
||||
This is the test that actually catches incompatibility. Design:
|
||||
|
||||
1. **Static fixtures, committed to the repo** (`testdata/*.bin`):
|
||||
- Built once by `flatc --binary <schema>.fbs <data>.json` (deterministic,
|
||||
|
||||
@@ -70,16 +70,6 @@ directly from the buffer each frame without allocations.
|
||||
|
||||
## Immediate Next Steps (when you're ready to implement)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Prototype schema<br/>catalog.fbs: Catalog · CatalogObject · ServerMessage union"]
|
||||
B["Generate Rust code<br/>flatc --rust → api/build.rs · serve WS via axum"]
|
||||
C["Prototype the Odin reader<br/>flatcc FFI · pure-Odin · OdinArrow"]
|
||||
D["Static fixture files<br/>flatc --binary → committed .bin"]
|
||||
E["Cross-language tests<br/>Rust + Odin read the same fixtures identically"]
|
||||
A --> B --> C --> D --> E
|
||||
```
|
||||
|
||||
1. **Prototype schema first.** Write `catalog.fbs` covering `Catalog`, `CatalogObject`,
|
||||
and a `ServerMessage` union (handshake / catalog list / chunk of objects / end).
|
||||
2. **Generate Rust code** via `flatc --rust` in an `api/build.rs` (see
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# 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
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
# 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
|
||||
+6
-15
@@ -18,19 +18,10 @@ case "$ARCH" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# If a prior job restored the install from cache, reuse it instead of
|
||||
# re-downloading. The extracted Odin binary already on PATH is authoritative;
|
||||
# otherwise fetch the version (plus the raylib workaround) fresh.
|
||||
if [ -x /tmp/odin/odin ]; then
|
||||
echo "Odin ${VERSION} found in cache (host arch: ${ARCH}, release arch: ${OBJ_ARCH})"
|
||||
BIN_DIR=/tmp/odin
|
||||
else
|
||||
curl -fL -o /tmp/odin.tar.gz \
|
||||
"https://github.com/odin-lang/Odin/releases/download/${VERSION}/odin-linux-${OBJ_ARCH}-${VERSION}.tar.gz"
|
||||
mkdir -p /tmp/odin
|
||||
tar -xzf /tmp/odin.tar.gz -C /tmp/odin --strip-components=1
|
||||
BIN_DIR=/tmp/odin
|
||||
fi
|
||||
curl -fL -o /tmp/odin.tar.gz \
|
||||
"https://github.com/odin-lang/Odin/releases/download/${VERSION}/odin-linux-${OBJ_ARCH}-${VERSION}.tar.gz"
|
||||
mkdir -p /tmp/odin
|
||||
tar -xzf /tmp/odin.tar.gz -C /tmp/odin --strip-components=1
|
||||
|
||||
# Work around an Odin binding bug: for ODIN_ARCH == .arm64 the vendored
|
||||
# raylib references `vendor/raylib/linux-arm/libraylib.a`, but the release
|
||||
@@ -42,7 +33,7 @@ if [ "${OBJ_ARCH}" = "arm64" ] \
|
||||
fi
|
||||
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
echo "${BIN_DIR}" >> "$GITHUB_PATH"
|
||||
echo "/tmp/odin" >> "$GITHUB_PATH"
|
||||
fi
|
||||
|
||||
echo "Odin ${VERSION} ready (host arch: ${ARCH}, release arch: ${OBJ_ARCH})"
|
||||
echo "Installed Odin ${VERSION} (host arch: ${ARCH}, release arch: ${OBJ_ARCH})"
|
||||
|
||||
Reference in New Issue
Block a user