af-web 0.2.0

Reusable axum web infrastructure: request-id + latency-SLO middleware, JSON error envelope, health. The cross-cutting half of agent_core/api/.
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 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 {
    pub status: StatusCode,
    pub message: String,
    pub request_id: Option<String>,
}

impl ApiError {
    pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
        Self {
            status,
            message: message.into(),
            request_id: None,
        }
    }

    pub fn bad_request(message: impl Into<String>) -> Self {
        Self::new(StatusCode::BAD_REQUEST, message)
    }
    pub fn not_found(message: impl Into<String>) -> Self {
        Self::new(StatusCode::NOT_FOUND, message)
    }
    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: impl Into<String>) -> Self {
        self.request_id = Some(id.into());
        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()
    }
}