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 URL patterns the ops console owns, read from the bundle being served.
//!
//! A single-page app has two servers in its life — the Vite dev server while it
//! is being built, and this one once it ships — and they have to answer a
//! document navigation the same way. When a browser asks for `/workflows` (a
//! hard refresh, a bookmark, "open link in new tab") there is no file at that
//! path and the only correct answer is `index.html`, so the app mounts and
//! routes itself.
//!
//! They used to disagree. The dev proxy served `index.html` for every document
//! navigation while this server kept an inverted list of paths it REFUSED to
//! fall back for — a list that named `workflows`, which is where the console's
//! main screen lives. The console's primary screen was a bare 404 on the
//! shipped binary and perfect on every builder's machine.
//!
//! So neither side keeps a list. The console declares its routes once
//! (`apps/aion-ops-console/src/app/clientRoutes.ts`), its build writes them into
//! the bundle as `client-routes.json`, and this module reads that file out of
//! the same bundle it is serving. A route added to the console reaches this
//! server through `cargo xtask build-ops-console`, with nothing to edit here.
//!
//! Everything outside the declared set stays a plain 404 — the property the old
//! reservation list existed to protect. An API client probing a wrong path must
//! never be handed a page of HTML for its JSON parser to choke on.

use serde::Deserialize;

/// The manifest's name in the bundle, beside `index.html`.
///
/// Must equal `CLIENT_ROUTES_MANIFEST_FILENAME` in the console's
/// `src/app/clientRoutes.ts`.
pub const CLIENT_ROUTES_MANIFEST: &str = "client-routes.json";

/// The parameter shape a pattern segment may carry.
///
/// One shape, because the console has one parameterised route:
/// `/workflows/{uuid}`, whose parameter is a `WorkflowId` — a newtype over
/// `Uuid`. Matching the SHAPE rather than "any segment" is what keeps
/// `/workflows/count` and every other fixed API verb under `/workflows/` out of
/// the console's route set.
const UUID_PARAMETER: &str = "{uuid}";

/// An AWL identifier segment — the definition route's workflow type. The
/// shape (a letter or underscore, then letters, digits, underscores) is what
/// keeps fixed verbs that might ever grow under `/definition/` from being
/// swallowed by the SPA fallback the way bare "any segment" matching would.
const NAME_PARAMETER: &str = "{name}";

/// A content-hash segment — 64 hex characters, an archive's own identity.
const HASH_PARAMETER: &str = "{hash}";

/// One segment of a declared client-route pattern.
#[derive(Clone, Debug, PartialEq, Eq)]
enum RouteSegment {
    /// A segment that must match exactly.
    Literal(String),
    /// A segment that must parse as a UUID.
    Uuid,
    /// A segment that must be an AWL identifier.
    Name,
    /// A segment that must be a 64-hex content hash.
    Hash,
}

/// The manifest as the console writes it.
#[derive(Deserialize)]
struct ClientRoutesManifest {
    routes: Vec<String>,
}

/// The console's client route set, parsed from a bundle's manifest.
#[derive(Clone, Debug)]
pub struct ClientRoutes {
    patterns: Vec<Vec<RouteSegment>>,
}

impl ClientRoutes {
    /// Parse a bundle's `client-routes.json`.
    ///
    /// # Errors
    ///
    /// Returns a human-readable message when the file is not JSON of the
    /// expected shape, when it declares no routes, or when a pattern carries a
    /// parameter shape this server does not implement. All three are refused
    /// rather than defaulted: a bundle whose route set cannot be read is a
    /// bundle whose screens would 404, and guessing at it would hide exactly
    /// the defect this file exists to prevent.
    pub fn parse(bytes: &[u8]) -> Result<Self, String> {
        let manifest: ClientRoutesManifest = serde_json::from_slice(bytes)
            .map_err(|error| format!("`{CLIENT_ROUTES_MANIFEST}` is not readable: {error}"))?;

        if manifest.routes.is_empty() {
            return Err(format!("`{CLIENT_ROUTES_MANIFEST}` declares no routes"));
        }

        let mut patterns = Vec::with_capacity(manifest.routes.len());
        for route in &manifest.routes {
            patterns.push(parse_pattern(route)?);
        }
        Ok(Self { patterns })
    }

    /// Whether a request path is one of the console's own routes.
    ///
    /// Matching is segment-wise, so a trailing slash is the same route and a
    /// path with more or fewer segments is not. This is the same algorithm the
    /// console's `matchesClientRoute` implements over the same manifest, and
    /// both are driven with the same table of cases so they cannot drift on an
    /// answer.
    #[must_use]
    pub fn matches(&self, path: &str) -> bool {
        let requested: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect();
        self.patterns
            .iter()
            .any(|pattern| pattern_matches(pattern, &requested))
    }
}

fn parse_pattern(route: &str) -> Result<Vec<RouteSegment>, String> {
    route
        .split('/')
        .filter(|part| !part.is_empty())
        .map(|part| {
            if part == UUID_PARAMETER {
                Ok(RouteSegment::Uuid)
            } else if part == NAME_PARAMETER {
                Ok(RouteSegment::Name)
            } else if part == HASH_PARAMETER {
                Ok(RouteSegment::Hash)
            } else if part.starts_with('{') {
                Err(format!(
                    "`{CLIENT_ROUTES_MANIFEST}` route `{route}` carries parameter shape `{part}`, \
                     which this server does not implement"
                ))
            } else {
                Ok(RouteSegment::Literal(part.to_owned()))
            }
        })
        .collect()
}

fn pattern_matches(pattern: &[RouteSegment], requested: &[&str]) -> bool {
    if pattern.len() != requested.len() {
        return false;
    }
    pattern
        .iter()
        .zip(requested)
        .all(|(segment, value)| match segment {
            RouteSegment::Literal(literal) => literal == value,
            RouteSegment::Uuid => uuid::Uuid::parse_str(value).is_ok(),
            RouteSegment::Name => is_awl_identifier(value),
            RouteSegment::Hash => is_content_hash(value),
        })
}

/// Whether a segment is shaped like an AWL identifier.
fn is_awl_identifier(value: &str) -> bool {
    let mut chars = value.chars();
    chars
        .next()
        .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}

/// Whether a segment is shaped like a content hash: exactly 64 hex characters.
///
/// Case-insensitive for the same reason the UUID arm is: the console renders
/// lowercase, but a hand-edited URL differing only in case names the same
/// archive, and serving the SPA there beats a bare server 404.
fn is_content_hash(value: &str) -> bool {
    value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The declaration as the console's build writes it, so these arms are
    /// driven by the real shape rather than a hand-rolled one.
    const MANIFEST: &str = r#"{
      "routes": [
        "/",
        "/workflows",
        "/workflows/{uuid}",
        "/studio",
        "/launch",
        "/triage",
        "/settings",
        "/kit",
        "/definition/{name}/{hash}"
      ]
    }"#;

    type TestResult = Result<(), Box<dyn std::error::Error>>;

    /// 🔴 THE SAME TABLE THE CONSOLE'S `clientRoutes.test.ts` DRIVES.
    ///
    /// Two implementations of one algorithm over one declaration only stay
    /// honest if both are asked the same questions, so the questions are
    /// written here and there in the same order with the same answers.
    #[test]
    fn client_routes_match_the_console() -> TestResult {
        let routes = ClientRoutes::parse(MANIFEST.as_bytes())?;
        let cases: [(&str, bool); 20] = [
            ("/", true),
            ("/workflows", true),
            ("/workflows/", true),
            ("/workflows/141852b2-20b9-4e94-8361-7a1ea3d5f910", true),
            ("/studio", true),
            ("/launch", true),
            ("/triage", true),
            ("/settings", true),
            ("/kit", true),
            ("/workflows/count", false),
            ("/workflows/not-a-uuid", false),
            ("/workflows/list/extra", false),
            ("/whoami", false),
            ("/events", false),
            ("/no-such-console-screen", false),
            ("/studio/deep/link", false),
            (
                "/definition/grade/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                true,
            ),
            ("/definition/grade/abc123", false),
            (
                "/definition/9grade/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                false,
            ),
            (
                "/definition/grade/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/extra",
                false,
            ),
        ];
        for (path, owned) in cases {
            assert_eq!(routes.matches(path), owned, "{path}");
        }
        Ok(())
    }

    /// A manifest this server cannot read is refused, never defaulted: the
    /// console's screens would 404 and the operator would meet the defect
    /// instead of the startup error.
    #[test]
    fn an_unreadable_manifest_is_refused() {
        let malformed = ClientRoutes::parse(b"not json");
        assert!(malformed.is_err(), "malformed JSON must be refused");

        let empty = ClientRoutes::parse(br#"{"routes": []}"#);
        assert!(empty.is_err(), "an empty route set must be refused");

        let unknown = ClientRoutes::parse(br#"{"routes": ["/runs/{slug}"]}"#);
        assert!(
            unknown.is_err(),
            "an unimplemented parameter shape must be refused"
        );
        let message = unknown.err().unwrap_or_default();
        assert!(
            message.contains("{slug}"),
            "the refusal must name the shape it cannot implement, got: {message}"
        );
    }
}