af-web 0.6.0

Reusable Agent Factory host infrastructure for HTTP and gRPC boundaries.
Documentation
//! JSON error envelope. Port of the reusable shape of `agent_core/api/errors.py`.
//!
//! Every handler returns `Result<T, ApiError>`; the error renders as a stable
//! JSON body `{ "error": "...", "request_id": "..." }` with the right status.
//! Carrying the request id back to the client is the Error-Localization rule on
//! the wire — a user-reported failure is greppable by id.

use af_context::RequestId;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;

/// A handler error with an HTTP status and a user-safe message.
#[derive(Debug, Clone)]
pub struct ApiError {
    /// HTTP status.
    pub status: StatusCode,
    /// Human-readable message.
    pub message: String,
    /// Transport request identity for tracing and idempotency.
    pub request_id: Option<RequestId>,
}

impl ApiError {
    /// An error envelope with an explicit status.
    pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
        Self {
            status,
            message: message.into(),
            request_id: None,
        }
    }

    /// An error whose user-safe message resolves through the shared catalog.
    pub fn localized(
        status: StatusCode,
        catalog: &af_i18n::I18n,
        locale: &str,
        key: &str,
        args: &[(&str, &str)],
    ) -> Self {
        Self::new(status, catalog.t(locale, key, args))
    }

    /// HTTP 400.
    pub fn bad_request(message: impl Into<String>) -> Self {
        Self::new(StatusCode::BAD_REQUEST, message)
    }
    /// HTTP 404.
    pub fn not_found(message: impl Into<String>) -> Self {
        Self::new(StatusCode::NOT_FOUND, message)
    }
    /// HTTP 500.
    pub fn internal(message: impl Into<String>) -> Self {
        Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
    }

    /// Attach the request id (the latency/request-id middleware sets one).
    pub fn with_request_id(mut self, id: RequestId) -> Self {
        self.request_id = Some(id);
        self
    }
}

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let body = json!({
            "error": self.message,
            "request_id": self.request_id,
        });
        (self.status, Json(body)).into_response()
    }
}

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

    #[test]
    fn localized_error_uses_catalog_fallback() {
        let mut catalog = af_i18n::I18n::new("en").unwrap();
        catalog
            .load_locale("en", serde_json::json!({"error": "Unavailable"}))
            .unwrap();
        assert_eq!(
            ApiError::localized(
                StatusCode::SERVICE_UNAVAILABLE,
                &catalog,
                "fr",
                "error",
                &[]
            )
            .message,
            "Unavailable"
        );
    }
}