aion-server 0.30.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! The step graph of a document, rendered — one picture for three surfaces.
//!
//! `aion awl doc` inlines this SVG into its HTML page; the console's
//! documentation view fetches it from here and inlines the same bytes. The
//! layout (`aion_awl::doc::layout`) runs on the server and never in the
//! console: a step graph laid out twice — once in Rust for the page, once in
//! JavaScript for the console — is two pictures of one document that drift
//! apart on the first patch to either. Coordinates stay OUT of the
//! documentation model (they are view state, computed per render), which is
//! why this is a route of its own beside `…/doc` rather than a field inside
//! it.
//!
//! Two routes, one derivation: the deployed half reads the archived revision
//! the model read, the workspace half derives from the buffer an author is
//! editing — the same pairing `…/doc` and `POST /awl/doc` already make.

use aion_awl::doc::{DocumentDoc, SourceState, StepGraph, layout, svg};
use aion_proto::WireError;
use axum::{
    Json,
    extract::{Path, State},
    http::header,
    response::{IntoResponse, Response},
};

use super::auth::HttpCaller;
use super::awl::{DocumentHttpError, require_authenticated, workspace};
use super::awl_deployed::{DeployedHttpError, authorized_engine, refusal};
use super::error::HttpWireError;
use crate::ServerState;
use crate::awl::deployed::DeployedError;
use crate::awl::{self, CheckRequest, DocResponse};

/// The media type the picture is served as.
const SVG_MEDIA_TYPE: &str = "image/svg+xml; charset=utf-8";

/// `GET /awl/deployed/{workflow_type}/{content_hash}/doc/graph.svg` — the
/// step graph of one deployed revision, rendered.
///
/// Gated exactly as `…/doc` is (the deploy guard): the picture is drawn from
/// the same archived source, so it reveals the same thing.
///
/// A revision whose source is absent, unpersisted, or unreadable has no
/// prose and therefore no graph. That is the same stated 404 the document
/// read gives — never an empty picture, which would read as "this workflow
/// has no steps".
pub(crate) async fn deployed_graph_svg(
    State(state): State<ServerState>,
    HttpCaller(caller): HttpCaller,
    Path((workflow_type, content_hash)): Path<(String, String)>,
) -> Result<Response, Response> {
    let engine = authorized_engine(&state, &caller).map_err(|error| refusal(&error))?;
    let doc = crate::awl::deployed::read_doc(&engine, &workflow_type, &content_hash)
        .await
        .map_err(|error| DeployedHttpError(error).into_response())?;
    let graph = deployed_graph(doc, &workflow_type, &content_hash)
        .map_err(|wire| HttpWireError(wire).into_response())?;
    Ok(render(&graph))
}

/// `POST /awl/doc/graph.svg` — the step graph of a workspace document,
/// rendered from the buffer the author is editing.
///
/// A buffer the checker cannot derive a model from is refused with the
/// derivation's own reason as a JSON wire error — a refusal, not an image, so
/// a caller that asked for a picture is never handed an empty one.
pub(crate) async fn workspace_graph_svg(
    State(state): State<ServerState>,
    HttpCaller(caller): HttpCaller,
    Json(request): Json<CheckRequest>,
) -> Result<Response, Response> {
    require_authenticated(&caller)?;
    let root = workspace(&state).map_err(IntoResponse::into_response)?;
    let response = awl::doc_source_in_workspace(&root, &request)
        .await
        .map_err(|error| DocumentHttpError(error).into_response())?;
    match response {
        DocResponse::Derived { doc } => match doc.prose {
            Some(prose) => Ok(render(&prose.graph)),
            // A model derived from source always carries prose; its absence
            // is the derivation contradicting itself, not the document.
            None => Err(HttpWireError(WireError::backend(
                "the documentation model derived from source carried no prose, so there is no \
                 step graph to draw",
            ))
            .into_response()),
        },
        DocResponse::Refused { reason } => Err(HttpWireError(
            WireError::invalid_input(reason).with_error_type("AwlDocumentRefused"),
        )
        .into_response()),
    }
}

/// The graph of a deployed revision's model, or the refusal that says why
/// there is none — the same words the document read uses for the same
/// absence, so one revision is described one way on both routes.
fn deployed_graph(
    doc: DocumentDoc,
    workflow_type: &str,
    content_hash: &str,
) -> Result<StepGraph, WireError> {
    if let Some(prose) = doc.prose {
        return Ok(prose.graph);
    }
    let workflow_type = workflow_type.to_owned();
    let content_hash = content_hash.to_owned();
    let wire = match doc.source_state {
        SourceState::Absent => DeployedError::NoArchivedSource {
            workflow_type,
            content_hash,
        }
        .to_wire_error(),
        SourceState::Unreadable { reason } => DeployedError::Unreadable {
            workflow_type,
            content_hash,
            reason,
        }
        .to_wire_error(),
        SourceState::NotPersisted => WireError::not_found_with_type(
            "DeployedAwlSourceNotPersisted",
            format!(
                "this server holds workflow type `{workflow_type}` version `{content_hash}` but \
                 persists no archive for it, so its source — and its step graph — cannot be read"
            ),
        ),
        // Available source with no prose cannot come out of the derivation;
        // if it ever does, that is the server's contradiction to report.
        SourceState::Available => WireError::backend(format!(
            "the documentation model of workflow type `{workflow_type}` version `{content_hash}` \
             reports its source available but carries no prose, so there is no step graph to draw"
        )),
    };
    Err(wire)
}

/// Lay the graph out and draw it, as `image/svg+xml`.
fn render(graph: &StepGraph) -> Response {
    let placed = layout::compute(graph);
    let body = svg::render(graph, &placed);
    ([(header::CONTENT_TYPE, SVG_MEDIA_TYPE)], body).into_response()
}