jerrycan-core 0.2.0

Core of the jerrycan framework: routing, extractors, dependency injection, middleware. https://jerrycan.cc
Documentation
//! jerrycan's single error type. Every error carries a stable `code` (JC####)
//! that maps to a documentation anchor — the error-driven-docs contract (spec §8).

use http::StatusCode;
use std::fmt;

/// Convenience alias used across jerrycan and generated apps.
pub type Result<T, E = Error> = std::result::Result<T, E>;

/// The one error type of the framework (spec §4.1 "Errors").
///
/// Production responses render only `code` + `message` as JSON; internals
/// (sources, backtraces) are for logs — enforced in Phase 1's observe layer.
#[derive(Debug)]
pub struct Error {
    status: StatusCode,
    code: &'static str,
    message: String,
    details: Option<serde_json::Value>,
}

impl Error {
    /// Build an error with an explicit status and stable code.
    pub fn new(status: StatusCode, code: &'static str, message: impl Into<String>) -> Self {
        Self {
            status,
            code,
            message: message.into(),
            details: None,
        }
    }

    pub fn bad_request(message: impl Into<String>) -> Self {
        Self::new(StatusCode::BAD_REQUEST, "JC0400", message)
    }
    pub fn not_found() -> Self {
        Self::new(StatusCode::NOT_FOUND, "JC0404", "not found")
    }
    pub fn method_not_allowed() -> Self {
        Self::new(
            StatusCode::METHOD_NOT_ALLOWED,
            "JC0405",
            "method not allowed",
        )
    }
    /// The write conflicts with existing state (e.g. a unique key already taken).
    pub fn conflict(message: impl Into<String>) -> Self {
        Self::new(StatusCode::CONFLICT, "JC0409", message)
    }
    pub fn payload_too_large() -> Self {
        Self::new(StatusCode::PAYLOAD_TOO_LARGE, "JC0413", "payload too large")
    }
    /// The request's content type is not what this endpoint consumes (e.g. a
    /// `Multipart` extractor on a non-`multipart/form-data` request).
    pub fn unsupported_media_type() -> Self {
        Self::new(
            StatusCode::UNSUPPORTED_MEDIA_TYPE,
            "JC0415",
            "unsupported media type",
        )
    }
    pub fn unprocessable(message: impl Into<String>) -> Self {
        Self::new(StatusCode::UNPROCESSABLE_ENTITY, "JC0422", message)
    }
    /// The client exceeded its configured rate limit for the current window
    /// (the rate-limit extension; spec §v2.2). The response also carries a
    /// `Retry-After` header, which the middleware sets — `Error` has no header
    /// channel.
    pub fn too_many_requests() -> Self {
        Self::new(
            StatusCode::TOO_MANY_REQUESTS,
            "JC0429",
            "rate limit exceeded",
        )
    }
    /// A background job exhausted its retries and was dead-lettered, or failed
    /// irrecoverably (the jobs engine; spec §v2.3). Surfaced in operator logs and
    /// the dead-letter table, not to an HTTP client.
    pub fn job_failed(message: impl Into<String>) -> Self {
        Self::new(StatusCode::INTERNAL_SERVER_ERROR, "JC0521", message)
    }
    /// Authentication is required or failed (spec §4.4 auth).
    pub fn unauthorized() -> Self {
        Self::new(
            StatusCode::UNAUTHORIZED,
            "JC0401",
            "authentication required",
        )
    }
    /// Authenticated but not permitted.
    pub fn forbidden() -> Self {
        Self::new(StatusCode::FORBIDDEN, "JC0403", "forbidden")
    }
    /// The handler exceeded the configured time budget (spec §4.4 timeouts).
    pub fn handler_timeout() -> Self {
        Self::new(
            StatusCode::SERVICE_UNAVAILABLE,
            "JC0503",
            "handler timed out",
        )
    }
    pub fn internal(message: impl Into<String>) -> Self {
        Self::new(StatusCode::INTERNAL_SERVER_ERROR, "JC0500", message)
    }
    /// A handler or dependency asked for a type no provider supplies (spec §4.3).
    pub fn missing_dependency(type_name: &str) -> Self {
        Self::new(
            StatusCode::INTERNAL_SERVER_ERROR,
            "JC1001",
            format!("no provider registered for dependency `{type_name}`"),
        )
    }

    /// Dependency factories recursed past the depth limit (cycle, or absurd chain).
    pub fn dependency_cycle() -> Self {
        Self::new(
            StatusCode::INTERNAL_SERVER_ERROR,
            "JC1002",
            "dependency cycle or chain too deep",
        )
    }

    /// An HTTP extractor ran inside a task context (no request to read from).
    pub fn task_context() -> Self {
        Self::new(
            StatusCode::INTERNAL_SERVER_ERROR,
            "JC1003",
            "dependency requires an HTTP request",
        )
    }

    /// Attach machine-readable detail (e.g. validation violations). Rendered
    /// as a `details` key in the response body; absent otherwise.
    pub fn with_details(mut self, details: serde_json::Value) -> Self {
        self.details = Some(details);
        self
    }

    pub fn details(&self) -> Option<&serde_json::Value> {
        self.details.as_ref()
    }

    pub fn status(&self) -> StatusCode {
        self.status
    }
    pub fn code(&self) -> &'static str {
        self.code
    }
    pub fn message(&self) -> &str {
        &self.message
    }
}

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

impl std::error::Error for Error {}

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

    #[test]
    fn errors_carry_status_and_stable_code() {
        assert_eq!(Error::not_found().status(), StatusCode::NOT_FOUND);
        assert_eq!(Error::not_found().code(), "JC0404");
        assert_eq!(Error::method_not_allowed().code(), "JC0405");
        assert_eq!(Error::bad_request("nope").status(), StatusCode::BAD_REQUEST);
        assert_eq!(Error::payload_too_large().code(), "JC0413");
        assert_eq!(Error::unsupported_media_type().code(), "JC0415");
        assert_eq!(Error::unsupported_media_type().status().as_u16(), 415);
        assert_eq!(Error::unprocessable("bad field").code(), "JC0422");
        assert_eq!(Error::too_many_requests().code(), "JC0429");
        assert_eq!(Error::too_many_requests().status().as_u16(), 429);
        assert_eq!(Error::job_failed("boom").code(), "JC0521");
        assert_eq!(Error::job_failed("boom").status().as_u16(), 500);
        assert_eq!(
            Error::internal("boom").status(),
            StatusCode::INTERNAL_SERVER_ERROR
        );
        let e = Error::missing_dependency("app::Db");
        assert_eq!(e.code(), "JC1001");
        assert_eq!(e.status(), StatusCode::INTERNAL_SERVER_ERROR);
        assert!(e.message().contains("app::Db"));
        assert_eq!(Error::dependency_cycle().code(), "JC1002");
        assert_eq!(Error::handler_timeout().code(), "JC0503");
        assert_eq!(
            Error::handler_timeout().status(),
            StatusCode::SERVICE_UNAVAILABLE
        );
        assert_eq!(Error::unauthorized().code(), "JC0401");
        assert_eq!(Error::unauthorized().status(), StatusCode::UNAUTHORIZED);
        assert_eq!(Error::forbidden().code(), "JC0403");
        assert_eq!(Error::forbidden().status(), StatusCode::FORBIDDEN);
    }

    #[test]
    fn details_attach_and_default_to_none() {
        let plain = Error::unprocessable("validation failed");
        assert!(plain.details().is_none());
        let detailed = Error::unprocessable("validation failed").with_details(
            serde_json::json!([{ "field": "title", "message": "must not be empty" }]),
        );
        assert!(detailed.details().unwrap().is_array());
    }

    #[test]
    fn display_includes_code_and_message() {
        let e = Error::bad_request("missing body");
        assert_eq!(format!("{e}"), "JC0400: missing body");
    }
}