foundation_connectrpc 0.0.3

ConnectRPC for the EWE platform — Connect/gRPC/gRPC-Web on foundation crates
//! `ConnectError` — the canonical RPC error context (Decision 03).
//!
//! WHY: The platform error type on RPC surfaces is `ErrorTrace<ConnectError>`
//! (errstacks trace carrying `ConnectError` as its context). `ConnectError`
//! holds the protocol-agnostic error facts — code, message, structured details,
//! trailing metadata, an optional source, and whether it came from the wire —
//! shared identically across Connect, gRPC, and gRPC-Web.
//!
//! WHAT: [`ConnectError`] with per-code convenience constructors, accessors,
//! `Display`/`Error`, a `From<ConnectError> for ErrorTrace<ConnectError>`
//! (so constructors compose with `?`), a wire-JSON renderer, and the GET-only
//! `not_modified` signalling variant.
//!
//! HOW: A plain struct; the 16 per-code constructors are generated by a local
//! macro. `not_modified` is `Code::Unknown` plus a private sentinel flag
//! (connect-go parity — the 16-code enum has no 304 member).

use std::error::Error as StdError;

use foundation_errstacks::ErrorTrace;
use foundation_netio::shared::http::SimpleHeaders;

use super::code::Code;
use super::detail::ErrorDetail;
use super::wire::{WireError, WireErrorDetail};

/// A ConnectRPC error: code + message (+ optional details, metadata, source).
#[derive(Debug)]
pub struct ConnectError {
    code: Code,
    message: String,
    details: Vec<ErrorDetail>,
    metadata: SimpleHeaders,
    source: Option<Box<dyn StdError + Send + Sync>>,
    wire_error: bool,
    /// GET-only 304 signalling sentinel (Decision 03 P15). Never on the wire as
    /// an error payload; `ErrorWriter` renders it as HTTP 304 with headers only.
    not_modified: bool,
}

/// Generates the per-code convenience constructors (`ConnectError::not_found(..)`).
macro_rules! code_constructors {
    ($($name:ident => $code:ident),+ $(,)?) => {
        $(
            #[doc = concat!("Construct a [`Code::", stringify!($code), "`] error.")]
            #[must_use]
            pub fn $name(message: impl Into<String>) -> Self {
                Self::new(Code::$code, message)
            }
        )+
    };
}

impl ConnectError {
    /// Create an error with a code and message.
    #[must_use]
    pub fn new(code: Code, message: impl Into<String>) -> Self {
        Self {
            code,
            message: message.into(),
            details: Vec::new(),
            metadata: SimpleHeaders::new(),
            source: None,
            wire_error: false,
            not_modified: false,
        }
    }

    /// Create an error wrapping an underlying source error. The message is the
    /// source's `Display`.
    #[must_use]
    pub fn with_source(code: Code, source: impl StdError + Send + Sync + 'static) -> Self {
        let message = source.to_string();
        Self {
            code,
            message,
            details: Vec::new(),
            metadata: SimpleHeaders::new(),
            source: Some(Box::new(source)),
            wire_error: false,
            not_modified: false,
        }
    }

    /// Create an error marked as **server-sent** (came from the wire) — this is
    /// what `is_wire_error()` reports, letting clients distinguish server errors
    /// from local transport/client errors.
    #[must_use]
    pub fn wire(code: Code, message: impl Into<String>) -> Self {
        let mut e = Self::new(code, message);
        e.wire_error = true;
        e
    }

    /// The GET-only 304 Not Modified signalling error (Decision 03 P15). Carries
    /// [`Code::Unknown`] plus a private sentinel; valid only in conditional-GET
    /// handling, where `ErrorWriter` renders it as a bodyless HTTP 304.
    #[must_use]
    pub fn not_modified() -> Self {
        let mut e = Self::new(Code::Unknown, "not modified");
        e.not_modified = true;
        e
    }

    code_constructors! {
        canceled => Canceled,
        unknown => Unknown,
        invalid_argument => InvalidArgument,
        deadline_exceeded => DeadlineExceeded,
        not_found => NotFound,
        already_exists => AlreadyExists,
        permission_denied => PermissionDenied,
        resource_exhausted => ResourceExhausted,
        failed_precondition => FailedPrecondition,
        aborted => Aborted,
        out_of_range => OutOfRange,
        unimplemented => Unimplemented,
        internal => Internal,
        unavailable => Unavailable,
        data_loss => DataLoss,
        unauthenticated => Unauthenticated,
    }

    // ── Accessors ──────────────────────────────────────────────────────────

    /// The status code.
    #[must_use]
    pub fn code(&self) -> Code {
        self.code
    }

    /// The human-readable message.
    #[must_use]
    pub fn message(&self) -> &str {
        &self.message
    }

    /// The attached structured details.
    #[must_use]
    pub fn details(&self) -> &[ErrorDetail] {
        &self.details
    }

    /// Attach a structured detail.
    pub fn add_detail(&mut self, detail: ErrorDetail) {
        self.details.push(detail);
    }

    /// Builder form of [`add_detail`](Self::add_detail).
    #[must_use]
    pub fn with_detail(mut self, detail: ErrorDetail) -> Self {
        self.details.push(detail);
        self
    }

    /// Trailing metadata carried with the error.
    #[must_use]
    pub fn metadata(&self) -> &SimpleHeaders {
        &self.metadata
    }

    /// Mutable trailing metadata.
    pub fn metadata_mut(&mut self) -> &mut SimpleHeaders {
        &mut self.metadata
    }

    /// Whether this error was received from the wire (server-sent).
    #[must_use]
    pub fn is_wire_error(&self) -> bool {
        self.wire_error
    }

    /// Whether this is the GET-only 304 Not Modified signal (Decision 03 P15).
    #[must_use]
    pub fn is_not_modified(&self) -> bool {
        self.not_modified
    }

    // ── Wire rendering ───────────────────────────────────────────────────────

    /// Build the Connect JSON error shape from this error.
    #[must_use]
    pub fn to_wire(&self) -> WireError {
        let message = if self.message.is_empty() {
            None
        } else {
            Some(self.message.clone())
        };
        let details = if self.details.is_empty() {
            None
        } else {
            Some(self.details.iter().map(WireErrorDetail::from_detail).collect())
        };
        WireError {
            code: self.code.as_str().to_string(),
            message,
            details,
        }
    }

    /// Serialize this error to the Connect protocol JSON body.
    ///
    /// # Errors
    /// Returns a `serde_json::Error` only if serialization fails (never in
    /// practice — the wire shape is plain data).
    pub fn to_json(&self) -> Result<String, serde_json::Error> {
        serde_json::to_string(&self.to_wire())
    }
}

impl core::fmt::Display for ConnectError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        write!(f, "[{}] {}", self.code, self.message)
    }
}

impl StdError for ConnectError {
    fn source(&self) -> Option<&(dyn StdError + 'static)> {
        self.source.as_ref().map(|s| s.as_ref() as &(dyn StdError + 'static))
    }
}

impl From<ConnectError> for ErrorTrace<ConnectError> {
    fn from(err: ConnectError) -> Self {
        ErrorTrace::new(err)
    }
}