cellos-server 0.5.1

HTTP control plane for CellOS — admission, projection over JetStream, WebSocket fan-out of CloudEvents. Pure event-sourced architecture.
Documentation
//! RFC 9457 Problem Details for HTTP APIs.
//!
//! Every error path in the server returns `application/problem+json` so
//! that `cellctl` (and the web UI) can render structured diagnostics
//! without parsing free-form strings. The `type` field is a stable
//! identifier — clients may switch on it; the `title`/`detail` fields are
//! human-readable and may change.

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;

/// Stable error identifier. Adding a variant is a non-breaking change;
/// renaming one IS breaking (clients pin on `type`).
#[derive(Debug, Clone, Copy)]
pub enum AppErrorKind {
    Unauthorized,
    BadRequest,
    NotFound,
    Conflict,
    Internal,
    /// Discriminants from ADR-0010 §Enforcement: cellos-server admission
    /// gate rejection reasons. Surfaced via `application/problem+json`
    /// so cellctl can switch on `type` without parsing `detail`.
    FormationCycle,
    FormationMultipleCoordinators,
    FormationNoCoordinator,
    FormationAuthorityNotNarrowing,
}

impl AppErrorKind {
    pub fn status(self) -> StatusCode {
        match self {
            AppErrorKind::Unauthorized => StatusCode::UNAUTHORIZED,
            AppErrorKind::BadRequest
            | AppErrorKind::FormationCycle
            | AppErrorKind::FormationMultipleCoordinators
            | AppErrorKind::FormationNoCoordinator
            | AppErrorKind::FormationAuthorityNotNarrowing => StatusCode::BAD_REQUEST,
            AppErrorKind::NotFound => StatusCode::NOT_FOUND,
            AppErrorKind::Conflict => StatusCode::CONFLICT,
            AppErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }

    /// `type` URI identifier per RFC 9457 §3.1. We use relative URI
    /// references rooted at `/problems/` so the server's deployment URL
    /// does not affect the stable identifier.
    pub fn type_uri(self) -> &'static str {
        match self {
            AppErrorKind::Unauthorized => "/problems/unauthorized",
            AppErrorKind::BadRequest => "/problems/bad-request",
            AppErrorKind::NotFound => "/problems/not-found",
            AppErrorKind::Conflict => "/problems/conflict",
            AppErrorKind::Internal => "/problems/internal",
            AppErrorKind::FormationCycle => "/problems/formation/cycle",
            AppErrorKind::FormationMultipleCoordinators => {
                "/problems/formation/multiple-coordinators"
            }
            AppErrorKind::FormationNoCoordinator => "/problems/formation/no-coordinator",
            AppErrorKind::FormationAuthorityNotNarrowing => {
                "/problems/formation/authority-not-narrowing"
            }
        }
    }

    pub fn title(self) -> &'static str {
        match self {
            AppErrorKind::Unauthorized => "Unauthorized",
            AppErrorKind::BadRequest => "Bad Request",
            AppErrorKind::NotFound => "Not Found",
            AppErrorKind::Conflict => "Conflict",
            AppErrorKind::Internal => "Internal Server Error",
            AppErrorKind::FormationCycle => "Formation rejected: authority cycle",
            AppErrorKind::FormationMultipleCoordinators => {
                "Formation rejected: multiple coordinators"
            }
            AppErrorKind::FormationNoCoordinator => "Formation rejected: no coordinator",
            AppErrorKind::FormationAuthorityNotNarrowing => {
                "Formation rejected: authority does not narrow"
            }
        }
    }
}

#[derive(Debug, Clone)]
pub struct AppError {
    pub kind: AppErrorKind,
    pub detail: String,
}

impl AppError {
    pub fn new(kind: AppErrorKind, detail: impl Into<String>) -> Self {
        Self {
            kind,
            detail: detail.into(),
        }
    }

    pub fn bad_request(detail: impl Into<String>) -> Self {
        Self::new(AppErrorKind::BadRequest, detail)
    }

    pub fn unauthorized(detail: impl Into<String>) -> Self {
        Self::new(AppErrorKind::Unauthorized, detail)
    }

    pub fn not_found(detail: impl Into<String>) -> Self {
        Self::new(AppErrorKind::NotFound, detail)
    }

    pub fn internal(detail: impl Into<String>) -> Self {
        Self::new(AppErrorKind::Internal, detail)
    }
}

/// Wire shape of the problem document (RFC 9457 §3.1).
#[derive(Debug, Serialize)]
struct ProblemDetails<'a> {
    #[serde(rename = "type")]
    type_uri: &'a str,
    title: &'a str,
    status: u16,
    detail: &'a str,
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let status = self.kind.status();
        let body = ProblemDetails {
            type_uri: self.kind.type_uri(),
            title: self.kind.title(),
            status: status.as_u16(),
            detail: &self.detail,
        };
        let mut resp = (status, Json(body)).into_response();
        // RFC 9457 §3 — the media type is `application/problem+json`.
        resp.headers_mut().insert(
            axum::http::header::CONTENT_TYPE,
            axum::http::HeaderValue::from_static("application/problem+json"),
        );
        resp
    }
}

impl From<anyhow::Error> for AppError {
    fn from(e: anyhow::Error) -> Self {
        AppError::internal(format!("{e:#}"))
    }
}

impl From<serde_json::Error> for AppError {
    fn from(e: serde_json::Error) -> Self {
        AppError::bad_request(format!("invalid json: {e}"))
    }
}