ograf-core 0.3.0

Pure Rust implementation of the OGraf v1 graphics-control API (HTTP + WebSocket) - 100% spec-compliant library
Documentation
use std::collections::HashMap;

use axum::{
    extract::{Path, Query, RawQuery, State},
    http::{HeaderMap, StatusCode},
    response::{IntoResponse, Response},
    Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;

use crate::{
    error::{AppError, Result},
    handlers::{api_key_from, authorize_target},
    models::{Graphic, RenderTarget, RendererInfo},
    renderer_ws,
    store::graphics::GraphicStore,
    AppState,
};

pub async fn list_renderers(
    State(state): State<AppState>,
    headers: HeaderMap,
) -> Result<Json<Value>> {
    let api_key = api_key_from(&headers);
    let all = state.renderers.list_info().await;
    let visible = state.access.filter_visible(&api_key, all).await;
    let list: Vec<Value> = visible
        .iter()
        .map(|r| json!({ "id": r.id, "name": r.name, "renderTarget": r.render_target }))
        .collect();
    Ok(Json(json!({ "renderers": list })))
}

pub async fn get_renderer(
    State(state): State<AppState>,
    Path(renderer_id): Path<String>,
    headers: HeaderMap,
) -> Result<Json<Value>> {
    let id = parse_renderer_id(&renderer_id)?;
    let info = authorize_target(&state, &headers, id).await?;
    let graphics = load_graphics_by_id(&state).await?;

    let render_target_schema = info
        .render_target_schema
        .clone()
        .unwrap_or_else(generic_render_target_schema);

    Ok(Json(json!({
        "renderer": {
            "id": info.id,
            "name": info.name,
            "status": { "status": "OK" },
            "renderTargetSchema": render_target_schema,
            "renderTargets": [target_info(&info, &graphics)],
        }
    })))
}

#[derive(Deserialize)]
pub struct TargetQuery {
    #[serde(rename = "renderTarget")]
    pub render_target: String,
}

pub async fn get_target(
    State(state): State<AppState>,
    Path(renderer_id): Path<String>,
    Query(query): Query<TargetQuery>,
    headers: HeaderMap,
) -> Result<Json<Value>> {
    let id = parse_renderer_id(&renderer_id)?;
    let info = authorize_target(&state, &headers, id).await?;

    let requested: RenderTarget = serde_json::from_str(&query.render_target)
        .map_err(|e| AppError::BadRequest(format!("invalid renderTarget: {e}")))?;

    if requested != info.render_target {
        return Err(AppError::NotFound(format!(
            "renderTarget {requested:?} on renderer '{renderer_id}' (bound to {:?})",
            info.render_target
        )));
    }

    let graphics = load_graphics_by_id(&state).await?;
    Ok(Json(target_info(&info, &graphics)))
}

/// `zone`/`token` (or whatever else an `AccessControl` impl wants) travel in
/// the raw query string, unparsed by Core — see `access::AccessControl`'s
/// doc comment. `AllowAllAccessControl` ignores it entirely.
pub async fn connect_renderer(
    ws: axum::extract::WebSocketUpgrade,
    RawQuery(query): RawQuery,
    State(state): State<AppState>,
) -> Response {
    let query = query.unwrap_or_default();

    if !state.access.authorize_connect(&query).await {
        return (
            StatusCode::FORBIDDEN,
            Json(json!({ "error": "not authorized to connect" })),
        )
            .into_response();
    }

    ws.on_upgrade(move |socket| renderer_ws::handle_session(socket, state, query))
}

pub(crate) fn parse_renderer_id(renderer_id: &str) -> Result<Uuid> {
    Uuid::parse_str(renderer_id)
        .map_err(|_| AppError::BadRequest(format!("invalid renderer id: {renderer_id}")))
}

pub(crate) async fn load_graphics_by_id(state: &AppState) -> Result<HashMap<String, Graphic>> {
    let graphics = GraphicStore::new(&state.config.graphics_storage)
        .list_cached(state.config.graphics_cache_ttl())
        .await?;
    Ok(graphics.into_iter().map(|g| (g.id.clone(), g)).collect())
}

/// Fallback for renderers that didn't declare their own `renderTargetSchema`
/// at `hello` — "any shallow object" is the most permissive schema that's
/// still spec-valid, since we genuinely don't know this renderer's shape.
fn generic_render_target_schema() -> Value {
    json!({ "type": "object" })
}

pub(crate) fn target_info(info: &RendererInfo, graphics: &HashMap<String, Graphic>) -> Value {
    json!({
        "renderTarget": info.render_target,
        "name": info.name,
        "graphicInstances": info.instances.iter().map(|inst| json!({
            "graphicInstanceId": inst.instance_id,
            "graphic": graphics
                .get(&inst.graphic_id)
                .map(Graphic::list_info)
                .unwrap_or_else(|| json!({ "id": inst.graphic_id, "name": inst.graphic_id })),
            // Renderer-confirmed data/state as of the last successful action —
            // not part of the OGraf spec's GraphicInstance shape, but useful for
            // verifying a live round-trip without eyeballing the actual output.
            "data": inst.data,
            "state": inst.state,
            "currentStep": inst.current_step,
        })).collect::<Vec<_>>(),
    })
}