use std::time::Duration;
use axum::{
extract::{Path, Query, State},
http::HeaderMap,
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use crate::{
error::{ApiError, ApiResult},
middleware::{resolve_org_context, AuthUser},
models::HostedMock,
AppState,
};
const RUNTIME_HTTP_PORT: u16 = 3000;
const PROXY_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Serialize)]
pub struct WorldStateEnvelope {
pub runtime_state: &'static str,
pub data: Value,
}
impl WorldStateEnvelope {
fn live(data: Value) -> Self {
Self {
runtime_state: "live",
data,
}
}
fn unreachable() -> Self {
Self {
runtime_state: "unreachable",
data: Value::Null,
}
}
}
#[derive(Debug, Deserialize)]
pub struct GraphQueryParams {
pub layers: Option<String>,
}
type QueryRequestBody = Value;
pub async fn get_snapshot(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(deployment_id): Path<Uuid>,
headers: HeaderMap,
) -> ApiResult<Json<WorldStateEnvelope>> {
let deployment = authorize_deployment(&state, user_id, &headers, deployment_id).await?;
let url = format!("{}/api/world-state/snapshot", runtime_base_url(&deployment));
Ok(Json(proxy_get(&url, deployment_id, "snapshot").await))
}
pub async fn get_snapshot_by_id(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path((deployment_id, snapshot_id)): Path<(Uuid, String)>,
headers: HeaderMap,
) -> ApiResult<Json<WorldStateEnvelope>> {
let deployment = authorize_deployment(&state, user_id, &headers, deployment_id).await?;
let url = format!(
"{}/api/world-state/snapshot/{}",
runtime_base_url(&deployment),
urlencoding::encode(&snapshot_id),
);
Ok(Json(proxy_get(&url, deployment_id, "snapshot_by_id").await))
}
pub async fn get_graph(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(deployment_id): Path<Uuid>,
Query(params): Query<GraphQueryParams>,
headers: HeaderMap,
) -> ApiResult<Json<WorldStateEnvelope>> {
let deployment = authorize_deployment(&state, user_id, &headers, deployment_id).await?;
let url = match params.layers {
Some(layers) if !layers.is_empty() => format!(
"{}/api/world-state/graph?layers={}",
runtime_base_url(&deployment),
urlencoding::encode(&layers),
),
_ => format!("{}/api/world-state/graph", runtime_base_url(&deployment)),
};
Ok(Json(proxy_get(&url, deployment_id, "graph").await))
}
pub async fn get_layers(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(deployment_id): Path<Uuid>,
headers: HeaderMap,
) -> ApiResult<Json<WorldStateEnvelope>> {
let deployment = authorize_deployment(&state, user_id, &headers, deployment_id).await?;
let url = format!("{}/api/world-state/layers", runtime_base_url(&deployment));
Ok(Json(proxy_get(&url, deployment_id, "layers").await))
}
pub async fn query(
State(state): State<AppState>,
AuthUser(user_id): AuthUser,
Path(deployment_id): Path<Uuid>,
headers: HeaderMap,
Json(body): Json<QueryRequestBody>,
) -> ApiResult<Json<WorldStateEnvelope>> {
let deployment = authorize_deployment(&state, user_id, &headers, deployment_id).await?;
let url = format!("{}/api/world-state/query", runtime_base_url(&deployment));
Ok(Json(proxy_post_json(&url, &body, deployment_id, "query").await))
}
fn runtime_base_url(deployment: &HostedMock) -> String {
format!("http://{}.internal:{RUNTIME_HTTP_PORT}", deployment.fly_app_name())
}
async fn proxy_get(url: &str, deployment_id: Uuid, op: &'static str) -> WorldStateEnvelope {
let client = match reqwest::Client::builder().timeout(PROXY_TIMEOUT).build() {
Ok(c) => c,
Err(err) => {
tracing::warn!(%deployment_id, op, error = %err, "reqwest client build failed");
return WorldStateEnvelope::unreachable();
}
};
match client.get(url).send().await {
Ok(resp) => match resp.error_for_status() {
Ok(resp) => match resp.json::<Value>().await {
Ok(body) => WorldStateEnvelope::live(body),
Err(err) => {
tracing::warn!(%deployment_id, op, error = %err, "world-state proxy GET JSON parse failed");
WorldStateEnvelope::unreachable()
}
},
Err(err) => {
tracing::warn!(%deployment_id, op, error = %err, "world-state proxy GET non-2xx");
WorldStateEnvelope::unreachable()
}
},
Err(err) => {
tracing::warn!(%deployment_id, op, error = %err, "world-state proxy GET failed");
WorldStateEnvelope::unreachable()
}
}
}
async fn proxy_post_json<B: Serialize>(
url: &str,
body: &B,
deployment_id: Uuid,
op: &'static str,
) -> WorldStateEnvelope {
let client = match reqwest::Client::builder().timeout(PROXY_TIMEOUT).build() {
Ok(c) => c,
Err(err) => {
tracing::warn!(%deployment_id, op, error = %err, "reqwest client build failed");
return WorldStateEnvelope::unreachable();
}
};
match client.post(url).json(body).send().await {
Ok(resp) => match resp.error_for_status() {
Ok(resp) => match resp.json::<Value>().await {
Ok(body) => WorldStateEnvelope::live(body),
Err(err) => {
tracing::warn!(%deployment_id, op, error = %err, "world-state proxy POST JSON parse failed");
WorldStateEnvelope::unreachable()
}
},
Err(err) => {
tracing::warn!(%deployment_id, op, error = %err, "world-state proxy POST non-2xx");
WorldStateEnvelope::unreachable()
}
},
Err(err) => {
tracing::warn!(%deployment_id, op, error = %err, "world-state proxy POST failed");
WorldStateEnvelope::unreachable()
}
}
}
async fn authorize_deployment(
state: &AppState,
user_id: Uuid,
headers: &HeaderMap,
deployment_id: Uuid,
) -> ApiResult<HostedMock> {
let deployment = HostedMock::find_by_id(state.db.pool(), deployment_id)
.await?
.ok_or_else(|| ApiError::InvalidRequest("Deployment not found".into()))?;
let ctx = resolve_org_context(state, user_id, headers, None)
.await
.map_err(|_| ApiError::InvalidRequest("Organization not found".into()))?;
if ctx.org_id != deployment.org_id {
return Err(ApiError::InvalidRequest("Deployment not found".into()));
}
Ok(deployment)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn envelope_live_round_trips_arbitrary_value() {
let env = WorldStateEnvelope::live(json!({
"snapshot_id": "snap-1",
"nodes": [],
"edges": [],
}));
let body = serde_json::to_value(&env).unwrap();
assert_eq!(body["runtime_state"], "live");
assert_eq!(body["data"]["snapshot_id"], "snap-1");
assert!(body["data"]["nodes"].is_array());
}
#[test]
fn envelope_unreachable_is_null_data() {
let env = WorldStateEnvelope::unreachable();
let body = serde_json::to_value(&env).unwrap();
assert_eq!(body["runtime_state"], "unreachable");
assert!(body["data"].is_null());
}
#[test]
fn graph_query_params_default_has_no_layers() {
let params: GraphQueryParams = serde_json::from_str("{}").unwrap();
assert!(params.layers.is_none());
}
#[test]
fn graph_query_params_carries_comma_list() {
let params: GraphQueryParams =
serde_json::from_str(r#"{"layers":"accounts,inventory"}"#).unwrap();
assert_eq!(params.layers.as_deref(), Some("accounts,inventory"));
}
}