feat(cosmobiologia-server): server HTTP single-user con CRUD completo (fase 2)
Crate nuevo `cosmobiologia-server` (binario axum, nativo) que
monta `cosmobiologia-engine` + `cosmobiologia-store` y expone
la rueda + el CRUD del tree por HTTP.
Endpoints v1:
- `GET /api/health`
- `GET /api/tree` tree completo anidado
- `POST /api/groups` crear grupo
- `PATCH /api/groups/:id` renombrar
- `DELETE /api/groups/:id` borrar
- `POST /api/contacts` crear contacto
- `PATCH /api/contacts/:id` renombrar
- `DELETE /api/contacts/:id` borrar
- `POST /api/charts` crear carta
- `GET /api/charts/:id` chart JSON
- `PATCH /api/charts/:id` editar (label/birth/config)
- `DELETE /api/charts/:id` borrar
- `GET /api/charts/:id/render` RenderModel JSON
- `GET /api/charts/:id/svg` SVG inline (reusa
svg_export del engine)
- `GET /api/sky` "Cielo ahora" — RenderModel
UTC actual sin chart_id real
Query params del render para activar overlays sin POST:
- `offset_min=<i64>` time scrubbing
- `transit=1` overlay de tránsito al now
- `prog_age=<f64>` progresión secundaria
- `sa_age=<f64>` solar arc
- `pd_age=<f64>` primary directions (Naibod)
Decisiones:
- Single-user, sin auth. Bind por default a `127.0.0.1:8787` —
el server NO debe exponerse a la red pública en esta fase.
- DB por default = misma del desktop (`$XDG_DATA_HOME/cosmobiologia/
charts.db`). `--db` permite override.
- CORS permissive (es localhost, single-user, sin auth).
- `ApiError` con mapeo a HTTP status: 404 NotFound,
400 BadRequest, 500 todo lo demás. Body JSON `{ "error": "..." }`.
Smoke test:
cargo run -p cosmobiologia-server -- --port 18787
curl /api/health → {"status":"ok",...}
curl POST /api/groups → {"id":"01KRYVP...","name":"Familia",...}
curl POST /api/contacts → {"id":"01KRYVP...","group_id":...}
curl /api/tree → árbol anidado
curl /api/sky → RenderModel con VSOP real
Pendiente (fase 3): cliente `cosmobiologia-web` (cdylib WASM)
que consuma estos endpoints y pinte SVG/Canvas2D.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Generated
+117
@@ -870,6 +870,61 @@ dependencies = [
|
||||
"arrayvec 0.7.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backtrace"
|
||||
version = "0.3.76"
|
||||
@@ -2248,6 +2303,26 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cosmobiologia-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"clap",
|
||||
"cosmobiologia-engine",
|
||||
"cosmobiologia-model",
|
||||
"cosmobiologia-render",
|
||||
"cosmobiologia-store",
|
||||
"directories",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cosmobiologia-store"
|
||||
version = "0.1.0"
|
||||
@@ -4917,12 +4992,24 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.3.0"
|
||||
@@ -4943,6 +5030,7 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -6571,6 +6659,12 @@ dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.10"
|
||||
@@ -9955,6 +10049,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -11551,6 +11656,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11561,13 +11667,23 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -11589,6 +11705,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
|
||||
@@ -172,6 +172,7 @@ members = [
|
||||
"crates/apps/lapaloma-financial-demo",
|
||||
"crates/apps/cosmobiologia",
|
||||
"crates/apps/cosmobiologia-cli",
|
||||
"crates/apps/cosmobiologia-server",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "cosmobiologia-server"
|
||||
version = { workspace = true }
|
||||
edition = { workspace = true }
|
||||
license = { workspace = true }
|
||||
description = "Cosmobiología — server HTTP single-user. axum + cosmobiologia-engine. Sirve cartas y assets del cliente web. CRUD completo de groups/contacts/charts."
|
||||
|
||||
[dependencies]
|
||||
cosmobiologia-engine = { path = "../../modules/cosmobiologia/cosmobiologia-engine" }
|
||||
cosmobiologia-model = { path = "../../modules/cosmobiologia/cosmobiologia-model" }
|
||||
cosmobiologia-render = { path = "../../modules/cosmobiologia/cosmobiologia-render" }
|
||||
cosmobiologia-store = { path = "../../modules/cosmobiologia/cosmobiologia-store" }
|
||||
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
directories = { workspace = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
axum = "0.7"
|
||||
tower-http = { version = "0.6", features = ["cors", "trace", "fs"] }
|
||||
|
||||
[[bin]]
|
||||
name = "cosmobiologia-server"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,536 @@
|
||||
//! Cosmobiología — server HTTP single-user.
|
||||
//!
|
||||
//! - Reusa `cosmobiologia-engine` (VSOP2013 + LRU cache) nativo.
|
||||
//! - Comparte (por default) la misma `charts.db` SQLite que la app
|
||||
//! desktop, vía `directories::ProjectDirs::from("net", "gioser",
|
||||
//! "cosmobiologia")`. La idea es: levantar `cosmobiologia-server`
|
||||
//! en localhost y abrir el wheel desde el browser cuando no se está
|
||||
//! con la app desktop.
|
||||
//! - Single-user, sin auth, bind a `127.0.0.1` por default. NO debe
|
||||
//! exponerse a la red pública sin agregar auth + HTTPS.
|
||||
//!
|
||||
//! ## Endpoints (v1)
|
||||
//!
|
||||
//! ```text
|
||||
//! GET /api/health healthcheck
|
||||
//! GET /api/tree tree completo (groups + contacts + charts)
|
||||
//! POST /api/groups crear grupo
|
||||
//! PATCH /api/groups/:id renombrar
|
||||
//! DELETE /api/groups/:id borrar
|
||||
//! POST /api/contacts crear contacto
|
||||
//! PATCH /api/contacts/:id renombrar
|
||||
//! DELETE /api/contacts/:id borrar
|
||||
//! POST /api/charts crear carta (contact_id + birth_data)
|
||||
//! GET /api/charts/:id chart JSON
|
||||
//! PATCH /api/charts/:id renombrar / editar birth_data
|
||||
//! DELETE /api/charts/:id borrar
|
||||
//! GET /api/charts/:id/render RenderModel JSON (overlays via query)
|
||||
//! GET /api/charts/:id/svg SVG inline
|
||||
//! GET /api/sky "Cielo ahora" — RenderModel UTC actual
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(rust_2018_idioms)]
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, patch, post};
|
||||
use axum::{Json, Router};
|
||||
use clap::Parser;
|
||||
use cosmobiologia_engine::{
|
||||
compose_with_options, svg_export, EngineError, NatalOptions, PipelineRequest, RenderModel,
|
||||
};
|
||||
use cosmobiologia_model::{
|
||||
Chart, ChartId, ChartKind, Contact, ContactId, Group, GroupId, StoredBirthData,
|
||||
StoredChartConfig,
|
||||
};
|
||||
use cosmobiologia_store::Store;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::info;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "cosmobiologia-server",
|
||||
about = "Servidor HTTP single-user de Cosmobiología."
|
||||
)]
|
||||
struct Cli {
|
||||
/// Puerto donde escuchar. Default 8787.
|
||||
#[arg(long, default_value = "8787")]
|
||||
port: u16,
|
||||
/// IP a bindear. Default `127.0.0.1` (solo localhost — single-user
|
||||
/// sin auth).
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
bind: String,
|
||||
/// Path al archivo SQLite. Default = el mismo de la app desktop
|
||||
/// (`$XDG_DATA_HOME/cosmobiologia/charts.db`).
|
||||
#[arg(long)]
|
||||
db: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
store: Arc<Store>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "cosmobiologia_server=info,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
let db_path = match cli.db {
|
||||
Some(p) => p,
|
||||
None => default_db_path()?,
|
||||
};
|
||||
info!("DB: {}", db_path.display());
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
let store = Arc::new(Store::open(&db_path)?);
|
||||
|
||||
let state = AppState { store };
|
||||
let app = router().with_state(state);
|
||||
|
||||
let addr: SocketAddr = format!("{}:{}", cli.bind, cli.port).parse()?;
|
||||
info!("listening on http://{}", addr);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn default_db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let dirs = directories::ProjectDirs::from("net", "gioser", "cosmobiologia")
|
||||
.ok_or("no se pudo determinar XDG data dir")?;
|
||||
Ok(dirs.data_dir().join("charts.db"))
|
||||
}
|
||||
|
||||
fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/api/health", get(health))
|
||||
.route("/api/tree", get(get_tree))
|
||||
.route("/api/sky", get(get_sky))
|
||||
.route("/api/groups", post(post_group))
|
||||
.route("/api/groups/:id", patch(patch_group).delete(delete_group))
|
||||
.route("/api/contacts", post(post_contact))
|
||||
.route(
|
||||
"/api/contacts/:id",
|
||||
patch(patch_contact).delete(delete_contact),
|
||||
)
|
||||
.route("/api/charts", post(post_chart))
|
||||
.route(
|
||||
"/api/charts/:id",
|
||||
get(get_chart).patch(patch_chart).delete(delete_chart),
|
||||
)
|
||||
.route("/api/charts/:id/render", get(get_chart_render))
|
||||
.route("/api/charts/:id/svg", get(get_chart_svg))
|
||||
.layer(CorsLayer::permissive()) // single-user, localhost: cors abierto
|
||||
.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Error
|
||||
// =====================================================================
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
enum ApiError {
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
#[error("store: {0}")]
|
||||
Store(#[from] cosmobiologia_store::StoreError),
|
||||
#[error("engine: {0}")]
|
||||
Engine(#[from] EngineError),
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (code, msg) = match &self {
|
||||
ApiError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
ApiError::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
||||
};
|
||||
(code, Json(serde_json::json!({ "error": msg }))).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
type ApiResult<T> = Result<Json<T>, ApiError>;
|
||||
|
||||
// =====================================================================
|
||||
// Health
|
||||
// =====================================================================
|
||||
|
||||
async fn health() -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({ "status": "ok", "service": "cosmobiologia-server" }))
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Tree — listado completo
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TreeNode {
|
||||
id: String,
|
||||
label: String,
|
||||
kind: &'static str, // "group" | "contact" | "chart"
|
||||
children: Vec<TreeNode>,
|
||||
}
|
||||
|
||||
async fn get_tree(State(s): State<AppState>) -> ApiResult<Vec<TreeNode>> {
|
||||
let mut roots = Vec::new();
|
||||
// Grupos top-level
|
||||
for g in s.store.list_groups(None)? {
|
||||
roots.push(group_node(&s.store, &g)?);
|
||||
}
|
||||
// Contactos sin grupo (van bajo "General" en el tree desktop;
|
||||
// acá los listamos directo al root para no confundir al cliente).
|
||||
for c in s.store.list_contacts(None)? {
|
||||
roots.push(contact_node(&s.store, &c)?);
|
||||
}
|
||||
Ok(Json(roots))
|
||||
}
|
||||
|
||||
fn group_node(store: &Store, g: &Group) -> Result<TreeNode, ApiError> {
|
||||
let mut children = Vec::new();
|
||||
for sub in store.list_groups(Some(g.id))? {
|
||||
children.push(group_node(store, &sub)?);
|
||||
}
|
||||
for c in store.list_contacts(Some(g.id))? {
|
||||
children.push(contact_node(store, &c)?);
|
||||
}
|
||||
Ok(TreeNode {
|
||||
id: format!("g:{}", g.id),
|
||||
label: g.name.clone(),
|
||||
kind: "group",
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
fn contact_node(store: &Store, c: &Contact) -> Result<TreeNode, ApiError> {
|
||||
let charts = store.list_charts(c.id).unwrap_or_default();
|
||||
let children: Vec<TreeNode> = charts
|
||||
.into_iter()
|
||||
.map(|h| TreeNode {
|
||||
id: format!("h:{}", h.id),
|
||||
label: h.label,
|
||||
kind: "chart",
|
||||
children: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
Ok(TreeNode {
|
||||
id: format!("c:{}", c.id),
|
||||
label: c.name.clone(),
|
||||
kind: "contact",
|
||||
children,
|
||||
})
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Groups CRUD
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateGroupBody {
|
||||
name: String,
|
||||
parent: Option<GroupId>,
|
||||
}
|
||||
|
||||
async fn post_group(
|
||||
State(s): State<AppState>,
|
||||
Json(b): Json<CreateGroupBody>,
|
||||
) -> ApiResult<Group> {
|
||||
let g = s.store.create_group(b.parent, &b.name, None)?;
|
||||
Ok(Json(g))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PatchGroupBody {
|
||||
name: String,
|
||||
}
|
||||
|
||||
async fn patch_group(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<GroupId>,
|
||||
Json(b): Json<PatchGroupBody>,
|
||||
) -> ApiResult<serde_json::Value> {
|
||||
s.store.rename_group(id, &b.name)?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
async fn delete_group(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<GroupId>,
|
||||
) -> ApiResult<serde_json::Value> {
|
||||
s.store.delete_group(id)?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Contacts CRUD
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateContactBody {
|
||||
name: String,
|
||||
group: Option<GroupId>,
|
||||
}
|
||||
|
||||
async fn post_contact(
|
||||
State(s): State<AppState>,
|
||||
Json(b): Json<CreateContactBody>,
|
||||
) -> ApiResult<Contact> {
|
||||
let c = s.store.create_contact(b.group, &b.name, None)?;
|
||||
Ok(Json(c))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PatchContactBody {
|
||||
name: String,
|
||||
}
|
||||
|
||||
async fn patch_contact(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<ContactId>,
|
||||
Json(b): Json<PatchContactBody>,
|
||||
) -> ApiResult<serde_json::Value> {
|
||||
s.store.rename_contact(id, &b.name)?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
async fn delete_contact(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<ContactId>,
|
||||
) -> ApiResult<serde_json::Value> {
|
||||
s.store.delete_contact(id)?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Charts CRUD
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreateChartBody {
|
||||
contact_id: ContactId,
|
||||
#[serde(default)]
|
||||
kind: Option<ChartKind>,
|
||||
label: String,
|
||||
birth_data: StoredBirthData,
|
||||
#[serde(default)]
|
||||
config: Option<StoredChartConfig>,
|
||||
}
|
||||
|
||||
async fn post_chart(
|
||||
State(s): State<AppState>,
|
||||
Json(b): Json<CreateChartBody>,
|
||||
) -> ApiResult<Chart> {
|
||||
let kind = b.kind.unwrap_or(ChartKind::Natal);
|
||||
let cfg = b.config.unwrap_or_default();
|
||||
let chart = s
|
||||
.store
|
||||
.create_chart(b.contact_id, kind, &b.label, &b.birth_data, &cfg, None)?;
|
||||
Ok(Json(chart))
|
||||
}
|
||||
|
||||
async fn get_chart(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<ChartId>,
|
||||
) -> ApiResult<Chart> {
|
||||
let chart = s
|
||||
.store
|
||||
.get_chart(id)
|
||||
.map_err(|_| ApiError::NotFound(format!("chart {}", id)))?;
|
||||
Ok(Json(chart))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PatchChartBody {
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
#[serde(default)]
|
||||
birth_data: Option<StoredBirthData>,
|
||||
#[serde(default)]
|
||||
config: Option<StoredChartConfig>,
|
||||
}
|
||||
|
||||
async fn patch_chart(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<ChartId>,
|
||||
Json(b): Json<PatchChartBody>,
|
||||
) -> ApiResult<serde_json::Value> {
|
||||
let current = s
|
||||
.store
|
||||
.get_chart(id)
|
||||
.map_err(|_| ApiError::NotFound(format!("chart {}", id)))?;
|
||||
let label = b.label.unwrap_or(current.label);
|
||||
let birth = b.birth_data.unwrap_or(current.birth_data);
|
||||
let cfg = b.config.unwrap_or(current.config);
|
||||
s.store.update_chart(id, &label, &birth, &cfg)?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
async fn delete_chart(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<ChartId>,
|
||||
) -> ApiResult<serde_json::Value> {
|
||||
s.store.delete_chart(id)?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Render
|
||||
// =====================================================================
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct RenderQuery {
|
||||
/// Offset de tiempo en minutos (para "scrubbing").
|
||||
#[serde(default)]
|
||||
offset_min: i64,
|
||||
/// "1" = activar overlay de tránsitos al `now` del server.
|
||||
#[serde(default)]
|
||||
transit: u8,
|
||||
/// Edad (años) — activa progresión secundaria si se setea.
|
||||
#[serde(default)]
|
||||
prog_age: Option<f64>,
|
||||
/// Edad (años) — activa solar arc si se setea.
|
||||
#[serde(default)]
|
||||
sa_age: Option<f64>,
|
||||
/// Edad (años) — activa primary directions si se setea.
|
||||
#[serde(default)]
|
||||
pd_age: Option<f64>,
|
||||
}
|
||||
|
||||
fn build_requests(q: &RenderQuery) -> Vec<PipelineRequest> {
|
||||
let mut r = Vec::new();
|
||||
if q.transit == 1 {
|
||||
r.push(PipelineRequest::Transit);
|
||||
}
|
||||
if let Some(a) = q.prog_age {
|
||||
r.push(PipelineRequest::SecondaryProgression { target_age_years: a });
|
||||
}
|
||||
if let Some(a) = q.sa_age {
|
||||
r.push(PipelineRequest::SolarArc { target_age_years: a });
|
||||
}
|
||||
if let Some(a) = q.pd_age {
|
||||
r.push(PipelineRequest::PrimaryDirections {
|
||||
target_age_years: a,
|
||||
key: "naibod".into(),
|
||||
});
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
async fn get_chart_render(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<ChartId>,
|
||||
Query(q): Query<RenderQuery>,
|
||||
) -> ApiResult<RenderModel> {
|
||||
let chart = s
|
||||
.store
|
||||
.get_chart(id)
|
||||
.map_err(|_| ApiError::NotFound(format!("chart {}", id)))?;
|
||||
let model =
|
||||
compose_with_options(&chart, q.offset_min, &build_requests(&q), &NatalOptions::default())?;
|
||||
Ok(Json(model))
|
||||
}
|
||||
|
||||
async fn get_chart_svg(
|
||||
State(s): State<AppState>,
|
||||
Path(id): Path<ChartId>,
|
||||
Query(q): Query<RenderQuery>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let chart = s
|
||||
.store
|
||||
.get_chart(id)
|
||||
.map_err(|_| ApiError::NotFound(format!("chart {}", id)))?;
|
||||
let model =
|
||||
compose_with_options(&chart, q.offset_min, &build_requests(&q), &NatalOptions::default())?;
|
||||
let svg = svg_export::render_to_svg(&model);
|
||||
Ok((
|
||||
[(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
|
||||
svg,
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Sky now — sin chart
|
||||
// =====================================================================
|
||||
|
||||
async fn get_sky() -> ApiResult<RenderModel> {
|
||||
let chart = build_present_sky_chart();
|
||||
let model = compose_with_options(&chart, 0, &[], &NatalOptions::default())?;
|
||||
Ok(Json(model))
|
||||
}
|
||||
|
||||
fn build_present_sky_chart() -> Chart {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
let (year, month, day, hour, minute, second) = unix_to_civil_utc(secs);
|
||||
let birth = StoredBirthData {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
second: second as f64,
|
||||
tz_offset_minutes: 0,
|
||||
latitude_deg: 51.4769, // Greenwich
|
||||
longitude_deg: 0.0,
|
||||
altitude_m: 47.0,
|
||||
time_certainty: Default::default(),
|
||||
subject_name: Some("Cielo".into()),
|
||||
birthplace_label: Some("Greenwich (UTC)".into()),
|
||||
};
|
||||
Chart {
|
||||
id: ChartId::default(),
|
||||
contact_id: ContactId::default(),
|
||||
kind: ChartKind::Natal,
|
||||
label: format!(
|
||||
"Cielo {:04}-{:02}-{:02} {:02}:{:02} UTC",
|
||||
year, month, day, hour, minute
|
||||
),
|
||||
birth_data: birth,
|
||||
config: StoredChartConfig::default(),
|
||||
related_chart_id: None,
|
||||
created_at_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Howard Hinnant `days_to_civil` — Unix UTC → calendario.
|
||||
/// Mismo algoritmo que en la app desktop; duplicado mínimo para no
|
||||
/// arrastrar el shell entero como dep del server.
|
||||
fn unix_to_civil_utc(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
|
||||
let day_seconds: i64 = 86_400;
|
||||
let z = secs.div_euclid(day_seconds);
|
||||
let s = secs.rem_euclid(day_seconds);
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u32;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = if month <= 2 { (y + 1) as i32 } else { y as i32 };
|
||||
let hour = (s / 3600) as u32;
|
||||
let minute = ((s % 3600) / 60) as u32;
|
||||
let second = (s % 60) as u32;
|
||||
(year, month, day, hour, minute, second)
|
||||
}
|
||||
Reference in New Issue
Block a user